package service import ( "testing" "hy2xs-admin/dao" "hy2xs-admin/model/constant" ) // Число фиксированных джоб планировщика: CronHandleAccount, // CollectMetricsSnapshot, CleanupStatsRetention. Записано числом намеренно — // тест обязан заметить и появление новой джобы, и потерю существующей. const fixedCronJobCount = 3 func startTestCron(t *testing.T, resetTrafficExpression string) { t.Helper() newTestDB(t) if err := dao.UpdateConfig( []string{constant.ResetTrafficCron}, map[string]interface{}{"value": resetTrafficExpression}, ); err != nil { t.Fatalf("не удалось записать %s: %v", constant.ResetTrafficCron, err) } if err := InitCron(); err != nil { t.Fatalf("InitCron: %v", err) } t.Cleanup(StopCron) } // Главная регрессия P0: смена расписания не имеет права добавлять джобы. // // Раньше применение новой настройки шло через StopServer() и повторный // runServer, а InitCron на каждом вызове создавал новый cron.New() и нигде его // не сохранял. Старый планировщик никто не останавливал, поэтому после каждой // правки на процессе оставался ЦЕЛЫЙ дублирующий набор джоб, а старое // расписание сброса трафика продолжало срабатывать. func TestRescheduleResetTrafficDoesNotAccumulateJobs(t *testing.T) { startTestCron(t, "@monthly") initial := CronEntryCount() if initial != fixedCronJobCount+1 { t.Fatalf("после старта ожидалось %d записей, получено %d", fixedCronJobCount+1, initial) } for _, expression := range []string{"@weekly", "@monthly", "0 3 * * *", "@daily"} { if err := RescheduleResetTraffic(expression); err != nil { t.Fatalf("RescheduleResetTraffic(%q): %v", expression, err) } if count := CronEntryCount(); count != initial { t.Fatalf("после смены расписания на %q записей стало %d, ожидалось %d", expression, count, initial) } } } // Пустое выражение означает «автоматический сброс выключен», а не «оставить // как было»: поле в панели clearable, и оператор имеет право его очистить. func TestRescheduleResetTrafficWithEmptyExpressionDisablesJob(t *testing.T) { startTestCron(t, "@monthly") if !ResetTrafficScheduled() { t.Fatal("после старта с непустым расписанием джоба сброса не запланирована") } if err := RescheduleResetTraffic(" "); err != nil { t.Fatalf("RescheduleResetTraffic(пусто): %v", err) } if ResetTrafficScheduled() { t.Fatal("пустое выражение не сняло джобу сброса трафика") } if count := CronEntryCount(); count != fixedCronJobCount { t.Fatalf("после отключения сброса ожидалось %d записей, получено %d", fixedCronJobCount, count) } // Включение обратно должно работать без перезапуска процесса. if err := RescheduleResetTraffic("@weekly"); err != nil { t.Fatalf("повторное включение: %v", err) } if !ResetTrafficScheduled() { t.Fatal("джоба сброса не вернулась после повторного включения") } } // Невалидное выражение не имеет права ни изменить планировщик, ни пройти // молча: раньше ошибка AddFunc только логировалась, и сброс трафика тихо // исчезал. func TestRescheduleResetTrafficRejectsInvalidExpression(t *testing.T) { startTestCron(t, "@monthly") before := CronEntryCount() if err := RescheduleResetTraffic("какая-нибудь ерунда"); err == nil { t.Fatal("невалидное выражение принято") } if after := CronEntryCount(); after != before { t.Fatalf("отклонённое выражение изменило планировщик: было %d записей, стало %d", before, after) } if !ResetTrafficScheduled() { t.Fatal("отклонённое выражение сняло действующую джобу") } } // Валидация обязана пользоваться ТЕМ ЖЕ парсером, что и планировщик. Набор // проверяет обе стороны: то, что cron умеет, должно приниматься, а то, что не // умеет, — отклоняться до записи в базу. func TestValidateResetTrafficCronMatchesSchedulerParser(t *testing.T) { valid := []string{ "", " ", "@monthly", "@weekly", "@daily", "@hourly", "@every 1h30m", "0 0 * * *", "*/15 * * * *", "0 3 1 * 1", } for _, expression := range valid { if err := ValidateResetTrafficCron(expression); err != nil { t.Errorf("валидное выражение %q отклонено: %v", expression, err) } } invalid := []string{ "какая-нибудь ерунда", "@yearly extra", "* * * *", "* * * * * *", "99 * * * *", "@every", "@every банан", "- - - - -", } for _, expression := range invalid { if err := ValidateResetTrafficCron(expression); err == nil { t.Errorf("невалидное выражение %q принято", expression) } } } // Невалидное значение в базе не имеет права уронить старт: на панели висит // /internal/hysteria/auth, и отказ старта из-за строки расписания положил бы // подключения пользователей. func TestInitCronSurvivesInvalidStoredExpression(t *testing.T) { startTestCron(t, "совершенно невалидная строка") if ResetTrafficScheduled() { t.Fatal("невалидное выражение из базы было запланировано") } if count := CronEntryCount(); count != fixedCronJobCount { t.Fatalf("фиксированные джобы не поднялись: ожидалось %d записей, получено %d", fixedCronJobCount, count) } // Оператор обязан иметь возможность починить настройку через API, без // перезапуска сервиса. if err := RescheduleResetTraffic("@weekly"); err != nil { t.Fatalf("исправление расписания: %v", err) } if !ResetTrafficScheduled() { t.Fatal("исправленное расписание не применилось") } } // Планировщик принадлежит процессу: второй InitCron без остановки первого — // это ровно тот сценарий, который и создавал дубликаты. func TestInitCronRefusesSecondScheduler(t *testing.T) { startTestCron(t, "@monthly") if err := InitCron(); err == nil { t.Fatal("второй планировщик поднялся поверх работающего") } if count := CronEntryCount(); count != fixedCronJobCount+1 { t.Fatalf("отклонённый повторный старт изменил планировщик: %d записей", count) } } func TestStopCronIsIdempotent(t *testing.T) { startTestCron(t, "@monthly") StopCron() if count := CronEntryCount(); count != 0 { t.Fatalf("после остановки осталось %d записей", count) } if ResetTrafficScheduled() { t.Fatal("после остановки джоба сброса числится запланированной") } // Второй вызов не должен паниковать: StopCron стоит в defer runServer и // может быть вызван повторно на пути обработки ошибки. StopCron() if err := RescheduleResetTraffic("@weekly"); err == nil { t.Fatal("перепланирование на остановленном планировщике не отклонено") } }