package tasks import ( "os" "regexp" "strings" "testing" "time" _ "time/tzdata" "github.com/robfig/cron/v3" ) func monthlyExpiryMigration(t *testing.T) string { t.Helper() data, err := os.ReadFile("../../resource/migrations/20261014_monthly_package_expiry_notice.sql") if err != nil { t.Fatal(err) } return regexp.MustCompile(`(?m)--[^\r\n]*`).ReplaceAllString(string(data), "") } func TestMonthlyExpiryDeploymentSchedule(t *testing.T) { sql := monthlyExpiryMigration(t) matches := regexp.MustCompile(`'CRON_TZ=[^']+'`).FindString(sql) if matches == "" { t.Fatal("migration must specify cron expression") } parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) schedule, err := parser.Parse(strings.Trim(matches, "'")) if err != nil { t.Fatalf("deployment expression must parse with the real scheduler: %v", err) } for _, tc := range []struct{ start, expected string }{ {"2026-10-14T01:00:00Z", "2026-10-15T01:00:00Z"}, {"2026-10-15T01:00:00Z", "2026-11-15T01:00:00Z"}, {"2026-12-15T01:00:00Z", "2027-01-15T01:00:00Z"}, {"2028-02-15T01:00:00Z", "2028-03-15T01:00:00Z"}, } { start, _ := time.Parse(time.RFC3339, tc.start) expected, _ := time.Parse(time.RFC3339, tc.expected) if got := schedule.Next(start); !got.Equal(expected) { t.Errorf("from %s: got %s, want %s", tc.start, got, expected) } } for _, alias := range []string{"ScanSimExpireCandidateWarning", "scan_sim_expire_candidate_warning"} { if got := GetInnerTaskName(alias); got != "ScanSimExpireCandidateWarning" { t.Errorf("existing alias %s no longer registered: %s", alias, got) } } if got := GetInnerTaskName("NotifyMonthlyPackageExpiry"); got != "NotifyMonthlyPackageExpiry" { t.Errorf("monthly task not registered independently: %s", got) } if got, want := jobLockKey("NotifyMonthlyPackageExpiry", time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), monthlyExpiryJobPeriod), jobLockKey("scan_sim_expire_candidate_warning", time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), warningExpireScanJobPeriod); got == want { t.Fatal("monthly and legacy tasks must not share the same lock") } } // 只检查迁移脚本结构,不连接数据库或执行 SQL;不替代部署前的 MySQL 验证。 func TestMonthlyExpiryDeploymentInsertOnly(t *testing.T) { sql := strings.Join(strings.Fields(monthlyExpiryMigration(t)), " ") if strings.Count(strings.ToUpper(sql), "INSERT INTO SYS_JOB") != 1 { t.Fatal("migration must insert only one independent sys_job") } if regexp.MustCompile(`(?i)\bUPDATE\s+sys_job\b|\bDELETE\s+FROM\s+sys_job\b|\bjob_id\b`).MatchString(sql) { t.Fatal("migration must not update/delete jobs or provide a generated job_id") } for _, required := range []string{ "'NotifyMonthlyPackageExpiry'", "'CRON_TZ=Asia/Shanghai 0 0 9 15 * *'", "1, 1, '1'", "WHERE NOT EXISTS ( SELECT 1 FROM sys_job WHERE invoke_target = 'NotifyMonthlyPackageExpiry' )", } { if !strings.Contains(sql, required) { t.Errorf("migration missing required independent paused job behavior: %q", required) } } if strings.Contains(sql, "ScanSimExpireCandidateWarning") || strings.Contains(sql, "scan_sim_expire_candidate_warning") { t.Fatal("migration must not touch legacy job targets") } }