agent-orchestrator/backend/internal/cli/send_test.go

272 lines
8.4 KiB
Go

package cli
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
)
// sendServer wires an httptest server expecting POST /api/v1/sessions/{id}/send
// and captures the request body and path the CLI hit.
type sendCapture struct {
body string
path string
}
// writeRunFileFor points the CLI's run-file at srv so postJSON dials the test
// server. It mirrors the run-file convention the other CLI tests use.
func writeRunFileFor(t *testing.T, cfg testConfig, srv *httptest.Server) {
t.Helper()
if err := runfile.Write(cfg.runFile, runfile.Info{
PID: os.Getpid(), Port: serverPort(t, srv.URL), StartedAt: time.Unix(100, 0).UTC(),
}); err != nil {
t.Fatalf("write run-file: %v", err)
}
}
func sendServer(t *testing.T, status int, respBody string) (*httptest.Server, *sendCapture) {
t.Helper()
capture := &sendCapture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
if !strings.HasPrefix(r.URL.Path, "/api/v1/sessions/") || !strings.HasSuffix(r.URL.Path, "/send") {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
capture.body = string(body)
capture.path = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, respBody)
}))
t.Cleanup(srv.Close)
return srv, capture
}
func TestSend_Success(t *testing.T) {
t.Setenv("AO_SESSION_ID", "")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK,
`{"ok":true,"sessionId":"demo-1","message":"hello agent"}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", "hello agent")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if capture.path != "/api/v1/sessions/demo-1/send" {
t.Errorf("path = %q, want /api/v1/sessions/demo-1/send", capture.path)
}
var req struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
if req.Message != "hello agent" {
t.Errorf("captured message = %q, want %q", req.Message, "hello agent")
}
}
func TestSend_PrefixesMessageWithSenderSessionID(t *testing.T) {
t.Setenv("AO_SESSION_ID", "aa-47")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK,
`{"ok":true,"sessionId":"demo-1","message":"hi"}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", " hi ")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
var req struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
want := "[from aa-47] hi "
if req.Message != want {
t.Errorf("captured message = %q, want %q", req.Message, want)
}
}
func TestSend_BlankSenderSessionIDDoesNotPrefixMessage(t *testing.T) {
t.Setenv("AO_SESSION_ID", " \t ")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK,
`{"ok":true,"sessionId":"demo-1","message":"hello agent"}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", "hello agent")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
var req struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
if req.Message != "hello agent" {
t.Errorf("captured message = %q, want %q", req.Message, "hello agent")
}
}
func TestSend_PreservesMessageWhitespace(t *testing.T) {
t.Setenv("AO_SESSION_ID", "")
cfg := setConfigEnv(t)
srv, capture := sendServer(t, http.StatusOK, `{"ok":true,"sessionId":"demo-1","message":"hi"}`)
writeRunFileFor(t, cfg, srv)
_, _, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", " hi ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var req struct {
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
if req.Message != " hi " {
t.Errorf("server received %q, want preserved whitespace", req.Message)
}
}
func TestSend_EmptyMessageIsUsageError(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, Deps{}, "send", "--session", "demo-1", "--message", " ")
if err == nil {
t.Fatal("expected usage error for empty message")
}
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
if !strings.Contains(err.Error(), "--message is required") {
t.Fatalf("error missing usage message: %v", err)
}
}
func TestSend_MissingSessionIsUsageError(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, Deps{}, "send", "--message", "hi")
if err == nil {
t.Fatal("expected usage error for missing --session")
}
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
}
func TestSend_ServerBadRequestExits1(t *testing.T) {
cfg := setConfigEnv(t)
srv, _ := sendServer(t, http.StatusBadRequest,
`{"error":"bad_request","code":"MESSAGE_REQUIRED","message":"Message is required"}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", "hi")
if err == nil {
t.Fatal("expected runtime error from 400")
}
if got := ExitCode(err); got != 1 {
t.Fatalf("exit code = %d, want 1", got)
}
if !strings.Contains(err.Error(), "MESSAGE_REQUIRED") && !strings.Contains(errOut, "MESSAGE_REQUIRED") {
t.Fatalf("error did not surface the server error envelope: %v\nstderr=%s", err, errOut)
}
}
func TestSend_ServerNotFoundExits1(t *testing.T) {
cfg := setConfigEnv(t)
srv, _ := sendServer(t, http.StatusNotFound,
`{"error":"not_found","code":"SESSION_NOT_FOUND","message":"Unknown session"}`)
writeRunFileFor(t, cfg, srv)
_, _, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "missing", "--message", "hi")
if err == nil {
t.Fatal("expected runtime error from 404")
}
if got := ExitCode(err); got != 1 {
t.Fatalf("exit code = %d, want 1", got)
}
}
func TestSend_ServerInternalErrorExits1(t *testing.T) {
cfg := setConfigEnv(t)
srv, _ := sendServer(t, http.StatusInternalServerError,
`{"error":"internal","code":"SESSION_OPERATION_FAILED","message":"Session operation failed"}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", "hi")
if err == nil {
t.Fatal("expected runtime error from 500")
}
if got := ExitCode(err); got != 1 {
t.Fatalf("exit code = %d, want 1", got)
}
// Regression guard: a future change that swallows the API envelope and
// prints only "daemon returned HTTP 500" would silently hide what the
// daemon was trying to tell the operator.
if !strings.Contains(err.Error(), "SESSION_OPERATION_FAILED") && !strings.Contains(errOut, "SESSION_OPERATION_FAILED") {
t.Fatalf("error did not surface the server error envelope: %v\nstderr=%s", err, errOut)
}
}
func TestSend_DaemonNotRunningExits1(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, Deps{}, "send", "--session", "demo-1", "--message", "hi")
if err == nil {
t.Fatal("expected error when daemon is not running")
}
if got := ExitCode(err); got != 1 {
t.Fatalf("exit code = %d, want 1", got)
}
}
func TestSend_NetworkErrorExits1(t *testing.T) {
cfg := setConfigEnv(t)
// Start and immediately close a server so the run-file points at a closed port.
srv, _ := sendServer(t, http.StatusOK, "{}")
writeRunFileFor(t, cfg, srv)
srv.Close()
_, _, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "send", "--session", "demo-1", "--message", "hi")
if err == nil {
t.Fatal("expected runtime error from network failure")
}
if got := ExitCode(err); got != 1 {
t.Fatalf("exit code = %d, want 1", got)
}
}