rdev/internal/service/session_service_test.go
jordan 7249575dea
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(sessions): add command execution endpoint and activity tracking
- Add POST /sessions/:id/exec endpoint for executing commands in sessions
- Add session activity tracking (last_activity_at timestamp)
- Add database migration 024 for session activity column
- Add comprehensive tests for session handlers and service layer
- Add wildcard TLS certificate for preview.threesix.ai subdomain
- Add infrastructure mocks for testing preview service
- Refactor preview cleanup logic to remove unused methods
- Add AIOS core documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-13 08:41:05 -07:00

1051 lines
31 KiB
Go

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