package scheduler import ( "testing" "time" ) // TestBlackoutWindowBlocksAndDefersRun prüft das Sperrfenster. // // Ein durch ein Fenster verhinderter Lauf wird verschoben, nicht übergangen. // Ihn ausfallen zu lassen erzeugte eine Lücke in der Sicherungskette, von der // niemand erführe: Dass ein Lauf verspätet ist, sieht man; dass er fehlt, nicht. func TestBlackoutWindowBlocksAndDefersRun(testInstance *testing.T) { windowSet, setError := NewWindowSet([]MaintenanceWindow{ { Identifier: "inventur", Name: "Jahresinventur", WindowKind: WindowKindBlackout, StartsAt: time.Date(2026, time.December, 27, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, time.December, 28, 6, 0, 0, 0, time.UTC), }, }) if setError != nil { testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError) } insideWindow := time.Date(2026, time.December, 27, 2, 0, 0, 0, time.UTC) isBlocked, blockReason := windowSet.IsBlocked(insideWindow, "taeglich") if !isBlocked { testInstance.Fatal("ein Zeitpunkt im Sperrfenster wurde freigegeben") } if blockReason.WindowName != "Jahresinventur" { testInstance.Errorf("die Begründung nennt das Fenster nicht: %+v", blockReason) } // Der Lauf muss nachgeholt werden — unmittelbar nach dem Fenster. deferredTime, deferError := windowSet.NextAllowedTime(insideWindow, "taeglich") if deferError != nil { testInstance.Fatalf("es wurde kein Ausweichzeitpunkt gefunden: %v", deferError) } if deferredTime.Before(time.Date(2026, time.December, 28, 6, 0, 0, 0, time.UTC)) { testInstance.Errorf("der Ausweichzeitpunkt %s liegt noch im Fenster", deferredTime) } } // TestAllowedWindowBlocksOutsideItself prüft die Umkehrung. func TestAllowedWindowBlocksOutsideItself(testInstance *testing.T) { windowSet, setError := NewWindowSet([]MaintenanceWindow{ { Name: "Nachtfenster", WindowKind: WindowKindAllowed, Recurring: &RecurringWindow{ StartHour: 22, Duration: 8 * time.Hour, }, }, }) if setError != nil { testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError) } // Mitten am Tag: außerhalb des Erlaubnisfensters. middayTime := time.Date(2026, time.June, 1, 14, 0, 0, 0, time.UTC) if isBlocked, _ := windowSet.IsBlocked(middayTime, "taeglich"); !isBlocked { testInstance.Fatal("ein Zeitpunkt außerhalb des Erlaubnisfensters wurde freigegeben") } // In der Nacht: zugelassen. nightTime := time.Date(2026, time.June, 1, 23, 0, 0, 0, time.UTC) if isBlocked, blockReason := windowSet.IsBlocked(nightTime, "taeglich"); isBlocked { testInstance.Fatalf("ein Zeitpunkt im Erlaubnisfenster wurde gesperrt: %+v", blockReason) } } // TestRecurringWindowSpansMidnight ist der Test für den häufigsten Denkfehler. // // Ein Fenster, das samstags um 22:00 beginnt und zwölf Stunden dauert, reicht // bis Sonntag 10:00. Wer nur den Tag des Zeitpunkts prüft, hielte Sonntag 09:00 // für frei — und liesse den Auftrag mitten in die Wartung laufen. func TestRecurringWindowSpansMidnight(testInstance *testing.T) { windowSet, setError := NewWindowSet([]MaintenanceWindow{ { Name: "Wochenendwartung", WindowKind: WindowKindBlackout, Recurring: &RecurringWindow{ Weekdays: []time.Weekday{time.Saturday}, StartHour: 22, Duration: 12 * time.Hour, }, }, }) if setError != nil { testInstance.Fatalf("die Fenstermenge wurde abgelehnt: %v", setError) } // Samstag, 6. Juni 2026, 23:00 — im Fenster. saturdayNight := time.Date(2026, time.June, 6, 23, 0, 0, 0, time.UTC) if isBlocked, _ := windowSet.IsBlocked(saturdayNight, "taeglich"); !isBlocked { testInstance.Error("Samstag 23:00 wurde freigegeben, obwohl das Fenster um 22:00 begann") } // Sonntag 09:00 — noch im Fenster, obwohl es ein anderer Tag ist. sundayMorning := time.Date(2026, time.June, 7, 9, 0, 0, 0, time.UTC) if isBlocked, _ := windowSet.IsBlocked(sundayMorning, "taeglich"); !isBlocked { testInstance.Fatal("Sonntag 09:00 wurde freigegeben, obwohl das Samstagsfenster bis 10:00 reicht") } // Sonntag 11:00 — nach dem Fenster. sundayLate := time.Date(2026, time.June, 7, 11, 0, 0, 0, time.UTC) if isBlocked, _ := windowSet.IsBlocked(sundayLate, "taeglich"); isBlocked { testInstance.Error("Sonntag 11:00 wurde gesperrt, obwohl das Fenster um 10:00 endete") } } // TestWindowAppliesOnlyToNamedJobs prüft die Einschränkung. func TestWindowAppliesOnlyToNamedJobs(testInstance *testing.T) { windowSet, _ := NewWindowSet([]MaintenanceWindow{ { Name: "Nur für die Datenbank", WindowKind: WindowKindBlackout, StartsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC), AppliesToJobIDs: []string{"datenbank"}, }, }) insideWindow := time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC) if isBlocked, _ := windowSet.IsBlocked(insideWindow, "datenbank"); !isBlocked { testInstance.Error("der benannte Auftrag wurde nicht gesperrt") } if isBlocked, _ := windowSet.IsBlocked(insideWindow, "dateiserver"); isBlocked { testInstance.Error("ein nicht benannter Auftrag wurde mitgesperrt") } } // TestAllowedWindowForOtherJobDoesNotBlockEveryone ist der Test gegen einen // gefährlichen Kurzschluss. // // Gäbe es irgendwo ein Erlaubnisfenster und würde die Regel auf alle Aufträge // angewandt, fielen sämtliche Sicherungen aus, die nicht gemeint waren. func TestAllowedWindowForOtherJobDoesNotBlockEveryone(testInstance *testing.T) { windowSet, _ := NewWindowSet([]MaintenanceWindow{ { Name: "Nur nachts für die Datenbank", WindowKind: WindowKindAllowed, AppliesToJobIDs: []string{"datenbank"}, Recurring: &RecurringWindow{ StartHour: 2, Duration: 2 * time.Hour, }, }, }) middayTime := time.Date(2026, time.June, 1, 14, 0, 0, 0, time.UTC) // Die Datenbank darf mittags nicht. if isBlocked, _ := windowSet.IsBlocked(middayTime, "datenbank"); !isBlocked { testInstance.Error("die Datenbank lief außerhalb ihres Erlaubnisfensters") } // Alle anderen sind davon unberührt. if isBlocked, blockReason := windowSet.IsBlocked(middayTime, "dateiserver"); isBlocked { testInstance.Fatalf("ein fremdes Erlaubnisfenster sperrte einen unbeteiligten Auftrag: %+v", blockReason) } } // TestNextAllowedTimeFailsOnPermanentBlackout prüft die Fehlkonfiguration. // // Ein Sperrfenster ohne absehbares Ende ist fast immer ein Fehler. Er wird als // solcher gemeldet statt als stiller Ausfall. func TestNextAllowedTimeFailsOnPermanentBlackout(testInstance *testing.T) { windowSet, _ := NewWindowSet([]MaintenanceWindow{ { Name: "Dauersperre", WindowKind: WindowKindBlackout, Recurring: &RecurringWindow{ StartHour: 0, // Ein Fenster über die volle Woche sperrt jeden Zeitpunkt. Duration: 7 * 24 * time.Hour, }, }, }) _, searchError := windowSet.NextAllowedTime(time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), "taeglich") if searchError == nil { testInstance.Fatal("eine Dauersperre wurde nicht als Fehlkonfiguration gemeldet") } } // TestWindowValidationRejectsBrokenDefinitions prüft die Eingabeprüfung. func TestWindowValidationRejectsBrokenDefinitions(testInstance *testing.T) { brokenWindows := map[string]MaintenanceWindow{ "unbekannte Wirkung": {WindowKind: "vielleicht", StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour)}, "Ende vor Anfang": { WindowKind: WindowKindBlackout, StartsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), }, "einmalig ohne Zeiten": {WindowKind: WindowKindBlackout}, "wiederkehrend ohne Länge": { WindowKind: WindowKindBlackout, Recurring: &RecurringWindow{StartHour: 2}, }, "wiederkehrend zu lang": { WindowKind: WindowKindBlackout, Recurring: &RecurringWindow{StartHour: 2, Duration: 8 * 24 * time.Hour}, }, "unbekannte Zeitzone": { WindowKind: WindowKindBlackout, Recurring: &RecurringWindow{StartHour: 2, Duration: time.Hour, TimeZone: "Mars/Olympus_Mons"}, }, } for caseName, brokenWindow := range brokenWindows { if validationError := brokenWindow.Validate(); validationError == nil { testInstance.Errorf("%s: das Fenster wurde angenommen", caseName) } } } // TestActiveWindowsExplainsWhyJobWaits prüft die Auskunft für die Oberfläche. func TestActiveWindowsExplainsWhyJobWaits(testInstance *testing.T) { windowSet, _ := NewWindowSet([]MaintenanceWindow{ { Name: "Monatsabschluss", WindowKind: WindowKindBlackout, StartsAt: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC), }, }) activeWindows := windowSet.ActiveWindows(time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC), "taeglich") if len(activeWindows) != 1 || activeWindows[0].Name != "Monatsabschluss" { testInstance.Fatalf("das wirksame Fenster wurde nicht ausgewiesen: %+v", activeWindows) } }