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