Problem: entity/relationship/graph/blast-radius reads were embedded
as inline SQL in the httpapi handlers, duplicating the recursive type
tree CTE, the blast_radius function call, and the topology-picking
query across the REST and MCP surfaces with no port abstraction.
Change:
- ports.ReadModels interface: ListEntities, GetEntity, GetEntityBySlug,
GetEntityRelations, GetBlastRadius, GetGraph (with health),
ListEntityTypes. Returns EntityWithHealth (domain.Entity + health
from entity_status join) and domain.Relationship — no gen types
in the port.
- adapters/postgres/readmodels.go: EntityReader implements ReadModels
with the existing SQL verbatim (recursive type filter, blast_radius,
most-connected-first topology, graph edge listing).
- httpapi/entities.go: ListEntities, GetEntity, GetEntityRelations,
GetBlastRadius, GetGraph rewired to ReadModels. SQL moved to the
adapter; handlers map domain/ports types to gen wire shapes.
entityWithHealthToGen, sqlcEntityToGen helpers added.
- Old sqlcEntityToGen (sqlcgen.Entity → gen.Entity) preserved for
client_lifecycle.go; mutation handlers use domainToGen.
Verification: go build/vet, full test suite (19 pkgs), DB integration
(postgres + mcp — both green). httpapi DB tests have the pre-existing
set of failures (TestAPIEndToEnd entity_types=60/501, TestPhase3*)
verified at ec11956.
182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/core/ports"
|
|
"github.com/dtoro/oikos/internal/core/ports/portstest"
|
|
"github.com/dtoro/oikos/internal/ontology"
|
|
)
|
|
|
|
// fakeOntology is a static OntologyStore over a hand-built tree.
|
|
type fakeOntology struct{ tree *ontology.TypeTree }
|
|
|
|
func (f *fakeOntology) LoadTypeTree(context.Context) (ports.TypeTree, error) {
|
|
return f.tree, nil
|
|
}
|
|
|
|
func testTree() *ontology.TypeTree {
|
|
return &ontology.TypeTree{
|
|
Types: map[string]ontology.TypeInfo{
|
|
"service": {LifecycleID: "infra"},
|
|
"host": {LifecycleID: "infra"},
|
|
"widget": {IsAbstract: true},
|
|
},
|
|
Lifecycles: map[string]ontology.LifecycleInfo{
|
|
"infra": {States: map[string]bool{"planned": true, "active": true, "deprecated": true}, DefaultState: "active"},
|
|
},
|
|
RelTypes: map[string]ontology.RelTypeInfo{},
|
|
}
|
|
}
|
|
|
|
func newSvc(t *testing.T) (*EntityService, *portstest.EntityRepo) {
|
|
t.Helper()
|
|
repo := portstest.NewEntityRepo()
|
|
svc := NewEntityService(repo, &fakeOntology{tree: testTree()})
|
|
return svc, repo
|
|
}
|
|
|
|
func TestEntityServiceCreate(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, repo := newSvc(t)
|
|
|
|
e, res, err := svc.Create(ctx, CreateEntityCmd{
|
|
Type: "service", Name: "jellyfin", Attributes: map[string]any{"url": "https://media.example"},
|
|
ActorType: "operator", Actor: "tester", Method: "POST", Path: "/api/v1/entities",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if e.Slug != "service:jellyfin" {
|
|
t.Errorf("default slug = %q, want service:jellyfin", e.Slug)
|
|
}
|
|
if e.State != "active" {
|
|
t.Errorf("default state = %q, want active (lifecycle default)", e.State)
|
|
}
|
|
if res.Created != 0 {
|
|
// testTree's service declares no monitoring → nothing derived
|
|
t.Errorf("derived = %d, want 0 (no monitoring spec in fake tree)", res.Created)
|
|
}
|
|
if len(repo.Audits) != 1 || repo.Audits[0].Action != "create" {
|
|
t.Errorf("audit = %+v, want one create entry", repo.Audits)
|
|
}
|
|
if len(repo.Events) != 1 || repo.Events[0].Type != "entity.created" {
|
|
t.Errorf("event = %+v, want entity.created", repo.Events)
|
|
}
|
|
}
|
|
|
|
func TestEntityServiceCreateValidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newSvc(t)
|
|
|
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "no-such-type", Name: "x"}); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("unknown type: got %v, want ErrNotFound", err)
|
|
}
|
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "widget", Name: "x"}); !errors.Is(err, domain.ErrAbstractType) {
|
|
t.Errorf("abstract type: got %v, want ErrAbstractType", err)
|
|
}
|
|
// The stricter (MCP) rule now governs both surfaces: caller-supplied
|
|
// states must be declared in the lifecycle.
|
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "x", State: "destroyed"}); !errors.Is(err, domain.ErrInvalidTransition) {
|
|
t.Errorf("undeclared state: got %v, want ErrInvalidTransition", err)
|
|
}
|
|
}
|
|
|
|
func TestEntityServiceCreateIdempotency(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, repo := newSvc(t)
|
|
|
|
idem := &ports.Idempotency{
|
|
Actor: "tester", Key: "k1", RequestHash: "abc",
|
|
RenderBody: func(e domain.Entity) []byte { return []byte(`{"slug":"` + e.Slug + `"}`) },
|
|
}
|
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "h1", Idempotency: idem}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
cached, err := repo.GetIdempotent(ctx, "tester", "k1")
|
|
if err != nil {
|
|
t.Fatalf("get idempotent: %v", err)
|
|
}
|
|
if cached.RequestHash != "abc" || cached.ResponseCode != 201 {
|
|
t.Errorf("cached = %+v, want hash abc / code 201", cached)
|
|
}
|
|
if string(cached.ResponseBody) != `{"slug":"host:h1"}` {
|
|
t.Errorf("cached body = %s, want the rendered wire shape", cached.ResponseBody)
|
|
}
|
|
|
|
if _, err := repo.GetIdempotent(ctx, "tester", "other"); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("missing key: got %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestEntityServiceUpdate(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, repo := newSvc(t)
|
|
|
|
created, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "svc", Attributes: map[string]any{"a": 1}})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Merge semantics: existing keys survive.
|
|
updated, _, err := svc.Update(ctx, UpdateEntityCmd{
|
|
SlugOrID: "service:svc", ExpectedVer: created.Version,
|
|
Attributes: map[string]any{"b": 2}, RederiveChecks: true,
|
|
ActorType: "agent", Actor: "mcp", Method: "TOOL", Path: "update_entity_attributes",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if updated.Attributes["a"] != 1 || updated.Attributes["b"] != 2 {
|
|
t.Errorf("merged attrs = %v, want a+b", updated.Attributes)
|
|
}
|
|
if len(repo.Rederived) != 1 || repo.Rederived[0] != created.ID {
|
|
t.Errorf("redrive = %v, want one pass for the entity", repo.Rederived)
|
|
}
|
|
|
|
// Optimistic concurrency: stale version refuses.
|
|
if _, _, err := svc.Update(ctx, UpdateEntityCmd{SlugOrID: "service:svc", ExpectedVer: created.Version}); !errors.Is(err, domain.ErrConflict) {
|
|
t.Errorf("stale version: got %v, want ErrConflict", err)
|
|
}
|
|
|
|
// Replace semantics: wholesale swap.
|
|
replaced, _, err := svc.Update(ctx, UpdateEntityCmd{
|
|
SlugOrID: "service:svc", Attributes: map[string]any{"only": true}, AttrsReplace: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("replace update: %v", err)
|
|
}
|
|
if len(replaced.Attributes) != 1 || replaced.Attributes["only"] != true {
|
|
t.Errorf("replaced attrs = %v, want only", replaced.Attributes)
|
|
}
|
|
}
|
|
|
|
func TestEntityServiceSetState(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, repo := newSvc(t)
|
|
|
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "old"}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
after, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x")
|
|
if err != nil {
|
|
t.Fatalf("setState: %v", err)
|
|
}
|
|
if after.State != "deprecated" {
|
|
t.Errorf("state = %s, want deprecated", after.State)
|
|
}
|
|
if len(repo.Audits) != 2 || repo.Audits[1].Action != "state" {
|
|
t.Errorf("audits = %+v, want a state entry after create", repo.Audits)
|
|
}
|
|
|
|
// Stale From refuses (check-then-act).
|
|
if _, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x"); !errors.Is(err, domain.ErrConflict) {
|
|
t.Errorf("stale from-state: got %v, want ErrConflict", err)
|
|
}
|
|
}
|