All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
1051 lines
31 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|