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