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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
alleRegelnPruefen,
|
||||
REGELN,
|
||||
type HostDaten,
|
||||
type LaufzeitDaten,
|
||||
type RegelKontext,
|
||||
} from "@/server/alerts/regeln";
|
||||
|
||||
const JETZT = new Date("2026-09-17T12:00:00Z");
|
||||
const TAG = 86_400_000;
|
||||
|
||||
function inTagen(tage: number): Date {
|
||||
return new Date(JETZT.getTime() + tage * TAG);
|
||||
}
|
||||
|
||||
function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
|
||||
return {
|
||||
id: "h1",
|
||||
hostname: "pve-01",
|
||||
kundeId: "k1",
|
||||
kundeName: "Brandl Elektrotechnik GmbH",
|
||||
subKey: "pve1c-aaaa",
|
||||
subStatus: "ACTIVE",
|
||||
subNextduedate: inTagen(200),
|
||||
subLizenzierteSockets: 1,
|
||||
sockets: 1,
|
||||
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
|
||||
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
|
||||
...überschreibungen,
|
||||
};
|
||||
}
|
||||
|
||||
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
|
||||
return {
|
||||
id: "l1",
|
||||
hostId: "h1",
|
||||
hostname: "pve-01",
|
||||
kundeId: "k1",
|
||||
kundeName: "Brandl Elektrotechnik GmbH",
|
||||
key: "pve1c-aaaa",
|
||||
start: inTagen(-100),
|
||||
ende: inTagen(265),
|
||||
einkaufNummer: "PX-2026-1",
|
||||
einkaufBetrag: 115,
|
||||
einkaufBezahlt: true,
|
||||
einkaufFaelligAm: inTagen(-80),
|
||||
verkaufNummer: "RE-2026-1",
|
||||
verkaufBetrag: 165,
|
||||
verkaufErstellt: true,
|
||||
verkaufBezahlt: true,
|
||||
verkaufDatum: inTagen(-90),
|
||||
...überschreibungen,
|
||||
};
|
||||
}
|
||||
|
||||
function kontext(teil: Partial<RegelKontext> = {}): RegelKontext {
|
||||
return {
|
||||
jetzt: JETZT,
|
||||
hosts: [],
|
||||
laufzeiten: [],
|
||||
einstellungen: new Map(),
|
||||
...teil,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hilfsfunktion: Kandidaten einer bestimmten Regel. */
|
||||
function ausRegel(k: RegelKontext, name: string) {
|
||||
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
|
||||
}
|
||||
|
||||
describe("Warnregeln", () => {
|
||||
it("prüft alle zehn Regeln, wenn nichts abgeschaltet ist", () => {
|
||||
expect(REGELN).toHaveLength(10);
|
||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(10);
|
||||
});
|
||||
|
||||
describe("Subscription läuft ab", () => {
|
||||
it.each([
|
||||
[31, 0, null],
|
||||
[30, 1, "INFO"],
|
||||
[20, 1, "INFO"],
|
||||
[14, 1, "WARNUNG"],
|
||||
[8, 1, "WARNUNG"],
|
||||
[7, 1, "GEFAHR"],
|
||||
[1, 1, "GEFAHR"],
|
||||
[0, 1, "GEFAHR"],
|
||||
[-1, 0, null],
|
||||
])("bei %i Tagen Restlaufzeit: %i Warnung(en)", (tage, anzahl, schwere) => {
|
||||
const kandidaten = ausRegel(
|
||||
kontext({ hosts: [host({ subNextduedate: inTagen(tage) })] }),
|
||||
"subscription-laeuft-ab",
|
||||
);
|
||||
|
||||
expect(kandidaten).toHaveLength(anzahl);
|
||||
if (schwere) expect(kandidaten[0].schwere).toBe(schwere);
|
||||
});
|
||||
|
||||
it("meldet nur die strengste erreichte Stufe", () => {
|
||||
const kandidaten = ausRegel(
|
||||
kontext({ hosts: [host({ subNextduedate: inTagen(5) })] }),
|
||||
"subscription-laeuft-ab",
|
||||
);
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].fingerprint).toBe("subscription-laeuft-ab:h1:7");
|
||||
});
|
||||
|
||||
it("beachtet eigene Schwellwerte", () => {
|
||||
const k = kontext({
|
||||
hosts: [host({ subNextduedate: inTagen(50) })],
|
||||
einstellungen: new Map([
|
||||
["subscription-laeuft-ab", { aktiv: true, schwellwerte: { tage: [60, 30] } }],
|
||||
]),
|
||||
});
|
||||
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("meldet nichts für Hosts ohne aktive Subscription", () => {
|
||||
const k = kontext({ hosts: [host({ subStatus: "EXPIRED", subNextduedate: inTagen(3) })] });
|
||||
expect(ausRegel(k, "subscription-laeuft-ab")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status nicht aktiv", () => {
|
||||
it.each([
|
||||
["EXPIRED", "GEFAHR"],
|
||||
["INVALID", "GEFAHR"],
|
||||
["SUSPENDED", "GEFAHR"],
|
||||
["NOTFOUND", "WARNUNG"],
|
||||
["UNKNOWN", "WARNUNG"],
|
||||
] as const)("meldet %s als %s", (status, schwere) => {
|
||||
const kandidaten = ausRegel(kontext({ hosts: [host({ subStatus: status })] }), "status-nicht-aktiv");
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].schwere).toBe(schwere);
|
||||
});
|
||||
|
||||
it("meldet aktive Subscriptions nicht", () => {
|
||||
expect(ausRegel(kontext({ hosts: [host()] }), "status-nicht-aktiv")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sockets", () => {
|
||||
it("meldet eine Abweichung", () => {
|
||||
const k = kontext({ hosts: [host({ subLizenzierteSockets: 1, sockets: 2 })] });
|
||||
const kandidaten = ausRegel(k, "sockets-abweichung");
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].beschreibung).toContain("1 lizenziert, 2 physisch");
|
||||
});
|
||||
|
||||
it("meldet nichts, wenn ein Wert fehlt", () => {
|
||||
expect(ausRegel(kontext({ hosts: [host({ sockets: null })] }), "sockets-abweichung")).toHaveLength(0);
|
||||
expect(
|
||||
ausRegel(kontext({ hosts: [host({ subLizenzierteSockets: null })] }), "sockets-abweichung"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keine Meldung", () => {
|
||||
it("meldet nach Ablauf der Frist", () => {
|
||||
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 49 * 3_600_000) })] });
|
||||
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("meldet innerhalb der Frist nicht", () => {
|
||||
const k = kontext({ hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 47 * 3_600_000) })] });
|
||||
expect(ausRegel(k, "keine-meldung")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("beachtet einen eigenen Schwellwert", () => {
|
||||
const k = kontext({
|
||||
hosts: [host({ letzterReportAm: new Date(JETZT.getTime() - 13 * 3_600_000) })],
|
||||
einstellungen: new Map([["keine-meldung", { aktiv: true, schwellwerte: { stunden: 12 } }]]),
|
||||
});
|
||||
expect(ausRegel(k, "keine-meldung")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Server-ID", () => {
|
||||
it("meldet einen frischen Wechsel", () => {
|
||||
const k = kontext({
|
||||
hosts: [
|
||||
host({
|
||||
serverIds: [
|
||||
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-10) },
|
||||
{ serverId: "NEU", ersteMeldung: inTagen(-5), letzteMeldung: JETZT },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const kandidaten = ausRegel(k, "serverid-geaendert");
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].beschreibung).toContain("neu NEU, vorher ALT");
|
||||
});
|
||||
|
||||
it("meldet einen alten Wechsel nicht mehr", () => {
|
||||
const k = kontext({
|
||||
hosts: [
|
||||
host({
|
||||
serverIds: [
|
||||
{ serverId: "ALT", ersteMeldung: inTagen(-400), letzteMeldung: inTagen(-200) },
|
||||
{ serverId: "NEU", ersteMeldung: inTagen(-190), letzteMeldung: JETZT },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(ausRegel(k, "serverid-geaendert")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key mehrfach gemeldet", () => {
|
||||
it("meldet denselben Key auf zwei Hosts", () => {
|
||||
const k = kontext({
|
||||
hosts: [host(), host({ id: "h2", hostname: "pve-02" })],
|
||||
});
|
||||
const kandidaten = ausRegel(k, "key-mehrfach-gemeldet");
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].beschreibung).toContain("pve-01, pve-02");
|
||||
});
|
||||
|
||||
it("meldet unterschiedliche Keys nicht", () => {
|
||||
const k = kontext({ hosts: [host(), host({ id: "h2", subKey: "pve1c-bbbb" })] });
|
||||
expect(ausRegel(k, "key-mehrfach-gemeldet")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Host ohne Kunde", () => {
|
||||
it("meldet nicht zugeordnete Hosts", () => {
|
||||
const k = kontext({ hosts: [host({ kundeId: null, kundeName: null })] });
|
||||
const kandidaten = ausRegel(k, "host-ohne-kunde");
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].schwere).toBe("INFO");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Proxmox-Rechnung fällig", () => {
|
||||
it("meldet eine bald fällige unbezahlte Rechnung als Warnung", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(5) })],
|
||||
});
|
||||
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].schwere).toBe("WARNUNG");
|
||||
});
|
||||
|
||||
it("meldet eine überfällige Rechnung als Gefahr", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(-2) })],
|
||||
});
|
||||
const kandidaten = ausRegel(k, "proxmox-rechnung-faellig");
|
||||
expect(kandidaten[0].schwere).toBe("GEFAHR");
|
||||
expect(kandidaten[0].titel).toBe("Proxmox-Rechnung überfällig");
|
||||
});
|
||||
|
||||
it("meldet bezahlte oder noch nicht fällige Rechnungen nicht", () => {
|
||||
expect(
|
||||
ausRegel(kontext({ laufzeiten: [laufzeit({ einkaufFaelligAm: inTagen(3) })] }), "proxmox-rechnung-faellig"),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
ausRegel(
|
||||
kontext({ laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(20) })] }),
|
||||
"proxmox-rechnung-faellig",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kundenrechnung", () => {
|
||||
it("meldet eine fehlende Kundenrechnung, wenn der Einkauf erfasst ist", () => {
|
||||
const k = kontext({ laufzeiten: [laufzeit({ verkaufErstellt: false, verkaufNummer: null })] });
|
||||
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("meldet nichts für Hosts ohne Kunde", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ verkaufErstellt: false, kundeId: null, kundeName: null })],
|
||||
});
|
||||
expect(ausRegel(k, "kundenrechnung-fehlt")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("meldet eine seit über 30 Tagen unbezahlte Kundenrechnung", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-31) })],
|
||||
});
|
||||
const kandidaten = ausRegel(k, "kundenrechnung-unbezahlt");
|
||||
expect(kandidaten).toHaveLength(1);
|
||||
expect(kandidaten[0].titel).toContain("31 Tagen");
|
||||
});
|
||||
|
||||
it("meldet innerhalb der Frist nicht", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ verkaufBezahlt: false, verkaufDatum: inTagen(-29) })],
|
||||
});
|
||||
expect(ausRegel(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("überspringt abgeschaltete Regeln", () => {
|
||||
const k = kontext({
|
||||
hosts: [host({ kundeId: null, kundeName: null })],
|
||||
einstellungen: new Map([["host-ohne-kunde", { aktiv: false, schwellwerte: {} }]]),
|
||||
});
|
||||
expect(ausRegel(k, "host-ohne-kunde")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("erzeugt für jeden Fall einen eindeutigen Fingerabdruck", () => {
|
||||
const k = kontext({
|
||||
hosts: [
|
||||
host({ subStatus: "EXPIRED", sockets: 2, kundeId: null, kundeName: null }),
|
||||
host({ id: "h2", hostname: "pve-02", subStatus: "NOTFOUND" }),
|
||||
],
|
||||
laufzeiten: [laufzeit({ einkaufBezahlt: false, einkaufFaelligAm: inTagen(1), verkaufErstellt: false })],
|
||||
});
|
||||
|
||||
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
|
||||
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user