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) } }