feat(alerts): zehn Warnregeln mit Worker, Liste und Einstellungen (#011)

Regeln als reine Funktionen, Abgleich oeffnet und schliesst Warnungen anhand
eines Fingerabdrucks. Worker wertet stuendlich aus, zusaetzlich nach jedem
Report. 123 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 22:25:33 +02:00
co-authored by Claude Opus 5
parent 5018589f08
commit 8a299f6c5e
25 changed files with 1638 additions and 21 deletions
+134
View File
@@ -0,0 +1,134 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
const JETZT = new Date("2026-09-17T12:00:00Z");
async function hostMitAblauf(tage: number) {
return testPrisma.host.create({
data: {
agentId: "agent-1",
hostname: "pve-01",
produkt: "PVE",
subStatus: "ACTIVE",
subKey: "pve1c-aaaa",
subNextduedate: new Date(JETZT.getTime() + tage * 86_400_000),
letzterReportAm: JETZT,
},
});
}
describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
beforeEach(async () => {
await datenbankLeeren();
await warnregelnSicherstellen();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(10);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
});
it("öffnet eine Warnung und meldet sie nicht doppelt", async () => {
await hostMitAblauf(5);
const erster = await alertsAbgleichen(JETZT);
expect(erster.geoeffnet.map((a) => a.regel)).toContain("subscription-laeuft-ab");
const zweiter = await alertsAbgleichen(JETZT);
expect(zweiter.geoeffnet).toHaveLength(0);
const offene = await testPrisma.alert.findMany({ where: { erledigtAm: null } });
expect(offene.filter((a) => a.regel === "subscription-laeuft-ab")).toHaveLength(1);
});
it("schließt eine Warnung, sobald die Ursache behoben ist", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
// Verlängert: neues Ablaufdatum weit in der Zukunft.
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 400 * 86_400_000) },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.erledigt).toContain(`subscription-laeuft-ab:${host.id}:7`);
const alert = await testPrisma.alert.findFirstOrThrow({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
expect(alert.erledigtAm).not.toBeNull();
});
it("öffnet einen erledigten Fall erneut, wenn er wieder auftritt", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 400 * 86_400_000) },
});
await alertsAbgleichen(JETZT);
await testPrisma.host.update({
where: { id: host.id },
data: { subNextduedate: new Date(JETZT.getTime() + 5 * 86_400_000) },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.map((a) => a.fingerprint)).toContain(
`subscription-laeuft-ab:${host.id}:7`,
);
const alerts = await testPrisma.alert.findMany({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
// Derselbe Eintrag wird wiederverwendet, es entsteht kein zweiter.
expect(alerts).toHaveLength(1);
expect(alerts[0].erledigtAm).toBeNull();
});
it("führt die Beschreibung nach, wenn sich der Fall ändert", async () => {
const host = await hostMitAblauf(5);
await alertsAbgleichen(JETZT);
const spaeter = new Date(JETZT.getTime() + 2 * 86_400_000);
await alertsAbgleichen(spaeter);
const alert = await testPrisma.alert.findFirstOrThrow({
where: { fingerprint: `subscription-laeuft-ab:${host.id}:7` },
});
expect(alert.titel).toBe("Subscription läuft in 3 Tagen ab");
});
it("steigt von der 30- auf die 7-Tage-Stufe", async () => {
const host = await hostMitAblauf(25);
const erster = await alertsAbgleichen(JETZT);
expect(erster.geoeffnet.some((a) => a.fingerprint.endsWith(":30"))).toBe(true);
const spaeter = new Date(JETZT.getTime() + 20 * 86_400_000);
const zweiter = await alertsAbgleichen(spaeter);
expect(zweiter.geoeffnet.some((a) => a.fingerprint.endsWith(":7"))).toBe(true);
expect(zweiter.erledigt).toContain(`subscription-laeuft-ab:${host.id}:30`);
});
it("beachtet abgeschaltete Regeln", async () => {
await testPrisma.host.create({
data: { agentId: "agent-2", hostname: "pve-ohne-kunde", produkt: "PVE", letzterReportAm: JETZT },
});
await testPrisma.warnregelEinstellung.update({
where: { regel: "host-ohne-kunde" },
data: { aktiv: false },
});
const ergebnis = await alertsAbgleichen(JETZT);
expect(ergebnis.geoeffnet.some((a) => a.regel === "host-ohne-kunde")).toBe(false);
});
});