package usecase import ( "context" "sync" "testing" "time" jobDomain "apps/backend/internal/module/job/domain" jobRepo "apps/backend/internal/module/job/repository" jobUC "apps/backend/internal/module/job/usecase" "apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/repository" ) func TestPastDailySweepSlot(t *testing.T) { day := time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC) before := day.Add(21 * time.Hour) at := day.Add(22 * time.Hour) after := day.Add(22*time.Hour + time.Minute) if PastDailySweepSlot(before) { t.Fatal("21:00 UTC should not schedule") } if !PastDailySweepSlot(at) { t.Fatal("22:00 UTC should schedule") } if !PastDailySweepSlot(after) { t.Fatal("22:01 UTC should schedule") } } func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) { ctx := context.Background() radarMem := repository.NewMemory() jobMem := jobRepo.NewMemory() jobs := jobUC.New(jobMem) svc := New(radarMem) svc.SweepJobs = SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt) if err != nil { return "", err } return j.ID, nil }) // Seed profile so CreateWatch would pass if we used usecase; write watches directly. now := domain.NowNano() for _, id := range []string{"w-a", "w-b"} { w := &domain.RadarWatch{ ID: id, OwnerUID: 100, Terms: []string{"婚攝"}, Status: domain.WatchActive, CreatedAt: now, UpdatedAt: now, } if err := radarMem.SaveWatch(ctx, w); err != nil { t.Fatal(err) } } // paused must not get a job _ = radarMem.SaveWatch(ctx, &domain.RadarWatch{ ID: "w-paused", OwnerUID: 100, Terms: []string{"x"}, Status: domain.WatchPaused, CreatedAt: now, UpdatedAt: now, }) // Before 22:00 → no jobs morning := time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC) n, err := svc.ScheduleDailySweeps(ctx, morning) if err != nil { t.Fatal(err) } if n != 0 { t.Fatalf("before slot: want 0 jobs, got %d", n) } // After 22:00 → two jobs (active only) evening := time.Date(2026, 7, 31, 22, 5, 0, 0, time.UTC) n, err = svc.ScheduleDailySweeps(ctx, evening) if err != nil { t.Fatal(err) } if n != 2 { t.Fatalf("SW-01: want 2 jobs scheduled, got %d", n) } list, err := jobs.List(ctx, 100) if err != nil { t.Fatal(err) } sweepJobs := 0 refs := map[string]bool{} for _, j := range list { if j.TemplateType != jobDomain.TemplateRadarSweep { continue } sweepJobs++ if refs[j.RefID] { t.Fatalf("duplicate ref %s", j.RefID) } refs[j.RefID] = true if j.Status != jobDomain.StatusQueued { t.Fatalf("status=%s", j.Status) } } if sweepJobs != 2 { t.Fatalf("want 2 radar_sweep jobs, got %d", sweepJobs) } // Idempotent: second tick same day does not create more n2, err := svc.ScheduleDailySweeps(ctx, evening) if err != nil { t.Fatal(err) } if n2 != 2 { t.Fatalf("re-tick should still report 2 watches ensured, got %d", n2) } list, _ = jobs.List(ctx, 100) count := 0 for _, j := range list { if j.TemplateType == jobDomain.TemplateRadarSweep { count++ } } if count != 2 { t.Fatalf("after re-tick want still 2 jobs, got %d", count) } } func TestScheduleRadarSweep_ConcurrentNoDuplicate(t *testing.T) { ctx := context.Background() jobs := jobUC.New(jobRepo.NewMemory()) runAt := time.Date(2026, 7, 31, 22, 0, 0, 0, time.UTC).UnixNano() var wg sync.WaitGroup ids := make(chan string, 20) for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() j, err := jobs.ScheduleRadarSweep(ctx, 7, "watch-x", runAt) if err != nil { t.Errorf("schedule: %v", err) return } ids <- j.ID }() } wg.Wait() close(ids) seen := map[string]bool{} for id := range ids { seen[id] = true } if len(seen) != 1 { t.Fatalf("concurrent schedule should yield one job id, got %d distinct", len(seen)) } list, _ := jobs.List(ctx, 7) if len(list) != 1 { t.Fatalf("want 1 job in store, got %d", len(list)) } }