All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add claude_id field to sessions (migration 026) for tracking Claude process IDs across pod restarts - Extend session repository with UpdateClaudeID and session lookup methods - Improve kubernetes executor with better error handling and exec streaming - Add claudebox client/server improvements for session lifecycle - Expand sessions handler with exec streaming endpoint - Add comprehensive tests for sessions and kubernetes executor Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
819 lines
25 KiB
Go
819 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
)
|
|
|
|
// mockConversationRepository implements port.ConversationRepository for testing.
|
|
type mockConversationRepository struct {
|
|
conversations map[string]*domain.Conversation
|
|
messages map[string][]*domain.Message
|
|
nextConvID int
|
|
nextMsgID int
|
|
}
|
|
|
|
func newMockConversationRepository() *mockConversationRepository {
|
|
return &mockConversationRepository{
|
|
conversations: make(map[string]*domain.Conversation),
|
|
messages: make(map[string][]*domain.Message),
|
|
}
|
|
}
|
|
|
|
func (m *mockConversationRepository) CreateConversation(_ context.Context, projectID, title string) (*domain.Conversation, error) {
|
|
m.nextConvID++
|
|
conv := &domain.Conversation{
|
|
ID: domain.ConversationID(fmt.Sprintf("conv-%d", m.nextConvID)),
|
|
ProjectID: projectID,
|
|
Title: title,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
m.conversations[string(conv.ID)] = conv
|
|
return conv, nil
|
|
}
|
|
|
|
func (m *mockConversationRepository) GetConversation(_ context.Context, id domain.ConversationID) (*domain.Conversation, error) {
|
|
c, ok := m.conversations[string(id)]
|
|
if !ok {
|
|
return nil, fmt.Errorf("conversation not found: %s", id)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (m *mockConversationRepository) ListConversations(_ context.Context, projectID string) ([]*domain.Conversation, error) {
|
|
var result []*domain.Conversation
|
|
for _, c := range m.conversations {
|
|
if c.ProjectID == projectID {
|
|
result = append(result, c)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockConversationRepository) UpdateConversationTitle(_ context.Context, id domain.ConversationID, title string) error {
|
|
if c, ok := m.conversations[string(id)]; ok {
|
|
c.Title = title
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockConversationRepository) DeleteConversation(_ context.Context, id domain.ConversationID) error {
|
|
delete(m.conversations, string(id))
|
|
delete(m.messages, string(id))
|
|
return nil
|
|
}
|
|
|
|
func (m *mockConversationRepository) AddMessage(_ context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) {
|
|
m.nextMsgID++
|
|
msg := &domain.Message{
|
|
ID: domain.MessageID(fmt.Sprintf("msg-%d", m.nextMsgID)),
|
|
ConversationID: conversationID,
|
|
Role: role,
|
|
Content: content,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
m.messages[string(conversationID)] = append(m.messages[string(conversationID)], msg)
|
|
return msg, nil
|
|
}
|
|
|
|
func (m *mockConversationRepository) GetMessages(_ context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) {
|
|
return m.messages[string(conversationID)], nil
|
|
}
|
|
|
|
func (m *mockConversationRepository) GetConversationWithMessages(_ context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) {
|
|
c, ok := m.conversations[string(id)]
|
|
if !ok {
|
|
return nil, fmt.Errorf("conversation not found: %s", id)
|
|
}
|
|
return &domain.ConversationWithMessages{
|
|
Conversation: *c,
|
|
Messages: m.messages[string(id)],
|
|
}, nil
|
|
}
|
|
|
|
// Compile-time check.
|
|
var _ port.ConversationRepository = (*mockConversationRepository)(nil)
|
|
|
|
// mockSessionRepository implements port.SessionRepository for testing.
|
|
type mockSessionRepository struct {
|
|
sessions map[string]*domain.Session
|
|
err error
|
|
claudeSessionIDPersisted chan struct{}
|
|
persistedOnce sync.Once
|
|
}
|
|
|
|
func newMockSessionRepository() *mockSessionRepository {
|
|
return &mockSessionRepository{
|
|
sessions: make(map[string]*domain.Session),
|
|
claudeSessionIDPersisted: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (m *mockSessionRepository) Create(_ context.Context, session *domain.Session) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
// Check unique constraint: only one active per project.
|
|
for _, s := range m.sessions {
|
|
if s.ProjectID == session.ProjectID && s.Status == domain.SessionStatusActive {
|
|
return domain.ErrSessionExists
|
|
}
|
|
}
|
|
session.ID = domain.SessionID("test-session-id")
|
|
m.sessions[string(session.ID)] = session
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSessionRepository) Get(_ 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 *mockSessionRepository) GetActiveByProject(_ 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 *mockSessionRepository) ListByProject(_ 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 *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
|
|
}
|
|
s, ok := m.sessions[string(id)]
|
|
if !ok || s.Status != domain.SessionStatusActive {
|
|
return domain.ErrSessionNotActive
|
|
}
|
|
s.Status = domain.SessionStatusEnded
|
|
now := time.Now()
|
|
s.EndedAt = &now
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSessionRepository) CleanupExpired(_ 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
|
|
}
|
|
|
|
func (m *mockSessionRepository) SetClaudeSessionID(_ context.Context, id domain.SessionID, claudeSessionID, conversationRecordID string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
s, ok := m.sessions[string(id)]
|
|
if !ok {
|
|
return domain.ErrSessionNotFound
|
|
}
|
|
s.ClaudeSessionID = claudeSessionID
|
|
s.ConversationRecordID = conversationRecordID
|
|
m.persistedOnce.Do(func() { close(m.claudeSessionIDPersisted) })
|
|
return nil
|
|
}
|
|
|
|
// mockCheckoutRepository implements port.CheckoutRepository for testing sessions.
|
|
type mockCheckoutRepository struct {
|
|
checkouts map[string]*domain.Checkout
|
|
err error
|
|
}
|
|
|
|
func newMockCheckoutRepository() *mockCheckoutRepository {
|
|
return &mockCheckoutRepository{checkouts: make(map[string]*domain.Checkout)}
|
|
}
|
|
|
|
func (m *mockCheckoutRepository) Create(_ context.Context, c *domain.Checkout) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
c.ID = domain.CheckoutID("test-checkout-id")
|
|
m.checkouts[string(c.ID)] = c
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCheckoutRepository) Get(_ 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 *mockCheckoutRepository) GetByProjectBranch(_ context.Context, _ domain.ProjectID, _ string) (*domain.Checkout, error) {
|
|
return nil, domain.ErrCheckoutNotFound
|
|
}
|
|
|
|
func (m *mockCheckoutRepository) List(_ context.Context, _ domain.CheckoutListOptions) ([]*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 {
|
|
if c, ok := m.checkouts[string(id)]; ok {
|
|
c.Status = status
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCheckoutRepository) SetCheckedIn(_ context.Context, id domain.CheckoutID, _ string) error {
|
|
if c, ok := m.checkouts[string(id)]; ok {
|
|
c.Status = domain.CheckoutStatusCheckedIn
|
|
now := time.Now()
|
|
c.CheckedInAt = &now
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCheckoutRepository) SetReviewTask(_ context.Context, _ domain.CheckoutID, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCheckoutRepository) CleanupExpired(_ context.Context) ([]int64, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// setupSessionTest creates a sessions handler with mock dependencies.
|
|
// It reuses mockProjectRepo from queue_test.go.
|
|
func setupSessionTest() (*SessionsHandler, *mockSessionRepository, *mockProjectRepo) {
|
|
handler, sessionRepo, projectRepo, _, _ := setupSessionTestFull()
|
|
return handler, sessionRepo, projectRepo
|
|
}
|
|
|
|
// setupSessionTestFull creates a sessions handler with all mock dependencies exposed.
|
|
func setupSessionTestFull() (*SessionsHandler, *mockSessionRepository, *mockProjectRepo, *mockConversationRepository, *mockExecutor) {
|
|
sessionRepo := newMockSessionRepository()
|
|
checkoutRepo := newMockCheckoutRepository()
|
|
projectRepo := newMockProjectRepo()
|
|
gitRepo := newMockGitRepository()
|
|
previewMgr := newMockPreviewManager()
|
|
convRepo := newMockConversationRepository()
|
|
|
|
// Add a test project.
|
|
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,
|
|
},
|
|
)
|
|
|
|
sessionService := service.NewSessionService(
|
|
sessionRepo,
|
|
checkoutService,
|
|
projectRepo,
|
|
previewMgr,
|
|
service.SessionServiceConfig{
|
|
PreviewDomain: "preview.threesix.ai",
|
|
DefaultExpiry: 24 * time.Hour,
|
|
},
|
|
)
|
|
|
|
conversationService := service.NewConversationService(convRepo)
|
|
executor := newMockExecutor()
|
|
streams := newMockStreamPublisher()
|
|
|
|
handler := NewSessionsHandler(sessionService, executor, streams).
|
|
WithConversationService(conversationService)
|
|
return handler, sessionRepo, projectRepo, convRepo, executor
|
|
}
|
|
|
|
func TestSessionsHandler_Create(t *testing.T) {
|
|
handler, _, _ := setupSessionTest()
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
t.Run("create_session", func(t *testing.T) {
|
|
body := `{"branch": "develop"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", 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"] == "" {
|
|
t.Error("expected non-empty session id")
|
|
}
|
|
if data["preview_url"] == "" {
|
|
t.Error("expected non-empty preview_url")
|
|
}
|
|
if data["auth_clone_url"] == "" {
|
|
t.Error("expected auth_clone_url on creation")
|
|
}
|
|
if data["instructions"] == "" {
|
|
t.Error("expected instructions on creation")
|
|
}
|
|
if data["pod_name"] != "test-project-0" {
|
|
t.Errorf("expected pod_name=test-project-0, got %v", data["pod_name"])
|
|
}
|
|
})
|
|
|
|
t.Run("create_session_no_branch", func(t *testing.T) {
|
|
body := `{}`
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", 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", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
t.Run("create_session_project_not_found", func(t *testing.T) {
|
|
body := `{"branch": "develop"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/sessions", 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 TestSessionsHandler_Get(t *testing.T) {
|
|
handler, sessionRepo, _ := setupSessionTest()
|
|
|
|
// Seed a session.
|
|
session := &domain.Session{
|
|
ID: "session-abc",
|
|
ProjectID: "test-project",
|
|
CheckoutID: "checkout-abc",
|
|
PodName: "test-project-0",
|
|
PreviewURL: "https://abc.preview.threesix.ai",
|
|
PreviewHost: "abc.preview.threesix.ai",
|
|
CreatedBy: "test",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
Status: domain.SessionStatusActive,
|
|
}
|
|
sessionRepo.sessions["session-abc"] = session
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
t.Run("get_existing", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/session-abc", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("get_wrong_project", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/projects/other-project/sessions/session-abc", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
})
|
|
|
|
t.Run("get_not_found", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/nonexistent", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSessionsHandler_List(t *testing.T) {
|
|
handler, sessionRepo, _ := setupSessionTest()
|
|
|
|
// Seed sessions.
|
|
sessionRepo.sessions["session-1"] = &domain.Session{
|
|
ID: "session-1",
|
|
ProjectID: "test-project",
|
|
CheckoutID: "checkout-1",
|
|
PodName: "test-project-0",
|
|
PreviewURL: "https://s1.preview.threesix.ai",
|
|
PreviewHost: "s1.preview.threesix.ai",
|
|
CreatedBy: "test",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
Status: domain.SessionStatusActive,
|
|
}
|
|
sessionRepo.sessions["session-2"] = &domain.Session{
|
|
ID: "session-2",
|
|
ProjectID: "test-project",
|
|
CheckoutID: "checkout-2",
|
|
PodName: "test-project-0",
|
|
PreviewURL: "https://s2.preview.threesix.ai",
|
|
PreviewHost: "s2.preview.threesix.ai",
|
|
CreatedBy: "test",
|
|
CreatedAt: time.Now().Add(-1 * time.Hour),
|
|
ExpiresAt: time.Now().Add(23 * time.Hour),
|
|
Status: domain.SessionStatusEnded,
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions", 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"])
|
|
}
|
|
sessions, ok := data["sessions"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected sessions array, got %T", data["sessions"])
|
|
}
|
|
if len(sessions) != 2 {
|
|
t.Errorf("got %d sessions, want 2", len(sessions))
|
|
}
|
|
}
|
|
|
|
func TestSessionsHandler_Checkin(t *testing.T) {
|
|
handler, sessionRepo, _ := setupSessionTest()
|
|
|
|
// Seed an active session with a matching checkout.
|
|
sessionRepo.sessions["session-end"] = &domain.Session{
|
|
ID: "session-end",
|
|
ProjectID: "test-project",
|
|
CheckoutID: "checkout-end",
|
|
PodName: "test-project-0",
|
|
PreviewURL: "https://end.preview.threesix.ai",
|
|
PreviewHost: "end.preview.threesix.ai",
|
|
CreatedBy: "test",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
Status: domain.SessionStatusActive,
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
t.Run("checkin_session", func(t *testing.T) {
|
|
// Checkout doesn't exist in the mock, but session service continues on checkout errors.
|
|
body := `{"skip_review": true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("checkin_already_ended", func(t *testing.T) {
|
|
body := `{}`
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/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())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWorkersHandler_PoolStatus(t *testing.T) {
|
|
registry := newMockWorkerRegistry()
|
|
queue := newMockWorkQueue()
|
|
workerService := service.NewWorkerService(registry, queue)
|
|
handler := NewWorkersHandler(workerService).WithWorkQueue(queue)
|
|
|
|
registry.workers["w1"] = &domain.Worker{
|
|
ID: "w1", Hostname: "h1", Status: domain.WorkerStatusIdle,
|
|
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
|
|
}
|
|
registry.workers["w2"] = &domain.Worker{
|
|
ID: "w2", Hostname: "h2", Status: domain.WorkerStatusBusy,
|
|
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
|
|
}
|
|
|
|
// Add a pending task to the queue.
|
|
queue.tasks["t1"] = &domain.WorkTask{ID: "t1", Status: domain.WorkTaskStatusPending}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/workers/pool", 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 int(data["total"].(float64)) != 2 {
|
|
t.Errorf("total: got %v, want 2", data["total"])
|
|
}
|
|
if int(data["idle"].(float64)) != 1 {
|
|
t.Errorf("idle: got %v, want 1", data["idle"])
|
|
}
|
|
if int(data["busy"].(float64)) != 1 {
|
|
t.Errorf("busy: got %v, want 1", data["busy"])
|
|
}
|
|
if int(data["available"].(float64)) != 1 {
|
|
t.Errorf("available: got %v, want 1", data["available"])
|
|
}
|
|
if int(data["queue_depth"].(float64)) != 1 {
|
|
t.Errorf("queue_depth: got %v, want 1", data["queue_depth"])
|
|
}
|
|
}
|
|
|
|
// Verify port.PreviewManager is implemented by the mock.
|
|
var _ port.PreviewManager = (*mockPreviewManager)(nil)
|
|
|
|
func TestSessionsHandler_Exec_ContinueConversation(t *testing.T) {
|
|
handler, sessionRepo, _, convRepo, _ := setupSessionTestFull()
|
|
|
|
// Configure executor to emit JSONL output with a session_id.
|
|
jsonlOutput := `{"type":"assistant","session_id":"new-claude-sess-id","message":{"role":"assistant","content":[{"type":"text","text":"Done"}]}}`
|
|
jsonlExec := &jsonlMockExecutor{
|
|
result: &domain.CommandResult{ExitCode: 0, DurationMs: 100},
|
|
lines: []domain.OutputLine{
|
|
{Stream: "stdout", Line: jsonlOutput, Timestamp: time.Now()},
|
|
},
|
|
}
|
|
handler.executor = jsonlExec
|
|
|
|
// Seed an active session with a stored claude_session_id.
|
|
sessionRepo.sessions["session-conv"] = &domain.Session{
|
|
ID: "session-conv",
|
|
ProjectID: "test-project",
|
|
CheckoutID: "checkout-conv",
|
|
PodName: "test-project-0",
|
|
PreviewURL: "https://conv.preview.threesix.ai",
|
|
PreviewHost: "conv.preview.threesix.ai",
|
|
CreatedBy: "test",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
Status: domain.SessionStatusActive,
|
|
ClaudeSessionID: "prior-claude-sess-id",
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
t.Run("exec_returns_201_with_stream_url", func(t *testing.T) {
|
|
body := `{"type": "claude", "prompt": "add a test", "continue_conversation": true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-conv/exec", 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, _ := resp["data"].(map[string]any)
|
|
if data["stream_url"] == "" {
|
|
t.Error("expected non-empty stream_url")
|
|
}
|
|
if data["status"] != "running" {
|
|
t.Errorf("expected status=running, got %v", data["status"])
|
|
}
|
|
})
|
|
|
|
// Wait for the background goroutine to persist the claude session ID.
|
|
// SetClaudeSessionID is called after Execute and conversation writes, so
|
|
// waiting for it guarantees all prior side-effects are visible.
|
|
waitForPersist := func(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case <-sessionRepo.claudeSessionIDPersisted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for claude session ID to be persisted")
|
|
}
|
|
}
|
|
|
|
// Verify the executor received the prior session ID as resume.
|
|
t.Run("resume_session_id_forwarded", func(t *testing.T) {
|
|
waitForPersist(t)
|
|
if jsonlExec.lastResumeSessionID != "prior-claude-sess-id" {
|
|
t.Errorf("expected lastResumeSessionID=prior-claude-sess-id, got %q", jsonlExec.lastResumeSessionID)
|
|
}
|
|
})
|
|
|
|
// Verify the conversation record was created and session updated.
|
|
t.Run("conversation_record_created", func(t *testing.T) {
|
|
waitForPersist(t)
|
|
if len(convRepo.conversations) == 0 {
|
|
t.Error("expected a conversation record to be created")
|
|
}
|
|
s := sessionRepo.sessions["session-conv"]
|
|
if s.ClaudeSessionID != "new-claude-sess-id" {
|
|
t.Errorf("expected ClaudeSessionID=new-claude-sess-id, got %q", s.ClaudeSessionID)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSessionsHandler_Exec_ConversationIDOverride(t *testing.T) {
|
|
handler, sessionRepo, _, _, _ := setupSessionTestFull()
|
|
|
|
jsonlOutput := `{"type":"result","session_id":"fresh-sess","result":"The fix is done."}`
|
|
jsonlExec := &jsonlMockExecutor{
|
|
result: &domain.CommandResult{ExitCode: 0, DurationMs: 100},
|
|
lines: []domain.OutputLine{
|
|
{Stream: "stdout", Line: jsonlOutput, Timestamp: time.Now()},
|
|
},
|
|
}
|
|
handler.executor = jsonlExec
|
|
|
|
sessionRepo.sessions["session-override"] = &domain.Session{
|
|
ID: "session-override",
|
|
ProjectID: "test-project",
|
|
CheckoutID: "checkout-override",
|
|
PodName: "test-project-0",
|
|
PreviewURL: "https://override.preview.threesix.ai",
|
|
PreviewHost: "override.preview.threesix.ai",
|
|
CreatedBy: "test",
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
Status: domain.SessionStatusActive,
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
body := `{"type": "claude", "prompt": "fix auth", "conversation_id": "explicit-claude-sess-id"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-override/exec", 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())
|
|
}
|
|
|
|
// Wait for the background goroutine to persist the session ID (deterministic sync).
|
|
select {
|
|
case <-sessionRepo.claudeSessionIDPersisted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for claude session ID to be persisted")
|
|
}
|
|
|
|
// Verify the executor received the explicit conversation ID as resume.
|
|
if jsonlExec.lastResumeSessionID != "explicit-claude-sess-id" {
|
|
t.Errorf("expected lastResumeSessionID=explicit-claude-sess-id, got %q", jsonlExec.lastResumeSessionID)
|
|
}
|
|
}
|
|
|
|
// jsonlMockExecutor implements port.CommandExecutor and emits pre-configured lines.
|
|
type jsonlMockExecutor struct {
|
|
result *domain.CommandResult
|
|
lines []domain.OutputLine
|
|
lastResumeSessionID string
|
|
lastCmd *domain.Command
|
|
}
|
|
|
|
func (m *jsonlMockExecutor) Execute(_ context.Context, cmd *domain.Command, _ string, handler domain.OutputHandler) (*domain.CommandResult, error) {
|
|
m.lastCmd = cmd
|
|
m.lastResumeSessionID = cmd.ResumeSessionID
|
|
if handler != nil {
|
|
for _, line := range m.lines {
|
|
handler(line)
|
|
}
|
|
}
|
|
if m.result == nil {
|
|
return &domain.CommandResult{ExitCode: 0, DurationMs: 50}, nil
|
|
}
|
|
return m.result, nil
|
|
}
|
|
|
|
func (m *jsonlMockExecutor) Cancel(_ context.Context, _ domain.CommandID) error { return nil }
|
|
func (m *jsonlMockExecutor) PodExists(_ context.Context, _ string) (bool, error) { return true, nil }
|
|
func (m *jsonlMockExecutor) CheckConnection(_ context.Context) error { return nil }
|
|
|
|
var _ port.CommandExecutor = (*jsonlMockExecutor)(nil)
|