claude-failover/internal/quota/monitor_test.go
Ubuntu 133165b432 feat(quota): Phase 2.3 — QuotaMonitor (scraping pane tmux)
- internal/quota: SwitchRequest, poll() toutes les 30s
- isQuotaExhausted: 5 patterns (hit limit, rate limit, quota exceeded, etc.)
- extractResetTime: regex pour "resets 8pm / resets at 11:30pm / resets in N min"
- Seuils: >=2 sessions pool OU >=1 session dedicated → SwitchRequest channel(1)
- 5 tests: patterns, reset time, trigger 2 pool, trigger 1 dedicated, no-trigger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:31:04 +00:00

152 lines
4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package quota
import (
"testing"
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
)
// mockTmux for quota tests.
type mockTmux struct {
sessions map[string]bool
paneOutput map[string]string
}
func newMockTmux() *mockTmux {
return &mockTmux{
sessions: make(map[string]bool),
paneOutput: make(map[string]string),
}
}
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
func (m *mockTmux) KillSession(_ string) error { return nil }
func (m *mockTmux) SendKeys(_, _ string) error { return nil }
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
// TestIsQuotaExhausted verifies pattern matching on pane output.
func TestIsQuotaExhausted(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"You've hit your limit for Claude Pro.", true},
{"rate limit exceeded", true},
{"quota exceeded for this period", true},
{"Usage limit reached", true},
{"Too many requests", true},
{"Some normal output ", false},
{" ", false},
{"still running 5s · ", false},
}
for _, c := range cases {
if got := isQuotaExhausted(c.input); got != c.want {
t.Errorf("isQuotaExhausted(%q) = %v, want %v", c.input, got, c.want)
}
}
}
// TestExtractResetTime parses various reset time formats.
func TestExtractResetTime(t *testing.T) {
cases := []struct {
input string
want string
}{
{"Usage resets 8pm", "8pm"},
{"Your quota resets at 11:30pm", "11:30pm"},
{"resets in 45 minutes", "in 45 minutes"},
{"resets in 2 hours", "in 2 hours"},
{"no reset info here", ""},
}
for _, c := range cases {
if got := extractResetTime(c.input); got != c.want {
t.Errorf("extractResetTime(%q) = %q, want %q", c.input, got, c.want)
}
}
}
// TestPollTriggersSwitchOnTwoBlockedPool verifies swap trigger for >=2 blocked pool sessions.
func TestPollTriggersSwitchOnTwoBlockedPool(t *testing.T) {
tc := newMockTmux()
tc.sessions["ccl-auto-0"] = true
tc.sessions["ccl-auto-1"] = true
tc.paneOutput["ccl-auto-0"] = "You've hit your limit for Claude Pro."
tc.paneOutput["ccl-auto-1"] = "rate limit exceeded"
s := state.New("")
s.SetActiveAccount("compte1")
cfg := &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Max: 2},
},
}
m := New(tc, s, cfg)
m.poll()
select {
case req := <-m.switchCh:
if req.From != "compte1" {
t.Errorf("expected From=compte1, got %q", req.From)
}
default:
t.Fatal("expected SwitchRequest on channel")
}
}
// TestPollTriggersSwitchOnOneBlockedInteractive verifies swap trigger for >=1 dedicated session.
func TestPollTriggersSwitchOnOneBlockedInteractive(t *testing.T) {
tc := newMockTmux()
tc.sessions["my-session"] = true
tc.paneOutput["my-session"] = "quota exceeded"
s := state.New("")
s.SetActiveAccount("compte1")
cfg := &config.Config{
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "my-session"}},
Autonomous: config.AutonomousConfig{Max: 0},
},
}
m := New(tc, s, cfg)
m.poll()
select {
case req := <-m.switchCh:
if req.From != "compte1" {
t.Errorf("expected From=compte1, got %q", req.From)
}
default:
t.Fatal("expected SwitchRequest on channel")
}
}
// TestPollNoTriggerWhenBelowThreshold verifies no swap for a single blocked pool session.
func TestPollNoTriggerWhenBelowThreshold(t *testing.T) {
tc := newMockTmux()
tc.sessions["ccl-auto-0"] = true
tc.sessions["ccl-auto-1"] = true
tc.paneOutput["ccl-auto-0"] = "rate limit exceeded"
tc.paneOutput["ccl-auto-1"] = " " // fine
s := state.New("")
cfg := &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Max: 2},
},
}
m := New(tc, s, cfg)
m.poll()
select {
case req := <-m.switchCh:
t.Errorf("unexpected SwitchRequest: %+v", req)
default:
// Correct: only 1 blocked pool session, threshold is 2.
}
}