package service import ( "context" "errors" "fmt" "testing" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // --------------------------------------------------------------------------- // Mock: SessionRepository // --------------------------------------------------------------------------- type mockSessionRepo struct { sessions map[string]*domain.Session err error nextID int } func (m *mockSessionRepo) Create(ctx context.Context, session *domain.Session) error { if m.err != nil { return m.err } m.nextID++ session.ID = domain.SessionID(fmt.Sprintf("test-session-%d", m.nextID)) m.sessions[string(session.ID)] = session return nil } func (m *mockSessionRepo) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) { if m.err != nil { return nil, m.err } s, ok := m.sessions[string(id)] if !ok { return nil, domain.ErrSessionNotFound } return s, nil } func (m *mockSessionRepo) GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error) { if m.err != nil { return nil, m.err } for _, s := range m.sessions { if s.ProjectID == projectID && s.Status == domain.SessionStatusActive { return s, nil } } return nil, domain.ErrSessionNotFound } func (m *mockSessionRepo) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) { if m.err != nil { return nil, m.err } var result []*domain.Session for _, s := range m.sessions { if s.ProjectID == projectID { result = append(result, s) } } return result, nil } func (m *mockSessionRepo) SetEnded(ctx context.Context, id domain.SessionID) error { if m.err != nil { return m.err } s, ok := m.sessions[string(id)] if !ok { return domain.ErrSessionNotFound } s.Status = domain.SessionStatusEnded now := time.Now() s.EndedAt = &now return nil } func (m *mockSessionRepo) TouchActivity(ctx context.Context, id domain.SessionID) error { if m.err != nil { return m.err } s, ok := m.sessions[string(id)] if !ok { return domain.ErrSessionNotFound } s.LastActivityAt = time.Now() return nil } func (m *mockSessionRepo) CleanupExpired(ctx context.Context) ([]*domain.Session, error) { if m.err != nil { return nil, m.err } var expired []*domain.Session for _, s := range m.sessions { if s.Status == domain.SessionStatusActive && time.Now().After(s.ExpiresAt) { s.Status = domain.SessionStatusExpired now := time.Now() s.EndedAt = &now expired = append(expired, s) } } return expired, nil } // --------------------------------------------------------------------------- // Mock: CheckoutRepository // --------------------------------------------------------------------------- type mockCheckoutRepo struct { checkouts map[string]*domain.Checkout err error nextID int } func (m *mockCheckoutRepo) Create(ctx context.Context, checkout *domain.Checkout) error { if m.err != nil { return m.err } m.nextID++ checkout.ID = domain.CheckoutID(fmt.Sprintf("test-checkout-%d", m.nextID)) m.checkouts[string(checkout.ID)] = checkout return nil } func (m *mockCheckoutRepo) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) { if m.err != nil { return nil, m.err } c, ok := m.checkouts[string(id)] if !ok { return nil, domain.ErrCheckoutNotFound } return c, nil } func (m *mockCheckoutRepo) GetByProjectBranch(ctx context.Context, projectID domain.ProjectID, branch string) (*domain.Checkout, error) { if m.err != nil { return nil, m.err } for _, c := range m.checkouts { if c.ProjectID == projectID && c.Branch == branch && c.Status == domain.CheckoutStatusActive { return c, nil } } return nil, domain.ErrCheckoutNotFound } func (m *mockCheckoutRepo) List(ctx context.Context, opts domain.CheckoutListOptions) ([]*domain.Checkout, error) { if m.err != nil { return nil, m.err } var result []*domain.Checkout for _, c := range m.checkouts { if opts.Status != nil && c.Status != *opts.Status { continue } result = append(result, c) } return result, nil } func (m *mockCheckoutRepo) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) { if m.err != nil { return nil, m.err } var result []*domain.Checkout for _, c := range m.checkouts { if c.ProjectID == projectID { result = append(result, c) } } return result, nil } func (m *mockCheckoutRepo) UpdateStatus(ctx context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error { if m.err != nil { return m.err } c, ok := m.checkouts[string(id)] if !ok { return domain.ErrCheckoutNotFound } c.Status = status return nil } func (m *mockCheckoutRepo) SetCheckedIn(ctx context.Context, id domain.CheckoutID, reviewTaskID string) error { if m.err != nil { return m.err } c, ok := m.checkouts[string(id)] if !ok { return domain.ErrCheckoutNotFound } c.Status = domain.CheckoutStatusCheckedIn now := time.Now() c.CheckedInAt = &now c.ReviewTaskID = reviewTaskID return nil } func (m *mockCheckoutRepo) SetReviewTask(ctx context.Context, id domain.CheckoutID, taskID string) error { if m.err != nil { return m.err } c, ok := m.checkouts[string(id)] if !ok { return domain.ErrCheckoutNotFound } c.ReviewTaskID = taskID return nil } func (m *mockCheckoutRepo) CleanupExpired(ctx context.Context) ([]int64, error) { if m.err != nil { return nil, m.err } var tokenIDs []int64 for _, c := range m.checkouts { if c.Status == domain.CheckoutStatusActive && time.Now().After(c.ExpiresAt) { c.Status = domain.CheckoutStatusExpired tokenIDs = append(tokenIDs, c.GiteaTokenID) } } return tokenIDs, nil } // --------------------------------------------------------------------------- // Mock: GitRepository // --------------------------------------------------------------------------- type mockGitRepo struct { err error deletedTokens []int64 } func (m *mockGitRepo) CreateRepo(_ context.Context, _, _ string, _ bool) (*domain.Repo, error) { return &domain.Repo{}, m.err } func (m *mockGitRepo) DeleteRepo(_ context.Context, _, _ string) error { return m.err } func (m *mockGitRepo) ListRepos(_ context.Context, _ string) ([]*domain.Repo, error) { return nil, m.err } func (m *mockGitRepo) GetRepo(_ context.Context, _, _ string) (*domain.Repo, error) { return &domain.Repo{}, m.err } func (m *mockGitRepo) AddCollaborator(_ context.Context, _, _, _, _ string) error { return m.err } func (m *mockGitRepo) RemoveCollaborator(_ context.Context, _, _, _ string) error { return m.err } func (m *mockGitRepo) AddDeployKey(_ context.Context, _, _, _, _ string, _ bool) (*domain.DeployKey, error) { return &domain.DeployKey{}, m.err } func (m *mockGitRepo) DeleteDeployKey(_ context.Context, _, _ string, _ int64) error { return m.err } func (m *mockGitRepo) CreateWebhook(_ context.Context, _, _, _, _ string, _ []string) (*domain.RepoWebhook, error) { return &domain.RepoWebhook{}, m.err } func (m *mockGitRepo) DeleteWebhook(_ context.Context, _, _ string, _ int64) error { return m.err } func (m *mockGitRepo) ListBranches(_ context.Context, _, _ string) ([]*domain.GitBranch, error) { if m.err != nil { return nil, m.err } return []*domain.GitBranch{ {Name: "main", Protected: true}, {Name: "develop", Protected: false}, }, nil } func (m *mockGitRepo) CreateBranch(_ context.Context, _, _, branchName, _ string) (*domain.GitBranch, error) { if m.err != nil { return nil, m.err } return &domain.GitBranch{Name: branchName}, nil } func (m *mockGitRepo) CreateAccessToken(_ context.Context, name string, _ []string, _ *time.Time) (*domain.GitAccessToken, error) { if m.err != nil { return nil, m.err } return &domain.GitAccessToken{ ID: 12345, Name: name, Token: "test-token", }, nil } func (m *mockGitRepo) DeleteAccessToken(_ context.Context, tokenID int64) error { m.deletedTokens = append(m.deletedTokens, tokenID) return m.err } // mockProjectRepo is defined in sdlc_service_test.go (same package). // Use newMockProjectRepo() from there. // --------------------------------------------------------------------------- // Mock: PreviewManager // --------------------------------------------------------------------------- type mockPreviewMgr struct { previews map[string]bool createErr error deleteErr error } func (m *mockPreviewMgr) CreatePreview(_ context.Context, opts port.PreviewOptions) error { if m.createErr != nil { return m.createErr } m.previews[opts.SessionID] = true return nil } func (m *mockPreviewMgr) DeletePreview(_ context.Context, sessionID string) error { if m.deleteErr != nil { return m.deleteErr } delete(m.previews, sessionID) return nil } // --------------------------------------------------------------------------- // Test helper // --------------------------------------------------------------------------- func setupTestSessionService() (*SessionService, *mockSessionRepo, *mockPreviewMgr, *mockCheckoutRepo) { sessionRepo := &mockSessionRepo{sessions: make(map[string]*domain.Session)} checkoutRepo := &mockCheckoutRepo{checkouts: make(map[string]*domain.Checkout)} gitRepo := &mockGitRepo{} projectRepo := newMockProjectRepo(&domain.Project{ ID: "test-project", Name: "test-project", PodName: "test-project-0", Status: domain.ProjectStatusRunning, }) previewMgr := &mockPreviewMgr{previews: make(map[string]bool)} checkoutSvc := NewCheckoutService(checkoutRepo, gitRepo, projectRepo, CheckoutServiceConfig{ GiteaURL: "https://git.threesix.ai", DefaultOwner: "threesix", DefaultExpiry: 24 * time.Hour, }) svc := NewSessionService(sessionRepo, checkoutSvc, projectRepo, previewMgr, SessionServiceConfig{ PreviewDomain: "preview.threesix.ai", DefaultExpiry: 24 * time.Hour, }) return svc, sessionRepo, previewMgr, checkoutRepo } // --------------------------------------------------------------------------- // Tests: CreateSession // --------------------------------------------------------------------------- func TestSessionService_CreateSession(t *testing.T) { t.Run("create_success", func(t *testing.T) { svc, sessionRepo, previewMgr, _ := setupTestSessionService() result, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "test-project", NewBranch: "feat-test", FromRef: "main", CreatedBy: "tester", }) if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify session ID is assigned. if result.Session.ID == "" { t.Error("session ID should not be empty") } // Verify preview URL is set and well-formed. if result.Session.PreviewURL == "" { t.Error("preview URL should not be empty") } if len(result.Session.PreviewHost) == 0 { t.Error("preview host should not be empty") } // Verify instructions contain useful info. if result.Instructions == "" { t.Error("instructions should not be empty") } // Verify LastActivityAt is set. if result.Session.LastActivityAt.IsZero() { t.Error("LastActivityAt should be set") } // Verify session is stored in the repo. if len(sessionRepo.sessions) != 1 { t.Errorf("session repo count = %d, want 1", len(sessionRepo.sessions)) } // Verify preview was created. if len(previewMgr.previews) != 1 { t.Errorf("preview count = %d, want 1", len(previewMgr.previews)) } // Verify session status is active. if result.Session.Status != domain.SessionStatusActive { t.Errorf("session status = %s, want %s", result.Session.Status, domain.SessionStatusActive) } // Verify branch is returned. if result.Branch == "" { t.Error("branch should not be empty") } // Verify authenticated clone URL is returned. if result.AuthenticatedCloneURL == "" { t.Error("authenticated clone URL should not be empty") } }) t.Run("create_project_not_found", func(t *testing.T) { svc, _, _, _ := setupTestSessionService() _, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "nonexistent", NewBranch: "feat-test", CreatedBy: "tester", }) if err == nil { t.Fatal("expected error for nonexistent project") } if !errors.Is(err, domain.ErrProjectNotFound) { t.Errorf("error = %v, want wrapping %v", err, domain.ErrProjectNotFound) } }) t.Run("create_project_not_running", func(t *testing.T) { svc, _, _, _ := setupTestSessionService() // Add a project with no PodName (not running). svc.projectRepo.(*mockProjectRepo).projects[domain.ProjectID("stopped-project")] = &domain.Project{ ID: "stopped-project", Name: "stopped-project", PodName: "", Status: domain.ProjectStatusPending, } _, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "stopped-project", NewBranch: "feat-test", CreatedBy: "tester", }) if err == nil { t.Fatal("expected error for project not running") } if !errors.Is(err, domain.ErrProjectNotRunning) { t.Errorf("error = %v, want %v", err, domain.ErrProjectNotRunning) } }) t.Run("create_active_session_exists", func(t *testing.T) { svc, sessionRepo, _, _ := setupTestSessionService() // Seed an active, non-expired session. sessionRepo.sessions["existing"] = &domain.Session{ ID: "existing", ProjectID: "test-project", Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), LastActivityAt: time.Now(), } _, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "test-project", NewBranch: "feat-test", CreatedBy: "tester", }) if err == nil { t.Fatal("expected error for existing active session") } if !errors.Is(err, domain.ErrSessionExists) { t.Errorf("error = %v, want %v", err, domain.ErrSessionExists) } }) t.Run("create_preview_failure_rollback", func(t *testing.T) { svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService() previewMgr.createErr = fmt.Errorf("k8s ingress failed") _, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "test-project", NewBranch: "feat-test", CreatedBy: "tester", }) if err == nil { t.Fatal("expected error for preview creation failure") } // Verify session was rolled back (marked as ended). for _, s := range sessionRepo.sessions { if s.Status == domain.SessionStatusActive { t.Error("session should have been marked ended during rollback") } } // Verify checkout was revoked during rollback. for _, c := range checkoutRepo.checkouts { if c.Status == domain.CheckoutStatusActive { t.Error("checkout should have been revoked during rollback") } } }) t.Run("create_db_failure_rollback", func(t *testing.T) { svc, sessionRepo, _, checkoutRepo := setupTestSessionService() sessionRepo.err = fmt.Errorf("db error") _, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "test-project", NewBranch: "feat-test", CreatedBy: "tester", }) if err == nil { t.Fatal("expected error for DB failure") } // The session create failed, so the checkout should have been revoked. // The checkout was created before the session record, so it exists. for _, c := range checkoutRepo.checkouts { if c.Status == domain.CheckoutStatusActive { t.Error("checkout should have been revoked after session DB failure") } } }) t.Run("create_replaces_expired_session", func(t *testing.T) { svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService() // Seed an expired active session with a checkout. expiredCheckoutID := domain.CheckoutID("expired-checkout") checkoutRepo.checkouts["expired-checkout"] = &domain.Checkout{ ID: expiredCheckoutID, ProjectID: "test-project", Branch: "old-branch", GiteaTokenID: 99999, Status: domain.CheckoutStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), // checkout not expired } sessionRepo.sessions["expired-session"] = &domain.Session{ ID: "expired-session", ProjectID: "test-project", CheckoutID: expiredCheckoutID, Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(-1 * time.Hour), // expired LastActivityAt: time.Now().Add(-2 * time.Hour), } previewMgr.previews["expired-session"] = true result, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "test-project", NewBranch: "feat-new", CreatedBy: "tester", }) if err != nil { t.Fatalf("unexpected error: %v", err) } // The new session should have been created. if result.Session.ID == "" { t.Error("new session ID should not be empty") } // The expired session should be ended. expiredSession := sessionRepo.sessions["expired-session"] if expiredSession.Status != domain.SessionStatusEnded { t.Errorf("expired session status = %s, want %s", expiredSession.Status, domain.SessionStatusEnded) } // The expired session's preview should have been deleted. if previewMgr.previews["expired-session"] { t.Error("expired session preview should have been deleted") } }) } // --------------------------------------------------------------------------- // Tests: EndSession // --------------------------------------------------------------------------- func TestSessionService_EndSession(t *testing.T) { t.Run("end_success", func(t *testing.T) { svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService() // Seed an active checkout. checkoutRepo.checkouts["checkout-1"] = &domain.Checkout{ ID: "checkout-1", ProjectID: "test-project", Branch: "feat-test", GiteaTokenID: 11111, Status: domain.CheckoutStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), } // Seed an active session. sessionRepo.sessions["session-1"] = &domain.Session{ ID: "session-1", ProjectID: "test-project", CheckoutID: "checkout-1", PodName: "test-project-0", PreviewURL: "https://abc.preview.threesix.ai", PreviewHost: "abc.preview.threesix.ai", Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), LastActivityAt: time.Now(), } previewMgr.previews["session-1"] = true result, err := svc.EndSession(context.Background(), EndSessionRequest{ SessionID: "session-1", }) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Status != domain.SessionStatusEnded { t.Errorf("result status = %s, want %s", result.Status, domain.SessionStatusEnded) } if result.SessionID != "session-1" { t.Errorf("session ID = %s, want session-1", result.SessionID) } // Verify session is marked ended in repo. session := sessionRepo.sessions["session-1"] if session.Status != domain.SessionStatusEnded { t.Errorf("session repo status = %s, want %s", session.Status, domain.SessionStatusEnded) } // Verify preview was deleted. if previewMgr.previews["session-1"] { t.Error("preview should have been deleted") } // Verify checkout was checked in (EndSession calls Checkin which marks checked_in). checkout := checkoutRepo.checkouts["checkout-1"] if checkout.Status != domain.CheckoutStatusCheckedIn { t.Errorf("checkout status = %s, want %s", checkout.Status, domain.CheckoutStatusCheckedIn) } }) t.Run("end_not_active", func(t *testing.T) { svc, sessionRepo, _, _ := setupTestSessionService() // Seed an ended session. now := time.Now() sessionRepo.sessions["ended-session"] = &domain.Session{ ID: "ended-session", ProjectID: "test-project", Status: domain.SessionStatusEnded, EndedAt: &now, } _, err := svc.EndSession(context.Background(), EndSessionRequest{ SessionID: "ended-session", }) if err == nil { t.Fatal("expected error for ended session") } if !errors.Is(err, domain.ErrSessionNotActive) { t.Errorf("error = %v, want %v", err, domain.ErrSessionNotActive) } }) t.Run("end_not_found", func(t *testing.T) { svc, _, _, _ := setupTestSessionService() _, err := svc.EndSession(context.Background(), EndSessionRequest{ SessionID: "nonexistent", }) if err == nil { t.Fatal("expected error for nonexistent session") } if !errors.Is(err, domain.ErrSessionNotFound) { t.Errorf("error = %v, want %v", err, domain.ErrSessionNotFound) } }) } // --------------------------------------------------------------------------- // Tests: ForceEnd // --------------------------------------------------------------------------- func TestSessionService_ForceEnd(t *testing.T) { t.Run("force_end_success", func(t *testing.T) { svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService() // Seed an active checkout. checkoutRepo.checkouts["checkout-f"] = &domain.Checkout{ ID: "checkout-f", ProjectID: "test-project", Branch: "feat-force", GiteaTokenID: 22222, Status: domain.CheckoutStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), } // Seed an active session. sessionRepo.sessions["session-f"] = &domain.Session{ ID: "session-f", ProjectID: "test-project", CheckoutID: "checkout-f", PodName: "test-project-0", Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), LastActivityAt: time.Now(), } previewMgr.previews["session-f"] = true err := svc.ForceEnd(context.Background(), "session-f") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify session is ended. session := sessionRepo.sessions["session-f"] if session.Status != domain.SessionStatusEnded { t.Errorf("session status = %s, want %s", session.Status, domain.SessionStatusEnded) } // Verify preview was deleted. if previewMgr.previews["session-f"] { t.Error("preview should have been deleted") } // Verify checkout was revoked (ForceEnd calls Revoke, not Checkin). checkout := checkoutRepo.checkouts["checkout-f"] if checkout.Status != domain.CheckoutStatusRevoked { t.Errorf("checkout status = %s, want %s", checkout.Status, domain.CheckoutStatusRevoked) } }) t.Run("force_end_not_active", func(t *testing.T) { svc, sessionRepo, _, _ := setupTestSessionService() // Seed an ended session. now := time.Now() sessionRepo.sessions["ended-f"] = &domain.Session{ ID: "ended-f", ProjectID: "test-project", Status: domain.SessionStatusEnded, EndedAt: &now, } err := svc.ForceEnd(context.Background(), "ended-f") if err == nil { t.Fatal("expected error for ended session") } if !errors.Is(err, domain.ErrSessionNotActive) { t.Errorf("error = %v, want %v", err, domain.ErrSessionNotActive) } }) t.Run("force_end_not_found", func(t *testing.T) { svc, _, _, _ := setupTestSessionService() err := svc.ForceEnd(context.Background(), "nonexistent") if err == nil { t.Fatal("expected error for nonexistent session") } if !errors.Is(err, domain.ErrSessionNotFound) { t.Errorf("error = %v, want %v", err, domain.ErrSessionNotFound) } }) } // --------------------------------------------------------------------------- // Tests: TouchActivity // --------------------------------------------------------------------------- func TestSessionService_TouchActivity(t *testing.T) { t.Run("touch_success", func(t *testing.T) { svc, sessionRepo, _, _ := setupTestSessionService() originalTime := time.Now().Add(-10 * time.Minute) sessionRepo.sessions["session-t"] = &domain.Session{ ID: "session-t", ProjectID: "test-project", Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), LastActivityAt: originalTime, } err := svc.TouchActivity(context.Background(), "session-t") if err != nil { t.Fatalf("unexpected error: %v", err) } session := sessionRepo.sessions["session-t"] if !session.LastActivityAt.After(originalTime) { t.Error("LastActivityAt should have been updated to a more recent time") } }) t.Run("touch_not_found", func(t *testing.T) { svc, _, _, _ := setupTestSessionService() err := svc.TouchActivity(context.Background(), "nonexistent") if err == nil { t.Fatal("expected error for nonexistent session") } if !errors.Is(err, domain.ErrSessionNotFound) { t.Errorf("error = %v, want %v", err, domain.ErrSessionNotFound) } }) t.Run("touch_repo_error", func(t *testing.T) { svc, sessionRepo, _, _ := setupTestSessionService() sessionRepo.err = fmt.Errorf("database unavailable") err := svc.TouchActivity(context.Background(), "any-id") if err == nil { t.Fatal("expected error from repo") } }) } // --------------------------------------------------------------------------- // Tests: CleanupExpired // --------------------------------------------------------------------------- func TestSessionService_CleanupExpired(t *testing.T) { t.Run("cleanup_expired", func(t *testing.T) { svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService() // Seed an expired session with a checkout and preview. checkoutRepo.checkouts["checkout-exp"] = &domain.Checkout{ ID: "checkout-exp", ProjectID: "test-project", Branch: "feat-expired", GiteaTokenID: 33333, Status: domain.CheckoutStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), // checkout not expired } sessionRepo.sessions["session-exp"] = &domain.Session{ ID: "session-exp", ProjectID: "test-project", CheckoutID: "checkout-exp", PodName: "test-project-0", Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(-1 * time.Hour), // expired 1 hour ago LastActivityAt: time.Now().Add(-2 * time.Hour), } previewMgr.previews["session-exp"] = true count, err := svc.CleanupExpired(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if count != 1 { t.Errorf("cleanup count = %d, want 1", count) } // Verify preview was deleted. if previewMgr.previews["session-exp"] { t.Error("expired session preview should have been deleted") } // Verify checkout was revoked. checkout := checkoutRepo.checkouts["checkout-exp"] if checkout.Status != domain.CheckoutStatusRevoked { t.Errorf("checkout status = %s, want %s", checkout.Status, domain.CheckoutStatusRevoked) } // Verify session was marked expired. session := sessionRepo.sessions["session-exp"] if session.Status != domain.SessionStatusExpired { t.Errorf("session status = %s, want %s", session.Status, domain.SessionStatusExpired) } }) t.Run("cleanup_no_expired", func(t *testing.T) { svc, sessionRepo, _, _ := setupTestSessionService() // Seed an active, non-expired session. sessionRepo.sessions["session-active"] = &domain.Session{ ID: "session-active", ProjectID: "test-project", Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), LastActivityAt: time.Now(), } count, err := svc.CleanupExpired(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if count != 0 { t.Errorf("cleanup count = %d, want 0", count) } }) t.Run("cleanup_repo_error", func(t *testing.T) { svc, sessionRepo, _, _ := setupTestSessionService() sessionRepo.err = fmt.Errorf("database error") _, err := svc.CleanupExpired(context.Background()) if err == nil { t.Fatal("expected error from repo") } }) t.Run("cleanup_multiple_expired", func(t *testing.T) { svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService() // Seed two expired sessions. for i, name := range []string{"multi-1", "multi-2"} { coID := domain.CheckoutID(fmt.Sprintf("co-multi-%d", i)) checkoutRepo.checkouts[string(coID)] = &domain.Checkout{ ID: coID, ProjectID: "test-project", Branch: fmt.Sprintf("feat-%s", name), GiteaTokenID: int64(40000 + i), Status: domain.CheckoutStatusActive, ExpiresAt: time.Now().Add(1 * time.Hour), } sessionRepo.sessions[name] = &domain.Session{ ID: domain.SessionID(name), ProjectID: "test-project", CheckoutID: coID, PodName: "test-project-0", Status: domain.SessionStatusActive, ExpiresAt: time.Now().Add(-1 * time.Hour), LastActivityAt: time.Now().Add(-2 * time.Hour), } previewMgr.previews[name] = true } count, err := svc.CleanupExpired(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if count != 2 { t.Errorf("cleanup count = %d, want 2", count) } // All previews should be deleted. if len(previewMgr.previews) != 0 { t.Errorf("remaining previews = %d, want 0", len(previewMgr.previews)) } }) } // --------------------------------------------------------------------------- // Tests: TeardownSession (indirect via CreateSession with expired existing) // --------------------------------------------------------------------------- func TestSessionService_TeardownSession(t *testing.T) { t.Run("teardown_revokes_token", func(t *testing.T) { svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService() // Create the first session normally. result1, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "test-project", NewBranch: "feat-first", FromRef: "main", CreatedBy: "tester", }) if err != nil { t.Fatalf("failed to create first session: %v", err) } firstSessionID := result1.Session.ID firstCheckoutID := result1.Session.CheckoutID // Verify the first session is active. if result1.Session.Status != domain.SessionStatusActive { t.Fatalf("first session status = %s, want active", result1.Session.Status) } // Now expire the first session by manipulating its ExpiresAt. sessionRepo.sessions[string(firstSessionID)].ExpiresAt = time.Now().Add(-1 * time.Hour) // Create a second session for the same project. This should trigger // teardown of the expired first session. result2, err := svc.CreateSession(context.Background(), CreateSessionRequest{ ProjectID: "test-project", NewBranch: "feat-second", FromRef: "main", CreatedBy: "tester", }) if err != nil { t.Fatalf("failed to create second session: %v", err) } // Verify the second session was created. if result2.Session.ID == "" { t.Error("second session ID should not be empty") } // Verify the first session was ended (torn down). firstSession := sessionRepo.sessions[string(firstSessionID)] if firstSession.Status != domain.SessionStatusEnded { t.Errorf("first session status = %s, want %s", firstSession.Status, domain.SessionStatusEnded) } // Verify the first session's preview was deleted. if previewMgr.previews[string(firstSessionID)] { t.Error("first session preview should have been deleted during teardown") } // Verify the first session's checkout was revoked. firstCheckout := checkoutRepo.checkouts[string(firstCheckoutID)] if firstCheckout.Status != domain.CheckoutStatusRevoked { t.Errorf("first checkout status = %s, want %s", firstCheckout.Status, domain.CheckoutStatusRevoked) } // Verify the second session is active. if result2.Session.Status != domain.SessionStatusActive { t.Errorf("second session status = %s, want active", result2.Session.Status) } }) }