Files
gastown/internal/cmd/seance_test.go
2026-01-21 19:30:49 -08:00

367 lines
11 KiB
Go

package cmd
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/gastown/internal/config"
)
// setupSeanceTestEnv creates a test environment with multiple accounts and sessions.
func setupSeanceTestEnv(t *testing.T) (townRoot, fakeHome string, cleanup func()) {
t.Helper()
// Create fake home directory
fakeHome = t.TempDir()
// Create town root
townRoot = t.TempDir()
// Create mayor directory structure
mayorDir := filepath.Join(townRoot, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir mayor: %v", err)
}
// Create two account config directories
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
if err := os.MkdirAll(account1Dir, 0755); err != nil {
t.Fatalf("mkdir account1: %v", err)
}
if err := os.MkdirAll(account2Dir, 0755); err != nil {
t.Fatalf("mkdir account2: %v", err)
}
// Create accounts.json pointing to both accounts
accountsCfg := &config.AccountsConfig{
Version: 1,
Default: "account1",
Accounts: map[string]config.Account{
"account1": {Email: "test1@example.com", ConfigDir: account1Dir},
"account2": {Email: "test2@example.com", ConfigDir: account2Dir},
},
}
accountsPath := filepath.Join(mayorDir, "accounts.json")
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
t.Fatalf("save accounts.json: %v", err)
}
// Create ~/.claude symlink pointing to account1 (current account)
claudeDir := filepath.Join(fakeHome, ".claude")
if err := os.Symlink(account1Dir, claudeDir); err != nil {
t.Fatalf("symlink .claude: %v", err)
}
// Set up HOME env var
oldHome := os.Getenv("HOME")
os.Setenv("HOME", fakeHome)
cleanup = func() {
os.Setenv("HOME", oldHome)
}
return townRoot, fakeHome, cleanup
}
// createTestSession creates a mock session file and index entry.
func createTestSession(t *testing.T, configDir, projectName, sessionID string) {
t.Helper()
projectDir := filepath.Join(configDir, "projects", projectName)
if err := os.MkdirAll(projectDir, 0755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
// Create session file
sessionFile := filepath.Join(projectDir, sessionID+".jsonl")
if err := os.WriteFile(sessionFile, []byte(`{"type":"test"}`), 0600); err != nil {
t.Fatalf("write session file: %v", err)
}
// Create or update sessions-index.json
indexPath := filepath.Join(projectDir, "sessions-index.json")
var index sessionsIndex
if data, err := os.ReadFile(indexPath); err == nil {
_ = json.Unmarshal(data, &index)
} else {
index.Version = 1
}
// Add session entry
entry := map[string]interface{}{
"sessionId": sessionID,
"name": "Test Session",
"lastAccessed": "2026-01-22T00:00:00Z",
}
entryJSON, _ := json.Marshal(entry)
index.Entries = append(index.Entries, entryJSON)
indexData, _ := json.MarshalIndent(index, "", " ")
if err := os.WriteFile(indexPath, indexData, 0600); err != nil {
t.Fatalf("write sessions-index.json: %v", err)
}
}
func TestFindSessionLocation(t *testing.T) {
t.Run("finds session in account1", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
createTestSession(t, account1Dir, "test-project", "session-abc123")
loc := findSessionLocation(townRoot, "session-abc123")
if loc == nil {
t.Fatal("expected to find session, got nil")
}
if loc.configDir != account1Dir {
t.Errorf("expected configDir %s, got %s", account1Dir, loc.configDir)
}
if loc.projectDir != "test-project" {
t.Errorf("expected projectDir test-project, got %s", loc.projectDir)
}
})
t.Run("finds session in account2", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
createTestSession(t, account2Dir, "other-project", "session-xyz789")
loc := findSessionLocation(townRoot, "session-xyz789")
if loc == nil {
t.Fatal("expected to find session, got nil")
}
if loc.configDir != account2Dir {
t.Errorf("expected configDir %s, got %s", account2Dir, loc.configDir)
}
if loc.projectDir != "other-project" {
t.Errorf("expected projectDir other-project, got %s", loc.projectDir)
}
})
t.Run("returns nil for nonexistent session", func(t *testing.T) {
townRoot, _, cleanup := setupSeanceTestEnv(t)
defer cleanup()
loc := findSessionLocation(townRoot, "session-notfound")
if loc != nil {
t.Errorf("expected nil for nonexistent session, got %+v", loc)
}
})
t.Run("returns nil for empty townRoot", func(t *testing.T) {
loc := findSessionLocation("", "session-abc")
if loc != nil {
t.Errorf("expected nil for empty townRoot, got %+v", loc)
}
})
}
func TestSymlinkSessionToCurrentAccount(t *testing.T) {
t.Run("creates symlink for session in other account", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
// Create session in account2 (not the current account)
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
createTestSession(t, account2Dir, "cross-project", "session-cross123")
// Call symlinkSessionToCurrentAccount
cleanupFn, err := symlinkSessionToCurrentAccount(townRoot, "session-cross123")
if err != nil {
t.Fatalf("symlinkSessionToCurrentAccount failed: %v", err)
}
if cleanupFn == nil {
t.Fatal("expected cleanup function, got nil")
}
// Verify symlink was created in current account (account1)
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
symlinkPath := filepath.Join(account1Dir, "projects", "cross-project", "session-cross123.jsonl")
info, err := os.Lstat(symlinkPath)
if err != nil {
t.Fatalf("symlink not found: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Error("expected symlink, got regular file")
}
// Verify sessions-index.json was updated
indexPath := filepath.Join(account1Dir, "projects", "cross-project", "sessions-index.json")
data, err := os.ReadFile(indexPath)
if err != nil {
t.Fatalf("reading index: %v", err)
}
var index sessionsIndex
if err := json.Unmarshal(data, &index); err != nil {
t.Fatalf("parsing index: %v", err)
}
found := false
for _, entry := range index.Entries {
var e sessionsIndexEntry
if json.Unmarshal(entry, &e) == nil && e.SessionID == "session-cross123" {
found = true
break
}
}
if !found {
t.Error("session not found in target index")
}
// Test cleanup
cleanupFn()
// Verify symlink was removed
if _, err := os.Lstat(symlinkPath); !os.IsNotExist(err) {
t.Error("symlink should have been removed after cleanup")
}
// Verify session was removed from index
data, _ = os.ReadFile(indexPath)
_ = json.Unmarshal(data, &index)
for _, entry := range index.Entries {
var e sessionsIndexEntry
if json.Unmarshal(entry, &e) == nil && e.SessionID == "session-cross123" {
t.Error("session should have been removed from index after cleanup")
}
}
})
t.Run("returns nil cleanup for session in current account", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
// Create session in account1 (the current account)
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
createTestSession(t, account1Dir, "local-project", "session-local456")
cleanupFn, err := symlinkSessionToCurrentAccount(townRoot, "session-local456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cleanupFn != nil {
t.Error("expected nil cleanup for session in current account")
}
})
t.Run("returns error for nonexistent session", func(t *testing.T) {
townRoot, _, cleanup := setupSeanceTestEnv(t)
defer cleanup()
_, err := symlinkSessionToCurrentAccount(townRoot, "session-notfound")
if err == nil {
t.Error("expected error for nonexistent session")
}
})
}
func TestCleanupOrphanedSessionSymlinks(t *testing.T) {
t.Run("removes orphaned symlinks", func(t *testing.T) {
_, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
projectDir := filepath.Join(account1Dir, "projects", "orphan-project")
if err := os.MkdirAll(projectDir, 0755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
// Create an orphaned symlink (target doesn't exist)
orphanSymlink := filepath.Join(projectDir, "orphan-session.jsonl")
nonexistentTarget := filepath.Join(fakeHome, "nonexistent", "session.jsonl")
if err := os.Symlink(nonexistentTarget, orphanSymlink); err != nil {
t.Fatalf("create orphan symlink: %v", err)
}
// Create a sessions-index.json with the orphaned entry
index := sessionsIndex{
Version: 1,
Entries: []json.RawMessage{
json.RawMessage(`{"sessionId":"orphan-session","name":"Orphan"}`),
},
}
indexPath := filepath.Join(projectDir, "sessions-index.json")
data, _ := json.MarshalIndent(index, "", " ")
if err := os.WriteFile(indexPath, data, 0600); err != nil {
t.Fatalf("write index: %v", err)
}
// Run cleanup
cleanupOrphanedSessionSymlinks()
// Verify orphan symlink was removed
if _, err := os.Lstat(orphanSymlink); !os.IsNotExist(err) {
t.Error("orphaned symlink should have been removed")
}
// Verify entry was removed from index
data, _ = os.ReadFile(indexPath)
var updatedIndex sessionsIndex
_ = json.Unmarshal(data, &updatedIndex)
if len(updatedIndex.Entries) != 0 {
t.Errorf("expected 0 entries after cleanup, got %d", len(updatedIndex.Entries))
}
})
t.Run("preserves valid symlinks", func(t *testing.T) {
_, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
// Create a real session in account2
createTestSession(t, account2Dir, "valid-project", "valid-session")
// Create project dir in account1
projectDir := filepath.Join(account1Dir, "projects", "valid-project")
if err := os.MkdirAll(projectDir, 0755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
// Create a valid symlink pointing to the real session
validSymlink := filepath.Join(projectDir, "valid-session.jsonl")
realTarget := filepath.Join(account2Dir, "projects", "valid-project", "valid-session.jsonl")
if err := os.Symlink(realTarget, validSymlink); err != nil {
t.Fatalf("create valid symlink: %v", err)
}
// Create index with valid entry
index := sessionsIndex{
Version: 1,
Entries: []json.RawMessage{
json.RawMessage(`{"sessionId":"valid-session","name":"Valid"}`),
},
}
indexPath := filepath.Join(projectDir, "sessions-index.json")
data, _ := json.MarshalIndent(index, "", " ")
if err := os.WriteFile(indexPath, data, 0600); err != nil {
t.Fatalf("write index: %v", err)
}
// Run cleanup
cleanupOrphanedSessionSymlinks()
// Verify valid symlink was preserved
if _, err := os.Lstat(validSymlink); err != nil {
t.Error("valid symlink should have been preserved")
}
// Verify entry was preserved in index
data, _ = os.ReadFile(indexPath)
var updatedIndex sessionsIndex
_ = json.Unmarshal(data, &updatedIndex)
if len(updatedIndex.Entries) != 1 {
t.Errorf("expected 1 entry preserved, got %d", len(updatedIndex.Entries))
}
})
}