package manager import ( "context" "io" "log/slog" "os" "path/filepath" "testing" "time" "syncbot/internal/config" ) func testConfig(workDir string, jobs ...config.Job) *config.Config { return &config.Config{ WorkDir: workDir, Concurrency: 2, ReloadInterval: time.Second, Jobs: jobs, } } // newJob builds a spec pointing at paths that do not exist. The job goroutine // will fail its sync quickly and harmlessly, which is all these tests need — // they are about supervision, not about git. func newJob(name, workDir string, interval time.Duration) config.Job { return config.Job{ Name: name, Src: config.Endpoint{URL: filepath.Join(workDir, name+"-src.git")}, Dst: config.Endpoint{URL: filepath.Join(workDir, name+"-dst.git")}, Dir: filepath.Join(workDir, "mirrors", name+".git"), Interval: interval, Timeout: 5 * time.Second, MaxBackoff: time.Hour, Refs: config.DefaultRefs, Prune: true, Force: true, } } func newTestManager(t *testing.T) (*Manager, string) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) workDir := t.TempDir() m := New(ctx, slog.New(slog.NewTextHandler(io.Discard, nil))) t.Cleanup(func() { m.Stop(5 * time.Second) }) return m, workDir } func (m *Manager) jobPointers() map[string]*job { m.mu.Lock() defer m.mu.Unlock() out := make(map[string]*job, len(m.jobs)) for k, v := range m.jobs { out[k] = v } return out } func TestApplyStartsJobs(t *testing.T) { m, dir := newTestManager(t) if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil { t.Fatal(err) } if got := len(m.jobPointers()); got != 2 { t.Fatalf("running jobs = %d, want 2", got) } if got := len(m.Statuses()); got != 2 { t.Errorf("statuses = %d, want 2", got) } } // The whole point of an incremental reload: editing one repo must not disturb // the others' timers or in-flight work. func TestApplyLeavesUnchangedJobsRunning(t *testing.T) { m, dir := newTestManager(t) cfg := testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour)) if err := m.Apply(cfg); err != nil { t.Fatal(err) } before := m.jobPointers() // Identical configuration: nothing should be touched. if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil { t.Fatal(err) } after := m.jobPointers() for name, j := range before { if after[name] != j { t.Errorf("job %q was restarted despite an identical config", name) } } // Change only "a": "b" must survive untouched. if err := m.Apply(testConfig(dir, newJob("a", dir, 30*time.Minute), newJob("b", dir, time.Hour))); err != nil { t.Fatal(err) } changed := m.jobPointers() if changed["a"] == before["a"] { t.Error("job \"a\" should have been restarted after its interval changed") } if changed["b"] != before["b"] { t.Error("job \"b\" should not have been restarted") } if got := changed["a"].spec.Interval; got != 30*time.Minute { t.Errorf("restarted job carries interval %s, want 30m", got) } } func TestApplyStopsRemovedJobs(t *testing.T) { m, dir := newTestManager(t) if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour), newJob("b", dir, time.Hour))); err != nil { t.Fatal(err) } removed := m.jobPointers()["b"] if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour))); err != nil { t.Fatal(err) } if _, still := m.jobPointers()["b"]; still { t.Fatal("job \"b\" is still registered after being removed from the config") } select { case <-removed.done: case <-time.After(5 * time.Second): t.Fatal("goroutine for the removed job did not exit") } for _, s := range m.Statuses() { if s.Name == "b" { t.Error("status for the removed job should be dropped") } } } func TestCountersSurviveJobRestart(t *testing.T) { m, dir := newTestManager(t) if err := m.Apply(testConfig(dir, newJob("a", dir, time.Hour))); err != nil { t.Fatal(err) } m.update("a", func(s *Status) { s.Syncs, s.Pushes = 7, 3 }) if err := m.Apply(testConfig(dir, newJob("a", dir, 5*time.Minute))); err != nil { t.Fatal(err) } got := m.Statuses()[0] if got.Syncs != 7 || got.Pushes != 3 { t.Errorf("counters reset on restart: syncs=%d pushes=%d, want 7/3", got.Syncs, got.Pushes) } if got.Interval != "5m0s" { t.Errorf("status interval = %q, want it to follow the new spec", got.Interval) } } func TestConcurrencyChangeKeepsSemaphoreConsistent(t *testing.T) { m, dir := newTestManager(t) cfg := testConfig(dir, newJob("a", dir, time.Hour)) cfg.Concurrency = 1 if err := m.Apply(cfg); err != nil { t.Fatal(err) } // Hold a slot from the old semaphore, then resize. release, err := m.acquire(context.Background()) if err != nil { t.Fatal(err) } cfg2 := testConfig(dir, newJob("a", dir, time.Hour)) cfg2.Concurrency = 4 if err := m.Apply(cfg2); err != nil { t.Fatal(err) } release() // must return the slot to the channel it came from, not the new one // The new semaphore should have its full capacity available. var releases []func() for i := 0; i < 4; i++ { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) r, err := m.acquire(ctx) cancel() if err != nil { t.Fatalf("acquire %d/4 blocked after resize: %v", i+1, err) } releases = append(releases, r) } for _, r := range releases { r() } } func TestApplyRejectsUnusableWorkDir(t *testing.T) { m, dir := newTestManager(t) // A regular file cannot host the mirror directories. blocked := filepath.Join(dir, "not-a-dir") if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil { t.Fatal(err) } if err := m.Apply(testConfig(blocked, newJob("a", dir, time.Hour))); err == nil { t.Fatal("want an error for a work_dir that cannot be created") } } func TestBackoff(t *testing.T) { const interval, max = time.Minute, 30 * time.Minute cases := []struct { failures int want time.Duration }{ {0, time.Minute}, {1, time.Minute}, {2, 2 * time.Minute}, {3, 4 * time.Minute}, {4, 8 * time.Minute}, {6, 30 * time.Minute}, // capped {100, 30 * time.Minute}, } for _, tc := range cases { if got := backoff(interval, max, tc.failures); got != tc.want { t.Errorf("backoff(failures=%d) = %s, want %s", tc.failures, got, tc.want) } } } func TestStaggerIsBounded(t *testing.T) { if got := stagger(0); got != 0 { t.Errorf("the first repo should start immediately, got %s", got) } if got := stagger(3); got != 750*time.Millisecond { t.Errorf("stagger(3) = %s, want 750ms", got) } if got := stagger(1000); got != 15*time.Second { t.Errorf("stagger should be capped at 15s, got %s", got) } }