Drei Anlaesse: fehlgeschlagener Abgleich, rote CI, neues Release. Die Daten dafuer liegen seit dem CI-Status vor, es fehlte nur der Weg nach draussen. Der Kern ist die Entprellung. Gemeldet wird ausschliesslich bei einer Aenderung gegenueber dem zuletzt gemeldeten Stand, der am Projekt vermerkt ist. Ein Repo mit abgelaufenem Token meldet sich damit einmal statt bei jedem stuendlichen Durchlauf. Zwei Feinheiten: - Beim allerersten Abgleich wird kein Release gemeldet, sonst waere jede bereits vorhandene Version eine "Neuigkeit". - Der Merkstand wird auch ohne eingerichtetes Ziel fortgeschrieben, sonst kaeme beim ersten Ziel die ganze Vergangenheit hinterher. Zustellwege: - ntfy mit Titel, Prioritaet und Symbol als Kopfzeilen, optionalem Bearer-Token. Emojis werden aus den Kopfzeilen entfernt statt durch Fragezeichen ersetzt - Kopfzeilen duerfen nur Latin-1 tragen, und das Sinnbild liefert ohnehin der Tags-Header. - Webhook mit JSON, Projektbezug und Tieflink. Mit Geheimnis wird der Rumpf per HMAC-SHA256 signiert, damit der Empfaenger die Herkunft pruefen kann. - E-Mail ueber SMTP, verfuegbar sobald SMTP_HOST gesetzt ist. Zieladressen sind Benutzereingaben, die der Server abruft - sie laufen deshalb durch denselben SSRF-Schutz wie die Git-Verbindungen. Redirects werden nicht verfolgt, weil ein Umleitungsziel nicht geprueft waere. Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung darf den Abgleich nicht scheitern lassen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { Project, RepoSnapshot } from "@prisma/client";
|
|
import { pendingEvents } from "./index";
|
|
|
|
/**
|
|
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
|
|
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
|
|
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
|
|
*/
|
|
|
|
function projekt(overrides: Partial<Project> = {}): Project {
|
|
return {
|
|
id: "p1",
|
|
name: "Testprojekt",
|
|
slug: "testprojekt",
|
|
syncError: null,
|
|
notifiedSyncError: null,
|
|
notifiedCiState: null,
|
|
notifiedRelease: null,
|
|
...overrides,
|
|
} as Project;
|
|
}
|
|
|
|
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
|
|
return {
|
|
ciState: null,
|
|
ciBranch: "main",
|
|
ciName: null,
|
|
ciUrl: null,
|
|
latestTag: null,
|
|
latestRelease: null,
|
|
htmlUrl: null,
|
|
...overrides,
|
|
} as RepoSnapshot;
|
|
}
|
|
|
|
const events = (p: Project, s: RepoSnapshot | null) =>
|
|
pendingEvents(p, s).map((entry) => entry.event);
|
|
|
|
describe("Abgleichsfehler", () => {
|
|
it("meldet einen neuen Fehler", () => {
|
|
expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
|
|
});
|
|
|
|
it("meldet denselben Fehler kein zweites Mal", () => {
|
|
expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
|
|
});
|
|
|
|
it("meldet, wenn sich der Fehler aendert", () => {
|
|
expect(
|
|
events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null),
|
|
).toEqual(["SYNC_FAILED"]);
|
|
});
|
|
|
|
it("meldet nichts, wenn kein Fehler vorliegt", () => {
|
|
expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("CI", () => {
|
|
it("meldet den Wechsel auf rot", () => {
|
|
expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
|
|
});
|
|
|
|
it("meldet dauerhaft rote CI nur einmal", () => {
|
|
expect(
|
|
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("meldet nicht, wenn die CI gruen ist", () => {
|
|
expect(
|
|
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
|
|
expect(
|
|
events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
|
|
).toEqual(["CI_FAILED"]);
|
|
});
|
|
});
|
|
|
|
describe("Release", () => {
|
|
it("schweigt beim allerersten Abgleich", () => {
|
|
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
|
|
expect(
|
|
events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("meldet eine neue Version", () => {
|
|
expect(
|
|
events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
|
|
).toEqual(["NEW_RELEASE"]);
|
|
});
|
|
|
|
it("meldet dieselbe Version nicht erneut", () => {
|
|
expect(
|
|
events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("zieht den Releasenamen dem Tag vor", () => {
|
|
const treffer = pendingEvents(
|
|
projekt({ notifiedRelease: "alt" }),
|
|
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
|
|
);
|
|
expect(treffer[0].message.title).toContain("Zweite Ausgabe");
|
|
});
|
|
});
|
|
|
|
describe("mehrere Anlaesse gleichzeitig", () => {
|
|
it("meldet Fehler und rote CI zusammen", () => {
|
|
expect(
|
|
events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
|
|
).toEqual(["SYNC_FAILED", "CI_FAILED"]);
|
|
});
|
|
|
|
it("haengt Projektbezug und Tieflink an jede Nachricht", () => {
|
|
const treffer = pendingEvents(projekt({ syncError: "404" }), null);
|
|
expect(treffer[0].message.project?.slug).toBe("testprojekt");
|
|
expect(treffer[0].message.url).toContain("/projects/p1");
|
|
expect(treffer[0].message.priority).toBe("high");
|
|
});
|
|
});
|