package models_test import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "rocketgit.ru/rsmon/worker/app/models" ) func TestMaintenanceValidateGeneratesRecurringCron(t *testing.T) { tests := []struct { name string m models.Maintenance want string }{ {"interval", models.Maintenance{Title: "interval", Strategy: models.MaintenanceRecurringInterval, StartDate: maintenanceTimePtr(time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)), StartTime: "02:30", IntervalDay: 3, DurationSec: 60, Timezone: "UTC"}, ""}, {"weekday", models.Maintenance{Title: "weekdays", Strategy: models.MaintenanceRecurringWeekday, StartTime: "02:30", Weekdays: []int64{1, 5}, DurationSec: 60, Timezone: "UTC"}, "30 02 * * 1,5"}, {"month", models.Maintenance{Title: "month", Strategy: models.MaintenanceRecurringDayOfMonth, StartTime: "02:30", DaysOfMonth: []string{"1", "lastDay1"}, DurationSec: 60, Timezone: "UTC"}, "30 02 1,28-31 * *"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { require.NoError(t, test.m.Validate()); assert.Equal(t, test.want, test.m.Cron) }) } } func TestMaintenanceIsUnderMaintenanceBoundariesAndTimezone(t *testing.T) { start := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) end := start.Add(time.Hour) single := models.Maintenance{Title: "single", Strategy: models.MaintenanceSingle, StartDate: &start, EndDate: &end, Active: true, Timezone: "UTC"} assert.True(t, single.IsUnderMaintenance(start)) assert.True(t, single.IsUnderMaintenance(end.Add(-time.Nanosecond))) assert.False(t, single.IsUnderMaintenance(end)) cron := models.Maintenance{Title: "moscow", Strategy: models.MaintenanceCron, Cron: "0 12 * * *", DurationSec: 3600, Active: true, Timezone: "Europe/Moscow"} require.NoError(t, cron.Validate()) assert.True(t, cron.IsUnderMaintenance(time.Date(2026, 7, 1, 9, 30, 0, 0, time.UTC)), "12:30 Moscow is 09:30 UTC in July") assert.False(t, cron.IsUnderMaintenance(time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC))) } func TestMaintenanceValidationRejectsInvalidSchedules(t *testing.T) { badCron := models.Maintenance{Title: "bad", Strategy: models.MaintenanceCron, Cron: "not cron", DurationSec: 1, Timezone: "UTC"} badSingle := models.Maintenance{Title: "bad", Strategy: models.MaintenanceSingle, Timezone: "UTC"} assert.Error(t, badCron.Validate()) assert.Error(t, badSingle.Validate()) } func TestMaintenanceLastDayIsNotEveryDayInExpandedCronRange(t *testing.T) { m := models.Maintenance{Title: "last", Strategy: models.MaintenanceRecurringDayOfMonth, StartTime: "12:00", DaysOfMonth: []string{"lastDay1"}, DurationSec: 3600, Active: true, Timezone: "UTC"} require.NoError(t, m.Validate()) assert.False(t, m.IsUnderMaintenance(time.Date(2026, 3, 28, 12, 30, 0, 0, time.UTC))) assert.True(t, m.IsUnderMaintenance(time.Date(2026, 3, 31, 12, 30, 0, 0, time.UTC))) } func TestMaintenanceRecurringIntervalUsesAnchorAndIntervalDay(t *testing.T) { anchor := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) m := models.Maintenance{Title: "every three days", Strategy: models.MaintenanceRecurringInterval, StartDate: &anchor, StartTime: "12:00", IntervalDay: 3, DurationSec: 3600, Active: true, Timezone: "UTC"} require.NoError(t, m.Validate()) assert.True(t, m.IsUnderMaintenance(time.Date(2026, 7, 4, 12, 30, 0, 0, time.UTC))) assert.False(t, m.IsUnderMaintenance(time.Date(2026, 7, 5, 12, 30, 0, 0, time.UTC))) next := m.NextRun(time.Date(2026, 7, 2, 13, 0, 0, 0, time.UTC)) require.NotNil(t, next) assert.Equal(t, time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC), *next) } func TestMaintenanceRecurringIntervalRequiresAnchorForMultiDayAndPositiveInterval(t *testing.T) { m := models.Maintenance{Title: "invalid", Strategy: models.MaintenanceRecurringInterval, StartTime: "12:00", IntervalDay: 2, DurationSec: 60, Timezone: "UTC"} assert.Error(t, m.Validate()) } func TestMaintenanceRecurringIntervalKeepsCivilDayAcrossDST(t *testing.T) { loc, err := time.LoadLocation("Europe/Berlin") require.NoError(t, err) // March 29, 2026 is the spring-forward day in Berlin. The second run is // still two civil days after the anchor, not one because a day was 23h. anchor := time.Date(2026, 3, 27, 0, 0, 0, 0, loc) m := models.Maintenance{Title: "DST", Strategy: models.MaintenanceRecurringInterval, StartDate: &anchor, StartTime: "03:30", IntervalDay: 2, DurationSec: 3600, Active: true, Timezone: "Europe/Berlin"} require.NoError(t, m.Validate()) assert.True(t, m.IsUnderMaintenance(time.Date(2026, 3, 29, 4, 0, 0, 0, loc).UTC())) } func TestMaintenanceStartNotificationKeyIncludesRevisionAndOccurrence(t *testing.T) { revision := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) start := revision.Add(5 * time.Minute) key := models.MaintenanceStartNotificationKey(7, revision, start, "Europe/Berlin", 11, 13) assert.Equal(t, key, models.MaintenanceStartNotificationKey(7, revision, start, "Europe/Berlin", 11, 13)) assert.NotEqual(t, key, models.MaintenanceStartNotificationKey(7, revision.Add(time.Second), start, "Europe/Berlin", 11, 13)) } func TestMaintenanceStartNotificationKeyDeduplicatesDSTFallbackCivilOccurrence(t *testing.T) { revision := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) first := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC) second := first.Add(time.Hour) assert.Equal(t, models.MaintenanceStartNotificationKey(7, revision, first, "Europe/Berlin", 11, 13), models.MaintenanceStartNotificationKey(7, revision, second, "Europe/Berlin", 11, 13)) } func TestMaintenanceNextRunSkipsNonFinalLastDayCandidates(t *testing.T) { m := models.Maintenance{Title: "last", Strategy: models.MaintenanceRecurringDayOfMonth, StartTime: "12:00", DaysOfMonth: []string{"lastDay1"}, DurationSec: 60, Active: true, Timezone: "UTC"} require.NoError(t, m.Validate()) next := m.NextRun(time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)) require.NotNil(t, next) assert.Equal(t, time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC), *next) } func maintenanceTimePtr(value time.Time) *time.Time { return &value }