From 7249575dea1a75cdc20b759f322e9efa97573d39 Mon Sep 17 00:00:00 2001 From: jordan Date: Fri, 13 Feb 2026 08:41:05 -0700 Subject: [PATCH] feat(sessions): add command execution endpoint and activity tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/rdev-api/main.go | 2 +- deployments/k8s/base/kustomization.yaml | 3 + deployments/k8s/base/preview-cert.yaml | 12 + docs/aios-core/info.md | 282 +++++ internal/adapter/kubernetes/preview.go | 19 +- .../adapter/postgres/session_repository.go | 36 +- .../db/migrations/024_session_activity.sql | 10 + internal/domain/session.go | 16 + internal/handlers/checkout_test.go | 761 ++++++++++++ .../handlers/infrastructure_mocks_test.go | 68 ++ internal/handlers/sessions.go | 23 +- internal/handlers/sessions_exec.go | 260 ++++ internal/handlers/sessions_test.go | 30 +- internal/port/preview.go | 3 - internal/port/session_repository.go | 3 + internal/service/checkout_service.go | 27 - internal/service/session_service.go | 37 +- internal/service/session_service_test.go | 1050 +++++++++++++++++ 18 files changed, 2574 insertions(+), 68 deletions(-) create mode 100644 deployments/k8s/base/preview-cert.yaml create mode 100644 docs/aios-core/info.md create mode 100644 internal/db/migrations/024_session_activity.sql create mode 100644 internal/handlers/checkout_test.go create mode 100644 internal/handlers/sessions_exec.go create mode 100644 internal/service/session_service_test.go diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index f4135d9..cfbb264 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -544,7 +544,7 @@ func main() { // Initialize sessions handler (for interactive remote development) var sessionsHandler *handlers.SessionsHandler if sessionService != nil { - sessionsHandler = handlers.NewSessionsHandler(sessionService) + sessionsHandler = handlers.NewSessionsHandler(sessionService, k8sExecutor, streamPub) } // Initialize saga system (resilient workflow orchestration) diff --git a/deployments/k8s/base/kustomization.yaml b/deployments/k8s/base/kustomization.yaml index c313bc7..3ea9597 100644 --- a/deployments/k8s/base/kustomization.yaml +++ b/deployments/k8s/base/kustomization.yaml @@ -33,3 +33,6 @@ resources: - pdb.yaml - network-policy.yaml + # Wildcard TLS for session preview URLs + - preview-cert.yaml + diff --git a/deployments/k8s/base/preview-cert.yaml b/deployments/k8s/base/preview-cert.yaml new file mode 100644 index 0000000..981afe2 --- /dev/null +++ b/deployments/k8s/base/preview-cert.yaml @@ -0,0 +1,12 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: preview-wildcard-tls + namespace: rdev +spec: + secretName: preview-wildcard-tls + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + dnsNames: + - "*.preview.threesix.ai" diff --git a/docs/aios-core/info.md b/docs/aios-core/info.md new file mode 100644 index 0000000..7644716 --- /dev/null +++ b/docs/aios-core/info.md @@ -0,0 +1,282 @@ +# Synkra AIOS-Core Analysis + +> Repository: [github.com/SynkraAI/aios-core](https://github.com/SynkraAI/aios-core) +> Version: 4.0.4 | Language: JavaScript (ES2022, CommonJS) | Runtime: Node.js 18+ + +## What Is It? + +**AIOS (AI-Orchestrated System)** is a CLI framework that orchestrates specialized AI agents to collaboratively build software using structured methodologies. It eliminates two critical problems in LLM-assisted development: **planning inconsistency** (solved via hierarchical agent roles) and **context loss** (solved via a deterministic 8-layer context injection engine called SYNAPSE). + +It implements an **"Agentic Agile"** methodology: hierarchical planning (analyst -> PM -> architect) followed by contextual implementation (scrum master -> developer -> QA). Each agent has exclusive authority over its domain — only `@devops` can push, only `@architect` makes architecture decisions, only `@qa` issues quality verdicts. + +**Target audience:** Teams using AI-assisted development who want structured, repeatable workflows with agent specialization rather than a single general-purpose LLM chat. + +**Install:** `npx aios-core@latest init my-project` — drops a `.aios-core/` directory into your project and integrates with Claude Code, Cursor, or Windsurf. + +## Architecture + +``` +CLI (bin/aios.js, Commander) + | + +-- Master Orchestrator (core/orchestration/) + | +-- Agent Invoker # Activate agents by @name + | +-- Workflow Executor # Multi-step orchestration + | +-- SYNAPSE Context Engine # 8-layer context injection (L0-L7) + | +-- Recovery Handler # Error rollback & retry + | +-- Session State Manager # Cross-session continuity + | + +-- Agent System (.aios-core/development/agents/) + | 12 agents, each with: persona, commands, dependencies, activation flow + | + +-- Task System (.aios-core/development/tasks/) + | 201 executable tasks as Markdown+YAML files + | + +-- Execution Engines (core/execution/) + | +-- Build Orchestrator # Autonomous code generation + | +-- Semantic Merge Engine # AST-aware code merging + | +-- Subagent Dispatcher # Parallel task execution + | +-- Wave Executor # Batched operations with deps + | + +-- Quality Gates (quality/, hooks/) + | Pre-commit, pre-push, CI validation + | + +-- Configuration (4-level hierarchy) + L1 Framework -> L2 Project -> L3 Core -> L4 Local (user) +``` + +### Directory Structure + +``` +aios-core/ ++-- .aios-core/ # Framework core (installed into projects) +| +-- core/ # Runtime engines (~25 modules) +| | +-- orchestration/ # Master orchestrator (16KB) +| | +-- execution/ # Build, merge, dispatch (12KB) +| | +-- synapse/ # 8-layer context injection +| | +-- config/ # Config loaders (L0-L4) +| | +-- ids/ # ID generators +| | +-- health-check/ # Diagnostics +| +-- development/ # Agent definitions & workflows +| | +-- agents/ # 12 agent definitions +| | +-- tasks/ # 201 executable tasks +| | +-- workflows/ # 15 workflow definitions +| | +-- templates/ # 9 document templates +| | +-- agent-teams/ # 7 bundled agent groups +| +-- infrastructure/ # Build scripts, CI templates +| +-- hooks/ # Git hooks +| +-- product/ # Squads, mind contexts +| +-- quality/ # Quality gate implementations +| +-- schemas/ # JSON Schema validators +| +-- constitution.md # Formal principles & gates ++-- bin/ # CLI executables ++-- packages/ # Monorepo (aios-install, gemini-ext) ++-- .claude/ # Claude Code integration ++-- docs/ # Documentation (~6.8MB) ++-- tests/ # Test suite (~4MB, 80%+ coverage) +``` + +### Key Modules by Size + +| Module | LOC | Purpose | +|--------|-----|---------| +| master-orchestrator.js | 1,542 | Main orchestration engine | +| semantic-merge-engine.js | 1,180 | AST-aware code merging | +| workflow-executor.js | 1,180 | Multi-step workflow execution | +| terminal-spawner.js | 1,043 | Shell command execution with streaming | +| build-state-manager.js | 948 | Build execution state tracking | +| greenfield-handler.js | 888 | New project initialization | +| session-state.js | 875 | Cross-session continuity | +| recovery-handler.js | 720 | Error recovery & rollback | + +## The 12 Agents + +| Agent | Persona | Role | Exclusive Authority | +|-------|---------|------|---------------------| +| `@dev` | Dex (Builder) | Senior Developer | Code implementation | +| `@qa` | Quinn (Guardian) | Test Architect | Quality verdicts | +| `@architect` | Aria (Architect) | System Designer | Architecture decisions | +| `@pm` | Morgan (Analyzer) | Product Manager | Requirements & specs | +| `@po` | Pax (Curator) | Product Owner | Story creation & prioritization | +| `@sm` | River (Facilitator) | Scrum Master | Sprint planning & story flow | +| `@analyst` | Alex (Researcher) | Business Analyst | Research & pattern extraction | +| `@data-engineer` | Dara (Data Architect) | DB Designer | Schema design & migrations | +| `@ux-design-expert` | Uma (Designer) | UX/UI Specialist | Design & usability | +| `@devops` | Gage (Operator) | DevOps Engineer | git push, PRs, releases | +| `@aios-master` | Orion (Orchestrator) | Framework Developer | All capabilities | +| `@squad-creator` | Scout (Builder) | Expansion Creator | Build new squads/domains | + +Agent authority is non-negotiable. Only `@devops` can `git push`. Only `@architect` makes architecture decisions. Enforced by the Constitution system. + +## Typical Workflow + +``` +1. npx aios-core init my-project +2. @pm: *gather-requirements -> *write-spec +3. Approve spec, activate @architect +4. @architect: *create-plan -> *map-codebase +5. @po: *shard-doc (split large docs) -> *create-next-story +6. @dev: *develop (implement story) +7. @qa: *review-story +8. @devops: *git-push +9. Loop to step 5 for next story +``` + +## 10 Interesting Things + +### 1. SYNAPSE: 8-Layer Deterministic Context Injection + +The standout innovation. Rather than stuffing everything into one LLM prompt, SYNAPSE builds context through 8 prioritized layers: + +| Layer | Name | What It Injects | +|-------|------|-----------------| +| L0 | Constitution | Non-negotiable principles (CLI-first, agent authority) | +| L1 | Global | Framework-wide coding standards | +| L2 | Agent | Agent-specific rules ("Dev only updates story Dev section") | +| L3 | Workflow | Current phase, expected next step | +| L4 | Task | Acceptance criteria, inputs/outputs | +| L5 | Squad | Domain-specific conventions | +| L6 | Keyword | Triggered by tags like `[LLM: complex]` | +| L7 | Star-Command | Command-specific context for `*develop`, `*review`, etc. | + +Output is XML prepended to every LLM prompt: +```xml + + + CLI First: All functionality works 100% via CLI + Agent Authority: Only @devops can git push + + + CRITICAL: ONLY update story Dev Agent Record + + +``` + +### 2. Markdown as Executable Specification + +All 201 tasks are Markdown files with YAML front matter that serve as both documentation AND executable specs. The framework parses them into execution plans: + +```yaml +--- +task: create-next-story() +responsible: River (Scrum Master) +entrada: + - campo: epic_name + tipo: string + required: true +saida: + - campo: created_file + tipo: string +acceptance-criteria: + - [ ] Story has all required sections +--- +## Execution Steps +1. Validate epic exists +2. Create story file +3. Update epic index +``` + +The Markdown tree parser extracts YAML metadata, numbered steps become execution plans, and checkboxes become progress tracking. + +### 3. Formal Constitution with Automated Enforcement + +A `constitution.md` file defines legally-styled principles with three severity levels enforced by quality gates: + +- **NON-NEGOTIABLE**: Gates block execution (CLI-first, agent authority) +- **MUST**: Gates warn but allow (story-driven dev, quality-first) +- **SHOULD**: Gates inform only (absolute imports) + +This is unusual — most projects have informal "contributing" docs. AIOS has a formal governance document with automated enforcement. + +### 4. Semantic Merge Engine (AST-Aware) + +Instead of line-based git merges, the semantic merge engine understands code structure: +- Function-level conflict detection +- Import reorganization +- 3-way merge (ours/theirs/base) with AST awareness +- Eliminates typical merge conflicts in multi-agent workflows where different agents modify the same files + +### 5. Session State Machine with Recovery + +Agent conversations survive IDE restarts. A `SessionState` tracks current agent, task, story, execution history, and checkpoints. The recovery handler can resume from the last good state after failures — critical for long-running multi-agent workflows that might span hours. + +Persisted to `.aios/session-state.yaml`. + +### 6. No Database — Git Repository IS the Database + +Zero traditional database. All state lives in the filesystem: +- YAML for configuration +- Markdown for stories, tasks, specs +- JSON for metadata +- Git history for audit trail + +The repository itself is the single source of truth. This makes the entire system portable and version-controlled by default. + +### 7. Wave Executor for Parallel Agent Work + +Independent tasks run in parallel, respecting dependency graphs: + +```javascript +const wave = [ + { task: 'task-a', deps: [] }, + { task: 'task-b', deps: [] }, // Parallel with A + { task: 'task-c', deps: ['a', 'b'] } // Waits for A+B +]; +await waveExecutor.execute(wave); +``` + +Enables fast multi-agent workflows without race conditions. + +### 8. Squads: Domain-Extensible Agent Teams + +The framework isn't locked to software development. "Squads" are pluggable domain-specific agent teams with their own agents, tasks, templates, and knowledge bases. A squad for legal, marketing, healthcare, or education could define 1-5 specialized agents with 20-50 domain tasks. + +Structure: `squads/your-squad/{config.yaml, agents/, tasks/, templates/, data/}`. + +### 9. Three Execution Modes + +Every task can run in one of three modes: +- **YOLO** (0-1 prompts): Fully autonomous with logging. Fast, dangerous. +- **Interactive** (5-10 prompts): Explicit checkpoints at key decision points. Default. +- **Pre-Flight** (comprehensive): Zero-ambiguity upfront planning before any execution. + +This is a thoughtful UX choice — developers pick their risk tolerance per task. + +### 10. Portuguese-First, Multi-Language + +The codebase and task definitions use Portuguese for many field names (`responsavel`, `entrada`, `saida`, `pre-condicoes`). Documentation exists in Portuguese, English, and Spanish. This is unusual for an open-source framework and reflects its origin from Pedro Valerio's hybrid-ops methodology. The English docs are comprehensive but the Portuguese roots show the project's cultural DNA. + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Runtime | Node.js 18+ | +| Language | JavaScript ES2022 (CommonJS, ESM migration planned) | +| Types | TypeScript 5.9.3 (definitions only, not compiled) | +| CLI | Commander 12.1.0 | +| Prompts | @clack/prompts 0.11.0 | +| Templates | Handlebars 4.7.8 | +| Validation | ajv (JSON Schema) + validator | +| YAML | js-yaml 4.1.0 | +| Markdown | @kayvan/markdown-tree-parser 1.5.0 | +| Process | execa 5.1.1 | +| Testing | Jest 30.2.0 (80%+ coverage required) | +| Linting | ESLint 9.38.0 + Prettier 3.5.3 | +| Release | semantic-release 25.0.2 | +| Git Hooks | husky 9.1.7 | + +**19 production dependencies** — deliberately minimal for a framework of this scope. + +## Scale + +| Metric | Count | +|--------|-------| +| Agents | 12 | +| Executable tasks | 201 | +| Workflows | 15 | +| Document templates | 9 | +| Agent teams | 7 | +| SYNAPSE layers | 8 | +| Config levels | 4 | +| Supported IDEs | 3 (Claude Code, Cursor, Windsurf) | +| Test coverage | 80%+ | +| Docs | ~6.8MB | diff --git a/internal/adapter/kubernetes/preview.go b/internal/adapter/kubernetes/preview.go index 330e6d0..2f8079e 100644 --- a/internal/adapter/kubernetes/preview.go +++ b/internal/adapter/kubernetes/preview.go @@ -3,7 +3,6 @@ package kubernetes import ( "context" "fmt" - "strings" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -59,10 +58,7 @@ func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOpt } name := resourceName(opts.SessionID) - ns := opts.Namespace - if ns == "" { - ns = m.config.Namespace - } + ns := m.config.Namespace // Create Service targeting the pod by its rdev project label. // Project pods are labeled with rdev.orchard9.ai/name=. @@ -101,14 +97,15 @@ func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOpt // Create Ingress for TLS-terminated route. pathType := networkingv1.PathTypePrefix - tlsSecretName := strings.ReplaceAll(opts.Host, ".", "-") + "-tls" - annotations := map[string]string{} - if m.config.TLSIssuer != "" { - annotations["cert-manager.io/cluster-issuer"] = m.config.TLSIssuer + // Use the shared wildcard TLS secret (preview-wildcard-tls) for all preview + // ingresses. This avoids per-session cert-manager certificate requests. + tlsSecretName := "preview-wildcard-tls" + + annotations := map[string]string{ + "traefik.ingress.kubernetes.io/router.entrypoints": "websecure", + "traefik.ingress.kubernetes.io/router.tls": "true", } - annotations["traefik.ingress.kubernetes.io/router.entrypoints"] = "websecure" - annotations["traefik.ingress.kubernetes.io/router.tls"] = "true" ingress := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/adapter/postgres/session_repository.go b/internal/adapter/postgres/session_repository.go index ff329b6..31d1ab8 100644 --- a/internal/adapter/postgres/session_repository.go +++ b/internal/adapter/postgres/session_repository.go @@ -30,9 +30,9 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) err := r.db.QueryRowContext(ctx, ` INSERT INTO sessions ( project_id, checkout_id, pod_name, preview_url, preview_host, - created_by, created_at, expires_at, status + created_by, created_at, expires_at, status, last_activity_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id `, string(session.ProjectID), @@ -44,6 +44,7 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) session.CreatedAt, session.ExpiresAt, string(session.Status), + session.LastActivityAt, ).Scan(&id) if err != nil { @@ -61,7 +62,7 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) { session, err := r.scanSession(r.db.QueryRowContext(ctx, ` SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, - created_by, created_at, expires_at, status, ended_at + created_by, created_at, expires_at, status, last_activity_at, ended_at FROM sessions WHERE id = $1 `, string(id))) @@ -79,7 +80,7 @@ func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*doma func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error) { session, err := r.scanSession(r.db.QueryRowContext(ctx, ` SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, - created_by, created_at, expires_at, status, ended_at + created_by, created_at, expires_at, status, last_activity_at, ended_at FROM sessions WHERE project_id = $1 AND status = 'active' `, string(projectID))) @@ -97,7 +98,7 @@ func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID do func (r *SessionRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, - created_by, created_at, expires_at, status, ended_at + created_by, created_at, expires_at, status, last_activity_at, ended_at FROM sessions WHERE project_id = $1 ORDER BY created_at DESC @@ -131,14 +132,36 @@ func (r *SessionRepository) SetEnded(ctx context.Context, id domain.SessionID) e return nil } +// TouchActivity updates the last_activity_at timestamp for an active session. +func (r *SessionRepository) TouchActivity(ctx context.Context, id domain.SessionID) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sessions + SET last_activity_at = NOW() + WHERE id = $1 AND status = 'active' + `, string(id)) + if err != nil { + return fmt.Errorf("touch session activity: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return domain.ErrSessionNotActive + } + return nil +} + // CleanupExpired marks expired sessions and returns them for preview teardown. func (r *SessionRepository) CleanupExpired(ctx context.Context) ([]*domain.Session, error) { rows, err := r.db.QueryContext(ctx, ` UPDATE sessions SET status = 'expired', ended_at = NOW() WHERE status = 'active' AND expires_at < NOW() + AND last_activity_at < NOW() - INTERVAL '30 minutes' RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host, - created_by, created_at, expires_at, status, ended_at + created_by, created_at, expires_at, status, last_activity_at, ended_at `) if err != nil { return nil, fmt.Errorf("cleanup expired sessions: %w", err) @@ -175,6 +198,7 @@ func (r *SessionRepository) scanSessionFields(scanner sessionScanner) (*domain.S &session.CreatedAt, &session.ExpiresAt, &status, + &session.LastActivityAt, &endedAt, ) if err != nil { diff --git a/internal/db/migrations/024_session_activity.sql b/internal/db/migrations/024_session_activity.sql new file mode 100644 index 0000000..7052457 --- /dev/null +++ b/internal/db/migrations/024_session_activity.sql @@ -0,0 +1,10 @@ +-- Migration: Add activity tracking to sessions for grace-period cleanup +-- Sessions with recent activity survive cleanup even if past expires_at + +ALTER TABLE sessions ADD COLUMN last_activity_at TIMESTAMPTZ; +UPDATE sessions SET last_activity_at = created_at WHERE last_activity_at IS NULL; +ALTER TABLE sessions ALTER COLUMN last_activity_at SET NOT NULL; + +-- Replace simple expires index with composite index for cleanup queries +DROP INDEX IF EXISTS idx_sessions_expires; +CREATE INDEX idx_sessions_cleanup ON sessions(status, expires_at, last_activity_at) WHERE status = 'active'; diff --git a/internal/domain/session.go b/internal/domain/session.go index 071b91c..830c0ee 100644 --- a/internal/domain/session.go +++ b/internal/domain/session.go @@ -55,6 +55,9 @@ type Session struct { // Status is the current state of the session. Status SessionStatus + // LastActivityAt is the most recent command or interaction timestamp. + LastActivityAt time.Time + // EndedAt is when the session was ended (if ended or expired). EndedAt *time.Time } @@ -68,3 +71,16 @@ func (s *Session) IsActive() bool { func (s *Session) IsExpired() bool { return s.Status == SessionStatusActive && time.Now().After(s.ExpiresAt) } + +// IsExpiredWithGrace returns true if the session has expired and the grace period +// since the last activity has also elapsed. This prevents expiring sessions that +// are still actively being used. +func (s *Session) IsExpiredWithGrace(gracePeriod time.Duration) bool { + if s.Status != SessionStatusActive { + return false + } + if time.Now().Before(s.ExpiresAt) { + return false + } + return time.Since(s.LastActivityAt) > gracePeriod +} diff --git a/internal/handlers/checkout_test.go b/internal/handlers/checkout_test.go new file mode 100644 index 0000000..33ec60c --- /dev/null +++ b/internal/handlers/checkout_test.go @@ -0,0 +1,761 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" +) + +func setupCheckoutTest() (*CheckoutHandler, *mockCheckoutRepository, *mockProjectRepo) { + checkoutRepo := newMockCheckoutRepository() + projectRepo := newMockProjectRepo() + gitRepo := newMockGitRepository() + + projectRepo.projects["test-project"] = &domain.Project{ + ID: "test-project", + Name: "test-project", + PodName: "test-project-0", + Status: domain.ProjectStatusRunning, + } + + checkoutService := service.NewCheckoutService( + checkoutRepo, gitRepo, projectRepo, + service.CheckoutServiceConfig{ + GiteaURL: "https://git.threesix.ai", + DefaultOwner: "threesix", + DefaultExpiry: 24 * time.Hour, + }, + ) + + handler := NewCheckoutHandler(checkoutService) + return handler, checkoutRepo, projectRepo +} + +func TestCheckoutHandler_ListBranches(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + t.Run("list_branches_success", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/branches", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + branches, ok := data["branches"].([]any) + if !ok { + t.Fatalf("expected branches array, got %T", data["branches"]) + } + + if len(branches) != 2 { + t.Fatalf("got %d branches, want 2", len(branches)) + } + + names := make(map[string]bool) + for _, b := range branches { + bm := b.(map[string]any) + names[bm["name"].(string)] = true + } + if !names["main"] { + t.Error("expected 'main' branch in response") + } + if !names["develop"] { + t.Error("expected 'develop' branch in response") + } + }) + + t.Run("list_branches_project_not_found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/projects/nonexistent/checkout/branches", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) +} + +func TestCheckoutHandler_Create(t *testing.T) { + t.Run("create_existing_branch", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{"branch": "develop"}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + if data["id"] == nil || data["id"] == "" { + t.Error("expected non-empty id") + } + if data["clone_url"] == nil || data["clone_url"] == "" { + t.Error("expected non-empty clone_url") + } + if data["instructions"] == nil || data["instructions"] == "" { + t.Error("expected non-empty instructions") + } + if data["branch"] != "develop" { + t.Errorf("expected branch=develop, got %v", data["branch"]) + } + if data["project_id"] != "test-project" { + t.Errorf("expected project_id=test-project, got %v", data["project_id"]) + } + if data["status"] != "active" { + t.Errorf("expected status=active, got %v", data["status"]) + } + }) + + t.Run("create_new_branch", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{"new_branch": "feature-test"}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + if data["branch"] != "feature-test" { + t.Errorf("expected branch=feature-test, got %v", data["branch"]) + } + }) + + t.Run("create_branch_conflict", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{"branch": "develop", "new_branch": "feature-test"}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) + + t.Run("create_no_branch", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) + + t.Run("create_protected_branch", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{"branch": "main"}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) + + t.Run("create_project_not_found", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{"branch": "develop"}` + req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) + + t.Run("create_expiry_7d", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{"branch": "develop", "expires_in": "7d"}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + // Verify expiry is approximately 7 days from now. + expiresAtStr, ok := data["expires_at"].(string) + if !ok || expiresAtStr == "" { + t.Fatal("expected non-empty expires_at") + } + + expiresAt, err := time.Parse(time.RFC3339, expiresAtStr) + if err != nil { + t.Fatalf("parse expires_at: %v", err) + } + + expectedExpiry := time.Now().Add(7 * 24 * time.Hour) + diff := expiresAt.Sub(expectedExpiry) + if diff < -time.Minute || diff > time.Minute { + t.Errorf("expires_at off by %v, expected ~7 days from now", diff) + } + }) +} + +func TestCheckoutHandler_List(t *testing.T) { + t.Run("list_empty", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + checkouts, ok := data["checkouts"].([]any) + if !ok { + t.Fatalf("expected checkouts array, got %T", data["checkouts"]) + } + + if len(checkouts) != 0 { + t.Errorf("got %d checkouts, want 0", len(checkouts)) + } + }) + + t.Run("list_with_results", func(t *testing.T) { + handler, checkoutRepo, _ := setupCheckoutTest() + + // Seed a checkout for the test project. + checkoutRepo.checkouts["checkout-list-1"] = &domain.Checkout{ + ID: "checkout-list-1", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusActive, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + checkouts, ok := data["checkouts"].([]any) + if !ok { + t.Fatalf("expected checkouts array, got %T", data["checkouts"]) + } + + if len(checkouts) != 1 { + t.Errorf("got %d checkouts, want 1", len(checkouts)) + } + }) +} + +func TestCheckoutHandler_Get(t *testing.T) { + t.Run("get_success", func(t *testing.T) { + handler, checkoutRepo, _ := setupCheckoutTest() + + checkoutRepo.checkouts["checkout-123"] = &domain.Checkout{ + ID: "checkout-123", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusActive, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/checkout-123", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + if data["id"] != "checkout-123" { + t.Errorf("expected id=checkout-123, got %v", data["id"]) + } + if data["branch"] != "develop" { + t.Errorf("expected branch=develop, got %v", data["branch"]) + } + if data["project_id"] != "test-project" { + t.Errorf("expected project_id=test-project, got %v", data["project_id"]) + } + }) + + t.Run("get_wrong_project", func(t *testing.T) { + handler, checkoutRepo, projectRepo := setupCheckoutTest() + + // Add a second project so the URL is valid. + projectRepo.projects["other-project"] = &domain.Project{ + ID: "other-project", + Name: "other-project", + PodName: "other-project-0", + Status: domain.ProjectStatusRunning, + } + + checkoutRepo.checkouts["checkout-123"] = &domain.Checkout{ + ID: "checkout-123", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusActive, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/projects/other-project/checkout/checkout-123", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) + + t.Run("get_not_found", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/projects/test-project/checkout/nonexistent", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) +} + +func TestCheckoutHandler_Checkin(t *testing.T) { + t.Run("checkin_with_review", func(t *testing.T) { + handler, checkoutRepo, _ := setupCheckoutTest() + + checkoutRepo.checkouts["checkout-checkin"] = &domain.Checkout{ + ID: "checkout-checkin", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusActive, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-checkin/checkin", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + if data["checkout_id"] != "checkout-checkin" { + t.Errorf("expected checkout_id=checkout-checkin, got %v", data["checkout_id"]) + } + if data["status"] != "checked_in" { + t.Errorf("expected status=checked_in, got %v", data["status"]) + } + if data["message"] == nil || data["message"] == "" { + t.Error("expected non-empty message") + } + }) + + t.Run("checkin_skip_review", func(t *testing.T) { + handler, checkoutRepo, _ := setupCheckoutTest() + + checkoutRepo.checkouts["checkout-skip"] = &domain.Checkout{ + ID: "checkout-skip", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusActive, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{"skip_review": true}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-skip/checkin", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + if data["status"] != "checked_in" { + t.Errorf("expected status=checked_in, got %v", data["status"]) + } + // With skip_review=true and no work queue, no review task should be set. + if data["review_task_id"] != nil && data["review_task_id"] != "" { + t.Errorf("expected empty review_task_id with skip_review, got %v", data["review_task_id"]) + } + }) + + t.Run("checkin_not_active", func(t *testing.T) { + handler, checkoutRepo, _ := setupCheckoutTest() + + now := time.Now() + checkoutRepo.checkouts["checkout-done"] = &domain.Checkout{ + ID: "checkout-done", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: now.Add(-1 * time.Hour), + ExpiresAt: now.Add(23 * time.Hour), + Status: domain.CheckoutStatusCheckedIn, + CheckedInAt: &now, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/checkout-done/checkin", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) + + t.Run("checkin_not_found", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/checkout/nonexistent/checkin", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) + + t.Run("checkin_wrong_project", func(t *testing.T) { + handler, checkoutRepo, projectRepo := setupCheckoutTest() + + projectRepo.projects["other-project"] = &domain.Project{ + ID: "other-project", + Name: "other-project", + PodName: "other-project-0", + Status: domain.ProjectStatusRunning, + } + + checkoutRepo.checkouts["checkout-wrong"] = &domain.Checkout{ + ID: "checkout-wrong", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusActive, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/projects/other-project/checkout/checkout-wrong/checkin", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) +} + +func TestCheckoutHandler_Revoke(t *testing.T) { + t.Run("revoke_success", func(t *testing.T) { + handler, checkoutRepo, _ := setupCheckoutTest() + + checkoutRepo.checkouts["checkout-revoke"] = &domain.Checkout{ + ID: "checkout-revoke", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusActive, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/checkout-revoke", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data map, got %T", resp["data"]) + } + + if data["status"] != "revoked" { + t.Errorf("expected status=revoked, got %v", data["status"]) + } + if data["id"] != "checkout-revoke" { + t.Errorf("expected id=checkout-revoke, got %v", data["id"]) + } + }) + + t.Run("revoke_not_active", func(t *testing.T) { + handler, checkoutRepo, _ := setupCheckoutTest() + + checkoutRepo.checkouts["checkout-already-revoked"] = &domain.Checkout{ + ID: "checkout-already-revoked", + ProjectID: "test-project", + Branch: "develop", + GiteaTokenID: 12345, + CloneURL: "https://git.threesix.ai/threesix/test-project.git", + CheckedOutBy: "test", + CheckedOutAt: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + Status: domain.CheckoutStatusRevoked, + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/checkout-already-revoked", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) + + t.Run("revoke_not_found", func(t *testing.T) { + handler, _, _ := setupCheckoutTest() + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/checkout/nonexistent", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) +} diff --git a/internal/handlers/infrastructure_mocks_test.go b/internal/handlers/infrastructure_mocks_test.go index 4322bb0..6a16cbf 100644 --- a/internal/handlers/infrastructure_mocks_test.go +++ b/internal/handlers/infrastructure_mocks_test.go @@ -367,3 +367,71 @@ func (m *mockPreviewManager) DeletePreview(_ context.Context, sessionID string) delete(m.previews, sessionID) return nil } + +// mockExecutor implements port.CommandExecutor for testing. +type mockExecutor struct { + result *domain.CommandResult + err error +} + +func newMockExecutor() *mockExecutor { + return &mockExecutor{ + result: &domain.CommandResult{ExitCode: 0, DurationMs: 100}, + } +} + +func (m *mockExecutor) Execute(_ context.Context, _ *domain.Command, _ string, handler domain.OutputHandler) (*domain.CommandResult, error) { + if m.err != nil { + return nil, m.err + } + if handler != nil { + handler(domain.OutputLine{Stream: "stdout", Line: "mock output", Timestamp: time.Now()}) + } + return m.result, nil +} + +func (m *mockExecutor) Cancel(_ context.Context, _ domain.CommandID) error { + return nil +} + +func (m *mockExecutor) PodExists(_ context.Context, _ string) (bool, error) { + return true, nil +} + +func (m *mockExecutor) CheckConnection(_ context.Context) error { + return nil +} + +// mockStreamPublisher implements port.StreamPublisher for testing. +type mockStreamPublisher struct { + events map[string][]port.StreamEvent + seq int64 +} + +func newMockStreamPublisher() *mockStreamPublisher { + return &mockStreamPublisher{events: make(map[string][]port.StreamEvent)} +} + +func (m *mockStreamPublisher) Subscribe(streamID string) (<-chan port.StreamEvent, func()) { + ch := make(chan port.StreamEvent, 100) + return ch, func() { close(ch) } +} + +func (m *mockStreamPublisher) SubscribeFromID(streamID string, _ string) (<-chan port.StreamEvent, func()) { + return m.Subscribe(streamID) +} + +func (m *mockStreamPublisher) Publish(streamID string, event port.StreamEvent) string { + m.seq++ + event.ID = fmt.Sprintf("%s:%d", streamID, m.seq) + m.events[streamID] = append(m.events[streamID], event) + return event.ID +} + +func (m *mockStreamPublisher) Close(streamID string) { + delete(m.events, streamID) +} + +// Verify mock interfaces at compile time. +var _ port.CommandExecutor = (*mockExecutor)(nil) +var _ port.StreamPublisher = (*mockStreamPublisher)(nil) diff --git a/internal/handlers/sessions.go b/internal/handlers/sessions.go index c7330bc..76d0c2b 100644 --- a/internal/handlers/sessions.go +++ b/internal/handlers/sessions.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" @@ -18,11 +19,21 @@ import ( // SessionsHandler handles interactive remote development session endpoints. type SessionsHandler struct { sessionService *service.SessionService + executor port.CommandExecutor + streams port.StreamPublisher } // NewSessionsHandler creates a new sessions handler. -func NewSessionsHandler(sessionService *service.SessionService) *SessionsHandler { - return &SessionsHandler{sessionService: sessionService} +func NewSessionsHandler( + sessionService *service.SessionService, + executor port.CommandExecutor, + streams port.StreamPublisher, +) *SessionsHandler { + return &SessionsHandler{ + sessionService: sessionService, + executor: executor, + streams: streams, + } } // Mount registers the session routes. @@ -44,6 +55,14 @@ func (h *SessionsHandler) Mount(r api.Router) { r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/{sid}/checkin", h.Checkin) + // Execute command in session context + r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)). + Post("/{sid}/exec", h.Exec) + + // Stream session command output via SSE + r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)). + Get("/{sid}/events", h.Events) + // Force-terminate session (admin only) r.With(auth.RequireScope(auth.ScopeAdmin)). Delete("/{sid}", h.Delete) diff --git a/internal/handlers/sessions_exec.go b/internal/handlers/sessions_exec.go new file mode 100644 index 0000000..97c71c8 --- /dev/null +++ b/internal/handlers/sessions_exec.go @@ -0,0 +1,260 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/pkg/api" +) + +// SessionExecRequest is the JSON body for executing a command in a session. +type SessionExecRequest struct { + Type string `json:"type"` // "claude", "shell", or "git" + Prompt string `json:"prompt,omitempty"` // For claude commands + Command string `json:"command,omitempty"` // For shell/git commands + Args []string `json:"args,omitempty"` // Additional arguments + StreamID string `json:"stream_id,omitempty"` // Client-provided stream ID (optional) +} + +// SessionExecResponse is the JSON response for a session exec command. +type SessionExecResponse struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Type string `json:"type"` + Status string `json:"status"` + StreamURL string `json:"stream_url"` +} + +// Exec executes a command in the context of an active session. +// POST /projects/{id}/sessions/{sid}/exec +func (h *SessionsHandler) Exec(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if err := domain.ValidateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, "invalid project id") + return + } + + sid := chi.URLParam(r, "sid") + if sid == "" { + api.WriteBadRequest(w, r, "session id is required") + return + } + + var req SessionExecRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + // Validate command type and type-specific fields. + cmdType := domain.CommandType(req.Type) + switch cmdType { + case domain.CommandTypeClaude: + if req.Prompt == "" { + api.WriteBadRequest(w, r, "prompt is required for claude commands") + return + } + case domain.CommandTypeShell: + if req.Command == "" { + api.WriteBadRequest(w, r, "command is required for shell commands") + return + } + case domain.CommandTypeGit: + if req.Command == "" { + api.WriteBadRequest(w, r, "command is required for git commands") + return + } + default: + api.WriteBadRequest(w, r, "type must be claude, shell, or git") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + // Get session and verify it belongs to this project and is active. + session, err := h.sessionService.Get(ctx, domain.SessionID(sid)) + if err != nil { + if errors.Is(err, domain.ErrSessionNotFound) { + api.WriteNotFound(w, r, "session not found") + return + } + api.WriteInternalError(w, r, "Failed to get session") + return + } + if string(session.ProjectID) != projectID { + api.WriteNotFound(w, r, "session not found") + return + } + if !session.IsActive() { + api.WriteBadRequest(w, r, "session is not active") + return + } + + // Touch activity. + _ = h.sessionService.TouchActivity(ctx, session.ID) + + // Build command args. + var args []string + switch cmdType { + case domain.CommandTypeClaude: + args = append([]string{req.Prompt}, req.Args...) + case domain.CommandTypeShell, domain.CommandTypeGit: + args = append([]string{req.Command}, req.Args...) + } + + // Generate stream ID. + streamID := req.StreamID + if streamID == "" { + streamID = fmt.Sprintf("session-%s-%d", sid, time.Now().UnixNano()) + } + + cmd := &domain.Command{ + ID: domain.CommandID(streamID), + ProjectID: domain.ProjectID(projectID), + Type: cmdType, + Args: args, + StartedAt: time.Now(), + } + + // Execute in background goroutine. + go h.executeSessionCommand(r.Context(), cmd, session.PodName, streamID) + + streamURL := fmt.Sprintf("/projects/%s/sessions/%s/events?stream_id=%s", projectID, sid, streamID) + + api.WriteCreated(w, r, SessionExecResponse{ + ID: streamID, + SessionID: string(session.ID), + Type: req.Type, + Status: "running", + StreamURL: streamURL, + }) +} + +// executeSessionCommand runs a command and streams output to subscribers. +func (h *SessionsHandler) executeSessionCommand(parentCtx context.Context, cmd *domain.Command, podName, streamID string) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), TimeoutLongRunning) + defer cancel() + + result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) { + h.streams.Publish(streamID, port.StreamEvent{ + Type: "output", + Data: map[string]any{ + "line": line.Line, + "stream": line.Stream, + }, + }) + }) + + // Publish completion event. + h.streams.Publish(streamID, port.StreamEvent{ + Type: "complete", + Data: map[string]any{ + "exit_code": result.ExitCode, + "duration_ms": result.DurationMs, + }, + }) + + // Allow subscribers time to receive the completion event before cleanup. + time.Sleep(30 * time.Second) + h.streams.Close(streamID) +} + +// Events streams session command output via Server-Sent Events. +// GET /projects/{id}/sessions/{sid}/events +func (h *SessionsHandler) Events(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if err := domain.ValidateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, "invalid project id") + return + } + + sid := chi.URLParam(r, "sid") + if sid == "" { + api.WriteBadRequest(w, r, "session id is required") + return + } + + streamID := r.URL.Query().Get("stream_id") + lastEventID := r.Header.Get("Last-Event-ID") + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) + defer cancel() + + // Verify session exists and belongs to project. + session, err := h.sessionService.Get(ctx, domain.SessionID(sid)) + if err != nil { + if errors.Is(err, domain.ErrSessionNotFound) { + api.WriteNotFound(w, r, "session not found") + return + } + api.WriteInternalError(w, r, "Failed to get session") + return + } + if string(session.ProjectID) != projectID { + api.WriteNotFound(w, r, "session not found") + return + } + + // Touch activity. + _ = h.sessionService.TouchActivity(ctx, session.ID) + + // Set SSE headers. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + api.WriteInternalError(w, r, "SSE not supported") + return + } + + // Subscribe to events with Last-Event-ID support. + var events <-chan port.StreamEvent + var cleanup func() + if lastEventID != "" { + events, cleanup = h.streams.SubscribeFromID(streamID, lastEventID) + } else { + events, cleanup = h.streams.Subscribe(streamID) + } + defer cleanup() + + // Send initial connected event. + writeSSE(w, flusher, "connected", map[string]any{ + "session_id": sid, + "stream_id": streamID, + "reconnecting": lastEventID != "", + }) + + // Stream events until client disconnects or stream closes. + reqCtx := r.Context() + heartbeat := time.NewTicker(30 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-reqCtx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + writeSSEWithID(w, flusher, event.ID, event.Type, event.Data) + if event.Type == "complete" { + return + } + case <-heartbeat.C: + writeSSE(w, flusher, "heartbeat", map[string]any{ + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + } + } +} diff --git a/internal/handlers/sessions_test.go b/internal/handlers/sessions_test.go index c0e23cb..7545b1b 100644 --- a/internal/handlers/sessions_test.go +++ b/internal/handlers/sessions_test.go @@ -76,6 +76,18 @@ func (m *mockSessionRepository) ListByProject(_ context.Context, projectID domai return result, nil } +func (m *mockSessionRepository) TouchActivity(_ context.Context, id domain.SessionID) error { + if m.err != nil { + return m.err + } + s, ok := m.sessions[string(id)] + if !ok || s.Status != domain.SessionStatusActive { + return domain.ErrSessionNotActive + } + s.LastActivityAt = time.Now() + return nil +} + func (m *mockSessionRepository) SetEnded(_ context.Context, id domain.SessionID) error { if m.err != nil { return m.err @@ -144,8 +156,17 @@ func (m *mockCheckoutRepository) List(_ context.Context, _ domain.CheckoutListOp return nil, nil } -func (m *mockCheckoutRepository) ListByProject(_ context.Context, _ domain.ProjectID) ([]*domain.Checkout, error) { - return nil, nil +func (m *mockCheckoutRepository) ListByProject(_ 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 *mockCheckoutRepository) UpdateStatus(_ context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error { @@ -211,7 +232,10 @@ func setupSessionTest() (*SessionsHandler, *mockSessionRepository, *mockProjectR }, ) - handler := NewSessionsHandler(sessionService) + executor := newMockExecutor() + streams := newMockStreamPublisher() + + handler := NewSessionsHandler(sessionService, executor, streams) return handler, sessionRepo, projectRepo } diff --git a/internal/port/preview.go b/internal/port/preview.go index d3a9549..495a9b4 100644 --- a/internal/port/preview.go +++ b/internal/port/preview.go @@ -18,9 +18,6 @@ type PreviewOptions struct { // SessionID is used as the resource name prefix (e.g., "session-{id}"). SessionID string - // Namespace is the K8s namespace (typically "rdev"). - Namespace string - // PodName is the target pod to route traffic to. PodName string diff --git a/internal/port/session_repository.go b/internal/port/session_repository.go index 0830d48..70caf98 100644 --- a/internal/port/session_repository.go +++ b/internal/port/session_repository.go @@ -24,6 +24,9 @@ type SessionRepository interface { // SetEnded marks a session as ended with a timestamp. SetEnded(ctx context.Context, id domain.SessionID) error + // TouchActivity updates the last_activity_at timestamp for an active session. + TouchActivity(ctx context.Context, id domain.SessionID) error + // CleanupExpired marks expired sessions and returns them for preview teardown. CleanupExpired(ctx context.Context) ([]*domain.Session, error) } diff --git a/internal/service/checkout_service.go b/internal/service/checkout_service.go index 65592bb..405a9c8 100644 --- a/internal/service/checkout_service.go +++ b/internal/service/checkout_service.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "net/url" - "strings" "time" "github.com/orchard9/rdev/internal/domain" @@ -451,29 +450,3 @@ func (s *CheckoutService) buildCloneURLs(token, owner, repo string) (authURL, ba return authURL, baseURL, nil } -// SanitizeCloneURL removes the token from a clone URL for safe logging/display. -func SanitizeCloneURL(cloneURL string) string { - u, err := url.Parse(cloneURL) - if err != nil { - return cloneURL - } - u.User = nil - return u.String() -} - -// ExtractRepoFromURL extracts owner/repo from a git URL. -func ExtractRepoFromURL(gitURL string) (owner, repo string, err error) { - u, err := url.Parse(gitURL) - if err != nil { - return "", "", err - } - - // Remove .git suffix and leading slash - path := strings.TrimSuffix(strings.TrimPrefix(u.Path, "/"), ".git") - parts := strings.SplitN(path, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid git url path: %s", path) - } - - return parts[0], parts[1], nil -} diff --git a/internal/service/session_service.go b/internal/service/session_service.go index b736364..41e84b1 100644 --- a/internal/service/session_service.go +++ b/internal/service/session_service.go @@ -138,16 +138,18 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq } // Create session record first to get the ID. + now := time.Now() session := &domain.Session{ - ProjectID: req.ProjectID, - CheckoutID: checkoutResult.Checkout.ID, - PodName: project.PodName, - PreviewURL: previewURL, - PreviewHost: previewHost, - CreatedBy: req.CreatedBy, - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(expiry), - Status: domain.SessionStatusActive, + ProjectID: req.ProjectID, + CheckoutID: checkoutResult.Checkout.ID, + PodName: project.PodName, + PreviewURL: previewURL, + PreviewHost: previewHost, + CreatedBy: req.CreatedBy, + CreatedAt: now, + ExpiresAt: now.Add(expiry), + LastActivityAt: now, + Status: domain.SessionStatusActive, } if err := s.sessionRepo.Create(ctx, session); err != nil { @@ -159,7 +161,6 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq // Create K8s preview (Service + Ingress). if err := s.previewMgr.CreatePreview(ctx, port.PreviewOptions{ SessionID: string(session.ID), - Namespace: "rdev", PodName: project.PodName, Host: previewHost, Port: previewPort, @@ -186,10 +187,9 @@ Clone URL: %s Branch: %s Pod: %s -Run commands via: - POST /projects/%s/claude (Claude Code commands) - POST /projects/%s/shell (shell commands) - GET /projects/%s/stream (SSE output) +Run commands via session: + POST /projects/%s/sessions/%s/exec (execute commands) + GET /projects/%s/sessions/%s/events (SSE output) End session: POST /projects/%s/sessions/%s/checkin @@ -199,7 +199,8 @@ Session expires: %s`, checkoutResult.AuthenticatedCloneURL, branch, project.PodName, - req.ProjectID, req.ProjectID, req.ProjectID, + req.ProjectID, session.ID, + req.ProjectID, session.ID, req.ProjectID, session.ID, session.ExpiresAt.Format(time.RFC3339), ) @@ -293,6 +294,11 @@ func (s *SessionService) ListByProject(ctx context.Context, projectID domain.Pro return s.sessionRepo.ListByProject(ctx, projectID) } +// TouchActivity updates the last activity timestamp for an active session. +func (s *SessionService) TouchActivity(ctx context.Context, id domain.SessionID) error { + return s.sessionRepo.TouchActivity(ctx, id) +} + // ForceEnd forcefully ends a session without checkout checkin (admin use). func (s *SessionService) ForceEnd(ctx context.Context, id domain.SessionID) error { log := logging.FromContext(ctx).WithService("SessionService") @@ -370,6 +376,7 @@ func (s *SessionService) teardownSession(ctx context.Context, session *domain.Se if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil { return err } + _ = s.checkoutSvc.Revoke(ctx, session.CheckoutID) return s.sessionRepo.SetEnded(ctx, session.ID) } diff --git a/internal/service/session_service_test.go b/internal/service/session_service_test.go new file mode 100644 index 0000000..b86bb1b --- /dev/null +++ b/internal/service/session_service_test.go @@ -0,0 +1,1050 @@ +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) + } + }) +}