package checkmk import ( "strings" "testing" "time" ) var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) func lines(out string) map[string]string { m := map[string]string{} for _, l := range strings.Split(strings.TrimSpace(out), "\n") { start := strings.Index(l, `"`) end := strings.Index(l[start+1:], `"`) m[l[start+1:start+1+end]] = l } return m } func TestTenants(t *testing.T) { out := Render(State{Tenants: []Tenant{ {ID: "kunde_schmidt", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 74)}, {ID: "meier_gmbh", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 25)}, {ID: "bald", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 12)}, {ID: "alt_ag", Commercial: true, Status: "expired", ValidUntil: now.AddDate(0, 0, -1)}, {ID: "homelab", Commercial: false, Status: "active", ValidUntil: now.AddDate(0, 0, -100)}, }}, now) l := lines(out) want := map[string]string{ "Sultan_Tenant_kunde_schmidt": `0 "Sultan_Tenant_kunde_schmidt" days_left=74;30;14 OK - Abo aktiv bis 2026-12-15 (74 Tage verbleibend)`, "Sultan_Tenant_meier_gmbh": `1 "Sultan_Tenant_meier_gmbh" days_left=25;30;14 WARN - Abo laeuft in 25 Tagen ab! Verlaengerung erforderlich.`, "Sultan_Tenant_bald": `2 "Sultan_Tenant_bald" days_left=12;30;14 CRIT - Abo laeuft in 12 Tagen ab! Verlaengerung erforderlich.`, "Sultan_Tenant_alt_ag": `2 "Sultan_Tenant_alt_ag" days_left=0;30;14 CRIT - Abo seit 1 Tagen abgelaufen! Status: expired`, } for svc, w := range want { if l[svc] != w { t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w) } } if _, ok := l["Sultan_Tenant_homelab"]; ok { t.Error("Community-Mandant mit Vertragsfrist (5c: keine)") } } func TestPayment(t *testing.T) { out := Render(State{Tenants: []Tenant{ {ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 0}, {ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 1, OverdueSince: now.AddDate(0, 0, -9)}, {ID: "c", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 2, OverdueSince: now.AddDate(0, 0, -26)}, {ID: "d", Commercial: true, Status: "suspended_payment", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 3, OverdueSince: now.AddDate(0, 0, -40)}, {ID: "h", Commercial: false, Status: "active", ValidUntil: now}, }}, now) l := lines(out) for svc, prefix := range map[string]string{ "Sultan_Payment_a": `0 "Sultan_Payment_a" dunning_level=0;1;2 OK - keine offene Zahlung`, "Sultan_Payment_b": `1 "Sultan_Payment_b" dunning_level=1;1;2 WARN - Mahnstufe 1 (Zahlung seit 9 Tagen offen)`, "Sultan_Payment_c": `2 "Sultan_Payment_c" dunning_level=2;1;2 CRIT - Mahnstufe 2 (Zahlung seit 26 Tagen offen)`, "Sultan_Payment_d": `2 "Sultan_Payment_d" dunning_level=3;1;2 CRIT - Mahnstufe 3 (Zahlung seit 40 Tagen offen) - Kunde offline`, } { if l[svc] != prefix { t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], prefix) } } if _, ok := l["Sultan_Payment_h"]; ok { t.Error("Community-Mandant mit Mahnwesen") } } func TestNodes(t *testing.T) { out := Render(State{Nodes: []Node{ {ID: "online", Status: "online", LeaseUntil: now.Add(48 * time.Hour)}, {ID: "wartung", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute), MaintenanceReason: "Kernel 6.12", LeaseUntil: now.Add(time.Hour)}, {ID: "kurz-weg", Status: "offline", OfflineSince: now.Add(-5 * time.Minute), LeaseUntil: now.Add(time.Hour)}, {ID: "lange-weg", Status: "offline", OfflineSince: now.Add(-20 * time.Minute)}, {ID: "abgezogen", Status: "drained"}, }}, now) l := lines(out) want := map[string]string{ "Sultan_Node_online": `0 "Sultan_Node_online" lease_hours_left=48 OK - online`, "Sultan_Node_wartung": `1 "Sultan_Node_wartung" lease_hours_left=1 WARN - Wartung bis 2026-10-02 13:30 UTC (Kernel 6.12)`, "Sultan_Node_kurz-weg": `1 "Sultan_Node_kurz-weg" lease_hours_left=1 WARN - offline seit 5 min`, "Sultan_Node_lange-weg": `2 "Sultan_Node_lange-weg" lease_hours_left=0 CRIT - offline seit 20 min`, "Sultan_Node_abgezogen": `0 "Sultan_Node_abgezogen" lease_hours_left=0 OK - drained`, } for svc, w := range want { if l[svc] != w { t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w) } } } // Angemeldete Wartung: auch offline nur WARN (geplant), nicht CRIT. func TestNodeOfflineDuringMaintenance(t *testing.T) { out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-time.Hour), MaintenanceActive: true}}}, now) if !strings.HasPrefix(strings.TrimSpace(out), `1 "Sultan_Node_pi"`) || !strings.Contains(out, "Wartung unbefristet") { t.Errorf("out = %s", out) } } func TestViolations(t *testing.T) { for n, want := range map[int]string{ 0: `0 "Sultan_Policy_Violations" violations_24h=0;1 OK - keine Policy-Verstoesse in 24 h`, 3: `1 "Sultan_Policy_Violations" violations_24h=3;1 WARN - 3 Policy-Verstoesse in 24 h (siehe Server-Log)`, } { if got := strings.TrimSpace(Render(State{Violations24h: n}, now)); got != want { t.Errorf("%d:\n got: %s\nwant: %s", n, got, want) } } } func TestOutputIsSortedAndASCII(t *testing.T) { out := Render(State{ Tenants: []Tenant{{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}, {ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}}, Nodes: []Node{{ID: "y", Status: "online"}, {ID: "x", Status: "online", MaintenanceActive: true, MaintenanceReason: "Grün & Ölwechsel\nzweite Zeile"}}, }, now) if strings.Index(out, "Sultan_Tenant_a") > strings.Index(out, "Sultan_Tenant_b") || strings.Index(out, "Sultan_Node_x") > strings.Index(out, "Sultan_Node_y") { t.Errorf("nicht sortiert:\n%s", out) } for _, r := range out { if r > 127 { t.Fatalf("Nicht-ASCII-Zeichen %q in:\n%s", r, out) } } if strings.Count(out, "\n") != len(lines(out)) { t.Errorf("Zeilenumbruch aus dem Grund in der Ausgabe:\n%s", out) } if !strings.Contains(out, "Gruen & Oelwechsel zweite Zeile") { t.Errorf("Umlaute nicht umschrieben:\n%s", out) } } func TestShortOfflineInSeconds(t *testing.T) { out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-45 * time.Second)}}}, now) if !strings.Contains(out, "offline seit 45 s") { t.Errorf("out = %s", out) } } // Wartungsende in der Zeitzone des Servers (wie auf der Lander-Seite). func TestMaintenanceUntilInLocation(t *testing.T) { berlin, err := time.LoadLocation("Europe/Berlin") if err != nil { t.Skip("Zeitzonendaten fehlen") } out := Render(State{ Location: berlin, Nodes: []Node{{ID: "pi", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute)}}, }, now) if !strings.Contains(out, "Wartung bis 2026-10-02 15:30 CEST") { t.Errorf("out = %s", out) } }