claude-failover/internal/switcher/account_switcher_test.go

219 lines
6.5 KiB
Go
Raw Normal View History

package switcher
import (
"strings"
"testing"
"time"
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
"forge.secuaas.ovh/olivier/claude-failover/internal/quota"
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
)
// mockTmux for switcher tests.
type mockTmux struct {
sessions map[string]bool
paneOutput map[string]string
killCalls []string
createCalls []string
sendKeyCalls []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
m.createCalls = append(m.createCalls, name)
return nil
}
func (m *mockTmux) KillSession(name string) error {
delete(m.sessions, name)
m.killCalls = append(m.killCalls, name)
return nil
}
func (m *mockTmux) SendKeys(session, keys string) error {
m.sendKeyCalls = append(m.sendKeyCalls, session+":"+keys)
return nil
}
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
// TestFindTargetAccount returns the first account that differs from current.
func TestFindTargetAccount(t *testing.T) {
tc := newMockTmux()
s := state.New("")
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Priority: 1},
{Name: "compte2", Priority: 2},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
target := a.findTargetAccount("compte1")
if target == nil || target.Name != "compte2" {
t.Errorf("expected compte2, got %v", target)
}
}
// TestFindTargetAccountSingleAccount returns nil when only one account exists.
func TestFindTargetAccountSingleAccount(t *testing.T) {
tc := newMockTmux()
s := state.New("")
cfg := &config.Config{
Accounts: []config.AccountConfig{{Name: "solo"}},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
if got := a.findTargetAccount("solo"); got != nil {
t.Errorf("expected nil for single account, got %v", got)
}
}
// TestExtractResumeUUID parses UUID from pane output.
func TestExtractResumeUUID(t *testing.T) {
input := "$ claude --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890 --model sonnet"
got := extractResumeUUID(input)
want := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
if got != want {
t.Errorf("expected %q, got %q", want, got)
}
}
// TestExtractResumeUUIDMissing returns empty string when no UUID present.
func TestExtractResumeUUIDMissing(t *testing.T) {
if got := extractResumeUUID("no uuid here"); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
// TestTimeUntilReset parses minute and hour formats correctly.
func TestTimeUntilReset(t *testing.T) {
cases := []struct {
input string
want time.Duration
}{
{"in 45 minutes", 45 * time.Minute},
{"in 2 hours", 2 * time.Hour},
{"in 1 hour", 1 * time.Hour},
{"", 2 * time.Hour},
{"8pm", 2 * time.Hour}, // fallback for unrecognised formats
}
for _, c := range cases {
if got := timeUntilReset(c.input); got != c.want {
t.Errorf("timeUntilReset(%q) = %v, want %v", c.input, got, c.want)
}
}
}
// TestKillAndRecreatePoolSessions verifies that executeSwitch restarts sessions.
func TestKillAndRecreatePoolSessions(t *testing.T) {
tc := newMockTmux()
tc.sessions["ccl-auto-0"] = true
tc.sessions["ccl-auto-1"] = true
tc.sessions["dedicated-1"] = true
s := state.New("")
s.SetActiveAccount("compte1")
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Home: t.TempDir()},
{Name: "compte2", Home: t.TempDir()},
},
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}},
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 2, Max: 2},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
// CRITICAL: isolate symlink manipulation in a tmpdir so the test never
// touches the real ~/.claude (regression: a reboot used to leave Claude
// Code unusable because the test had repointed ~/.claude to /tmp/...).
a.homeDir = t.TempDir()
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
// Active account must have changed.
if got := s.ActiveAccount(); got != "compte2" {
t.Errorf("expected active account compte2, got %q", got)
}
// All old sessions must have been killed.
for _, name := range []string{"ccl-auto-0", "ccl-auto-1", "dedicated-1"} {
found := false
for _, k := range tc.killCalls {
if k == name {
found = true
break
}
}
if !found {
t.Errorf("expected %q to be killed", name)
}
}
// Min pool sessions must be recreated.
recreated := map[string]bool{}
for _, c := range tc.createCalls {
recreated[c] = true
}
if !recreated["ccl-auto-0"] || !recreated["ccl-auto-1"] {
t.Errorf("expected autonomous sessions recreated; createCalls=%v", tc.createCalls)
}
}
// TestDedicatedRelaunchAfterSwap verifies that a dedicated session is
// automatically restarted with `claude --resume <uuid>` on the target
// account's home after a swap, so interactive user work is preserved.
func TestDedicatedRelaunchAfterSwap(t *testing.T) {
tc := newMockTmux()
tc.sessions["dedicated-1"] = true
// Pane shows the full resume command — saveDedicatedUUIDs will extract it.
tc.paneOutput["dedicated-1"] = "claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --dangerously-skip-permissions"
s := state.New("")
s.SetActiveAccount("compte1")
cfg := &config.Config{
Accounts: []config.AccountConfig{
{Name: "compte1", Home: "/tmp/claude-1-xxxx"},
{Name: "compte2", Home: "/tmp/claude-2-xxxx"},
},
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}},
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
},
}
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
a.homeDir = t.TempDir()
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
// The relaunch must send a resume command on the dedicated session,
// pointing CLAUDE_CONFIG_DIR at the target account's home.
var relaunch string
for _, k := range tc.sendKeyCalls {
if strings.HasPrefix(k, "dedicated-1:") && strings.Contains(k, "--resume") {
relaunch = k
break
}
}
if relaunch == "" {
t.Fatalf("expected dedicated-1 relaunch send-keys; got %v", tc.sendKeyCalls)
}
if !strings.Contains(relaunch, "CLAUDE_CONFIG_DIR=/tmp/claude-2-xxxx") {
t.Errorf("relaunch should set CLAUDE_CONFIG_DIR to target home; got %q", relaunch)
}
if !strings.Contains(relaunch, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") {
t.Errorf("relaunch should include captured UUID; got %q", relaunch)
}
}