Files
syncbot/internal/manager/manager_test.go
T
iceBear67 a006483bbc
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s
init
2026-08-14 07:13:22 +00:00

244 lines
6.6 KiB
Go

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)
}
}