package daemon
import (
"strings"
"testing"
"time"
)
func TestSessionRegistry_Register(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Register a session
session := &Session{
Code: "test-1",
OverlayPath: "/tmp/overlay.sock",
ProjectPath: "/home/user/project",
Command: "claude",
Args: []string{"--model", "opus"},
StartedAt: time.Now(),
Status: SessionStatusActive,
LastSeen: time.Now(),
}
err := registry.Register(session)
if err != nil {
t.Fatalf("Register() error = %v", err)
}
// Verify registration
got, found := registry.Get("test-1")
if !found {
t.Fatal("Get() returned false, expected true")
}
if got.Code != session.Code {
t.Errorf("Get() Code = %v, want %v", got.Code, session.Code)
}
if got.Command != session.Command {
t.Errorf("Get() Command = %v, want %v", got.Command, session.Command)
}
}
func TestSessionRegistry_Register_Duplicate(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
session := &Session{
Code: "test-1",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
err := registry.Register(session)
if err != nil {
t.Fatalf("First Register() error = %v", err)
}
// Try to register duplicate
session2 := &Session{
Code: "test-1",
Command: "copilot",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
err = registry.Register(session2)
if err == nil {
t.Error("Second Register() should return error for duplicate code")
}
}
func TestSessionRegistry_Register_EmptyCode(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
session := &Session{
Code: "",
Command: "claude",
}
err := registry.Register(session)
if err == nil {
t.Error("Register() should return error for empty code")
}
}
func TestSessionRegistry_Unregister(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
session := &Session{
Code: "test-1",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
err := registry.Register(session)
if err != nil {
t.Fatalf("Register() error = %v", err)
}
// Unregister
err = registry.Unregister("test-1")
if err != nil {
t.Fatalf("Unregister() error = %v", err)
}
// Verify removal
_, found := registry.Get("test-1")
if found {
t.Error("Get() returned true after Unregister, expected false")
}
}
func TestSessionRegistry_Unregister_NotFound(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
err := registry.Unregister("nonexistent")
if err == nil {
t.Error("Unregister() should return error for nonexistent code")
}
}
func TestSessionRegistry_Heartbeat(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
initialTime := time.Now().Add(-time.Minute)
session := &Session{
Code: "test-1",
Command: "claude",
StartedAt: initialTime,
Status: SessionStatusActive,
LastSeen: initialTime,
}
err := registry.Register(session)
if err != nil {
t.Fatalf("Register() error = %v", err)
}
// Send heartbeat
time.Sleep(10 * time.Millisecond) // Ensure time passes
err = registry.Heartbeat("test-1")
if err != nil {
t.Fatalf("Heartbeat() error = %v", err)
}
// Verify LastSeen updated
got, _ := registry.Get("test-1")
if !got.LastSeen.After(initialTime) {
t.Error("Heartbeat() did not update LastSeen")
}
}
func TestSessionRegistry_Heartbeat_NotFound(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
err := registry.Heartbeat("nonexistent")
if err == nil {
t.Error("Heartbeat() should return error for nonexistent code")
}
}
func TestSessionRegistry_List(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Register multiple sessions
for i, code := range []string{"test-1", "test-2", "test-3"} {
session := &Session{
Code: code,
ProjectPath: "/project" + string(rune('1'+i)),
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
_ = registry.Register(session)
}
sessions := registry.List("", true) // global list
if len(sessions) != 3 {
t.Errorf("List() returned %d sessions, want 3", len(sessions))
}
}
func TestSessionRegistry_ListByProject(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Register sessions in different projects
session1 := &Session{
Code: "test-1",
ProjectPath: "/home/user/project-a",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
session2 := &Session{
Code: "test-2",
ProjectPath: "/home/user/project-a",
Command: "copilot",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
session3 := &Session{
Code: "test-3",
ProjectPath: "/home/user/project-b",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
_ = registry.Register(session1)
_ = registry.Register(session2)
_ = registry.Register(session3)
// List sessions in project-a
sessions := registry.List("/home/user/project-a", false)
if len(sessions) != 2 {
t.Errorf("List() for project-a returned %d sessions, want 2", len(sessions))
}
// List sessions in project-b
sessions = registry.List("/home/user/project-b", false)
if len(sessions) != 1 {
t.Errorf("List() for project-b returned %d sessions, want 1", len(sessions))
}
// List sessions in nonexistent project
sessions = registry.List("/home/user/project-c", false)
if len(sessions) != 0 {
t.Errorf("List() for project-c returned %d sessions, want 0", len(sessions))
}
}
func TestSessionRegistry_ActiveCount(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
if registry.ActiveCount() != 0 {
t.Errorf("ActiveCount() = %d, want 0", registry.ActiveCount())
}
session := &Session{
Code: "test-1",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
_ = registry.Register(session)
if registry.ActiveCount() != 1 {
t.Errorf("ActiveCount() = %d, want 1", registry.ActiveCount())
}
_ = registry.Unregister("test-1")
if registry.ActiveCount() != 0 {
t.Errorf("ActiveCount() = %d, want 0", registry.ActiveCount())
}
}
func TestGenerateSessionCode(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Generate first code
code1 := registry.GenerateSessionCode("claude")
if code1 != "claude-1" {
t.Errorf("GenerateSessionCode() = %v, want claude-1", code1)
}
// Register it
session := &Session{
Code: code1,
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
_ = registry.Register(session)
// Generate second code
code2 := registry.GenerateSessionCode("claude")
if code2 != "claude-2" {
t.Errorf("GenerateSessionCode() = %v, want claude-2", code2)
}
// Generate code for different command
code3 := registry.GenerateSessionCode("copilot")
if code3 != "copilot-1" {
t.Errorf("GenerateSessionCode() = %v, want copilot-1", code3)
}
}
func TestSessionRegistry_Info(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Add some sessions
session := &Session{
Code: "test-1",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
_ = registry.Register(session)
info := registry.Info()
if info.ActiveCount != 1 {
t.Errorf("Info() ActiveCount = %d, want 1", info.ActiveCount)
}
if info.TotalRegistered != 1 {
t.Errorf("Info() TotalRegistered = %d, want 1", info.TotalRegistered)
}
}
func TestSession_GetStatus(t *testing.T) {
session := &Session{
Code: "test-1",
Status: SessionStatusActive,
}
if session.GetStatus() != SessionStatusActive {
t.Errorf("GetStatus() = %v, want active", session.GetStatus())
}
session.SetStatus(SessionStatusDisconnected)
if session.GetStatus() != SessionStatusDisconnected {
t.Errorf("GetStatus() = %v, want disconnected", session.GetStatus())
}
}
func TestSession_IsActive(t *testing.T) {
session := &Session{
Code: "test-1",
Status: SessionStatusActive,
}
if !session.IsActive() {
t.Error("IsActive() = false, want true")
}
session.SetStatus(SessionStatusDisconnected)
if session.IsActive() {
t.Error("IsActive() = true, want false")
}
}
func TestSession_UpdateLastSeen(t *testing.T) {
oldTime := time.Now().Add(-time.Hour)
session := &Session{
Code: "test-1",
Status: SessionStatusDisconnected,
LastSeen: oldTime,
}
session.UpdateLastSeen()
if !session.LastSeen.After(oldTime) {
t.Error("UpdateLastSeen() did not update LastSeen")
}
if session.GetStatus() != SessionStatusActive {
t.Error("UpdateLastSeen() did not set status to active")
}
}
func TestSession_ToJSON(t *testing.T) {
now := time.Now()
session := &Session{
Code: "test-1",
OverlayPath: "/tmp/overlay.sock",
ProjectPath: "/project",
Command: "claude",
Args: []string{"--model", "opus"},
StartedAt: now,
Status: SessionStatusActive,
LastSeen: now,
}
json := session.ToJSON()
if json["code"] != "test-1" {
t.Errorf("ToJSON() code = %v, want test-1", json["code"])
}
if json["command"] != "claude" {
t.Errorf("ToJSON() command = %v, want claude", json["command"])
}
if json["status"] != "active" {
t.Errorf("ToJSON() status = %v, want active", json["status"])
}
}
func TestSessionRegistry_FindByDirectory(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Register session for /home/user/project
session := &Session{
Code: "proj-1",
ProjectPath: "/home/user/project",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
LastSeen: time.Now(),
}
_ = registry.Register(session)
tests := []struct {
name string
directory string
wantCode string
wantFound bool
}{
{
name: "exact match",
directory: "/home/user/project",
wantCode: "proj-1",
wantFound: true,
},
{
name: "subdirectory match",
directory: "/home/user/project/src",
wantCode: "proj-1",
wantFound: true,
},
{
name: "deep subdirectory match",
directory: "/home/user/project/src/internal/foo",
wantCode: "proj-1",
wantFound: true,
},
{
name: "no match - parent directory",
directory: "/home/user",
wantCode: "",
wantFound: false,
},
{
name: "no match - sibling directory",
directory: "/home/user/other-project",
wantCode: "",
wantFound: false,
},
{
name: "no match - similar prefix but not path boundary",
directory: "/home/user/project-backup",
wantCode: "",
wantFound: false,
},
{
name: "empty directory",
directory: "",
wantCode: "",
wantFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found, ok := registry.FindByDirectory(tt.directory)
if ok != tt.wantFound {
t.Errorf("FindByDirectory(%q) found = %v, want %v", tt.directory, ok, tt.wantFound)
return
}
if ok && found.Code != tt.wantCode {
t.Errorf("FindByDirectory(%q) code = %v, want %v", tt.directory, found.Code, tt.wantCode)
}
})
}
}
func TestSessionRegistry_FindByDirectory_MostSpecificMatch(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Register sessions at different depths
session1 := &Session{
Code: "root-1",
ProjectPath: "/home/user",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
LastSeen: time.Now(),
}
session2 := &Session{
Code: "proj-1",
ProjectPath: "/home/user/project",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
LastSeen: time.Now(),
}
_ = registry.Register(session1)
_ = registry.Register(session2)
// Should find the most specific (deepest) match
found, ok := registry.FindByDirectory("/home/user/project/src")
if !ok {
t.Fatal("FindByDirectory should find a session")
}
if found.Code != "proj-1" {
t.Errorf("FindByDirectory should find most specific match, got %v want proj-1", found.Code)
}
// But exact match to parent should find parent
found, ok = registry.FindByDirectory("/home/user")
if !ok {
t.Fatal("FindByDirectory should find a session")
}
if found.Code != "root-1" {
t.Errorf("FindByDirectory should find root session, got %v want root-1", found.Code)
}
}
func TestSessionRegistry_FindByDirectory_OnlyActiveSession(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Register an inactive session
session := &Session{
Code: "inactive-1",
ProjectPath: "/home/user/project",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusDisconnected, // Not active
LastSeen: time.Now(),
}
_ = registry.Register(session)
// Should not find disconnected session
_, ok := registry.FindByDirectory("/home/user/project")
if ok {
t.Error("FindByDirectory should not find disconnected sessions")
}
}
func TestSessionRegistry_ListActive(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
// Register active and disconnected sessions
activeSession := &Session{
Code: "active-1",
ProjectPath: "/project",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
LastSeen: time.Now(),
}
disconnectedSession := &Session{
Code: "disconnected-1",
ProjectPath: "/project2",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusDisconnected,
LastSeen: time.Now(),
}
_ = registry.Register(activeSession)
_ = registry.Register(disconnectedSession)
active := registry.ListActive("", true)
if len(active) != 1 {
t.Errorf("ListActive() returned %d sessions, want 1", len(active))
}
if len(active) > 0 && active[0].Code != "active-1" {
t.Errorf("ListActive() returned wrong session: %s", active[0].Code)
}
}
func TestSessionRegistry_TotalRegistered(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
if registry.TotalRegistered() != 0 {
t.Errorf("TotalRegistered() = %d, want 0", registry.TotalRegistered())
}
session := &Session{
Code: "test-1",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
_ = registry.Register(session)
if registry.TotalRegistered() != 1 {
t.Errorf("TotalRegistered() = %d, want 1", registry.TotalRegistered())
}
}
func TestSessionRegistry_TotalUnregistered(t *testing.T) {
registry := NewSessionRegistry(60 * time.Second)
if registry.TotalUnregistered() != 0 {
t.Errorf("TotalUnregistered() = %d, want 0", registry.TotalUnregistered())
}
session := &Session{
Code: "test-1",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
}
_ = registry.Register(session)
_ = registry.Unregister("test-1")
if registry.TotalUnregistered() != 1 {
t.Errorf("TotalUnregistered() = %d, want 1", registry.TotalUnregistered())
}
}
func TestSessionRegistry_CheckHeartbeats(t *testing.T) {
registry := NewSessionRegistry(100 * time.Millisecond) // Very short timeout
staleSession := &Session{
Code: "stale-1",
ProjectPath: "/project",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
LastSeen: time.Now().Add(-1 * time.Hour), // Very old
}
freshSession := &Session{
Code: "fresh-1",
ProjectPath: "/project2",
Command: "claude",
StartedAt: time.Now(),
Status: SessionStatusActive,
LastSeen: time.Now(),
}
_ = registry.Register(staleSession)
_ = registry.Register(freshSession)
// Check heartbeats should mark stale session as disconnected
registry.CheckHeartbeats()
stale, _ := registry.Get("stale-1")
if stale.GetStatus() != SessionStatusDisconnected {
t.Errorf("Stale session should be disconnected, got status %s", stale.GetStatus())
}
fresh, _ := registry.Get("fresh-1")
if fresh.GetStatus() != SessionStatusActive {
t.Errorf("Fresh session should still be active, got status %s", fresh.GetStatus())
}
}
func TestSession_MarshalJSON(t *testing.T) {
now := time.Now()
session := &Session{
Code: "test-marshal-1",
OverlayPath: "/tmp/overlay.sock",
ProjectPath: "/home/user/project",
Command: "claude",
Args: []string{"--model", "opus"},
StartedAt: now,
Status: SessionStatusActive,
LastSeen: now,
}
data, err := session.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON() error = %v", err)
}
if len(data) == 0 {
t.Error("MarshalJSON() returned empty data")
}
// Verify it contains expected fields
jsonStr := string(data)
expectedFields := []string{
`"code":"test-marshal-1"`,
`"overlay_path":"/tmp/overlay.sock"`,
`"project_path":"/home/user/project"`,
`"command":"claude"`,
`"status":"active"`,
}
for _, field := range expectedFields {
if !strings.Contains(jsonStr, field) {
t.Errorf("MarshalJSON() missing field: %s", field)
}
}
}