Files
noema/internal/api/api_test.go
T
Andy Dixon 99a89ba847 feat: add the relationship graph with extraction, corrections and explorer
Phase 11. Entities are mind-scoped nodes with canonical matching,
aliases and deterministic kind guessing (hostnames and hyphenated ids
are systems, capitalised names people, file suffixes documents).
Relationships carry a relation from the shared vocabulary, confidence,
source, reinforcement count and the event that produced them; links tie
entities to memories, beliefs, events, goals and predictions.

The perceive and attend handlers upsert the entities a message or
observation mentions and record co-mention edges cited to the event, so
the graph grows only from what was actually seen. Operators can merge
("these are the same": edges and links move, the old name becomes an
alias, the old id still resolves), record that two entities are
different, correct kinds and add typed edges; every correction is an
operator action and a corrections row.

The explorer renders a bounded subgraph on a canvas with a small
force-directed layout in plain JavaScript, with kind filters, focus on
a neighbourhood and a side panel per entity. API, entity page, CLI and
docs/graph.md.
2026-06-29 19:28:55 +01:00

112 lines
5.0 KiB
Go

package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/consolidation"
"github.com/andydixon/noema/internal/conversation"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/motivation"
"github.com/andydixon/noema/internal/narrative"
"github.com/andydixon/noema/internal/notifications"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/predictions"
"github.com/andydixon/noema/internal/reflection"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
func testServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
pool := storage.TestPool(t, migrations.FS)
cfg := config.Defaults()
cfg.DatabaseURL = "postgres://test"
log := slog.New(slog.DiscardHandler)
sec, _ := secrets.New("")
llmStore := router.NewStore(pool)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), Secrets: sec, LLMStore: llmStore,
LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool),
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Conversations: conversation.NewStore(pool), Goals: goals.NewStore(pool), Motivation: motivation.NewStore(pool), Predictions: predictions.NewStore(pool), Beliefs: beliefs.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background()
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
deps.Graph = graph.NewStore(pool)
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
deps.Reflection = &reflection.Service{DB: pool, Events: deps.Events, Beliefs: deps.Beliefs, Goals: deps.Goals, Memory: deps.Memory, Predictions: deps.Predictions, Log: log}
group := scheduler.NewGroup(ctx, log)
t.Cleanup(func() { group.Stop() })
deps.Brain = brain.NewManager(brain.Config{DB: pool, Events: deps.Events, Minds: deps.Minds, Registry: brain.NewRegistry(), Pool: scheduler.NewPool(2), Log: log, Metrics: deps.Metrics}, group)
u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator)
if err != nil {
t.Fatal(err)
}
tok, _ := deps.Auth.CreateAPIToken(ctx, u.ID, "t", nil)
srv := httptest.NewServer(New(deps, nil).Handler())
t.Cleanup(srv.Close)
return srv, tok
}
func TestStatusAuthAndErrorEnvelope(t *testing.T) {
srv, tok := testServer(t)
res, _ := http.Get(srv.URL + "/api/v1/system/status")
if res.StatusCode != 401 {
t.Fatalf("expected 401, got %d", res.StatusCode)
}
var e ErrorBody
json.NewDecoder(res.Body).Decode(&e) //nolint:errcheck
if e.Error.Code != "unauthenticated" || e.Error.RequestID == "" {
t.Fatalf("bad error envelope: %+v", e)
}
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/system/status", nil)
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 200 {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var st SystemStatus
json.NewDecoder(res.Body).Decode(&st) //nolint:errcheck
if !st.Database.OK || len(st.Migrations) == 0 {
t.Fatalf("status incomplete: %+v", st)
}
if res.Header.Get("Content-Security-Policy") == "" {
t.Fatal("missing CSP header")
}
// Unknown JSON fields and body limits are rejected as 400.
req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"x","password":"long-enough-pass","role":"observer","bogus":1}`))
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 400 {
t.Fatalf("expected 400 for unknown field, got %d", res.StatusCode)
}
// Bearer requests are exempt from CSRF but still role-checked.
req, _ = http.NewRequest("POST", srv.URL+"/api/v1/users", strings.NewReader(`{"username":"obs","password":"long-enough-pass","role":"observer"}`))
req.Header.Set("Authorization", "Bearer "+tok)
res, _ = http.DefaultClient.Do(req)
if res.StatusCode != 201 {
t.Fatalf("expected 201, got %d", res.StatusCode)
}
}