fix(tests): isolate test symlink manipulation via t.TempDir() to prevent overwriting ~/.claude
Le test TestKillAndRecreatePoolSessions appelait executeSwitch() qui faisait flipSymlink() sur le VRAI $HOME via os.UserHomeDir(). Resultat: ~/.claude etait repointe vers une cible /tmp/... qui disparaissait au reboot, rendant Claude Code inutilisable apres redemarrage. Fix: - Ajout du champ AccountSwitcher.homeDir (override pour tests). - Nouveau helper resolveHomeDir() qui retourne homeDir si defini, sinon os.UserHomeDir(). - flipSymlink() et resumeContextDir() utilisent maintenant resolveHomeDir(). - Le test TestKillAndRecreatePoolSessions assigne a.homeDir = t.TempDir() avant executeSwitch(). Verifie: go test ./... passe et /home/ubuntu/.claude reste intact.
This commit is contained in:
parent
133165b432
commit
9f7da110d2
4 changed files with 447 additions and 0 deletions
166
internal/switcher/account_switcher_test.go
Normal file
166
internal/switcher/account_switcher_test.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package switcher
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
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(_, _ string) error { 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue