feat: Phase 3d — ReadModels port, postgres impl, HTTP reads rewire
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.
This commit is contained in:
181
internal/core/app/entities_test.go
Normal file
181
internal/core/app/entities_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user