Files
florian.wildeandClaude Opus 5.5 76bc0e4269 feat(auftraege): worker, Phasen und Live-Fortschritt per SSE
Aufträge laufen als pg-boss-Jobs im worker. Der Phasen-Runner der Engine
kennt weder Proxmox noch Datenbank; eine Senke schreibt Phasen und Log
in die DB und meldet per NOTIFY, die app streamt per SSE an Web und CLI
(Last-Event-ID, Herzschlag, Polling-Fallback im Browser).

VMIDs vergibt eine Advisory-Sperre je Server zusammen mit einer
Reservierungstabelle; zehn parallele Reservierungen im Test bekommen
zehn verschiedene IDs. Der Proxmox-Client hat einen Trockenmodus für
den Dry-Run: Lesendes echt, Schreibendes nur aufgezeichnet.

Live-Ansicht im Aufbau der CLI-Ausgabe. Im echten Betrieb aufgefallen:
ssh2 und pg sind CommonJS und brauchen im worker Default-Importe.

Closes #006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:54:41 +02:00

131 lines
4.5 KiB
TypeScript
Raw Permalink 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 { describe, expect, it } from "vitest";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { AblaufFehler } from "@/engine/ablauf/fehler";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
/** Eine Senke, die mitschreibt – so sieht ein Test genau, was Web und CLI zu sehen bekämen. */
function mitschreiber() {
const ereignisse: string[] = [];
const senke: EreignisSenke = {
phasenAnlegen: async (p) => void ereignisse.push(`anlegen ${p.map((x) => x.schluessel).join(",")}`),
phaseGestartet: async (s) => void ereignisse.push(`start ${s}`),
phaseDetail: async (s, d) => void ereignisse.push(`detail ${s}: ${d}`),
phaseBeendet: async (s, status, a) =>
void ereignisse.push(
`ende ${s} ${status}${a.detail ? `: ${a.detail}` : ""}${a.fehler ? ` – ${a.fehler.ursache} → ${a.fehler.hinweis}` : ""}`,
),
log: async (stufe, n) => void ereignisse.push(`log ${stufe} ${n}`),
};
return { senke, ereignisse };
}
describe("Phasen-Runner", () => {
it("führt Phasen der Reihe nach aus und meldet Detail, Überspringen und Erfolg", async () => {
const { senke, ereignisse } = mitschreiber();
const ergebnis = await phasenAusfuehren(
[
{ schluessel: "pruefen", titel: "Prüfungen", ausfuehren: async () => ({ detail: "VMID 120 frei" }) },
{
schluessel: "template",
titel: "Template",
ausfuehren: async () => ({ uebersprungen: true, detail: "9013 vorhanden" }),
},
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async (k) => {
await k.detail("läuft …");
await k.log("clone gestartet");
return { detail: "full clone 9013 → 120" };
},
},
],
senke,
);
expect(ergebnis).toEqual({ ok: true });
expect(ereignisse).toEqual([
"anlegen pruefen,template,klonen",
"start pruefen",
"ende pruefen ERLEDIGT: VMID 120 frei",
"start template",
"ende template UEBERSPRUNGEN: 9013 vorhanden",
"start klonen",
"detail klonen: läuft …",
"log info clone gestartet",
"ende klonen ERLEDIGT: full clone 9013 → 120",
]);
});
it("bricht bei einem Fehler ab: Ursache und Hinweis, folgende Phasen bleiben offen", async () => {
const { senke, ereignisse } = mitschreiber();
let danach = false;
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async () => {
throw new ProxmoxFehler(
"recht",
"Permission check failed (/vms/120, VM.Clone)",
"Der Rolle fehlt VM.Clone auf /vms/120.",
);
},
},
{ schluessel: "start", titel: "Start", ausfuehren: async () => void (danach = true) },
],
senke,
);
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: false, phase: "klonen" });
expect(danach).toBe(false);
expect(ereignisse).toContain(
"ende klonen FEHLER – Permission check failed (/vms/120, VM.Clone) → Der Rolle fehlt VM.Clone auf /vms/120.",
);
expect(ereignisse.some((e) => e.startsWith("start start"))).toBe(false);
});
it("prüft vor jeder Phase auf Abbruch", async () => {
const { senke } = mitschreiber();
let abbrechen = false;
const ergebnis = await phasenAusfuehren(
[
{ schluessel: "a", titel: "A", ausfuehren: async () => void (abbrechen = true) },
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
],
senke,
{ abbruchGewuenscht: async () => abbrechen },
);
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: true, phase: "b" });
});
it("benennt unerwartete Fehler ehrlich", async () => {
const { senke } = mitschreiber();
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "x",
titel: "X",
ausfuehren: async () => {
throw new TypeError("kaputt");
},
},
],
senke,
);
expect(ergebnis.ok || ergebnis.fehler.ursache).toBe("Unerwarteter Fehler: kaputt");
const bewusst = await phasenAusfuehren(
[
{
schluessel: "y",
titel: "Y",
ausfuehren: async () => {
throw new AblaufFehler("U", "H");
},
},
],
senke,
);
expect(bewusst.ok || bewusst.fehler).toEqual({ ursache: "U", hinweis: "H" });
});
});