Files
hexis/internal/api/executions_test.go
T
kami dda4acfbb6 Serve capabilities through one serializer and add GET /api/v1/executions
The two "refactor later" items from REVIEW-2026-07-30.md; they share the wire
types, so they land together.

A capability had four divergent wire shapes — the HTTP handler, the MCP
adapter, pkg/client, and Maven's vendored copy of it. There is now a single
definition in pkg/client, mapped from domain by internal/wire and used by the
HTTP list/create/get paths and all four MCP surfaces. It lives in pkg/client
rather than internal so external consumers need not vendor internal/domain,
and so producer and consumer are literally the same type.

The unified shape is a strict superset of all four predecessors; nothing was
dropped. It adds enabled and requires_confirmation to the list responses
(never omitempty — an absent bool reads as unknown, not false), capability_id
to the MCP and client shapes, and the timing/attribute/version fields
previously only on get-by-ID. target_types and the list itself now serialize
as [] rather than null.

Both `id` and `capability_id` are deliberately kept, carrying the same value.
Maven decodes `id`; the spec and the rest of the API say `capability_id`.
Bearer auth is already a breaking change for that consumer, and stacking a
second silent one is the wrong trade — the redundancy stays until every
consumer is confirmed on capability_id, then `id` goes in an announced
removal. A test pins this and says so.

GET /api/v1/executions?entity_id=&since=&limit= implements spec §4.5, which
the Command Center needs. `since` reuses the changes-feed cursor convention
rather than inventing a second paging idiom. That cursor is the row's implicit
SQLite rowid, which is safe only while nothing deletes executions and nothing
VACUUMs — both would renumber and silently invalidate outstanding cursors. If
retention is ever added, this must become an explicit monotonic column first;
the constraint is documented at the query site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
2026-07-30 23:40:20 +04:00

254 lines
7.9 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/kami/hexis/internal/domain"
"github.com/kami/hexis/internal/storage"
)
// seedExecution inserts a finished execution against targetEntityID.
func seedExecution(t *testing.T, store *storage.Store, capID, targetEntityID string) *domain.Execution {
t.Helper()
now := time.Now().UTC()
e := &domain.Execution{
ID: domain.NewExecutionID(),
CapabilityID: capID,
TargetEntityID: targetEntityID,
Status: domain.ExecutionSucceeded,
Arguments: map[string]any{},
CreatedAt: now,
UpdatedAt: now,
}
if err := store.CreateExecution(e); err != nil {
t.Fatalf("create execution: %v", err)
}
return e
}
func getExecutions(t *testing.T, h *Handler, query string) []*domain.Execution {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions"+query, nil)
w := httptest.NewRecorder()
h.handleExecutions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /api/v1/executions%s: expected 200, got %d: %s", query, w.Code, w.Body.String())
}
var out []*domain.Execution
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response: %v", err)
}
return out
}
// TestListExecutions_FiltersByEntity pins the entity_id filter from
// ECOSYSTEM-SPEC.md §4.5 — the Command Center renders per-entity history and
// must not be shown another entity's executions.
func TestListExecutions_FiltersByEntity(t *testing.T) {
h, store := newTestHandler(t)
seedExecution(t, store, "cap_a", "ent_alpha")
seedExecution(t, store, "cap_b", "ent_beta")
wantAlpha := seedExecution(t, store, "cap_c", "ent_alpha")
all := getExecutions(t, h, "")
if len(all) != 3 {
t.Fatalf("unfiltered: expected 3 executions, got %d", len(all))
}
alpha := getExecutions(t, h, "?entity_id=ent_alpha")
if len(alpha) != 2 {
t.Fatalf("entity_id=ent_alpha: expected 2 executions, got %d", len(alpha))
}
for _, e := range alpha {
if e.TargetEntityID != "ent_alpha" {
t.Errorf("entity_id filter leaked execution for %s", e.TargetEntityID)
}
}
if alpha[1].ID != wantAlpha.ID {
t.Errorf("expected ascending order ending at %s, got %s", wantAlpha.ID, alpha[1].ID)
}
if none := getExecutions(t, h, "?entity_id=ent_nonexistent"); len(none) != 0 {
t.Errorf("unknown entity: expected 0 executions, got %d", len(none))
}
}
// TestListExecutions_SinceIsAnExclusiveCursor pins the pagination convention:
// `since` is the same exclusive integer cursor the /api/v1/changes feed uses,
// so a client learns one paging idiom. A cursor that returned rows it had
// already seen would make the Executions surface duplicate history.
func TestListExecutions_SinceIsAnExclusiveCursor(t *testing.T) {
h, store := newTestHandler(t)
for i := 0; i < 4; i++ {
seedExecution(t, store, "cap_a", "ent_alpha")
}
all := getExecutions(t, h, "")
if len(all) != 4 {
t.Fatalf("expected 4 executions, got %d", len(all))
}
for i, e := range all {
if e.Seq == 0 {
t.Fatalf("execution %d has no seq cursor", i)
}
if i > 0 && e.Seq <= all[i-1].Seq {
t.Fatalf("seq not strictly increasing: %d then %d", all[i-1].Seq, e.Seq)
}
}
cursor := all[1].Seq
page := getExecutions(t, h, "?since="+strconv.FormatInt(cursor, 10))
if len(page) != 2 {
t.Fatalf("since=%d: expected 2 executions, got %d", cursor, len(page))
}
for _, e := range page {
if e.Seq <= cursor {
t.Errorf("execution seq %d should have been excluded by since=%d", e.Seq, cursor)
}
}
// Draining the cursor yields an empty array, not null — the Command
// Center iterates the response unconditionally.
last := all[len(all)-1].Seq
if drained := getExecutions(t, h, "?since="+strconv.FormatInt(last, 10)); len(drained) != 0 {
t.Errorf("since=%d (latest): expected 0 executions, got %d", last, len(drained))
}
}
// TestListExecutions_CombinesEntityAndSince checks the two filters compose,
// which is the actual Command Center polling call.
func TestListExecutions_CombinesEntityAndSince(t *testing.T) {
h, store := newTestHandler(t)
seedExecution(t, store, "cap_a", "ent_alpha")
seedExecution(t, store, "cap_b", "ent_beta")
third := seedExecution(t, store, "cap_c", "ent_alpha")
all := getExecutions(t, h, "")
cursor := all[0].Seq
got := getExecutions(t, h, "?entity_id=ent_alpha&since="+strconv.FormatInt(cursor, 10))
if len(got) != 1 {
t.Fatalf("expected 1 execution, got %d", len(got))
}
if got[0].ID != third.ID {
t.Errorf("expected %s, got %s", third.ID, got[0].ID)
}
}
func TestListExecutions_LimitBoundsThePage(t *testing.T) {
h, store := newTestHandler(t)
for i := 0; i < 5; i++ {
seedExecution(t, store, "cap_a", "ent_alpha")
}
got := getExecutions(t, h, "?limit=2")
if len(got) != 2 {
t.Fatalf("limit=2: expected 2 executions, got %d", len(got))
}
}
func TestListExecutions_RejectsBadCursor(t *testing.T) {
h, _ := newTestHandler(t)
for _, q := range []string{"?since=abc", "?since=-1", "?limit=0", "?limit=nope"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions"+q, nil)
w := httptest.NewRecorder()
h.handleExecutions(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: expected 400, got %d: %s", q, w.Code, w.Body.String())
}
}
}
func TestListExecutions_RejectsNonGET(t *testing.T) {
h, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/executions", nil)
w := httptest.NewRecorder()
h.handleExecutions(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// TestListExecutions_RequiresBearerToken pins that the new endpoint sits
// behind the same auth as the rest of /api/v1/ rather than being routed
// around it. Execution history names every entity and capability on the box;
// it is not public.
func TestListExecutions_RequiresBearerToken(t *testing.T) {
h, store := newTestHandler(t)
seedExecution(t, store, "cap_a", "ent_alpha")
mux := http.NewServeMux()
h.Register(mux)
// No token at all.
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/executions?entity_id=ent_alpha", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated: expected 401, got %d: %s", w.Code, w.Body.String())
}
// Wrong token.
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("wrong token: expected 401, got %d", w.Code)
}
// Correct token.
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions", nil)
req.Header.Set("Authorization", "Bearer "+testToken)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("authenticated: expected 200, got %d: %s", w.Code, w.Body.String())
}
var out []*domain.Execution
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("expected 1 execution, got %d", len(out))
}
}
// TestListExecutions_RoutedSeparatelyFromGetByID guards the ServeMux split:
// "/api/v1/executions" (list) and "/api/v1/executions/" (by ID) are distinct
// patterns, and adding the former must not have shadowed the latter.
func TestListExecutions_RoutedSeparatelyFromGetByID(t *testing.T) {
h, store := newTestHandler(t)
exec := seedExecution(t, store, "cap_a", "ent_alpha")
mux := http.NewServeMux()
h.Register(mux)
req := httptest.NewRequest(http.MethodGet, "/api/v1/executions/"+exec.ID, nil)
req.Header.Set("Authorization", "Bearer "+testToken)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get by id: expected 200, got %d: %s", w.Code, w.Body.String())
}
var single domain.Execution
if err := json.Unmarshal(w.Body.Bytes(), &single); err != nil {
t.Fatalf("decode: %v", err)
}
if single.ID != exec.ID {
t.Errorf("expected %s, got %s", exec.ID, single.ID)
}
}