package cmdlimit import ( "context" "sync" "testing" "time" ) func TestNew(t *testing.T) { t.Run("default config", func(t *testing.T) { l := New(Config{}) if l.cfg.MaxConcurrentPerProject != 5 { t.Errorf("MaxConcurrentPerProject = %d, want 5", l.cfg.MaxConcurrentPerProject) } if l.cfg.MaxConcurrentTotal != 20 { t.Errorf("MaxConcurrentTotal = %d, want 20", l.cfg.MaxConcurrentTotal) } if l.cfg.CommandTimeout != 30*time.Minute { t.Errorf("CommandTimeout = %v, want 30m", l.cfg.CommandTimeout) } }) t.Run("custom config", func(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 10, MaxConcurrentTotal: 50, CommandTimeout: time.Hour, }) if l.cfg.MaxConcurrentPerProject != 10 { t.Errorf("MaxConcurrentPerProject = %d, want 10", l.cfg.MaxConcurrentPerProject) } if l.cfg.MaxConcurrentTotal != 50 { t.Errorf("MaxConcurrentTotal = %d, want 50", l.cfg.MaxConcurrentTotal) } }) } func TestDefaultConfig(t *testing.T) { cfg := DefaultConfig() if cfg.MaxConcurrentPerProject != 5 { t.Errorf("MaxConcurrentPerProject = %d, want 5", cfg.MaxConcurrentPerProject) } if cfg.MaxConcurrentTotal != 20 { t.Errorf("MaxConcurrentTotal = %d, want 20", cfg.MaxConcurrentTotal) } if cfg.CommandTimeout != 30*time.Minute { t.Errorf("CommandTimeout = %v, want 30m", cfg.CommandTimeout) } } func TestAcquire(t *testing.T) { t.Run("allows commands within limit", func(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 3, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() // Should allow 3 commands for project-a releases := make([]func(), 0) for i := 0; i < 3; i++ { release, err := l.Acquire(ctx, "project-a", "cmd-"+string(rune('a'+i))) if err != nil { t.Fatalf("Acquire %d failed: %v", i, err) } releases = append(releases, release) } // Clean up for _, r := range releases { r() } }) t.Run("rejects when per-project limit reached", func(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 2, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() // Acquire 2 slots release1, _ := l.Acquire(ctx, "project-a", "cmd-1") defer release1() release2, _ := l.Acquire(ctx, "project-a", "cmd-2") defer release2() // Third should fail _, err := l.Acquire(ctx, "project-a", "cmd-3") if err != ErrLimitExceeded { t.Errorf("Acquire() error = %v, want ErrLimitExceeded", err) } }) t.Run("allows other projects when one is at limit", func(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 1, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() // Fill project-a releaseA, _ := l.Acquire(ctx, "project-a", "cmd-a") defer releaseA() // project-b should still work releaseB, err := l.Acquire(ctx, "project-b", "cmd-b") if err != nil { t.Errorf("Acquire(project-b) error = %v, want nil", err) } defer releaseB() }) t.Run("rejects when total limit reached", func(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 5, MaxConcurrentTotal: 3, CommandTimeout: time.Minute, }) ctx := context.Background() // Fill total limit across different projects release1, _ := l.Acquire(ctx, "project-a", "cmd-1") defer release1() release2, _ := l.Acquire(ctx, "project-b", "cmd-2") defer release2() release3, _ := l.Acquire(ctx, "project-c", "cmd-3") defer release3() // Fourth should fail even for new project _, err := l.Acquire(ctx, "project-d", "cmd-4") if err != ErrLimitExceeded { t.Errorf("Acquire() error = %v, want ErrLimitExceeded", err) } }) t.Run("release allows new commands", func(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 1, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() // Acquire and release release1, _ := l.Acquire(ctx, "project-a", "cmd-1") release1() // Should be able to acquire again release2, err := l.Acquire(ctx, "project-a", "cmd-2") if err != nil { t.Errorf("Acquire() after release error = %v, want nil", err) } release2() }) } func TestStats(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 5, MaxConcurrentTotal: 20, CommandTimeout: time.Minute, }) ctx := context.Background() release1, _ := l.Acquire(ctx, "project-a", "cmd-1") release2, _ := l.Acquire(ctx, "project-a", "cmd-2") release3, _ := l.Acquire(ctx, "project-b", "cmd-3") stats := l.Stats() if stats.TotalActive != 3 { t.Errorf("TotalActive = %d, want 3", stats.TotalActive) } if stats.MaxTotal != 20 { t.Errorf("MaxTotal = %d, want 20", stats.MaxTotal) } if stats.ProjectCounts["project-a"] != 2 { t.Errorf("ProjectCounts[project-a] = %d, want 2", stats.ProjectCounts["project-a"]) } if stats.ProjectCounts["project-b"] != 1 { t.Errorf("ProjectCounts[project-b] = %d, want 1", stats.ProjectCounts["project-b"]) } if len(stats.ActiveCommandIDs) != 3 { t.Errorf("ActiveCommandIDs length = %d, want 3", len(stats.ActiveCommandIDs)) } release1() release2() release3() } func TestIsProjectAtLimit(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 2, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() if l.IsProjectAtLimit("project-a") { t.Error("IsProjectAtLimit should be false initially") } release1, _ := l.Acquire(ctx, "project-a", "cmd-1") release2, _ := l.Acquire(ctx, "project-a", "cmd-2") if !l.IsProjectAtLimit("project-a") { t.Error("IsProjectAtLimit should be true after reaching limit") } release1() release2() } func TestIsTotalAtLimit(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 5, MaxConcurrentTotal: 2, CommandTimeout: time.Minute, }) ctx := context.Background() if l.IsTotalAtLimit() { t.Error("IsTotalAtLimit should be false initially") } release1, _ := l.Acquire(ctx, "project-a", "cmd-1") release2, _ := l.Acquire(ctx, "project-b", "cmd-2") if !l.IsTotalAtLimit() { t.Error("IsTotalAtLimit should be true after reaching limit") } release1() release2() } func TestActiveCount(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 5, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() if l.ActiveCount("project-a") != 0 { t.Error("ActiveCount should be 0 initially") } release1, _ := l.Acquire(ctx, "project-a", "cmd-1") if l.ActiveCount("project-a") != 1 { t.Errorf("ActiveCount = %d, want 1", l.ActiveCount("project-a")) } release1() if l.ActiveCount("project-a") != 0 { t.Errorf("ActiveCount after release = %d, want 0", l.ActiveCount("project-a")) } } func TestTotalActiveCount(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 5, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() if l.TotalActiveCount() != 0 { t.Error("TotalActiveCount should be 0 initially") } release1, _ := l.Acquire(ctx, "project-a", "cmd-1") release2, _ := l.Acquire(ctx, "project-b", "cmd-2") if l.TotalActiveCount() != 2 { t.Errorf("TotalActiveCount = %d, want 2", l.TotalActiveCount()) } release1() release2() if l.TotalActiveCount() != 0 { t.Errorf("TotalActiveCount after release = %d, want 0", l.TotalActiveCount()) } } func TestConcurrentAccess(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 10, MaxConcurrentTotal: 100, CommandTimeout: time.Minute, }) ctx := context.Background() var wg sync.WaitGroup var successCount, failCount int64 var mu sync.Mutex // Spawn many goroutines trying to acquire for i := 0; i < 150; i++ { wg.Add(1) go func(idx int) { defer wg.Done() release, err := l.Acquire(ctx, "project-a", "cmd-"+string(rune(idx))) mu.Lock() if err == nil { successCount++ mu.Unlock() time.Sleep(10 * time.Millisecond) release() } else { failCount++ mu.Unlock() } }(i) } wg.Wait() // Should have had some successes and some failures if successCount == 0 { t.Error("Expected some successful acquires") } if failCount == 0 { t.Error("Expected some failed acquires (limit exceeded)") } } func TestAutoReleaseOnTimeout(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 1, MaxConcurrentTotal: 10, CommandTimeout: 100 * time.Millisecond, }) ctx := context.Background() // Acquire a slot _, err := l.Acquire(ctx, "project-a", "cmd-1") if err != nil { t.Fatalf("Acquire failed: %v", err) } // Should be at limit if !l.IsProjectAtLimit("project-a") { t.Error("Should be at limit after acquire") } // Wait for timeout time.Sleep(150 * time.Millisecond) // Should be auto-released if l.IsProjectAtLimit("project-a") { t.Error("Should not be at limit after auto-release") } } func TestDoubleRelease(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 5, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx := context.Background() release, _ := l.Acquire(ctx, "project-a", "cmd-1") // Release twice - should not panic or cause issues release() release() // Count should be 0, not negative if l.ActiveCount("project-a") != 0 { t.Errorf("ActiveCount after double release = %d, want 0", l.ActiveCount("project-a")) } } func TestContextCancellation(t *testing.T) { l := New(Config{ MaxConcurrentPerProject: 1, MaxConcurrentTotal: 10, CommandTimeout: time.Minute, }) ctx, cancel := context.WithCancel(context.Background()) // Acquire a slot _, err := l.Acquire(ctx, "project-a", "cmd-1") if err != nil { t.Fatalf("Acquire failed: %v", err) } // Cancel the context cancel() // Give goroutine time to process time.Sleep(50 * time.Millisecond) // Should be auto-released due to context cancellation if l.IsProjectAtLimit("project-a") { t.Error("Should be released after context cancellation") } }