package scheduler import ( "errors" "testing" "time" ) // mustLoadBerlin lädt die mitteleuropäische Zeitzone. // // Sie wird gebraucht, weil die Zeitumstellung nur in einer Zone mit Umstellung // prüfbar ist. In UTC gäbe es die interessanten Fälle nicht. func mustLoadBerlin(testInstance *testing.T) *time.Location { testInstance.Helper() berlinLocation, loadError := time.LoadLocation("Europe/Berlin") if loadError != nil { testInstance.Skipf("die Zeitzonendaten fehlen auf diesem System: %v", loadError) } return berlinLocation } // TestDailyScheduleSurvivesSpringForward ist der wichtigste Test dieser Datei. // // In Mitteleuropa springt die Uhr am letzten Sonntag im März von 02:00 auf // 03:00. Ein Auftrag „täglich um 02:30" hat an diesem Tag keinen Zeitpunkt. // Ein Scheduler, der das nicht bedenkt, lässt die Sicherung einmal im Jahr // stillschweigend ausfallen — und niemand merkt es, bis man sie braucht. func TestDailyScheduleSurvivesSpringForward(testInstance *testing.T) { berlinLocation := mustLoadBerlin(testInstance) dailySchedule := &Schedule{ ScheduleType: ScheduleTypeDaily, Hour: 2, Minute: 30, TimeZone: "Europe/Berlin", } // 2026: Die Umstellung ist am Sonntag, 29. März. dayBeforeSwitch := time.Date(2026, time.March, 28, 12, 0, 0, 0, berlinLocation) nextRun, nextError := dailySchedule.NextRun(dayBeforeSwitch) if nextError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError) } // Der Lauf muss stattfinden — irgendwann am 29. März. if nextRun.Day() != 29 || nextRun.Month() != time.March { testInstance.Fatalf("der Lauf am Umstellungstag fiel aus; berechnet wurde %s", nextRun) } // 02:30 gibt es nicht; Go legt auf 03:30. Entscheidend ist, dass der Lauf // überhaupt stattfindet und nicht übersprungen wird. if nextRun.Hour() == 2 { testInstance.Errorf("es wurde eine nicht existierende Uhrzeit berechnet: %s", nextRun) } // Und der Tag danach muss wieder normal sein. dayAfterRun, dayAfterError := dailySchedule.NextRun(nextRun) if dayAfterError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", dayAfterError) } if dayAfterRun.Hour() != 2 || dayAfterRun.Minute() != 30 { testInstance.Errorf("nach der Umstellung wurde %s berechnet statt 02:30", dayAfterRun) } if dayAfterRun.Day() != 30 { testInstance.Errorf("der Folgetag wurde als %d berechnet statt als 30.", dayAfterRun.Day()) } } // TestDailyScheduleRunsOnceOnFallBack prüft die doppelte Stunde. // // Im Herbst wird 02:00 bis 03:00 wiederholt. Ein Auftrag „täglich um 02:30" // hätte an diesem Tag zwei Gelegenheiten. Er darf nur einmal laufen: zwei Läufe // erzeugten zwei Backups mit demselben Zeitplan und verwirrten jede Kette. func TestDailyScheduleRunsOnceOnFallBack(testInstance *testing.T) { berlinLocation := mustLoadBerlin(testInstance) dailySchedule := &Schedule{ ScheduleType: ScheduleTypeDaily, Hour: 2, Minute: 30, TimeZone: "Europe/Berlin", } // 2026: Die Rückstellung ist am Sonntag, 25. Oktober. dayBeforeSwitch := time.Date(2026, time.October, 24, 12, 0, 0, 0, berlinLocation) firstRun, firstError := dailySchedule.NextRun(dayBeforeSwitch) if firstError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", firstError) } if firstRun.Day() != 25 { testInstance.Fatalf("der Lauf am Umstellungstag wurde auf %s gelegt", firstRun) } secondRun, secondError := dailySchedule.NextRun(firstRun) if secondError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", secondError) } // Der Kern: Der nächste Lauf ist am Folgetag, nicht ein zweites Mal am // selben Tag in der wiederholten Stunde. if secondRun.Day() != 26 { testInstance.Fatalf("der Auftrag lief am Umstellungstag zweimal; der zweite Lauf wurde auf %s gelegt", secondRun) } } // TestNextRunIsAlwaysStrictlyLater prüft die Grundregel. // // Wäre der nächste Zeitpunkt gleich dem Bezugspunkt, liefe ein Auftrag // unmittelbar nach seinem eigenen Ende erneut — eine Endlosschleife, die sich // als Zeitplan ausgibt. func TestNextRunIsAlwaysStrictlyLater(testInstance *testing.T) { testSchedules := []*Schedule{ {ScheduleType: ScheduleTypeDaily, Hour: 2, Minute: 0}, {ScheduleType: ScheduleTypeHourly, Minute: 30}, {ScheduleType: ScheduleTypeWeekly, Weekdays: []time.Weekday{time.Monday}, Hour: 3}, {ScheduleType: ScheduleTypeMonthly, MonthDays: []int{1}, Hour: 4}, {ScheduleType: ScheduleTypeCron, CronExpression: "0 2 * * *"}, {ScheduleType: ScheduleTypeInterval, Interval: time.Hour}, } // Ein Bezugspunkt, der auf jeden dieser Zeitpläne genau passt. exactMatchTime := time.Date(2026, time.June, 1, 2, 0, 0, 0, time.UTC) for _, testSchedule := range testSchedules { nextRun, nextError := testSchedule.NextRun(exactMatchTime) if nextError != nil { testInstance.Fatalf("%s: die Berechnung schlug fehl: %v", testSchedule.ScheduleType, nextError) } if !nextRun.After(exactMatchTime) { testInstance.Errorf("%s: der nächste Lauf %s liegt nicht nach dem Bezugspunkt %s", testSchedule.ScheduleType, nextRun, exactMatchTime) } } } // TestMonthlyScheduleHandlesShortMonths prüft den 31. in kurzen Monaten. func TestMonthlyScheduleHandlesShortMonths(testInstance *testing.T) { monthlySchedule := &Schedule{ ScheduleType: ScheduleTypeMonthly, MonthDays: []int{31}, Hour: 1, } // Vom 31. Januar aus ist der nächste 31. der im März — den 31. Februar gibt // es nicht. januaryReference := time.Date(2026, time.January, 31, 12, 0, 0, 0, time.UTC) nextRun, nextError := monthlySchedule.NextRun(januaryReference) if nextError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError) } if nextRun.Month() != time.March || nextRun.Day() != 31 { testInstance.Errorf("nach dem 31. Januar wurde %s berechnet, erwartet war der 31. März", nextRun) } } // TestMonthlyScheduleSupportsLastDayOfMonth prüft den Monatsletzten. // // Wer eine Monatssicherung zum Monatsende will, meint den letzten Tag — nicht // „fällt in vier Monaten des Jahres aus". func TestMonthlyScheduleSupportsLastDayOfMonth(testInstance *testing.T) { monthlySchedule := &Schedule{ ScheduleType: ScheduleTypeMonthly, MonthDays: []int{lastDayOfMonth}, Hour: 23, } expectedLastDays := map[time.Month]int{ time.January: 31, time.February: 28, // 2026 ist kein Schaltjahr time.April: 30, } for referenceMonth, expectedDay := range expectedLastDays { monthStart := time.Date(2026, referenceMonth, 1, 0, 0, 0, 0, time.UTC) nextRun, nextError := monthlySchedule.NextRun(monthStart) if nextError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError) } if nextRun.Month() != referenceMonth || nextRun.Day() != expectedDay { testInstance.Errorf("für %s wurde %s berechnet, erwartet war der %d.", referenceMonth, nextRun, expectedDay) } } } // TestMonthlyScheduleFindsLeapDay prüft den 29. Februar. func TestMonthlyScheduleFindsLeapDay(testInstance *testing.T) { leapDaySchedule := &Schedule{ ScheduleType: ScheduleTypeCron, CronExpression: "0 3 29 2 *", } // 2026 und 2027 haben keinen 29. Februar; 2028 hat einen. referenceTime := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) nextRun, nextError := leapDaySchedule.NextRun(referenceTime) if nextError != nil { testInstance.Fatalf("der Schalttag wurde nicht gefunden: %v", nextError) } if nextRun.Year() != 2028 || nextRun.Month() != time.February || nextRun.Day() != 29 { testInstance.Errorf("es wurde %s berechnet, erwartet war der 29. Februar 2028", nextRun) } } // TestWeeklyScheduleFindsNextMatchingDay prüft den Wochenplan. func TestWeeklyScheduleFindsNextMatchingDay(testInstance *testing.T) { weeklySchedule := &Schedule{ ScheduleType: ScheduleTypeWeekly, Weekdays: []time.Weekday{time.Saturday, time.Sunday}, Hour: 22, } // Ein Mittwoch. wednesdayReference := time.Date(2026, time.June, 3, 12, 0, 0, 0, time.UTC) nextRun, nextError := weeklySchedule.NextRun(wednesdayReference) if nextError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", nextError) } if nextRun.Weekday() != time.Saturday { testInstance.Errorf("es wurde %s berechnet, erwartet war ein Samstag", nextRun.Weekday()) } // Vom Samstag aus muss der Sonntag folgen, nicht der nächste Samstag. followingRun, followingError := weeklySchedule.NextRun(nextRun) if followingError != nil { testInstance.Fatalf("die Berechnung schlug fehl: %v", followingError) } if followingRun.Weekday() != time.Sunday { testInstance.Errorf("nach dem Samstag wurde %s berechnet", followingRun.Weekday()) } } // TestScheduleTimeZoneIsRespected prüft die Zeitzonenbindung. // // Ohne sie liefe dieselbe Konfiguration auf zwei Servern zu verschiedenen // Zeiten — der häufigste Grund für ein Backup, das „mal um zwei und mal um // vier" läuft. func TestScheduleTimeZoneIsRespected(testInstance *testing.T) { mustLoadBerlin(testInstance) berlinSchedule := &Schedule{ ScheduleType: ScheduleTypeDaily, Hour: 2, TimeZone: "Europe/Berlin", } utcSchedule := &Schedule{ ScheduleType: ScheduleTypeDaily, Hour: 2, } // Im Sommer liegt Berlin zwei Stunden vor UTC. summerReference := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) berlinRun, _ := berlinSchedule.NextRun(summerReference) utcRun, _ := utcSchedule.NextRun(summerReference) if berlinRun.Equal(utcRun) { testInstance.Fatal("die Zeitzone wurde nicht berücksichtigt: beide Zeitpläne ergaben denselben Zeitpunkt") } // 02:00 Berliner Zeit ist 00:00 UTC. if berlinRun.UTC().Hour() != 0 { testInstance.Errorf("02:00 Berliner Zeit wurde als %s UTC berechnet", berlinRun.UTC()) } } // TestInvalidSchedulesAreRejectedAtCreation prüft die Eingabeprüfung. // // Ein Zeitplan, dessen Fehler sich erst um 02:00 Uhr zeigt, fällt genau dann // aus, wenn niemand hinsieht. func TestInvalidSchedulesAreRejectedAtCreation(testInstance *testing.T) { invalidSchedules := map[string]*Schedule{ "Stunde außerhalb": {ScheduleType: ScheduleTypeDaily, Hour: 24}, "Minute außerhalb": {ScheduleType: ScheduleTypeDaily, Minute: 60}, "Abstand zu klein": {ScheduleType: ScheduleTypeInterval, Interval: 10 * time.Second}, "Wochenplan ohne Tag": {ScheduleType: ScheduleTypeWeekly, Hour: 2}, "Monatsplan ohne Tag": {ScheduleType: ScheduleTypeMonthly, Hour: 2}, "Monatstag außerhalb": {ScheduleType: ScheduleTypeMonthly, MonthDays: []int{32}}, "unbekannte Zeitzone": {ScheduleType: ScheduleTypeDaily, TimeZone: "Mars/Olympus_Mons"}, "unbekannte Art": {ScheduleType: "gelegentlich"}, "cron mit zu wenig Feldern": {ScheduleType: ScheduleTypeCron, CronExpression: "0 2 *"}, "cron mit Unsinn": {ScheduleType: ScheduleTypeCron, CronExpression: "99 2 * * *"}, } for caseName, invalidSchedule := range invalidSchedules { if validationError := invalidSchedule.Validate(); validationError == nil { testInstance.Errorf("%s: der Zeitplan wurde angenommen", caseName) } } } // TestManualScheduleHasNoNextRun prüft den Zeitplan ohne Automatik. func TestManualScheduleHasNoNextRun(testInstance *testing.T) { manualSchedule := &Schedule{ScheduleType: ScheduleTypeManual} if validationError := manualSchedule.Validate(); validationError != nil { testInstance.Fatalf("ein Zeitplan auf Anforderung muss gültig sein: %v", validationError) } if _, nextError := manualSchedule.NextRun(time.Now()); !errors.Is(nextError, ErrNoNextRun) { testInstance.Fatalf("ein Zeitplan auf Anforderung darf keinen nächsten Lauf haben: %v", nextError) } } // TestCronDayOfMonthAndWeekdayUseOrLogic prüft die Cron-Sonderregel. // // Sind beide Tagesfelder eingeschränkt, gilt ODER statt UND. "0 0 13 * 5" // bedeutet „am 13. oder freitags". Wer das als UND umsetzt, baut einen // Zeitplan, der fast nie läuft — und es fällt erst nach Monaten auf. func TestCronDayOfMonthAndWeekdayUseOrLogic(testInstance *testing.T) { orSchedule := &Schedule{ScheduleType: ScheduleTypeCron, CronExpression: "0 0 13 * 5"} parsedExpression, parseError := parseCronExpression(orSchedule.CronExpression) if parseError != nil { testInstance.Fatalf("der Ausdruck wurde abgelehnt: %v", parseError) } // Der 13. Juni 2026 ist ein Samstag: passt über den Monatstag. thirteenthSaturday := time.Date(2026, time.June, 13, 0, 0, 0, 0, time.UTC) if !parsedExpression.matches(thirteenthSaturday) { testInstance.Error("der 13. wurde nicht erkannt, obwohl das Tagesfeld passt") } // Freitag, der 5. Juni 2026: passt über den Wochentag. ordinaryFriday := time.Date(2026, time.June, 5, 0, 0, 0, 0, time.UTC) if !parsedExpression.matches(ordinaryFriday) { testInstance.Error("ein Freitag wurde nicht erkannt, obwohl das Wochentagsfeld passt") } // Ein gewöhnlicher Dienstag, der nicht der 13. ist: passt nicht. ordinaryTuesday := time.Date(2026, time.June, 9, 0, 0, 0, 0, time.UTC) if parsedExpression.matches(ordinaryTuesday) { testInstance.Error("ein beliebiger Tag wurde angenommen") } } // TestCronUsesAndLogicWhenOnlyOneFieldRestricted prüft den Normalfall. func TestCronUsesAndLogicWhenOnlyOneFieldRestricted(testInstance *testing.T) { // Nur der Wochentag ist eingeschränkt: "montags um 3". mondaysOnly, parseError := parseCronExpression("0 3 * * 1") if parseError != nil { testInstance.Fatalf("der Ausdruck wurde abgelehnt: %v", parseError) } monday := time.Date(2026, time.June, 1, 3, 0, 0, 0, time.UTC) if !mondaysOnly.matches(monday) { testInstance.Error("ein Montag wurde nicht erkannt") } tuesday := time.Date(2026, time.June, 2, 3, 0, 0, 0, time.UTC) if mondaysOnly.matches(tuesday) { testInstance.Error("ein Dienstag wurde angenommen, obwohl nur Montage gelten") } } // TestCronParsesStepsRangesAndNames prüft die Schreibweisen. func TestCronParsesStepsRangesAndNames(testInstance *testing.T) { stepExpression, parseError := parseCronExpression("*/15 9-17 * * mon-fri") if parseError != nil { testInstance.Fatalf("der Ausdruck wurde abgelehnt: %v", parseError) } // Alle 15 Minuten zwischen 9 und 17 Uhr an Werktagen. workdayMorning := time.Date(2026, time.June, 3, 9, 15, 0, 0, time.UTC) if !stepExpression.matches(workdayMorning) { testInstance.Error("9:15 an einem Mittwoch wurde nicht erkannt") } if stepExpression.matches(time.Date(2026, time.June, 3, 9, 20, 0, 0, time.UTC)) { testInstance.Error("9:20 wurde angenommen, obwohl nur Viertelstunden gelten") } if stepExpression.matches(time.Date(2026, time.June, 6, 9, 15, 0, 0, time.UTC)) { testInstance.Error("ein Samstag wurde angenommen, obwohl nur Werktage gelten") } if stepExpression.matches(time.Date(2026, time.June, 3, 18, 15, 0, 0, time.UTC)) { testInstance.Error("18:15 wurde angenommen, obwohl der Bereich bei 17 endet") } } // TestCronSundayAcceptsBothSevenAndZero prüft die doppelte Schreibweise. func TestCronSundayAcceptsBothSevenAndZero(testInstance *testing.T) { sunday := time.Date(2026, time.June, 7, 4, 0, 0, 0, time.UTC) for _, sundayExpression := range []string{"0 4 * * 0", "0 4 * * 7", "0 4 * * sun"} { parsedExpression, parseError := parseCronExpression(sundayExpression) if parseError != nil { testInstance.Fatalf("%q wurde abgelehnt: %v", sundayExpression, parseError) } if !parsedExpression.matches(sunday) { testInstance.Errorf("%q erkannte den Sonntag nicht", sundayExpression) } } } // TestCronPredefinedExpressions prüft die Kurzformen. func TestCronPredefinedExpressions(testInstance *testing.T) { dailySchedule := &Schedule{ScheduleType: ScheduleTypeCron, CronExpression: "@daily"} referenceTime := time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC) nextRun, nextError := dailySchedule.NextRun(referenceTime) if nextError != nil { testInstance.Fatalf("@daily wurde abgelehnt: %v", nextError) } if nextRun.Hour() != 0 || nextRun.Day() != 2 { testInstance.Errorf("@daily ergab %s, erwartet war der 2. Juni um 00:00", nextRun) } } // TestDescribeProducesReadableText prüft die Beschreibung für die Oberfläche. func TestDescribeProducesReadableText(testInstance *testing.T) { describedSchedules := map[string]*Schedule{ "täglich um 02:00 Uhr (Europe/Berlin)": { ScheduleType: ScheduleTypeDaily, Hour: 2, TimeZone: "Europe/Berlin", }, "alle 6 Stunden": { ScheduleType: ScheduleTypeInterval, Interval: 6 * time.Hour, }, "nur auf Anforderung": {ScheduleType: ScheduleTypeManual}, } for expectedText, testSchedule := range describedSchedules { if describedText := testSchedule.Describe(); describedText != expectedText { testInstance.Errorf("beschrieben wurde %q, erwartet war %q", describedText, expectedText) } } }