agent-orchestrator/backend/internal/httpd/controllers/sessions_test.go

430 lines
15 KiB
Go

package controllers_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
)
type fakeSessionService struct {
sessions map[domain.SessionID]domain.Session
sent string
cleanupProjects []domain.ProjectID
cleanupResult []domain.SessionID
claimErr error
listPRErr error
}
func newFakeSessionService() *fakeSessionService {
now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: "ao-1", ProjectID: "ao", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}}
}
func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) {
var out []domain.Session
for _, s := range f.sessions {
if filter.ProjectID != "" && s.ProjectID != filter.ProjectID {
continue
}
if filter.Active != nil && s.IsTerminated == *filter.Active {
continue
}
if filter.OrchestratorOnly && s.Kind != domain.KindOrchestrator {
continue
}
out = append(out, s)
}
return out, nil
}
func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
f.sessions[s.ID] = s
return s, nil
}
func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) {
if clean {
active := true
existing, err := f.List(ctx, sessionsvc.ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true})
if err != nil {
return domain.Session{}, err
}
for _, o := range existing {
if _, err := f.Kill(ctx, o.ID); err != nil {
return domain.Session{}, err
}
}
}
return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
}
func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) {
return f.sessions[id], nil
}
func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) {
s := f.sessions[id]
s.IsTerminated = false
s.Status = domain.StatusIdle
f.sessions[id] = s
return s, nil
}
func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, error) {
s := f.sessions[id]
s.IsTerminated = true
s.Status = domain.StatusTerminated
f.sessions[id] = s
return true, nil
}
func (f *fakeSessionService) Cleanup(_ context.Context, project domain.ProjectID) ([]domain.SessionID, error) {
f.cleanupProjects = append(f.cleanupProjects, project)
if f.cleanupResult != nil {
return f.cleanupResult, nil
}
return []domain.SessionID{"ao-1"}, nil
}
func (f *fakeSessionService) Rename(_ context.Context, id domain.SessionID, displayName string) error {
s, ok := f.sessions[id]
if !ok {
return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
s.DisplayName = displayName
f.sessions[id] = s
return nil
}
func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message string) error {
f.sent = message
return nil
}
func (f *fakeSessionService) ListPRs(_ context.Context, id domain.SessionID) ([]domain.PRFacts, error) {
if f.listPRErr != nil {
return nil, f.listPRErr
}
if _, ok := f.sessions[id]; !ok {
return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
return []domain.PRFacts{{URL: "https://github.com/aoagents/agent-orchestrator/pull/142", Number: 142, CI: domain.CIPassing, Review: domain.ReviewRequired, Mergeability: domain.MergeMergeable, UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)}}, nil
}
func (f *fakeSessionService) ClaimPR(_ context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) {
if f.claimErr != nil {
return sessionsvc.ClaimPRResult{}, f.claimErr
}
if _, ok := f.sessions[id]; !ok {
return sessionsvc.ClaimPRResult{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
prs, _ := f.ListPRs(context.Background(), id)
return sessionsvc.ClaimPRResult{PRs: prs, TakenOverFrom: []domain.SessionID{}, BranchChanged: true}, nil
}
func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Sessions: svc}, httpd.ControlDeps{}))
t.Cleanup(srv.Close)
return srv
}
func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{}))
t.Cleanup(srv.Close)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}
func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions?project=ao", "")
if status != http.StatusOK {
t.Fatalf("GET sessions = %d, want 200; body=%s", status, body)
}
var list struct {
Sessions []sessionBody `json:"sessions"`
}
mustJSON(t, body, &list)
if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) {
t.Fatalf("list = %#v", list)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`)
if status != http.StatusCreated {
t.Fatalf("POST session = %d, want 201; body=%s", status, body)
}
var spawned struct {
Session sessionBody `json:"session"`
}
mustJSON(t, body, &spawned)
if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" {
t.Fatalf("spawned = %#v", spawned)
}
body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "")
if status != http.StatusOK {
t.Fatalf("GET session = %d, want 200; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/send", "{\"message\":\"con\\u0000tinue\"}")
if status != http.StatusOK || svc.sent != "continue" {
t.Fatalf("send status=%d sent=%q body=%s", status, svc.sent, body)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/kill", "")
if status != http.StatusOK {
t.Fatalf("kill = %d, want 200; body=%s", status, body)
}
var killed struct {
SessionID string `json:"sessionId"`
Freed bool `json:"freed"`
}
mustJSON(t, body, &killed)
if killed.SessionID != "ao-2" || !killed.Freed {
t.Fatalf("kill response = %#v", killed)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/restore", "")
if status != http.StatusOK {
t.Fatalf("restore = %d, want 200; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-2", `{"displayName":"Renamed"}`)
if status != http.StatusOK {
t.Fatalf("rename = %d, want 200; body=%s", status, body)
}
var renamed struct {
OK bool `json:"ok"`
SessionID string `json:"sessionId"`
DisplayName string `json:"displayName"`
}
mustJSON(t, body, &renamed)
if !renamed.OK || renamed.SessionID != "ao-2" || renamed.DisplayName != "Renamed" {
t.Fatalf("rename response = %#v", renamed)
}
if svc.sessions["ao-2"].DisplayName != "Renamed" {
t.Fatalf("session displayName not updated: %+v", svc.sessions["ao-2"])
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/orchestrators", `{"projectId":"ao"}`)
if status != http.StatusCreated {
t.Fatalf("orchestrator = %d, want 201; body=%s", status, body)
}
}
func TestSessionsAPI_RenameNotFound(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/missing-1", `{"displayName":"Renamed"}`)
assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND")
}
func TestSessionsAPI_RenameValidation(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{"displayName":" "}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_REQUIRED")
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
}
func TestSessionsAPI_ListOrchestratorsOnly(t *testing.T) {
svc := newFakeSessionService()
now := time.Now().UTC()
svc.sessions["ao-orch"] = domain.Session{
SessionRecord: domain.SessionRecord{
ID: "ao-orch",
ProjectID: "ao",
Kind: domain.KindOrchestrator,
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now},
CreatedAt: now,
UpdatedAt: now,
},
Status: domain.StatusIdle,
}
svc.sessions["other-orch"] = domain.Session{
SessionRecord: domain.SessionRecord{
ID: "other-orch",
ProjectID: "other",
Kind: domain.KindOrchestrator,
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now},
CreatedAt: now,
UpdatedAt: now,
},
Status: domain.StatusIdle,
}
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/orchestrators", "")
if status != http.StatusOK {
t.Fatalf("GET orchestrators = %d, want 200; body=%s", status, body)
}
var list struct {
Sessions []sessionBody `json:"sessions"`
}
mustJSON(t, body, &list)
if len(list.Sessions) != 2 {
t.Fatalf("len(orchestrators) = %d, want 2; body=%s", len(list.Sessions), body)
}
got := map[string]string{}
for _, sess := range list.Sessions {
got[sess.ID] = sess.Kind
}
if got["ao-orch"] != string(domain.KindOrchestrator) || got["other-orch"] != string(domain.KindOrchestrator) {
t.Fatalf("missing orchestrators: %#v", got)
}
if _, ok := got["ao-1"]; ok {
t.Fatalf("worker session leaked into orchestrator list: %#v", got)
}
}
func TestSessionsAPI_SendValidation(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/send", `{"message":""}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED")
}
func TestSessionsAPI_CleanupWithProjectFilter(t *testing.T) {
svc := newFakeSessionService()
svc.cleanupResult = []domain.SessionID{"ao-1"}
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup?project=ao", "")
if status != http.StatusOK {
t.Fatalf("cleanup = %d, want 200; body=%s", status, body)
}
var got struct {
OK bool `json:"ok"`
Cleaned []string `json:"cleaned"`
}
mustJSON(t, body, &got)
if !got.OK || len(got.Cleaned) != 1 || got.Cleaned[0] != "ao-1" {
t.Fatalf("cleanup response = %#v", got)
}
if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "ao" {
t.Fatalf("cleanupProjects = %#v, want [ao]", svc.cleanupProjects)
}
}
func TestSessionsAPI_CleanupWithoutProjectFilter(t *testing.T) {
svc := newFakeSessionService()
svc.cleanupResult = []domain.SessionID{"ao-1", "other-1"}
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup", "")
if status != http.StatusOK {
t.Fatalf("cleanup = %d, want 200; body=%s", status, body)
}
var got struct {
Cleaned []string `json:"cleaned"`
}
mustJSON(t, body, &got)
if len(got.Cleaned) != 2 || got.Cleaned[0] != "ao-1" || got.Cleaned[1] != "other-1" {
t.Fatalf("cleanup response = %#v", got)
}
if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "" {
t.Fatalf("cleanupProjects = %#v, want empty project filter", svc.cleanupProjects)
}
}
type sessionBody struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
IssueID string `json:"issueId"`
Kind string `json:"kind"`
Harness string `json:"harness"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
}
func TestSessionsAPI_PRRoutes(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions/ao-1/pr", "")
if status != http.StatusOK {
t.Fatalf("GET PRs = %d body=%s", status, body)
}
var listed struct {
SessionID string `json:"sessionId"`
PRs []struct {
URL string `json:"url"`
Number int `json:"number"`
State string `json:"state"`
UpdatedAt string `json:"updatedAt"`
} `json:"prs"`
}
mustJSON(t, body, &listed)
if listed.SessionID != "ao-1" || len(listed.PRs) != 1 || listed.PRs[0].State != "open" {
t.Fatalf("GET shape = %#v", listed)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/pr/claim", `{"pr":"142"}`)
if status != http.StatusOK {
t.Fatalf("claim = %d body=%s", status, body)
}
var claimed struct {
OK bool `json:"ok"`
SessionID string `json:"sessionId"`
PRs []any `json:"prs"`
BranchChanged bool `json:"branchChanged"`
TakenOverFrom []string `json:"takenOverFrom"`
}
mustJSON(t, body, &claimed)
if !claimed.OK || claimed.SessionID != "ao-1" || len(claimed.PRs) != 1 || !claimed.BranchChanged || len(claimed.TakenOverFrom) != 0 {
t.Fatalf("claim shape = %#v", claimed)
}
}
func TestSessionsAPI_ClaimPRErrors(t *testing.T) {
cases := []struct {
name string
body string
err error
code int
want string
}{
{"bad json", `{`, nil, http.StatusBadRequest, "INVALID_JSON"},
{"missing pr", `{}`, nil, http.StatusBadRequest, "PR_REQUIRED"},
{"invalid ref", `{"pr":"x"}`, sessionsvc.ErrInvalidPRRef, http.StatusBadRequest, "INVALID_PR_REF"},
{"session missing", `{"pr":"142"}`, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session"), http.StatusNotFound, "SESSION_NOT_FOUND"},
{"pr missing", `{"pr":"142"}`, sessionsvc.ErrPRNotFound, http.StatusNotFound, "PR_NOT_FOUND"},
{"not open", `{"pr":"142"}`, sessionsvc.ErrPRNotOpen, http.StatusConflict, "PR_NOT_OPEN"},
{"claimed", `{"pr":"142","allowTakeover":false}`, ports.PRClaimedByActiveSessionError{Owner: "ao-2"}, http.StatusConflict, "PR_CLAIMED_BY_ACTIVE_SESSION"},
{"not claimable", `{"pr":"142"}`, sessionsvc.ErrSessionNotClaimable, http.StatusUnprocessableEntity, "SESSION_NOT_CLAIMABLE"},
{"mismatch", `{"pr":"142"}`, sessionsvc.ErrProjectMismatch, http.StatusUnprocessableEntity, "PR_PROJECT_MISMATCH"},
{"scm", `{"pr":"142"}`, sessionsvc.ErrSCMUnavailable, http.StatusServiceUnavailable, "SCM_UNAVAILABLE"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := newFakeSessionService()
svc.claimErr = tc.err
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/pr/claim", tc.body)
assertErrorCode(t, body, status, tc.code, tc.want)
})
}
}