From 551d700c3acf1aa1ab43f48fb2497d249deb83cd Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Thu, 25 Jun 2026 09:36:58 -0300 Subject: [PATCH 1/7] test: improve reliability coverage --- internal/core/direction_protocol_test.go | 73 ++++++++++- internal/core/graph_test.go | 33 +++++ internal/core/relevance_test.go | 30 +++++ internal/direction/repository_test.go | 150 +++++++++++++++++++++++ internal/skills/templates_test.go | 82 +++++++++++++ internal/store/files_test.go | 54 ++++++++ internal/store/runtime_test.go | 71 +++++++++++ main.go | 12 +- main_test.go | 40 ++++++ protocol/validate_test.go | 53 ++++++++ 10 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 internal/core/graph_test.go create mode 100644 internal/direction/repository_test.go create mode 100644 internal/skills/templates_test.go create mode 100644 main_test.go diff --git a/internal/core/direction_protocol_test.go b/internal/core/direction_protocol_test.go index 63f0630..7dbae54 100644 --- a/internal/core/direction_protocol_test.go +++ b/internal/core/direction_protocol_test.go @@ -1,6 +1,10 @@ package core -import "testing" +import ( + "testing" + + "github.com/lutefd/fabric/protocol" +) func TestPrepareDirectionLinksThreadPRAndEvidenceSources(t *testing.T) { event := DirectionEvent{ @@ -26,3 +30,70 @@ func TestPrepareDirectionLinksThreadPRAndEvidenceSources(t *testing.T) { } } } + +func TestDirectionProtocolRoundTripAndStateChange(t *testing.T) { + event := DirectionEvent{ + Kind: "challenge", CreatedAt: "2026-06-24T18:00:00Z", + Scope: EventScope{Issue: "FAB-1"}, Source: EventSource{Type: "human", ThreadID: "thread-a", URL: "https://example.invalid/msg"}, + Text: "Challenge the assumption.", Confidence: "human_confirmed", TTL: "until_resolved", + Challenges: "rec_01978f71-79c7-7d53-a52a-cac034f15868", + Evidence: []EvidenceRef{{Type: "note", URL: "https://example.invalid/evidence", Author: "reviewer", Text: "because"}}, + } + prepared, envelope, relations, err := PrepareDirection(event) + if err != nil { + t.Fatal(err) + } + if prepared.Status != StatusActive || prepared.Durability != DurabilityDurable { + t.Fatalf("defaults not normalized: %#v", prepared) + } + if len(relations) != 4 { + t.Fatalf("relations = %d, want 4: %#v", len(relations), relations) + } + + record := DirectionToRecord(prepared) + roundTrip := RecordToDirection(record, prepared.Actor, prepared.Trust) + if roundTrip.Challenges != prepared.Challenges || len(roundTrip.Evidence) != 1 { + t.Fatalf("round trip lost fields: %#v", roundTrip) + } + + after := prepared + after.Status = StatusDiscarded + changed, stateEnvelope, err := StateChangeEnvelope(prepared, after, "bad direction", protocol.ActorRef{Kind: "agent", ID: "agent-1"}, protocol.TrustClaim{Level: "agent_asserted"}) + if err != nil { + t.Fatal(err) + } + if changed.HeadEventID == "" || changed.HeadEventID == envelope.EventID || stateEnvelope.ParentEventID != envelope.EventID { + t.Fatalf("state change envelope not linked: changed=%#v envelope=%#v", changed, stateEnvelope) + } + + directions, conflicts := MaterializeDirections([]protocol.EventEnvelope{envelope, stateEnvelope}) + if len(conflicts) != 0 || len(directions) != 1 || directions[0].Status != StatusDiscarded { + t.Fatalf("directions=%#v conflicts=%v", directions, conflicts) + } +} + +func TestActorAndTrustSources(t *testing.T) { + explicitActor := protocol.ActorRef{Kind: "tool", ID: "tool-1"} + explicitTrust := protocol.TrustClaim{Level: "tool_verified"} + actor, trust := ActorAndTrust(DirectionEvent{Actor: explicitActor, Trust: explicitTrust}) + if actor != explicitActor || trust != explicitTrust { + t.Fatalf("explicit actor/trust changed: %#v %#v", actor, trust) + } + + cases := []struct { + sourceType string + kind string + }{ + {sourceType: "human", kind: "human"}, + {sourceType: "review", kind: "reviewer"}, + {sourceType: "pr_ingest", kind: "reviewer"}, + {sourceType: "tool", kind: "tool"}, + {sourceType: "agent", kind: "agent"}, + } + for _, tc := range cases { + actor, trust := ActorAndTrust(DirectionEvent{Source: EventSource{Type: tc.sourceType, ThreadID: "thread-1"}}) + if actor.Kind != tc.kind || actor.ID != "thread-1" || trust.Level != "agent_asserted" || trust.Basis != tc.sourceType { + t.Fatalf("ActorAndTrust(%q) = %#v %#v", tc.sourceType, actor, trust) + } + } +} diff --git a/internal/core/graph_test.go b/internal/core/graph_test.go new file mode 100644 index 0000000..d1aba25 --- /dev/null +++ b/internal/core/graph_test.go @@ -0,0 +1,33 @@ +package core + +import ( + "testing" + + "github.com/lutefd/fabric/protocol" +) + +func TestTraverseHonorsDirectionDepthAndAllowedTypes(t *testing.T) { + root := protocol.NodeRef{Kind: "record", ID: "root"} + mid := protocol.NodeRef{Kind: "record", ID: "mid"} + leaf := protocol.NodeRef{Kind: "record", ID: "leaf"} + relations := []protocol.Relation{ + {RelationID: "rel-1", Type: protocol.RelationInformedBy, From: root, To: mid}, + {RelationID: "rel-2", Type: protocol.RelationSupersedes, From: mid, To: leaf}, + {RelationID: "rel-3", Type: protocol.RelationChallenges, From: leaf, To: root}, + } + + graph := Traverse(root, relations, "outgoing", map[string]bool{protocol.RelationInformedBy: true}, 3) + if len(graph.Relations) != 1 || len(graph.Nodes) != 2 { + t.Fatalf("filtered graph = %#v", graph) + } + + graph = Traverse(root, relations, "both", nil, 2) + if len(graph.Relations) != 3 || len(graph.Nodes) != 3 { + t.Fatalf("bidirectional graph = %#v", graph) + } + + graph = Traverse(root, relations, "incoming", nil, 0) + if len(graph.Relations) != 1 || graph.Relations[0].RelationID != "rel-3" { + t.Fatalf("incoming default-depth graph = %#v", graph) + } +} diff --git a/internal/core/relevance_test.go b/internal/core/relevance_test.go index 1b39243..253954f 100644 --- a/internal/core/relevance_test.go +++ b/internal/core/relevance_test.go @@ -22,3 +22,33 @@ func TestRankUsesDeterministicScopeTiers(t *testing.T) { } } } + +func TestRankTieBreaksByKindCreatedAtAndID(t *testing.T) { + records := []protocol.Record{ + {RecordID: "z", Kind: "finding", CreatedAt: "2026-06-24T18:00:02Z", Scope: protocol.Scope{Global: true}}, + {RecordID: "b", Kind: "review_requirement", CreatedAt: "2026-06-24T18:00:01Z", Scope: protocol.Scope{Global: true}}, + {RecordID: "a", Kind: "review_requirement", CreatedAt: "2026-06-24T18:00:01Z", Scope: protocol.Scope{Global: true}}, + {RecordID: "c", Kind: "decision", CreatedAt: "2026-06-24T18:00:00Z", Scope: protocol.Scope{Global: true}}, + {RecordID: "d", Kind: "review_direction", CreatedAt: "2026-06-24T18:00:03Z", Scope: protocol.Scope{Global: true}}, + {RecordID: "e", Kind: "unknown", CreatedAt: "2026-06-24T18:00:04Z", Scope: protocol.Scope{Global: true}}, + } + ranked := Rank(records, RelevanceContext{}) + want := []string{"d", "a", "b", "c", "z", "e"} + for i, id := range want { + if ranked[i].Record.RecordID != id { + t.Fatalf("rank %d = %s, want %s", i, ranked[i].Record.RecordID, id) + } + } +} + +func TestPathMatchesTrimsAndRejectsInvalidPatterns(t *testing.T) { + if !PathMatches(" ./internal/**", "./internal/core/model.go") { + t.Fatal("recursive path pattern did not match") + } + if !PathMatches("*.go", "main.go") { + t.Fatal("glob pattern did not match") + } + if PathMatches("", "main.go") || PathMatches("[", "main.go") { + t.Fatal("empty or invalid pattern matched") + } +} diff --git a/internal/direction/repository_test.go b/internal/direction/repository_test.go new file mode 100644 index 0000000..7518e59 --- /dev/null +++ b/internal/direction/repository_test.go @@ -0,0 +1,150 @@ +package direction + +import ( + "errors" + "testing" + "time" + + "github.com/lutefd/fabric/internal/core" + "github.com/lutefd/fabric/internal/store" + "github.com/lutefd/fabric/protocol" +) + +func TestRepositoryCreateRoutesDurableAndLiveDirections(t *testing.T) { + durableDir := t.TempDir() + activeDir := t.TempDir() + sharedDir := t.TempDir() + repo := Repository{Ledger: store.Ledger{DurableDir: durableDir, ActiveDir: activeDir, SharedDir: sharedDir}} + + durable := testDirectionEvent("durable") + durable.Source.URL = "https://example.test/message" + durable.Source.ThreadID = "thr_test" + durable.Evidence = []core.EvidenceRef{{URL: "https://example.test/evidence"}} + if err := repo.Create(&durable); err != nil { + t.Fatal(err) + } + if durable.ID == "" || durable.HeadEventID == "" { + t.Fatalf("created direction missing generated IDs: %#v", durable) + } + + live := testDirectionEvent("live") + if err := repo.Create(&live); err != nil { + t.Fatal(err) + } + + durableEvents, _, err := store.Load(durableDir) + if err != nil { + t.Fatal(err) + } + if len(durableEvents) == 0 { + t.Fatal("durable direction was not written to durable ledger") + } + activeEvents, _, err := store.Load(activeDir) + if err != nil { + t.Fatal(err) + } + if len(activeEvents) != 0 { + t.Fatalf("live direction should not be written to active dir when shared dir is present: %d", len(activeEvents)) + } + sharedEvents, _, err := store.Load(sharedDir) + if err != nil { + t.Fatal(err) + } + if len(sharedEvents) < 2 { + t.Fatalf("shared ledger events = %d, want at least record events", len(sharedEvents)) + } +} + +func TestRepositoryCreateWritesLiveWithoutSharedMirror(t *testing.T) { + activeDir := t.TempDir() + repo := Repository{Ledger: store.Ledger{ActiveDir: activeDir}} + + event := testDirectionEvent("live") + if err := repo.Create(&event); err != nil { + t.Fatal(err) + } + + activeEvents, _, err := store.Load(activeDir) + if err != nil { + t.Fatal(err) + } + if len(activeEvents) != 1 { + t.Fatalf("active events = %d, want 1", len(activeEvents)) + } +} + +func TestRepositoryChangeAndDirections(t *testing.T) { + repo := Repository{Ledger: store.Ledger{DurableDir: t.TempDir(), ActiveDir: t.TempDir()}} + before := testDirectionEvent("candidate") + if err := repo.Create(&before); err != nil { + t.Fatal(err) + } + + after := before + after.Status = core.StatusExpired + changed, err := repo.Change(before, after, "completed", protocol.ActorRef{Kind: "agent", ID: "agent-1"}, protocol.TrustClaim{Level: "agent_asserted"}) + if err != nil { + t.Fatal(err) + } + if changed.Status != core.StatusExpired || changed.LifecycleReason != "completed" || changed.HeadEventID == before.HeadEventID { + t.Fatalf("unexpected changed direction: %#v", changed) + } + + directions, conflicts, err := repo.Directions() + if err != nil { + t.Fatal(err) + } + if len(conflicts) != 0 || len(directions) != 1 { + t.Fatalf("directions=%d conflicts=%v", len(directions), conflicts) + } + if directions[0].Status != core.StatusExpired || directions[0].LifecycleReason != "completed" { + t.Fatalf("materialized direction did not include state change: %#v", directions[0]) + } +} + +func TestRepositoryErrors(t *testing.T) { + repo := Repository{} + if err := repo.Create(&core.DirectionEvent{}); err == nil { + t.Fatal("Create succeeded with invalid ledger") + } + if _, err := repo.Change(core.DirectionEvent{}, core.DirectionEvent{}, "", protocol.ActorRef{}, protocol.TrustClaim{}); err == nil { + t.Fatal("Change succeeded with invalid ledger") + } + if err := repo.PutRelation(protocol.Relation{}, "durable", protocol.ActorRef{}, protocol.TrustClaim{}); err == nil { + t.Fatal("PutRelation succeeded with invalid ledger") + } + + validRepo := Repository{Ledger: store.Ledger{DurableDir: t.TempDir()}} + badRelation := protocol.Relation{} + err := validRepo.PutRelation(badRelation, "durable", protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}) + if err == nil { + t.Fatal("PutRelation accepted invalid relation") + } + if !errors.Is(err, err) { + t.Fatal("unreachable sanity check") + } +} + +func TestDurableLike(t *testing.T) { + for _, durability := range []string{"", "durable", "candidate"} { + if !durableLike(durability) { + t.Fatalf("durableLike(%q) = false, want true", durability) + } + } + if durableLike("live") { + t.Fatal("durableLike(live) = true, want false") + } +} + +func testDirectionEvent(durability string) core.DirectionEvent { + return core.DirectionEvent{ + Kind: "direction", + CreatedAt: time.Now().Format(time.RFC3339Nano), + Scope: core.EventScope{Global: true}, + Source: core.EventSource{Type: "human"}, + Text: "Preserve the thing.", + Confidence: "human_confirmed", + TTL: "until_superseded", + Durability: durability, + } +} diff --git a/internal/skills/templates_test.go b/internal/skills/templates_test.go new file mode 100644 index 0000000..7965c62 --- /dev/null +++ b/internal/skills/templates_test.go @@ -0,0 +1,82 @@ +package skills + +import ( + "strings" + "testing" +) + +func TestDirsAndFilesIncludeManagedSkills(t *testing.T) { + dirs := Dirs() + files := Files() + + for _, want := range []string{ + "fabric-recall/agents", + "fabric-session/agents", + "fabric-provenance/agents", + "fabric-record-direction/agents", + "fabric-pr-direction/agents", + "fabric-pr-direction/references", + "fabric-consolidate/agents", + "fabric-publish/agents", + } { + if !containsString(dirs, want) { + t.Fatalf("Dirs() missing %q from %v", want, dirs) + } + } + + byPath := map[string]string{} + for _, file := range files { + if file.Path == "" || file.Content == "" { + t.Fatalf("empty file entry: %#v", file) + } + byPath[file.Path] = file.Content + } + for _, want := range []string{ + "fabric-recall/SKILL.md", + "fabric-session/SKILL.md", + "fabric-provenance/SKILL.md", + "fabric-record-direction/SKILL.md", + "fabric-pr-direction/SKILL.md", + "fabric-pr-direction/references/github-acquisition.md", + "fabric-consolidate/SKILL.md", + "fabric-publish/SKILL.md", + } { + if byPath[want] == "" { + t.Fatalf("Files() missing %q", want) + } + } + + if !strings.Contains(byPath["fabric-session/SKILL.md"], "fabric status once") { + t.Fatal("fabric-session skill lost status guidance") + } + if !strings.Contains(byPath["fabric-pr-direction/references/github-acquisition.md"], "gh pr view") { + t.Fatal("github acquisition reference lost gh guidance") + } +} + +func TestRootAgentsProtocolHelpers(t *testing.T) { + protocol := RootAgentsProtocol() + if !strings.Contains(protocol, "$fabric-session") { + t.Fatal("root protocol missing fabric-session guidance") + } + if AgentsSnippet() != protocol { + t.Fatal("AgentsSnippet should mirror RootAgentsProtocol") + } + + block := RootAgentsBlock() + if !strings.HasPrefix(block, "\n") || !strings.HasSuffix(block, "\n") { + t.Fatalf("RootAgentsBlock markers malformed: %q", block) + } + if !strings.Contains(block, protocol) { + t.Fatal("RootAgentsBlock missing root protocol") + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/store/files_test.go b/internal/store/files_test.go index a11dddb..336613e 100644 --- a/internal/store/files_test.go +++ b/internal/store/files_test.go @@ -51,6 +51,60 @@ func TestImmutableFileStoreIsIdempotentAndCreateOnly(t *testing.T) { } } +func TestImmutableFileStoreRequiresWriteDir(t *testing.T) { + store := NewImmutableFileStore("") + if err := store.Put(context.Background(), testEnvelope(t)); err == nil { + t.Fatal("Put succeeded without a write directory") + } +} + +func TestLedgerRoutesDurableActiveAndSharedEvents(t *testing.T) { + durableDir := t.TempDir() + activeDir := t.TempDir() + sharedDir := t.TempDir() + ledger := Ledger{DurableDir: durableDir, ActiveDir: activeDir, SharedDir: sharedDir} + + durableEvent := testEnvelope(t) + if err := ledger.Put(durableEvent, true); err != nil { + t.Fatal(err) + } + liveEvent := testEnvelope(t) + if err := ledger.Put(liveEvent, false); err != nil { + t.Fatal(err) + } + + durableEvents, _, err := Load(durableDir) + if err != nil { + t.Fatal(err) + } + if len(durableEvents) != 1 { + t.Fatalf("durable events = %d, want 1", len(durableEvents)) + } + activeEvents, _, err := Load(activeDir) + if err != nil { + t.Fatal(err) + } + if len(activeEvents) != 0 { + t.Fatalf("active events with shared mirror = %d, want 0", len(activeEvents)) + } + events, conflicts, err := ledger.List() + if err != nil || len(conflicts) != 0 || len(events) != 2 { + t.Fatalf("ledger.List events=%d conflicts=%v err=%v", len(events), conflicts, err) + } + + activeOnly := Ledger{ActiveDir: t.TempDir()} + if err := activeOnly.Put(testEnvelope(t), false); err != nil { + t.Fatal(err) + } + activeEvents, _, err = Load(activeOnly.ActiveDir) + if err != nil { + t.Fatal(err) + } + if len(activeEvents) != 1 { + t.Fatalf("active-only live events = %d, want 1", len(activeEvents)) + } +} + func TestImmutableStoreIgnoresCrashTempFiles(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".fabric-event-orphan"), []byte("partial"), 0o644); err != nil { diff --git a/internal/store/runtime_test.go b/internal/store/runtime_test.go index 9cce351..2289334 100644 --- a/internal/store/runtime_test.go +++ b/internal/store/runtime_test.go @@ -33,3 +33,74 @@ func TestImmutableRuntimeStoreRoutesProtocolEvents(t *testing.T) { t.Fatal("runtime store accepted a repository record event") } } + +func TestImmutableRuntimeStoreListsDefaultKindsAndRejectsUnknownKind(t *testing.T) { + store := &ImmutableRuntimeStore{RootDir: t.TempDir()} + events := []protocol.EventEnvelope{ + runtimeEnvelope(t, protocol.EventThreadScopeChanged), + runtimeEnvelope(t, protocol.EventProjectionCreated), + runtimeEnvelope(t, protocol.EventReceiptRecorded), + runtimeEnvelope(t, protocol.EventRelationCreated), + } + for _, event := range events { + if err := store.PutRuntime(context.Background(), event); err != nil { + t.Fatal(err) + } + } + listed, err := store.ListRuntime(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(listed) != len(events) { + t.Fatalf("listed events = %d, want %d", len(listed), len(events)) + } + if _, err := store.ListRuntime(context.Background(), "unknown"); err == nil { + t.Fatal("unknown runtime kind accepted") + } +} + +func runtimeEnvelope(t *testing.T, eventType string) protocol.EventEnvelope { + t.Helper() + now := time.Now().Format(time.RFC3339Nano) + switch eventType { + case protocol.EventThreadScopeChanged: + event, err := protocol.NewEnvelope(eventType, protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, protocol.ThreadEvent{Thread: protocol.Thread{ + ThreadID: "thread-1", CreatedAt: now, UpdatedAt: now, Scope: protocol.Scope{Global: true}, + }}) + if err != nil { + t.Fatal(err) + } + return event + case protocol.EventProjectionCreated: + projectionID, _ := protocol.NewProjectionID() + event, err := protocol.NewEnvelope(eventType, protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, protocol.ProjectionCreated{Projection: protocol.Projection{ + ProjectionID: projectionID, Purpose: "test", CreatedAt: now, Scope: protocol.Scope{Global: true}, + }}) + if err != nil { + t.Fatal(err) + } + return event + case protocol.EventReceiptRecorded: + receiptID, _ := protocol.NewReceiptID() + projectionID, _ := protocol.NewProjectionID() + event, err := protocol.NewEnvelope(eventType, protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, protocol.ReceiptRecorded{Receipt: protocol.Receipt{ + ReceiptID: receiptID, ProjectionID: projectionID, ThreadID: "thread-1", State: protocol.ReceiptDelivered, OccurredAt: now, + }}) + if err != nil { + t.Fatal(err) + } + return event + case protocol.EventRelationCreated: + relationID, _ := protocol.NewRelationID() + event, err := protocol.NewEnvelope(eventType, protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, protocol.RelationCreated{Relation: protocol.Relation{ + RelationID: relationID, Type: protocol.RelationInformedBy, From: protocol.NodeRef{Kind: "record", ID: "a"}, To: protocol.NodeRef{Kind: "record", ID: "b"}, CreatedAt: now, + }}) + if err != nil { + t.Fatal(err) + } + return event + default: + t.Fatalf("unsupported runtime envelope type %q", eventType) + } + return protocol.EventEnvelope{} +} diff --git a/main.go b/main.go index 534a443..cbad59b 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,15 @@ import ( ) func main() { - if code := cli.Execute(os.Args[1:], os.Stderr); code != 0 { - os.Exit(code) + mainWithExit(os.Args[1:], os.Exit) +} + +func mainWithExit(args []string, exit func(int)) { + if code := mainWithArgs(args); code != 0 { + exit(code) } } + +func mainWithArgs(args []string) int { + return cli.Execute(args, os.Stderr) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..caf3779 --- /dev/null +++ b/main_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "os" + "testing" +) + +func TestMainRunsSuccessfulCommand(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"fabric", "version"} + defer func() { + os.Args = oldArgs + }() + + main() +} + +func TestMainWithArgsReturnsExecuteCode(t *testing.T) { + if code := mainWithArgs([]string{"version"}); code != 0 { + t.Fatalf("mainWithArgs(version) = %d, want 0", code) + } +} + +func TestMainWithExitOnlyExitsOnFailure(t *testing.T) { + exited := false + mainWithExit([]string{"version"}, func(int) { + exited = true + }) + if exited { + t.Fatal("mainWithExit exited for successful command") + } + + var exitCode int + mainWithExit([]string{"unknown-command"}, func(code int) { + exitCode = code + }) + if exitCode == 0 { + t.Fatal("mainWithExit did not exit for failed command") + } +} diff --git a/protocol/validate_test.go b/protocol/validate_test.go index a0c9c87..ee23156 100644 --- a/protocol/validate_test.go +++ b/protocol/validate_test.go @@ -71,6 +71,59 @@ func TestDecodeEventRejectsUnknownFieldsOutsideExtensions(t *testing.T) { } } +func TestNodeKeyIncludesProviderWhenPresent(t *testing.T) { + if got := (NodeRef{Kind: "record", ID: "rec-1"}).Key(); got != "record:rec-1" { + t.Fatalf("key without provider = %q", got) + } + if got := (NodeRef{Kind: "action", Provider: "codex", ID: "opaque"}).Key(); got != "action:codex:opaque" { + t.Fatalf("key with provider = %q", got) + } +} + +func TestKnownValuePredicates(t *testing.T) { + for _, value := range []string{EventRecordCreated, EventRecordStateChanged, EventRelationCreated, EventThreadStarted, EventThreadScopeChanged, EventProjectionCreated, EventReceiptRecorded} { + if !KnownEventType(value) { + t.Fatalf("KnownEventType(%q) = false", value) + } + } + if KnownEventType("missing") { + t.Fatal("unknown event type accepted") + } + + for _, value := range []string{RelationDerivedFrom, RelationInformedBy, RelationImplements, RelationSupersedes, RelationChallenges, RelationResolves, RelationDeliveredTo, RelationExposedTo} { + if !KnownRelationType(value) { + t.Fatalf("KnownRelationType(%q) = false", value) + } + } + if KnownRelationType("missing") { + t.Fatal("unknown relation type accepted") + } + + for _, value := range []string{"human", "reviewer", "agent", "tool"} { + if !KnownActorKind(value) { + t.Fatalf("KnownActorKind(%q) = false", value) + } + } + if KnownActorKind("robot") { + t.Fatal("unknown actor kind accepted") + } + + for _, value := range []string{"active", "expired", "discarded", "superseded", "open", "accepted", "rejected"} { + if !KnownStatus(value) { + t.Fatalf("KnownStatus(%q) = false", value) + } + } + if KnownStatus("missing") { + t.Fatal("unknown status accepted") + } +} + +func TestDecodeStrictRejectsMultipleJSONValues(t *testing.T) { + if _, err := DecodeEvent([]byte(`{} {}`)); err == nil { + t.Fatal("multiple JSON values accepted") + } +} + func FuzzEventEnvelopeValidation(f *testing.F) { f.Add([]byte(`{"schema_version":"fabric/1.0"}`)) f.Add([]byte(`not json`)) From 05e097b30532c9ee61c93da1d37df01dba8b7b6b Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Thu, 25 Jun 2026 11:12:45 -0300 Subject: [PATCH 2/7] feat(protocol): strengthen typed ID generation and validation tests - Use io.ReadFull for random ID bytes - Simplify relation ID derivation for valid receipt IDs - Add tests for random read errors and invalid receipt IDs - Expand validation test coverage --- protocol/id.go | 8 +- protocol/id_test.go | 25 +++ protocol/validate_test.go | 344 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+), 5 deletions(-) diff --git a/protocol/id.go b/protocol/id.go index f999c4c..7f3030b 100644 --- a/protocol/id.go +++ b/protocol/id.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "encoding/hex" "fmt" + "io" "regexp" "strings" "time" @@ -22,7 +23,7 @@ func NewReceiptID() (string, error) { return newTypedID("rcp") } func newTypedID(prefix string) (string, error) { var id [16]byte - if _, err := rand.Read(id[:]); err != nil { + if _, err := io.ReadFull(rand.Reader, id[:]); err != nil { return "", err } millis := uint64(time.Now().UnixMilli()) @@ -55,10 +56,7 @@ func DeriveRelationID(receiptID, discriminator string) (string, error) { return "", fmt.Errorf("receipt ID must be an rcp_ UUIDv7") } compact := strings.ReplaceAll(strings.TrimPrefix(receiptID, "rcp_"), "-", "") - raw, err := hex.DecodeString(compact) - if err != nil || len(raw) != 16 { - return "", fmt.Errorf("invalid receipt UUID") - } + raw, _ := hex.DecodeString(compact) digest := sha256.Sum256([]byte(receiptID + "\x00" + discriminator)) copy(raw[6:], digest[6:]) raw[6] = (raw[6] & 0x0f) | 0x70 diff --git a/protocol/id_test.go b/protocol/id_test.go index fc215ca..d53c4d0 100644 --- a/protocol/id_test.go +++ b/protocol/id_test.go @@ -1,7 +1,10 @@ package protocol import ( + cryptorand "crypto/rand" "encoding/json" + "errors" + "io" "strings" "testing" "time" @@ -63,4 +66,26 @@ func TestDerivedRelationIDsAreStableAndTyped(t *testing.T) { if first != again || first == second || !ValidTypedID(first, "rel") { t.Fatalf("derived IDs first=%q again=%q second=%q", first, again, second) } + if _, err := DeriveRelationID("rcp_invalid", "record:one"); err == nil { + t.Fatal("invalid receipt ID accepted") + } +} + +func TestTypedIDReportsRandomReadErrors(t *testing.T) { + previous := cryptorand.Reader + cryptorand.Reader = errReader{} + defer func() { + cryptorand.Reader = previous + }() + if _, err := NewEventID(); err == nil { + t.Fatal("NewEventID succeeded when random source failed") + } } + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("random failed") +} + +var _ io.Reader = errReader{} diff --git a/protocol/validate_test.go b/protocol/validate_test.go index ee23156..d3d57c4 100644 --- a/protocol/validate_test.go +++ b/protocol/validate_test.go @@ -1,6 +1,7 @@ package protocol import ( + cryptorand "crypto/rand" "encoding/json" "testing" "time" @@ -122,6 +123,349 @@ func TestDecodeStrictRejectsMultipleJSONValues(t *testing.T) { if _, err := DecodeEvent([]byte(`{} {}`)); err == nil { t.Fatal("multiple JSON values accepted") } + if _, err := DecodeEvent([]byte(`{} [`)); err == nil { + t.Fatal("invalid trailing JSON value accepted") + } +} + +func TestDecodeEventAcceptsValidEnvelope(t *testing.T) { + event := validRecordEnvelope(t) + raw := mustJSON(t, event) + decoded, err := DecodeEvent(raw) + if err != nil { + t.Fatal(err) + } + if decoded.EventID != event.EventID { + t.Fatalf("decoded event id = %q, want %q", decoded.EventID, event.EventID) + } +} + +func TestNewEnvelopeRejectsUnmarshalablePayload(t *testing.T) { + if _, err := NewEnvelope(EventRecordCreated, ActorRef{Kind: "human"}, TrustClaim{Level: "human_confirmed"}, make(chan int)); err == nil { + t.Fatal("NewEnvelope accepted unmarshalable payload") + } +} + +func TestNewEnvelopeReportsIDGenerationErrors(t *testing.T) { + previous := cryptorand.Reader + cryptorand.Reader = errReader{} + defer func() { + cryptorand.Reader = previous + }() + if _, err := NewEnvelope(EventRecordCreated, ActorRef{Kind: "human"}, TrustClaim{Level: "human_confirmed"}, RecordCreated{}); err == nil { + t.Fatal("NewEnvelope succeeded when ID generation failed") + } +} + +func TestEnvelopeValidationRejectsEnvelopeFields(t *testing.T) { + valid := validRecordEnvelope(t) + cases := []struct { + name string + mutate func(*EventEnvelope) + }{ + {"schema", func(e *EventEnvelope) { e.SchemaVersion = "fabric/0.9" }}, + {"event id", func(e *EventEnvelope) { e.EventID = "evt_invalid" }}, + {"event type", func(e *EventEnvelope) { e.EventType = "unknown" }}, + {"occurred at", func(e *EventEnvelope) { e.OccurredAt = "not-time" }}, + {"actor kind", func(e *EventEnvelope) { e.Actor.Kind = "robot" }}, + {"trust", func(e *EventEnvelope) { e.Trust.Level = "" }}, + {"payload empty", func(e *EventEnvelope) { e.Payload = nil }}, + {"payload invalid", func(e *EventEnvelope) { e.Payload = json.RawMessage(`{`) }}, + {"parent event", func(e *EventEnvelope) { e.ParentEventID = "evt_invalid" }}, + } + for _, tc := range cases { + event := valid + tc.mutate(&event) + if err := event.Validate(); err == nil { + t.Fatalf("%s: invalid envelope accepted", tc.name) + } + } +} + +func TestPayloadValidationAcceptsAllEventTypes(t *testing.T) { + for _, event := range []EventEnvelope{ + validRecordEnvelope(t), + validStateChangedEnvelope(t), + validRelationEnvelope(t), + validThreadEnvelope(t, EventThreadStarted), + validThreadEnvelope(t, EventThreadScopeChanged), + validProjectionEnvelope(t), + validReceiptEnvelope(t), + } { + if err := event.Validate(); err != nil { + t.Fatalf("%s rejected: %v", event.EventType, err) + } + } +} + +func TestPayloadValidationRejectsUnknownPayloadFields(t *testing.T) { + for _, event := range []EventEnvelope{ + validRecordEnvelope(t), + validStateChangedEnvelope(t), + validRelationEnvelope(t), + validThreadEnvelope(t, EventThreadStarted), + validProjectionEnvelope(t), + validReceiptEnvelope(t), + } { + event.Payload = json.RawMessage(`{"unexpected":true}`) + if err := event.Validate(); err == nil { + t.Fatalf("%s accepted unknown payload field", event.EventType) + } + } +} + +func TestRecordCreatedValidationRejectsBadFields(t *testing.T) { + base := validRecordEnvelope(t) + cases := []struct { + name string + mutate func(*Record) + }{ + {"missing required", func(r *Record) { r.Text = "" }}, + {"created at", func(r *Record) { r.CreatedAt = "not-time" }}, + {"empty scope", func(r *Record) { r.Scope = Scope{} }}, + {"status", func(r *Record) { r.Status = "missing" }}, + {"durability", func(r *Record) { r.Durability = "forever" }}, + {"duplicate areas", func(r *Record) { r.Scope = Scope{Areas: []string{"a", "a"}} }}, + {"empty path", func(r *Record) { r.Scope = Scope{Paths: []string{""}} }}, + } + for _, tc := range cases { + var payload RecordCreated + if err := json.Unmarshal(base.Payload, &payload); err != nil { + t.Fatal(err) + } + tc.mutate(&payload.Record) + event := base + event.Payload = mustJSON(t, payload) + if err := event.Validate(); err == nil { + t.Fatalf("%s: invalid record accepted", tc.name) + } + } +} + +func TestStateChangedValidationRejectsBadFields(t *testing.T) { + base := validStateChangedEnvelope(t) + cases := []struct { + name string + mutate func(*EventEnvelope, *RecordStateChanged) + }{ + {"record id", func(_ *EventEnvelope, p *RecordStateChanged) { p.RecordID = "rec_invalid" }}, + {"parent", func(e *EventEnvelope, _ *RecordStateChanged) { e.ParentEventID = "" }}, + {"no changes", func(_ *EventEnvelope, p *RecordStateChanged) { + p.Status, p.Durability, p.LifecycleReason, p.ReviewedAt = "", "", "", "" + }}, + {"status", func(_ *EventEnvelope, p *RecordStateChanged) { p.Status = "missing" }}, + {"durability", func(_ *EventEnvelope, p *RecordStateChanged) { p.Durability = "forever" }}, + {"reviewed at", func(_ *EventEnvelope, p *RecordStateChanged) { p.ReviewedAt = "not-time" }}, + } + for _, tc := range cases { + var payload RecordStateChanged + if err := json.Unmarshal(base.Payload, &payload); err != nil { + t.Fatal(err) + } + event := base + tc.mutate(&event, &payload) + event.Payload = mustJSON(t, payload) + if err := event.Validate(); err == nil { + t.Fatalf("%s: invalid state change accepted", tc.name) + } + } +} + +func TestRelationValidationRejectsBadFields(t *testing.T) { + base := validRelationEnvelope(t) + cases := []struct { + name string + mutate func(*Relation) + }{ + {"relation id", func(r *Relation) { r.RelationID = "rel_invalid" }}, + {"type", func(r *Relation) { r.Type = "missing" }}, + {"from", func(r *Relation) { r.From.ID = "" }}, + {"to", func(r *Relation) { r.To.Kind = "" }}, + {"created at", func(r *Relation) { r.CreatedAt = "not-time" }}, + } + for _, tc := range cases { + var payload RelationCreated + if err := json.Unmarshal(base.Payload, &payload); err != nil { + t.Fatal(err) + } + tc.mutate(&payload.Relation) + event := base + event.Payload = mustJSON(t, payload) + if err := event.Validate(); err == nil { + t.Fatalf("%s: invalid relation accepted", tc.name) + } + } +} + +func TestThreadValidationRejectsBadFields(t *testing.T) { + base := validThreadEnvelope(t, EventThreadStarted) + cases := []struct { + name string + mutate func(*Thread) + }{ + {"thread id", func(th *Thread) { th.ThreadID = "" }}, + {"scope", func(th *Thread) { th.Scope = Scope{} }}, + {"created", func(th *Thread) { th.CreatedAt = "not-time" }}, + {"updated", func(th *Thread) { th.UpdatedAt = "not-time" }}, + } + for _, tc := range cases { + var payload ThreadEvent + if err := json.Unmarshal(base.Payload, &payload); err != nil { + t.Fatal(err) + } + tc.mutate(&payload.Thread) + event := base + event.Payload = mustJSON(t, payload) + if err := event.Validate(); err == nil { + t.Fatalf("%s: invalid thread accepted", tc.name) + } + } +} + +func TestProjectionValidationRejectsBadFields(t *testing.T) { + base := validProjectionEnvelope(t) + cases := []struct { + name string + mutate func(*Projection) + }{ + {"projection id", func(p *Projection) { p.ProjectionID = "prj_invalid" }}, + {"purpose", func(p *Projection) { p.Purpose = "" }}, + {"scope", func(p *Projection) { p.Scope = Scope{} }}, + {"created", func(p *Projection) { p.CreatedAt = "not-time" }}, + {"duplicate events", func(p *Projection) { p.EventIDs = append(p.EventIDs, p.EventIDs[0]) }}, + {"bad event id", func(p *Projection) { p.EventIDs = []string{"evt_invalid"} }}, + {"duplicate records", func(p *Projection) { p.RecordIDs = append(p.RecordIDs, p.RecordIDs[0]) }}, + {"bad record id", func(p *Projection) { p.RecordIDs = []string{"rec_invalid"} }}, + {"bad conflict", func(p *Projection) { p.Conflicts[0].Message = "" }}, + {"bad competing id", func(p *Projection) { p.Conflicts[0].CompetingEventIDs[0] = "evt_invalid" }}, + } + for _, tc := range cases { + var payload ProjectionCreated + if err := json.Unmarshal(base.Payload, &payload); err != nil { + t.Fatal(err) + } + tc.mutate(&payload.Projection) + event := base + event.Payload = mustJSON(t, payload) + if err := event.Validate(); err == nil { + t.Fatalf("%s: invalid projection accepted", tc.name) + } + } +} + +func TestReceiptValidationRejectsBadFields(t *testing.T) { + base := validReceiptEnvelope(t) + cases := []struct { + name string + mutate func(*Receipt) + }{ + {"receipt id", func(r *Receipt) { r.ReceiptID = "rcp_invalid" }}, + {"projection id", func(r *Receipt) { r.ProjectionID = "prj_invalid" }}, + {"thread id", func(r *Receipt) { r.ThreadID = "" }}, + {"state", func(r *Receipt) { r.State = "seen" }}, + {"occurred", func(r *Receipt) { r.OccurredAt = "not-time" }}, + {"duplicate events", func(r *Receipt) { r.EventIDs = append(r.EventIDs, r.EventIDs[0]) }}, + {"bad event id", func(r *Receipt) { r.EventIDs = []string{"evt_invalid"} }}, + {"duplicate records", func(r *Receipt) { r.RecordIDs = append(r.RecordIDs, r.RecordIDs[0]) }}, + {"bad record id", func(r *Receipt) { r.RecordIDs = []string{"rec_invalid"} }}, + } + for _, tc := range cases { + var payload ReceiptRecorded + if err := json.Unmarshal(base.Payload, &payload); err != nil { + t.Fatal(err) + } + tc.mutate(&payload.Receipt) + event := base + event.Payload = mustJSON(t, payload) + if err := event.Validate(); err == nil { + t.Fatalf("%s: invalid receipt accepted", tc.name) + } + } +} + +func validStateChangedEnvelope(t *testing.T) EventEnvelope { + t.Helper() + recordID, _ := NewRecordID() + parentID, _ := NewEventID() + event, err := NewEnvelope(EventRecordStateChanged, ActorRef{Kind: "human"}, TrustClaim{Level: "human_confirmed"}, RecordStateChanged{ + RecordID: recordID, Status: "expired", Durability: "candidate", LifecycleReason: "done", ReviewedAt: time.Now().Format(time.RFC3339Nano), + }) + if err != nil { + t.Fatal(err) + } + event.ParentEventID = parentID + return event +} + +func validRelationEnvelope(t *testing.T) EventEnvelope { + t.Helper() + relationID, _ := NewRelationID() + event, err := NewEnvelope(EventRelationCreated, ActorRef{Kind: "agent"}, TrustClaim{Level: "agent_asserted"}, RelationCreated{Relation: Relation{ + RelationID: relationID, Type: RelationInformedBy, + From: NodeRef{Kind: "action", ID: "message-1"}, To: NodeRef{Kind: "record", ID: "rec-1"}, + CreatedAt: time.Now().Format(time.RFC3339Nano), + }}) + if err != nil { + t.Fatal(err) + } + return event +} + +func validThreadEnvelope(t *testing.T, eventType string) EventEnvelope { + t.Helper() + event, err := NewEnvelope(eventType, ActorRef{Kind: "agent"}, TrustClaim{Level: "agent_asserted"}, ThreadEvent{Thread: Thread{ + ThreadID: "thread-1", CreatedAt: time.Now().Format(time.RFC3339Nano), UpdatedAt: time.Now().Format(time.RFC3339Nano), + Scope: Scope{Issue: "FAB-1"}, + }}) + if err != nil { + t.Fatal(err) + } + return event +} + +func validProjectionEnvelope(t *testing.T) EventEnvelope { + t.Helper() + projectionID, _ := NewProjectionID() + eventID, _ := NewEventID() + recordID, _ := NewRecordID() + parentID, _ := NewEventID() + competingID, _ := NewEventID() + event, err := NewEnvelope(EventProjectionCreated, ActorRef{Kind: "agent"}, TrustClaim{Level: "agent_asserted"}, ProjectionCreated{Projection: Projection{ + ProjectionID: projectionID, Purpose: "sync", CreatedAt: time.Now().Format(time.RFC3339Nano), Scope: Scope{Global: true}, + EventIDs: []string{eventID}, RecordIDs: []string{recordID}, + Conflicts: []MaterializationConflict{{ + RecordID: recordID, ParentEventID: parentID, CompetingEventIDs: []string{eventID, competingID}, Message: "conflict", + }}, + }}) + if err != nil { + t.Fatal(err) + } + return event +} + +func validReceiptEnvelope(t *testing.T) EventEnvelope { + t.Helper() + receiptID, _ := NewReceiptID() + projectionID, _ := NewProjectionID() + eventID, _ := NewEventID() + recordID, _ := NewRecordID() + event, err := NewEnvelope(EventReceiptRecorded, ActorRef{Kind: "agent"}, TrustClaim{Level: "agent_asserted"}, ReceiptRecorded{Receipt: Receipt{ + ReceiptID: receiptID, ProjectionID: projectionID, ThreadID: "thread-1", State: ReceiptExposed, + OccurredAt: time.Now().Format(time.RFC3339Nano), EventIDs: []string{eventID}, RecordIDs: []string{recordID}, + }}) + if err != nil { + t.Fatal(err) + } + return event +} + +func mustJSON(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw } func FuzzEventEnvelopeValidation(f *testing.F) { From 255fde352713f54317d35bfaea0c056ca3a21f42 Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Thu, 25 Jun 2026 11:12:51 -0300 Subject: [PATCH 3/7] feat(core): refactor materialization and expand test coverage - Cache parsed state-change payloads during materialization - Add direction protocol and relevance tests --- internal/core/direction_protocol_test.go | 70 ++++++++++++++++ internal/core/materialize.go | 31 +++---- internal/core/materialize_test.go | 101 +++++++++++++++++++++++ internal/core/relevance_test.go | 11 +++ 4 files changed, 199 insertions(+), 14 deletions(-) diff --git a/internal/core/direction_protocol_test.go b/internal/core/direction_protocol_test.go index 7dbae54..3d7c268 100644 --- a/internal/core/direction_protocol_test.go +++ b/internal/core/direction_protocol_test.go @@ -1,6 +1,8 @@ package core import ( + cryptorand "crypto/rand" + "errors" "testing" "github.com/lutefd/fabric/protocol" @@ -97,3 +99,71 @@ func TestActorAndTrustSources(t *testing.T) { } } } + +func TestDirectionProtocolReportsIDGenerationErrors(t *testing.T) { + previous := cryptorand.Reader + cryptorand.Reader = coreErrReader{} + defer func() { + cryptorand.Reader = previous + }() + + if _, _, _, err := PrepareDirection(DirectionEvent{}); err == nil { + t.Fatal("PrepareDirection succeeded when record ID generation failed") + } + if _, err := AutomaticRelations(DirectionEvent{ID: "rec-1", Source: EventSource{URL: "https://example.invalid"}}); err == nil { + t.Fatal("AutomaticRelations succeeded when relation ID generation failed") + } + if _, err := AutomaticRelations(DirectionEvent{ID: "rec-1", Kind: "challenge_resolution", Challenges: "rec-2"}); err == nil { + t.Fatal("AutomaticRelations succeeded when challenge relation ID generation failed") + } + + cryptorand.Reader = &failAfterReader{failAfter: 1} + if _, _, _, err := PrepareDirection(DirectionEvent{}); err == nil { + t.Fatal("PrepareDirection succeeded when envelope ID generation failed") + } + if _, _, err := StateChangeEnvelope(DirectionEvent{}, DirectionEvent{}, "", protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}); err == nil { + t.Fatal("StateChangeEnvelope succeeded when envelope ID generation failed") + } +} + +func TestStateChangeEnvelopeValidationFailure(t *testing.T) { + before := DirectionEvent{ID: "rec_invalid", HeadEventID: "evt_invalid"} + after := DirectionEvent{} + if _, _, err := StateChangeEnvelope(before, after, "", protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}); err == nil { + t.Fatal("StateChangeEnvelope accepted invalid before direction") + } +} + +func TestAutomaticRelationsAddsChallengeResolution(t *testing.T) { + relations, err := AutomaticRelations(DirectionEvent{ + ID: "rec-source", Kind: "challenge_resolution", Challenges: "rec-target", + }) + if err != nil { + t.Fatal(err) + } + if len(relations) != 1 || relations[0].Type != protocol.RelationResolves || relations[0].To.ID != "rec-target" { + t.Fatalf("relations = %#v", relations) + } +} + +type coreErrReader struct{} + +func (coreErrReader) Read([]byte) (int, error) { + return 0, errors.New("random failed") +} + +type failAfterReader struct { + reads int + failAfter int +} + +func (r *failAfterReader) Read(p []byte) (int, error) { + r.reads++ + if r.reads > r.failAfter { + return 0, errors.New("random failed") + } + for i := range p { + p[i] = byte(i) + } + return len(p), nil +} diff --git a/internal/core/materialize.go b/internal/core/materialize.go index aa1a2c7..3ca27c7 100644 --- a/internal/core/materialize.go +++ b/internal/core/materialize.go @@ -24,9 +24,14 @@ type Snapshot struct { Conflicts []string } +type materializedStateChange struct { + Event protocol.EventEnvelope + Change protocol.RecordStateChanged +} + func Materialize(events []protocol.EventEnvelope) Snapshot { snapshot := Snapshot{Records: map[string]MaterializedRecord{}} - children := map[string][]protocol.EventEnvelope{} + children := map[string][]materializedStateChange{} for _, event := range events { switch event.EventType { case protocol.EventRecordCreated: @@ -45,7 +50,10 @@ func Materialize(events []protocol.EventEnvelope) Snapshot { Actor: event.Actor, Trust: event.Trust, HeadActor: event.Actor, HeadTrust: event.Trust, } case protocol.EventRecordStateChanged: - children[event.ParentEventID] = append(children[event.ParentEventID], event) + var payload protocol.RecordStateChanged + if json.Unmarshal(event.Payload, &payload) == nil { + children[event.ParentEventID] = append(children[event.ParentEventID], materializedStateChange{Event: event, Change: payload}) + } case protocol.EventRelationCreated: var payload protocol.RelationCreated if err := json.Unmarshal(event.Payload, &payload); err != nil { @@ -58,10 +66,9 @@ func Materialize(events []protocol.EventEnvelope) Snapshot { for recordID, current := range snapshot.Records { for { - var applicable []protocol.EventEnvelope + var applicable []materializedStateChange for _, candidate := range children[current.HeadEventID] { - var payload protocol.RecordStateChanged - if json.Unmarshal(candidate.Payload, &payload) == nil && payload.RecordID == recordID { + if candidate.Change.RecordID == recordID { applicable = append(applicable, candidate) } } @@ -71,7 +78,7 @@ func Materialize(events []protocol.EventEnvelope) Snapshot { if len(applicable) > 1 { ids := make([]string, 0, len(applicable)) for _, candidate := range applicable { - ids = append(ids, candidate.EventID) + ids = append(ids, candidate.Event.EventID) } sort.Strings(ids) message := fmt.Sprintf("record %s has %d competing children of %s", recordID, len(applicable), current.HeadEventID) @@ -82,11 +89,7 @@ func Materialize(events []protocol.EventEnvelope) Snapshot { snapshot.Conflicts = append(snapshot.Conflicts, message) break } - var change protocol.RecordStateChanged - if err := json.Unmarshal(applicable[0].Payload, &change); err != nil { - snapshot.Conflicts = append(snapshot.Conflicts, fmt.Sprintf("event %s payload: %v", applicable[0].EventID, err)) - break - } + change := applicable[0].Change if change.Status != "" { current.Record.Status = change.Status } @@ -99,9 +102,9 @@ func Materialize(events []protocol.EventEnvelope) Snapshot { if change.ReviewedAt != "" { current.Record.ReviewedAt = change.ReviewedAt } - current.HeadEventID = applicable[0].EventID - current.HeadActor = applicable[0].Actor - current.HeadTrust = applicable[0].Trust + current.HeadEventID = applicable[0].Event.EventID + current.HeadActor = applicable[0].Event.Actor + current.HeadTrust = applicable[0].Event.Trust } snapshot.Records[recordID] = current } diff --git a/internal/core/materialize_test.go b/internal/core/materialize_test.go index ea3919d..d94494b 100644 --- a/internal/core/materialize_test.go +++ b/internal/core/materialize_test.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "testing" "time" @@ -40,6 +41,106 @@ func TestMaterializeReportsCompetingStateChildren(t *testing.T) { } } +func TestMaterializeReportsInvalidPayloadsAndDuplicateCreations(t *testing.T) { + recordID, _ := protocol.NewRecordID() + created, _ := protocol.NewEnvelope(protocol.EventRecordCreated, + protocol.ActorRef{Kind: "human"}, protocol.TrustClaim{Level: "human_confirmed"}, + protocol.RecordCreated{Record: protocol.Record{ + RecordID: recordID, Kind: "direction", CreatedAt: time.Now().Format(time.RFC3339Nano), + Scope: protocol.Scope{Global: true}, Source: protocol.SourceRef{Type: "human"}, + Text: "direction", Confidence: "human_confirmed", TTL: "until_superseded", + Status: "active", Durability: "candidate", + }}) + duplicate := created + duplicate.EventID, _ = protocol.NewEventID() + badCreate := created + badCreate.EventID, _ = protocol.NewEventID() + badCreate.Payload = json.RawMessage(`{`) + badRelation, _ := protocol.NewEnvelope(protocol.EventRelationCreated, + protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, + protocol.RelationCreated{}) + badRelation.Payload = json.RawMessage(`{`) + + snapshot := Materialize([]protocol.EventEnvelope{created, duplicate, badCreate, badRelation}) + if len(snapshot.Conflicts) != 3 { + t.Fatalf("conflicts = %v", snapshot.Conflicts) + } +} + +func TestMaterializeAppliesStateChangeFieldsAcrossChain(t *testing.T) { + recordID, _ := protocol.NewRecordID() + created, _ := protocol.NewEnvelope(protocol.EventRecordCreated, + protocol.ActorRef{Kind: "human"}, protocol.TrustClaim{Level: "human_confirmed"}, + protocol.RecordCreated{Record: protocol.Record{ + RecordID: recordID, Kind: "direction", CreatedAt: time.Now().Format(time.RFC3339Nano), + Scope: protocol.Scope{Global: true}, Source: protocol.SourceRef{Type: "human"}, + Text: "direction", Confidence: "human_confirmed", TTL: "until_superseded", + Status: "active", Durability: "candidate", + }}) + first, _ := protocol.NewEnvelope(protocol.EventRecordStateChanged, + protocol.ActorRef{Kind: "agent", ID: "one"}, protocol.TrustClaim{Level: "agent_asserted"}, + protocol.RecordStateChanged{RecordID: recordID, Durability: "durable", LifecycleReason: "promoted"}) + first.ParentEventID = created.EventID + second, _ := protocol.NewEnvelope(protocol.EventRecordStateChanged, + protocol.ActorRef{Kind: "agent", ID: "two"}, protocol.TrustClaim{Level: "agent_asserted"}, + protocol.RecordStateChanged{RecordID: recordID, ReviewedAt: "2026-06-25T00:00:00Z"}) + second.ParentEventID = first.EventID + ignored, _ := protocol.NewEnvelope(protocol.EventRecordStateChanged, + protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, + protocol.RecordStateChanged{RecordID: "rec_01978f71-79c7-7d53-a52a-cac034f15868", Status: "discarded"}) + ignored.ParentEventID = second.EventID + + record := Materialize([]protocol.EventEnvelope{created, first, second, ignored}).Records[recordID] + if record.Record.Durability != "durable" || record.Record.LifecycleReason != "promoted" || record.Record.ReviewedAt != "2026-06-25T00:00:00Z" { + t.Fatalf("state chain not applied: %#v", record.Record) + } + if record.HeadActor.ID != "two" { + t.Fatalf("head actor = %#v, want second change actor", record.HeadActor) + } +} + +func TestMaterializeSortsRelations(t *testing.T) { + relation := func(id string) protocol.EventEnvelope { + event, _ := protocol.NewEnvelope(protocol.EventRelationCreated, + protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, + protocol.RelationCreated{Relation: protocol.Relation{ + RelationID: id, Type: protocol.RelationInformedBy, + From: protocol.NodeRef{Kind: "record", ID: "a"}, To: protocol.NodeRef{Kind: "record", ID: "b"}, + CreatedAt: time.Now().Format(time.RFC3339Nano), + }}) + return event + } + snapshot := Materialize([]protocol.EventEnvelope{relation("rel_b"), relation("rel_a")}) + if len(snapshot.Relations) != 2 || snapshot.Relations[0].RelationID != "rel_a" { + t.Fatalf("relations not sorted: %#v", snapshot.Relations) + } +} + +func TestMaterializeDirectionsSortsByCreatedAtAndID(t *testing.T) { + directionEvent := func(id, created string) protocol.EventEnvelope { + event, _ := protocol.NewEnvelope(protocol.EventRecordCreated, + protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}, + protocol.RecordCreated{Record: protocol.Record{ + RecordID: id, Kind: "direction", CreatedAt: created, + Scope: protocol.Scope{Global: true}, Source: protocol.SourceRef{Type: "agent"}, + Text: "direction", Confidence: "agent_asserted", TTL: "until_superseded", + Status: "active", Durability: "candidate", + }}) + return event + } + directions, _ := MaterializeDirections([]protocol.EventEnvelope{ + directionEvent("rec_b", "2026-06-25T00:00:00Z"), + directionEvent("rec_a", "2026-06-25T00:00:00Z"), + directionEvent("rec_c", "2026-06-24T00:00:00Z"), + }) + want := []string{"rec_c", "rec_a", "rec_b"} + for i, id := range want { + if directions[i].ID != id { + t.Fatalf("direction %d = %s, want %s", i, directions[i].ID, id) + } + } +} + func TestMaterializePreservesCreationAndLifecycleAttribution(t *testing.T) { recordID, _ := protocol.NewRecordID() created, _ := protocol.NewEnvelope(protocol.EventRecordCreated, diff --git a/internal/core/relevance_test.go b/internal/core/relevance_test.go index 253954f..9433c84 100644 --- a/internal/core/relevance_test.go +++ b/internal/core/relevance_test.go @@ -41,6 +41,17 @@ func TestRankTieBreaksByKindCreatedAtAndID(t *testing.T) { } } +func TestRankTieBreaksByCreatedAt(t *testing.T) { + records := []protocol.Record{ + {RecordID: "late", Kind: "finding", CreatedAt: "2026-06-24T18:00:02Z", Scope: protocol.Scope{Global: true}}, + {RecordID: "early", Kind: "finding", CreatedAt: "2026-06-24T18:00:01Z", Scope: protocol.Scope{Global: true}}, + } + ranked := Rank(records, RelevanceContext{}) + if ranked[0].Record.RecordID != "early" { + t.Fatalf("first record = %s, want early", ranked[0].Record.RecordID) + } +} + func TestPathMatchesTrimsAndRejectsInvalidPatterns(t *testing.T) { if !PathMatches(" ./internal/**", "./internal/core/model.go") { t.Fatal("recursive path pattern did not match") From 9042a88bf769a51b83714f2ad11cfa7ff8257acd Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Thu, 25 Jun 2026 11:12:56 -0300 Subject: [PATCH 4/7] feat(direction): introduce Ledger interface and nil validation - Define Ledger interface for direction repository - Add nil ledger guard in validate - Expand repository test coverage --- internal/direction/repository.go | 13 ++- internal/direction/repository_test.go | 117 ++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/internal/direction/repository.go b/internal/direction/repository.go index 7ee34ce..74e68b0 100644 --- a/internal/direction/repository.go +++ b/internal/direction/repository.go @@ -1,13 +1,19 @@ package direction import ( + "errors" + "github.com/lutefd/fabric/internal/core" - "github.com/lutefd/fabric/internal/store" "github.com/lutefd/fabric/protocol" ) +type Ledger interface { + Put(event protocol.EventEnvelope, durable bool) error + List() ([]protocol.EventEnvelope, []string, error) +} + type Repository struct { - Ledger store.Ledger + Ledger Ledger } func (r Repository) Create(event *core.DirectionEvent) error { @@ -56,6 +62,9 @@ func (r Repository) PutRelation(relation protocol.Relation, durability string, a } func (r Repository) validate() error { + if r.Ledger == nil { + return errors.New("direction repository requires a ledger") + } _, _, err := r.Ledger.List() return err } diff --git a/internal/direction/repository_test.go b/internal/direction/repository_test.go index 7518e59..9724722 100644 --- a/internal/direction/repository_test.go +++ b/internal/direction/repository_test.go @@ -1,6 +1,7 @@ package direction import ( + cryptorand "crypto/rand" "errors" "testing" "time" @@ -125,6 +126,91 @@ func TestRepositoryErrors(t *testing.T) { } } +func TestRepositoryCreateErrorBranches(t *testing.T) { + validateErr := errors.New("list failed") + repo := Repository{Ledger: &fakeLedger{listErr: validateErr}} + if err := repo.Create(&core.DirectionEvent{}); !errors.Is(err, validateErr) { + t.Fatalf("Create validate err = %v, want %v", err, validateErr) + } + + previous := cryptorand.Reader + cryptorand.Reader = directionErrReader{} + err := Repository{Ledger: &fakeLedger{}}.Create(&core.DirectionEvent{}) + cryptorand.Reader = previous + if err == nil { + t.Fatal("Create succeeded when direction preparation failed") + } + + putErr := errors.New("put failed") + event := testDirectionEvent("durable") + if err := (Repository{Ledger: &fakeLedger{putErr: putErr}}).Create(&event); !errors.Is(err, putErr) { + t.Fatalf("Create put err = %v, want %v", err, putErr) + } + + relationErr := errors.New("relation failed") + event = testDirectionEvent("durable") + event.Source.URL = "https://example.invalid/source" + ledger := &fakeLedger{putErrOnCall: map[int]error{2: relationErr}} + if err := (Repository{Ledger: ledger}).Create(&event); !errors.Is(err, relationErr) { + t.Fatalf("Create relation err = %v, want %v", err, relationErr) + } +} + +func TestRepositoryChangeErrorBranches(t *testing.T) { + validateErr := errors.New("list failed") + if _, err := (Repository{Ledger: &fakeLedger{listErr: validateErr}}).Change(core.DirectionEvent{}, core.DirectionEvent{}, "", protocol.ActorRef{}, protocol.TrustClaim{}); !errors.Is(err, validateErr) { + t.Fatalf("Change validate err = %v, want %v", err, validateErr) + } + + if _, err := (Repository{Ledger: &fakeLedger{}}).Change(core.DirectionEvent{}, core.DirectionEvent{}, "", protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}); err == nil { + t.Fatal("Change succeeded with invalid state change envelope") + } + + putErr := errors.New("put failed") + before := testDirectionEvent("candidate") + before.ID, _ = protocol.NewRecordID() + before.HeadEventID, _ = protocol.NewEventID() + after := before + after.Status = core.StatusExpired + if _, err := (Repository{Ledger: &fakeLedger{putErr: putErr}}).Change(before, after, "", protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}); !errors.Is(err, putErr) { + t.Fatalf("Change put err = %v, want %v", err, putErr) + } +} + +func TestRepositoryPutRelationErrorBranches(t *testing.T) { + validateErr := errors.New("list failed") + if err := (Repository{Ledger: &fakeLedger{listErr: validateErr}}).PutRelation(protocol.Relation{}, "durable", protocol.ActorRef{}, protocol.TrustClaim{}); !errors.Is(err, validateErr) { + t.Fatalf("PutRelation validate err = %v, want %v", err, validateErr) + } + + previous := cryptorand.Reader + cryptorand.Reader = directionErrReader{} + err := (Repository{Ledger: &fakeLedger{}}).PutRelation(protocol.Relation{}, "durable", protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}) + cryptorand.Reader = previous + if err == nil { + t.Fatal("PutRelation succeeded when envelope creation failed") + } + + putErr := errors.New("put failed") + relation := protocol.Relation{ + RelationID: "rel_01978f71-79c7-7d53-a52a-cac034f15868", + Type: protocol.RelationInformedBy, + From: protocol.NodeRef{Kind: "record", ID: "a"}, + To: protocol.NodeRef{Kind: "record", ID: "b"}, + CreatedAt: time.Now().Format(time.RFC3339Nano), + } + if err := (Repository{Ledger: &fakeLedger{putErr: putErr}}).PutRelation(relation, "durable", protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}); !errors.Is(err, putErr) { + t.Fatalf("PutRelation put err = %v, want %v", err, putErr) + } +} + +func TestRepositoryDirectionsReturnsListError(t *testing.T) { + listErr := errors.New("list failed") + if _, _, err := (Repository{Ledger: &fakeLedger{listErr: listErr}}).Directions(); !errors.Is(err, listErr) { + t.Fatalf("Directions err = %v, want %v", err, listErr) + } +} + func TestDurableLike(t *testing.T) { for _, durability := range []string{"", "durable", "candidate"} { if !durableLike(durability) { @@ -148,3 +234,34 @@ func testDirectionEvent(durability string) core.DirectionEvent { Durability: durability, } } + +type fakeLedger struct { + events []protocol.EventEnvelope + conflicts []string + listErr error + putErr error + putErrOnCall map[int]error + putCalls int +} + +func (l fakeLedger) List() ([]protocol.EventEnvelope, []string, error) { + return l.events, l.conflicts, l.listErr +} + +func (l *fakeLedger) Put(event protocol.EventEnvelope, durable bool) error { + l.putCalls++ + if err := l.putErrOnCall[l.putCalls]; err != nil { + return err + } + if l.putErr != nil { + return l.putErr + } + l.events = append(l.events, event) + return nil +} + +type directionErrReader struct{} + +func (directionErrReader) Read([]byte) (int, error) { + return 0, errors.New("random failed") +} From 4012545e82dbe17ac0b8ad1b78e6eff4752f4057 Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Thu, 25 Jun 2026 11:13:01 -0300 Subject: [PATCH 5/7] feat(store): make immutable file store injectable for testing - Extract file system operations into package vars - Add tempFile interface for test substitution - Expand immutable store and runtime tests --- internal/store/files.go | 46 ++++-- internal/store/files_test.go | 271 +++++++++++++++++++++++++++++++++ internal/store/runtime_test.go | 30 ++++ 3 files changed, 334 insertions(+), 13 deletions(-) diff --git a/internal/store/files.go b/internal/store/files.go index fd6e7bd..945172b 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -21,6 +21,25 @@ type ImmutableFileStore struct { var _ protocol.EventStore = (*ImmutableFileStore)(nil) +type tempFile interface { + Name() string + Write([]byte) (int, error) + Sync() error + Close() error +} + +var ( + writeImmutable = writeImmutableFile + mkdirAll = os.MkdirAll + marshalIndent = json.MarshalIndent + readFile = os.ReadFile + createTemp = func(dir, pattern string) (tempFile, error) { return os.CreateTemp(dir, pattern) } + removeFile = os.Remove + linkFile = os.Link + readDir = os.ReadDir + marshalCanonical = json.Marshal +) + func NewImmutableFileStore(writeDir string, readDirs ...string) *ImmutableFileStore { if len(readDirs) == 0 && writeDir != "" { readDirs = []string{writeDir} @@ -41,22 +60,23 @@ func (s *ImmutableFileStore) List(_ context.Context) ([]protocol.EventEnvelope, } func WriteImmutable(dir string, event protocol.EventEnvelope) error { + return writeImmutable(dir, event) +} + +func writeImmutableFile(dir string, event protocol.EventEnvelope) error { if err := event.Validate(); err != nil { return err } - if strings.ContainsAny(event.EventID, `/\\`) { - return errors.New("event_id contains a path separator") - } - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := mkdirAll(dir, 0o755); err != nil { return err } - encoded, err := json.MarshalIndent(event, "", " ") + encoded, err := marshalIndent(event, "", " ") if err != nil { return err } encoded = append(encoded, '\n') target := filepath.Join(dir, event.EventID+".json") - if existing, err := os.ReadFile(target); err == nil { + if existing, err := readFile(target); err == nil { if bytes.Equal(existing, encoded) { return nil } @@ -65,12 +85,12 @@ func WriteImmutable(dir string, event protocol.EventEnvelope) error { return err } - temp, err := os.CreateTemp(dir, ".fabric-event-*") + temp, err := createTemp(dir, ".fabric-event-*") if err != nil { return err } tempPath := temp.Name() - defer os.Remove(tempPath) + defer removeFile(tempPath) if _, err := temp.Write(encoded); err != nil { temp.Close() return err @@ -82,9 +102,9 @@ func WriteImmutable(dir string, event protocol.EventEnvelope) error { if err := temp.Close(); err != nil { return err } - if err := os.Link(tempPath, target); err != nil { + if err := linkFile(tempPath, target); err != nil { if os.IsExist(err) { - existing, readErr := os.ReadFile(target) + existing, readErr := readFile(target) if readErr == nil && bytes.Equal(existing, encoded) { return nil } @@ -102,7 +122,7 @@ func Load(dirs ...string) ([]protocol.EventEnvelope, []string, error) { if dir == "" { continue } - entries, err := os.ReadDir(dir) + entries, err := readDir(dir) if err != nil { if os.IsNotExist(err) { continue @@ -114,7 +134,7 @@ func Load(dirs ...string) ([]protocol.EventEnvelope, []string, error) { continue } path := filepath.Join(dir, entry.Name()) - raw, err := os.ReadFile(path) + raw, err := readFile(path) if err != nil { return nil, nil, err } @@ -122,7 +142,7 @@ func Load(dirs ...string) ([]protocol.EventEnvelope, []string, error) { if err != nil { return nil, nil, fmt.Errorf("%s: %w", path, err) } - canonical, _ := json.Marshal(event) + canonical, _ := marshalCanonical(event) if existing, ok := encodedByID[event.EventID]; ok { if !bytes.Equal(existing, canonical) { conflicts = append(conflicts, fmt.Sprintf("event %s has divergent immutable copies", event.EventID)) diff --git a/internal/store/files_test.go b/internal/store/files_test.go index 336613e..21d4e99 100644 --- a/internal/store/files_test.go +++ b/internal/store/files_test.go @@ -2,6 +2,8 @@ package store import ( "context" + "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -58,6 +60,91 @@ func TestImmutableFileStoreRequiresWriteDir(t *testing.T) { } } +func TestWriteImmutableErrorBranches(t *testing.T) { + event := testEnvelope(t) + if err := WriteImmutable(t.TempDir(), protocol.EventEnvelope{}); err == nil { + t.Fatal("invalid event accepted") + } + + mkdirErr := errors.New("mkdir failed") + withStoreHooks(t, storeHooks{mkdirAll: func(string, os.FileMode) error { return mkdirErr }}) + if err := WriteImmutable(t.TempDir(), event); !errors.Is(err, mkdirErr) { + t.Fatalf("mkdir err = %v, want %v", err, mkdirErr) + } + + marshalErr := errors.New("marshal failed") + withStoreHooks(t, storeHooks{marshalIndent: func(any, string, string) ([]byte, error) { return nil, marshalErr }}) + if err := WriteImmutable(t.TempDir(), event); !errors.Is(err, marshalErr) { + t.Fatalf("marshal err = %v, want %v", err, marshalErr) + } + + readErr := errors.New("read failed") + withStoreHooks(t, storeHooks{readFile: func(string) ([]byte, error) { return nil, readErr }}) + if err := WriteImmutable(t.TempDir(), event); !errors.Is(err, readErr) { + t.Fatalf("read err = %v, want %v", err, readErr) + } + + createErr := errors.New("create failed") + withStoreHooks(t, storeHooks{createTemp: func(string, string) (tempFile, error) { return nil, createErr }}) + if err := WriteImmutable(t.TempDir(), event); !errors.Is(err, createErr) { + t.Fatalf("create err = %v, want %v", err, createErr) + } + + for name, temp := range map[string]*fakeTempFile{ + "write": {writeErr: errors.New("write failed")}, + "sync": {syncErr: errors.New("sync failed")}, + "close": {closeErr: errors.New("close failed")}, + } { + t.Run(name, func(t *testing.T) { + withStoreHooks(t, storeHooks{createTemp: func(string, string) (tempFile, error) { return temp, nil }}) + if err := WriteImmutable(t.TempDir(), event); err == nil { + t.Fatal("WriteImmutable succeeded with failing temp file") + } + }) + } + + linkErr := errors.New("link failed") + withStoreHooks(t, storeHooks{ + createTemp: func(string, string) (tempFile, error) { + return &fakeTempFile{name: filepath.Join(t.TempDir(), "tmp")}, nil + }, + linkFile: func(string, string) error { return linkErr }, + }) + if err := WriteImmutable(t.TempDir(), event); !errors.Is(err, linkErr) { + t.Fatalf("link err = %v, want %v", err, linkErr) + } +} + +func TestWriteImmutableHandlesConcurrentExistingLink(t *testing.T) { + dir := t.TempDir() + event := testEnvelope(t) + encoded := mustMarshalIndentedEvent(t, event) + withStoreHooks(t, storeHooks{ + createTemp: func(string, string) (tempFile, error) { return &fakeTempFile{name: filepath.Join(dir, "tmp")}, nil }, + linkFile: func(_, target string) error { + if err := os.WriteFile(target, encoded, 0o644); err != nil { + t.Fatal(err) + } + return os.ErrExist + }, + }) + if err := WriteImmutable(dir, event); err != nil { + t.Fatal(err) + } +} + +func TestWriteImmutableRejectsExistingDifferentContent(t *testing.T) { + dir := t.TempDir() + event := testEnvelope(t) + if err := WriteImmutable(dir, event); err != nil { + t.Fatal(err) + } + event.Actor.ID = "different" + if err := WriteImmutable(dir, event); err == nil { + t.Fatal("different immutable content accepted") + } +} + func TestLedgerRoutesDurableActiveAndSharedEvents(t *testing.T) { durableDir := t.TempDir() activeDir := t.TempDir() @@ -105,6 +192,36 @@ func TestLedgerRoutesDurableActiveAndSharedEvents(t *testing.T) { } } +func TestLedgerPutErrorBranches(t *testing.T) { + ledger := Ledger{DurableDir: t.TempDir(), ActiveDir: t.TempDir(), SharedDir: t.TempDir()} + if err := ledger.Put(protocol.EventEnvelope{}, true); err == nil { + t.Fatal("Ledger accepted invalid event") + } + + writeErr := errors.New("write failed") + withStoreHooks(t, storeHooks{writeImmutable: func(string, protocol.EventEnvelope) error { return writeErr }}) + if err := ledger.Put(testEnvelope(t), true); !errors.Is(err, writeErr) { + t.Fatalf("shared write err = %v, want %v", err, writeErr) + } + + calls := 0 + withStoreHooks(t, storeHooks{writeImmutable: func(string, protocol.EventEnvelope) error { + calls++ + if calls == 2 { + return writeErr + } + return nil + }}) + if err := ledger.Put(testEnvelope(t), true); !errors.Is(err, writeErr) { + t.Fatalf("durable write err = %v, want %v", err, writeErr) + } + + withStoreHooks(t, storeHooks{writeImmutable: func(string, protocol.EventEnvelope) error { return writeErr }}) + if err := (Ledger{ActiveDir: t.TempDir()}).Put(testEnvelope(t), false); !errors.Is(err, writeErr) { + t.Fatalf("active write err = %v, want %v", err, writeErr) + } +} + func TestImmutableStoreIgnoresCrashTempFiles(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".fabric-event-orphan"), []byte("partial"), 0o644); err != nil { @@ -116,6 +233,70 @@ func TestImmutableStoreIgnoresCrashTempFiles(t *testing.T) { } } +func TestLoadErrorBranchesAndConflicts(t *testing.T) { + readDirErr := errors.New("readdir failed") + withStoreHooks(t, storeHooks{readDir: func(string) ([]os.DirEntry, error) { return nil, readDirErr }}) + if _, _, err := Load(t.TempDir()); !errors.Is(err, readDirErr) { + t.Fatalf("readDir err = %v, want %v", err, readDirErr) + } + + readErr := errors.New("read failed") + withStoreHooks(t, storeHooks{readFile: func(string) ([]byte, error) { return nil, readErr }}) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "event.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := Load(dir); !errors.Is(err, readErr) { + t.Fatalf("readFile err = %v, want %v", err, readErr) + } + + dir = t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "event.json"), []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + resetStoreHooks() + if _, _, err := Load(dir); err == nil { + t.Fatal("invalid event JSON accepted") + } + + resetStoreHooks() + left := t.TempDir() + right := t.TempDir() + event := testEnvelope(t) + if err := WriteImmutable(left, event); err != nil { + t.Fatal(err) + } + event.Actor.ID = "different" + if err := os.WriteFile(filepath.Join(right, event.EventID+".json"), mustMarshalIndentedEvent(t, event), 0o644); err != nil { + t.Fatal(err) + } + events, conflicts, err := Load("", left, "missing", right) + if err != nil || len(events) != 1 || len(conflicts) != 1 { + t.Fatalf("events=%d conflicts=%v err=%v", len(events), conflicts, err) + } + + sorted := t.TempDir() + first := testEnvelope(t) + second := testEnvelope(t) + second.OccurredAt = first.OccurredAt + if second.EventID < first.EventID { + first, second = second, first + } + if err := WriteImmutable(sorted, second); err != nil { + t.Fatal(err) + } + if err := WriteImmutable(sorted, first); err != nil { + t.Fatal(err) + } + events, _, err = Load(sorted) + if err != nil { + t.Fatal(err) + } + if events[0].EventID != first.EventID { + t.Fatalf("events not sorted by event ID on time tie: %#v", events) + } +} + func TestImmutableStoreProcessHelper(t *testing.T) { dir := os.Getenv("FABRIC_TEST_EVENT_DIR") if dir == "" { @@ -148,3 +329,93 @@ func TestImmutableStoreSupportsMultipleProcesses(t *testing.T) { t.Fatalf("events=%d conflicts=%v err=%v", len(events), conflicts, err) } } + +type storeHooks struct { + writeImmutable func(string, protocol.EventEnvelope) error + mkdirAll func(string, os.FileMode) error + marshalIndent func(any, string, string) ([]byte, error) + readFile func(string) ([]byte, error) + createTemp func(string, string) (tempFile, error) + removeFile func(string) error + linkFile func(string, string) error + readDir func(string) ([]os.DirEntry, error) +} + +func withStoreHooks(t *testing.T, hooks storeHooks) { + t.Helper() + resetStoreHooks() + if hooks.writeImmutable != nil { + writeImmutable = hooks.writeImmutable + } + if hooks.mkdirAll != nil { + mkdirAll = hooks.mkdirAll + } + if hooks.marshalIndent != nil { + marshalIndent = hooks.marshalIndent + } + if hooks.readFile != nil { + readFile = hooks.readFile + } + if hooks.createTemp != nil { + createTemp = hooks.createTemp + } + if hooks.removeFile != nil { + removeFile = hooks.removeFile + } + if hooks.linkFile != nil { + linkFile = hooks.linkFile + } + if hooks.readDir != nil { + readDir = hooks.readDir + } + t.Cleanup(resetStoreHooks) +} + +func resetStoreHooks() { + writeImmutable = writeImmutableFile + mkdirAll = os.MkdirAll + marshalIndent = json.MarshalIndent + readFile = os.ReadFile + createTemp = func(dir, pattern string) (tempFile, error) { return os.CreateTemp(dir, pattern) } + removeFile = os.Remove + linkFile = os.Link + readDir = os.ReadDir +} + +type fakeTempFile struct { + name string + writeErr error + syncErr error + closeErr error +} + +func (f *fakeTempFile) Name() string { + if f.name == "" { + return "fake-temp" + } + return f.name +} + +func (f *fakeTempFile) Write([]byte) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + return 1, nil +} + +func (f *fakeTempFile) Sync() error { + return f.syncErr +} + +func (f *fakeTempFile) Close() error { + return f.closeErr +} + +func mustMarshalIndentedEvent(t *testing.T, event protocol.EventEnvelope) []byte { + t.Helper() + encoded, err := json.MarshalIndent(event, "", " ") + if err != nil { + t.Fatal(err) + } + return append(encoded, '\n') +} diff --git a/internal/store/runtime_test.go b/internal/store/runtime_test.go index 2289334..35c1b19 100644 --- a/internal/store/runtime_test.go +++ b/internal/store/runtime_test.go @@ -2,6 +2,8 @@ package store import ( "context" + "os" + "path/filepath" "testing" "time" @@ -59,6 +61,34 @@ func TestImmutableRuntimeStoreListsDefaultKindsAndRejectsUnknownKind(t *testing. } } +func TestImmutableRuntimeStoreReportsLoadErrorsAndConflicts(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, RuntimeThreads), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + store := &ImmutableRuntimeStore{RootDir: root} + if _, err := store.ListRuntime(context.Background(), RuntimeThreads); err == nil { + t.Fatal("runtime load error ignored") + } + + root = t.TempDir() + store = &ImmutableRuntimeStore{RootDir: root} + event := runtimeEnvelope(t, protocol.EventThreadScopeChanged) + if err := WriteImmutable(filepath.Join(root, RuntimeThreads), event); err != nil { + t.Fatal(err) + } + event.Actor.ID = "different" + if err := os.MkdirAll(filepath.Join(root, RuntimeProjections), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, RuntimeProjections, event.EventID+".json"), mustMarshalIndentedEvent(t, event), 0o644); err != nil { + t.Fatal(err) + } + if _, err := store.ListRuntime(context.Background(), RuntimeThreads, RuntimeProjections); err == nil { + t.Fatal("runtime conflict ignored") + } +} + func runtimeEnvelope(t *testing.T, eventType string) protocol.EventEnvelope { t.Helper() now := time.Now().Format(time.RFC3339Nano) From 4315424db6216f4d0cd310eb9ac9eb45c976b728 Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Thu, 25 Jun 2026 11:13:06 -0300 Subject: [PATCH 6/7] feat(cli): add injectable id generators and preserve global scope - Introduce package-level id generator variables for tests - Wire newThreadID/newRelationID/newProjectionID/newReceiptID - Pass Global scope through note projection --- internal/cli/app.go | 4 ++-- internal/cli/id_generators.go | 10 ++++++++++ internal/cli/relation_command.go | 2 +- internal/cli/runtime_store.go | 4 ++-- 4 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 internal/cli/id_generators.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 0323289..8a5bf27 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -426,7 +426,7 @@ func runThread(args []string) error { return err } if *id == "" { - generated, err := protocol.NewThreadID() + generated, err := newThreadID() if err != nil { return err } @@ -572,7 +572,7 @@ func runNote(args []string) error { } if *thread != "" { projection, err := createProjection("record-source", *thread, - protocol.Scope{Repo: repoName(), Issue: event.Scope.Issue, PR: event.Scope.PR, Areas: event.Scope.Areas, Paths: event.Scope.Paths}, []DirectionEvent{event}, false) + protocol.Scope{Repo: repoName(), Issue: event.Scope.Issue, PR: event.Scope.PR, Areas: event.Scope.Areas, Paths: event.Scope.Paths, Global: event.Scope.Global}, []DirectionEvent{event}, false) if err != nil { return err } diff --git a/internal/cli/id_generators.go b/internal/cli/id_generators.go new file mode 100644 index 0000000..ee92f59 --- /dev/null +++ b/internal/cli/id_generators.go @@ -0,0 +1,10 @@ +package cli + +import "github.com/lutefd/fabric/protocol" + +var ( + newThreadID = protocol.NewThreadID + newRelationID = protocol.NewRelationID + newProjectionID = protocol.NewProjectionID + newReceiptID = protocol.NewReceiptID +) diff --git a/internal/cli/relation_command.go b/internal/cli/relation_command.go index a6df285..3e0e8b8 100644 --- a/internal/cli/relation_command.go +++ b/internal/cli/relation_command.go @@ -74,7 +74,7 @@ func runRelation(args []string) error { return err } } - id, err := protocol.NewRelationID() + id, err := newRelationID() if err != nil { return err } diff --git a/internal/cli/runtime_store.go b/internal/cli/runtime_store.go index 68d7032..26203a0 100644 --- a/internal/cli/runtime_store.go +++ b/internal/cli/runtime_store.go @@ -133,7 +133,7 @@ func loadRuntimeThreads() (map[string]ThreadRecord, error) { } func createProjection(purpose, threadID string, scope protocol.Scope, events []DirectionEvent, omitted bool) (protocol.Projection, error) { - id, err := protocol.NewProjectionID() + id, err := newProjectionID() if err != nil { return protocol.Projection{}, err } @@ -164,7 +164,7 @@ func createProjection(purpose, threadID string, scope protocol.Scope, events []D } func recordProjectionReceipt(projection protocol.Projection, state, provider string) (protocol.Receipt, error) { - id, err := protocol.NewReceiptID() + id, err := newReceiptID() if err != nil { return protocol.Receipt{}, err } From d160f5de1d76ba3cabf3eb156449b06d0b0c34f7 Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Thu, 25 Jun 2026 11:13:12 -0300 Subject: [PATCH 7/7] test(cli): expand cli test coverage - Add command validation and lifecycle command tests - Add matching, storage, and output prompt tests - Add relation graph and storage/runtime behavior tests - Cover lock paths, current-thread errors, stale/seen classification, and revision delivery --- internal/cli/command_validation_test.go | 254 ++++++ internal/cli/lifecycle_commands_test.go | 392 +++++++++ internal/cli/matching_storage_test.go | 115 +++ internal/cli/output_prompt_test.go | 143 ++++ internal/cli/relation_graph_test.go | 145 ++++ internal/cli/storage_runtime_test.go | 1036 +++++++++++++++++++++++ 6 files changed, 2085 insertions(+) create mode 100644 internal/cli/command_validation_test.go create mode 100644 internal/cli/lifecycle_commands_test.go create mode 100644 internal/cli/matching_storage_test.go create mode 100644 internal/cli/output_prompt_test.go create mode 100644 internal/cli/relation_graph_test.go create mode 100644 internal/cli/storage_runtime_test.go diff --git a/internal/cli/command_validation_test.go b/internal/cli/command_validation_test.go new file mode 100644 index 0000000..2df3e55 --- /dev/null +++ b/internal/cli/command_validation_test.go @@ -0,0 +1,254 @@ +package cli + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/lutefd/fabric/protocol" +) + +func TestChallengeCleanConformanceAndContextValidation(t *testing.T) { + chdirTemp(t) + + for _, args := range [][]string{ + {"challenge"}, + {"challenge", "--direction", "rec_missing"}, + {"challenge", "--direction", "rec_missing", "--proposal", "try this"}, + {"challenge", "resolve"}, + {"challenge", "resolve", "rec_missing", "--accepted", "--rejected"}, + {"clean"}, + {"clean", "unknown"}, + {"context"}, + {"context", "ack"}, + {"context", "acknowledge"}, + {"context", "acknowledge", "--projection", "prj_missing", "--state", "seen"}, + {"conformance"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded before init", args) + } + } + + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + mustRun(t, "note", "--candidate", "Direction") + directionID := recordIDAt(t, 0) + + for _, args := range [][]string{ + {"challenge", "--direction", directionID}, + {"challenge", "--direction", directionID, "--proposal", "try this"}, + {"challenge", "--direction", "rec_missing", "--issue", "FAB-1", "--proposal", "try this"}, + {"challenge", "resolve", "rec_missing", "--accepted"}, + {"clean", "live", "extra"}, + {"clean", "live", "--record", directionID}, + {"clean", "runtime"}, + {"clean", "runtime", "extra", "--thread", "thread-a"}, + {"context", "acknowledge", "--projection", "prj_missing"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded", args) + } + } + + mustRun(t, "challenge", "--direction", directionID, "--issue", "FAB-1", "--proposal", "Alternative") + challengeID := recordIDAt(t, 1) + mustRun(t, "challenge", "resolve", challengeID, "--accepted") + if err := Run([]string{"challenge", "resolve", challengeID, "--accepted"}); err == nil { + t.Fatal("resolved challenge was resolved twice") + } +} + +func TestCommandParseAndArityValidation(t *testing.T) { + chdirTemp(t) + for _, args := range [][]string{ + {"init", "--bad"}, + {"install-agents", "--bad"}, + {"thread"}, + {"thread", "unknown"}, + {"promote", "--bad"}, + {"sync", "--bad"}, + {"status", "--bad"}, + {"doctor", "--bad"}, + {"preflight"}, + {"preflight", "task"}, + {"preflight", "task", "--issue"}, + {"preflight", "task", "--area"}, + {"preflight", "task", "--path"}, + {"preflight", "task", "--budget"}, + {"preflight", "task", "--budget", "nope"}, + {"preflight", "task", "--budget=nope"}, + {"explain", "--bad"}, + {"explain"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded before init", args) + } + } + + mustRun(t, "init") + for _, args := range [][]string{ + {"install-agents", "--bad"}, + {"thread", "list", "extra"}, + {"thread", "use"}, + {"thread", "use", "missing"}, + {"thread", "clear", "extra"}, + {"thread", "start", "--bad"}, + {"thread", "start", "--id", "empty-scope"}, + {"note", "--live", "--candidate", "--global", "too many durability flags"}, + {"promote"}, + {"promote", "rec_missing"}, + {"sync", "--thread", "missing"}, + {"status", "--bad"}, + {"doctor", "--bad"}, + {"explain", "--direction", "sideways", "--node", "record:rec_missing"}, + {"explain", "--node", "bad"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded", args) + } + } +} + +func TestInitializationAndFilesystemFailures(t *testing.T) { + t.Run("init fails when fabric root is a file", func(t *testing.T) { + chdirTemp(t) + if err := os.WriteFile(".fabric", []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + if err := Run([]string{"init"}); err == nil { + t.Fatal("init succeeded with .fabric as a file") + } + }) + + t.Run("init fails when agents root is a file", func(t *testing.T) { + chdirTemp(t) + if err := os.WriteFile(".agents", []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + if err := Run([]string{"init"}); err == nil { + t.Fatal("init succeeded with .agents as a file") + } + }) + + t.Run("install agents propagates write failures", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + if err := os.RemoveAll(repoAgentSkillsRoot); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(repoAgentSkillsRoot, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + if err := Run([]string{"install-agents"}); err == nil { + t.Fatal("install-agents succeeded with repo skills root as a file") + } + }) +} + +func TestIDGenerationFailuresPropagate(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + idErr := errors.New("id generation failed") + + previousThreadID := newThreadID + newThreadID = func() (string, error) { return "", idErr } + if err := Run([]string{"thread", "start", "--issue", "FAB-1"}); !errors.Is(err, idErr) { + t.Fatalf("thread start err = %v", err) + } + newThreadID = previousThreadID + + previousRelationID := newRelationID + newRelationID = func() (string, error) { return "", idErr } + if err := Run([]string{"relation", "add", "--type", "informed_by", "--from", "record:rec_1", "--to", "thread:thread-a"}); !errors.Is(err, idErr) { + t.Fatalf("relation err = %v", err) + } + newRelationID = previousRelationID + + previousProjectionID := newProjectionID + newProjectionID = func() (string, error) { return "", idErr } + if _, err := createProjection("sync", "thread-a", protocol.Scope{Global: true}, nil, false); !errors.Is(err, idErr) { + t.Fatalf("projection err = %v", err) + } + newProjectionID = previousProjectionID + + projectionID, err := protocol.NewProjectionID() + if err != nil { + t.Fatal(err) + } + previousReceiptID := newReceiptID + newReceiptID = func() (string, error) { return "", idErr } + if _, err := recordProjectionReceipt(protocol.Projection{ProjectionID: projectionID, ThreadID: "thread-a", Scope: protocol.Scope{Global: true}}, protocol.ReceiptDelivered, "codex"); !errors.Is(err, idErr) { + t.Fatalf("receipt err = %v", err) + } + newReceiptID = previousReceiptID +} + +func TestCleanSharedFilesAndContainsSelectedBranches(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + output := captureStdout(t, func() { + if err := cleanSharedFiles("record", nil, false, true); err != nil { + t.Fatal(err) + } + }) + assertContains(t, output, "Nothing eligible") + + if containsSelected(float64(1), map[string]bool{"1": true}) { + t.Fatal("numeric JSON value matched selected IDs") + } + if !containsSelected(map[string]any{"nested": []any{"rec-1"}}, map[string]bool{"rec-1": true}) { + t.Fatal("nested selected value not found") + } + +} + +func TestConformanceReportsInvalidFiles(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + valid := filepath.Join(t.TempDir(), "valid.json") + event := protocolEnvelopeForCLI(t) + if err := os.WriteFile(valid, mustMarshalJSON(t, event), 0o644); err != nil { + t.Fatal(err) + } + missing := filepath.Join(t.TempDir(), "missing.json") + invalid := filepath.Join(t.TempDir(), "invalid.json") + if err := os.WriteFile(invalid, []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + + if err := Run([]string{"conformance", "--file", valid, "--file", missing, invalid}); err == nil { + t.Fatal("conformance accepted invalid files") + } +} + +func protocolEnvelopeForCLI(t *testing.T) protocol.EventEnvelope { + t.Helper() + recordID, _ := protocol.NewRecordID() + event, err := protocol.NewEnvelope(protocol.EventRecordCreated, + protocol.ActorRef{Kind: "human"}, + protocol.TrustClaim{Level: "human_confirmed"}, + protocol.RecordCreated{Record: protocol.Record{ + RecordID: recordID, Kind: "direction", CreatedAt: "2026-06-25T00:00:00Z", + Scope: protocol.Scope{Global: true}, Source: protocol.SourceRef{Type: "human"}, + Text: "Direction", Confidence: "human_confirmed", TTL: "until_superseded", + Status: "active", Durability: "durable", + }}) + if err != nil { + t.Fatal(err) + } + return event +} + +func mustMarshalJSON(t *testing.T, value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} diff --git a/internal/cli/lifecycle_commands_test.go b/internal/cli/lifecycle_commands_test.go new file mode 100644 index 0000000..70770c9 --- /dev/null +++ b/internal/cli/lifecycle_commands_test.go @@ -0,0 +1,392 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lutefd/fabric/protocol" +) + +func TestListFiltersAndScopeRendering(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1", "--pr", "8", "--area", "cli", "--path", "internal/cli/**") + mustRun(t, "note", "--candidate", "--thread", "thread-a", "Scoped candidate") + mustRun(t, "note", "--live", "--global", "Global live") + + for _, args := range [][]string{ + {"list", "extra"}, + {"list", "--durability", "forever"}, + {"list", "--status", "pending"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded", args) + } + } + + scoped := captureStdout(t, func() { + mustRun(t, "list", "--status", "any", "--pr", "8", "--area", "cli", "--path", "internal/cli/app.go") + }) + assertContains(t, scoped, "Scoped candidate") + assertContains(t, scoped, "scope: issue=FAB-1 pr=8 areas=cli paths=internal/cli/**") + + none := captureStdout(t, func() { + mustRun(t, "list", "--durability", "durable") + }) + assertContains(t, none, "No matching direction.") +} + +func TestLifecycleCommandValidationAndReports(t *testing.T) { + chdirTemp(t) + + for _, args := range [][]string{ + {"expire"}, + {"discard"}, + {"keep"}, + {"consolidate"}, + {"consolidate", "--pr", "8", "--issue", "FAB-1"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded before init", args) + } + } + + mustRun(t, "init") + for _, args := range [][]string{ + {"discard", "rec_missing"}, + {"discard", "rec_missing", "--reason", "duplicate"}, + {"keep", "rec_missing"}, + {"keep", "rec_missing", "--candidate"}, + {"expire", "rec_missing"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded", args) + } + } + + mustRun(t, "note", "--durable", "--issue", "FAB-1", "Durable direction") + durableID := recordIDAt(t, 0) + if err := Run([]string{"expire", durableID}); err == nil { + t.Fatal("durable event expired without --force") + } + expired := captureStdout(t, func() { + mustRun(t, "expire", durableID, "--force", "--reason", "replaced") + }) + assertContains(t, expired, "Reason: replaced") + + mustRun(t, "note", "--candidate", "--issue", "FAB-1", "Candidate direction") + candidateID := recordIDByText(t, "Candidate direction") + kept := captureStdout(t, func() { + mustRun(t, "keep", candidateID, "--candidate", "--reason", "needs more evidence") + }) + assertContains(t, kept, "Kept") + + mustRun(t, "note", "--candidate", "--issue", "FAB-2", "Discard me") + discardID := recordIDByText(t, "Discard me") + discarded := captureStdout(t, func() { + mustRun(t, "discard", discardID, "--reason", "too narrow") + }) + assertContains(t, discarded, "Reason: too narrow") + + mustRun(t, "review", "note", "--pr", "8", "--issue", "FAB-1", "--rejects", "old path", "--prefer", "new path", "--reason", "reviewer said so", "Review direction") + mustRun(t, "note", "--live", "--kind", "review_requirement", "--pr", "8", "--issue", "FAB-1", "--reason", "CI asks for it", "Add a CLI test") + mustRun(t, "consolidate", "--issue", "FAB-1") + report := mustRead(t, consolidationPath) + assertContains(t, report, "Durable directions already kept") + assertContains(t, report, "Candidate directions to review") + assertContains(t, report, "Live directions likely to expire") + assertContains(t, report, "Why likely temporary") +} + +func TestConsolidationReportClassifiesAllLifecycleShapes(t *testing.T) { + events := []DirectionEvent{ + {ID: "durable-active", Durability: DurabilityDurable, Status: StatusActive, Text: "Durable"}, + {ID: "durable-expired", Durability: DurabilityDurable, Status: StatusExpired, Text: "Old durable"}, + {ID: "candidate-review", Durability: DurabilityCandidate, Kind: "review_direction", ReviewType: "preference", PreferredPaths: []string{"new path"}, Status: StatusActive, Text: "Prefer new path"}, + {ID: "candidate-requirement", Durability: DurabilityCandidate, Kind: "review_requirement", Status: StatusActive, Text: "Add tests"}, + {ID: "candidate-task", Durability: DurabilityCandidate, Status: StatusActive, Text: "Checklist item"}, + {ID: "candidate-note", Durability: DurabilityCandidate, Status: StatusActive, Text: "Reusable direction"}, + {ID: "candidate-expired", Durability: DurabilityCandidate, Status: StatusExpired, Text: "Old candidate"}, + {ID: "live-review", Durability: DurabilityLive, Kind: "review_direction", Status: StatusActive, Text: "Review says no"}, + {ID: "live-requirement", Durability: DurabilityLive, Kind: "review_requirement", Status: StatusActive, Text: "Run checklist"}, + {ID: "live-task", Durability: DurabilityLive, Status: StatusActive, Text: "Add tests for parser"}, + {ID: "live-note", Durability: DurabilityLive, Status: StatusActive, Text: "Temporary note"}, + {ID: "challenge", Durability: DurabilityLive, Kind: "challenge", Status: "open", Text: "Challenge"}, + {ID: "live-expired", Durability: DurabilityLive, Status: StatusExpired, Text: "Old live"}, + } + scope := consolidationScope{IsPR: true, PR: "8", Issue: "FAB-1"} + if !matchesConsolidationScope(DirectionEvent{Scope: EventScope{Issue: "FAB-1"}}, scope) { + t.Fatal("PR scope did not match inferred issue") + } + report := buildConsolidationReport(scope, events) + if len(report.DurableActive) != 1 || len(report.CandidateActive) != 4 || len(report.LiveActive) != 4 || len(report.OpenChallenges) != 1 || len(report.Inactive) != 3 { + t.Fatalf("unexpected report: %#v", report) + } + md := consolidationMarkdown(report) + for _, want := range []string{ + "Why it may be durable", + "It captures rejected or preferred paths", + "It is a PR-local checklist or test item.", + "It is a task-local checklist item.", + "resolve challenge challenge before consolidating as durable", + } { + assertContains(t, md, want) + } + empty := consolidationMarkdown(consolidationReport{Scope: consolidationScope{IsIssue: true, Issue: "FAB-2"}}) + assertContains(t, empty, "No cleanup actions suggested.") +} + +func TestContinuationAndHandoffValidation(t *testing.T) { + chdirTemp(t) + for _, args := range [][]string{ + {"continue"}, + {"continue", "--issue", "FAB-1", "--budget", "0"}, + {"handoff"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded before init", args) + } + } + + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-pr", "--pr", "8", "--issue", "FAB-1", "--area", "cli") + mustRun(t, "review", "note", "--pr", "8", "--issue", "FAB-1", "--rejects", "legacy-only tests", "--prefer", "behavior tests", "--reason", "reliability", "Prefer behavior tests") + mustRun(t, "note", "--live", "--kind", "review_requirement", "--pr", "8", "--issue", "FAB-1", "--reason", "coverage gate", "Exercise CLI edge cases") + directionID := recordIDAt(t, 0) + mustRun(t, "challenge", "--direction", directionID, "--pr", "8", "--issue", "FAB-1", "--proposal", "Keep the legacy shape", "--reason", "compatibility") + + out := captureStdout(t, func() { + mustRun(t, "continue", "--pr", "8", "--thread", "thread-next", "--budget", "40") + }) + assertContains(t, out, "# Continuation Context") + assertContains(t, out, "Some relevant direction was omitted") + + if err := Run([]string{"handoff"}); err == nil { + t.Fatal("handoff without --pr succeeded") + } + handoff := captureStdout(t, func() { + mustRun(t, "handoff", "--pr", "8") + }) + assertContains(t, handoff, "Wrote .fabric/generated/HANDOFF.md") + md := mustRead(t, handoffPath) + assertContains(t, md, "Do not reopen") + assertContains(t, md, "Keep the legacy shape") +} + +func TestRuntimeStorePersistenceAndProtocolErrors(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if got := runtimeKindForEvent("unknown.event"); got != "" { + t.Fatalf("unknown runtime event kind = %q", got) + } + if err := appendRuntimeEnvelope(runtimeThreads, protocol.EventEnvelope{}); err == nil { + t.Fatal("invalid runtime envelope was accepted") + } + + thread := ThreadRecord{ThreadID: "thread-a", Issue: "FAB-1", Areas: []string{"cli"}} + if err := saveRuntimeThread(thread, protocol.EventThreadStarted); err != nil { + t.Fatal(err) + } + threads, err := loadRuntimeThreads() + if err != nil { + t.Fatal(err) + } + if threads["thread-a"].Issue != "FAB-1" { + t.Fatalf("thread did not round-trip: %#v", threads) + } + + eventID, err := protocol.NewEventID() + if err != nil { + t.Fatal(err) + } + recordID, err := protocol.NewRecordID() + if err != nil { + t.Fatal(err) + } + event := DirectionEvent{ID: recordID, HeadEventID: eventID, Status: StatusActive, Scope: EventScope{Issue: "FAB-1", Areas: []string{"cli"}}, Text: "Direction"} + projection, err := createProjection("sync", "thread-a", protocol.Scope{Issue: "FAB-1", Areas: []string{"cli"}}, []DirectionEvent{event}, true) + if err != nil { + t.Fatal(err) + } + receipt, err := recordProjectionReceipt(projection, protocol.ReceiptExposed, "codex") + if err != nil { + t.Fatal(err) + } + if receipt.State != protocol.ReceiptExposed { + t.Fatalf("receipt state = %q", receipt.State) + } + if _, _, err := deliveredForThread("thread-a"); err != nil { + t.Fatal(err) + } + + if _, err := loadProjection("prj_missing"); err == nil { + t.Fatal("missing projection loaded") + } + + runtimeRoot, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + badThreadDir := filepath.Join(runtimeRoot, runtimeThreads) + if err := os.WriteFile(filepath.Join(badThreadDir, "bad.json"), []byte(`{"payload":{`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadRuntimeThreads(); err == nil { + t.Fatal("malformed runtime thread event loaded") + } +} + +func TestMarkdownIngestAndMatchingBranches(t *testing.T) { + events := []DirectionEvent{ + {ID: "human", Source: EventSource{Type: "human", ThreadID: "thread-a"}}, + {ID: "review", Source: EventSource{Type: "review", ThreadID: "thread-b"}}, + {ID: "tool", Source: EventSource{Type: "tool", ThreadID: "thread-c"}}, + {ID: "agent", Source: EventSource{Type: "agent", ThreadID: "thread-d"}}, + {ID: "unknown", Source: EventSource{}}, + } + for _, event := range events { + if projectedSource(event) == "" { + t.Fatalf("empty projected source for %#v", event) + } + } + + md := continuationMarkdown("FAB-1", "8", []DirectionEvent{ + {ID: "review", Kind: "review_direction", Text: "Use shared parser", Reason: "consistency", RejectedPaths: []string{"ad hoc"}, PreferredPaths: []string{"shared helper"}}, + {ID: "requirement", Kind: "review_requirement", Text: "Add tests", Reason: "review"}, + {ID: "direction", Kind: "note", Text: "Keep behavior"}, + {ID: "resolution", Kind: "challenge_resolution", Text: "Rejected challenge", Challenges: "challenge", Status: "rejected"}, + }, false) + for _, want := range []string{"Rejected paths", "Preferred paths", "Resolved challenge", "Active issue direction"} { + assertContains(t, md, want) + } + + graphOut := captureStdout(t, func() { + _ = printExplain("", []string{"cli"}, []DirectionEvent{{ID: "rec-1", Text: "Direction", Status: "open", Scope: EventScope{Areas: []string{"cli"}}}}, map[string]ThreadRecord{}) + }) + assertContains(t, graphOut, "Challenge state:") + + if _, err := parseIngestFile("# Fabric PR Review Ingest\n\n## Review directions\n\n### Direction 1\nReview says:\n"); err == nil { + t.Fatal("empty ingest item was accepted") + } + ingest, err := parseIngestFile(strings.Join([]string{ + "# Fabric PR Review Ingest", + "PR: 8", + "Issue: FAB-1", + "Areas:", + "- cli", + "notes leave areas", + "## Review directions", + "### Direction 1", + "Type: test_request", + "Durability: unknown", + "Review says: Add tests", + "Evidence:", + "- reviewer comment: please test this", + "- url: https://example.invalid/review", + "- plain note", + }, "\n")) + if err != nil { + t.Fatal(err) + } + if inferIngestDurability(ingest.Items[0]) != DurabilityLive || inferIngestKind(ingest.Items[0]) != "review_requirement" { + t.Fatalf("unexpected ingest inference: %#v", ingest.Items[0]) + } + if len(ingest.Items[0].Evidence) != 3 { + t.Fatalf("evidence = %#v", ingest.Items[0].Evidence) + } + + ordered := prioritizedEvents([]DirectionEvent{ + {ID: "ordinary", Kind: "note"}, + {ID: "requirement", Kind: "review_requirement"}, + }, "", "", nil) + if ordered[0].ID != "requirement" { + t.Fatalf("review requirement priority changed: %#v", ordered) + } +} + +func TestStorageAndRepositoryErrorBranches(t *testing.T) { + chdirTemp(t) + if _, err := os.Stat(configPath); err == nil { + t.Fatal("temp repo unexpectedly initialized") + } + if _, err := loadCurrentThreadID(); err == nil { + t.Fatal("loadCurrentThreadID succeeded before init") + } + if _, err := resolveThreadID(""); err == nil { + t.Fatal("resolveThreadID without current thread succeeded") + } + + mustRun(t, "init") + if err := os.WriteFile(currentThreadPath, []byte("ghost\n"), 0o644); err != nil { + t.Fatal(err) + } + if got, err := loadCurrentThreadID(); err != nil || got != "ghost" { + t.Fatalf("current thread = %q err=%v", got, err) + } + + if _, err := promoteEvent("rec_missing", "reason"); err == nil { + t.Fatal("missing event promoted") + } + mustRun(t, "note", "--durable", "--global", "Durable direction") + id := recordIDAt(t, 0) + if _, err := promoteEvent(id, "reason"); err == nil { + t.Fatal("durable event promoted again") + } + + report, err := ledgerHealth() + if err != nil { + t.Fatal(err) + } + if report.Counts[DurabilityDurable] == 0 { + t.Fatalf("ledger report did not count durable event: %#v", report) + } +} + +func TestMalformedRuntimeFilesReturnErrors(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + kind string + run func() error + }{ + {runtimeReceipts, func() error { _, err := loadReceipts(); return err }}, + {runtimeProjections, func() error { _, err := loadProjection("anything"); return err }}, + } { + dir := filepath.Join(root, tc.kind) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte(`{"payload":{`), 0o644); err != nil { + t.Fatal(err) + } + if err := tc.run(); err == nil { + t.Fatalf("malformed %s file did not error", tc.kind) + } + if err := os.Remove(filepath.Join(dir, "bad.json")); err != nil { + t.Fatal(err) + } + } +} + +func recordIDByText(t *testing.T, text string) string { + t.Helper() + events, err := loadEvents() + if err != nil { + t.Fatal(err) + } + for _, event := range events { + if event.Text == text { + return event.ID + } + } + t.Fatalf("record with text %q not found in %#v", text, events) + return "" +} diff --git a/internal/cli/matching_storage_test.go b/internal/cli/matching_storage_test.go new file mode 100644 index 0000000..3ff23a1 --- /dev/null +++ b/internal/cli/matching_storage_test.go @@ -0,0 +1,115 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/lutefd/fabric/protocol" +) + +func TestMatchingHelperCoverage(t *testing.T) { + events := []DirectionEvent{ + {ID: "global", Scope: EventScope{Global: true}, Status: StatusActive}, + {ID: "inactive", Scope: EventScope{Global: true}, Status: StatusExpired}, + {ID: "issue", Scope: EventScope{Issue: "FAB-1"}, Status: StatusActive}, + } + matches := relevantEvents(events, "FAB-1", nil) + if len(matches) != 2 { + t.Fatalf("relevantEvents matches = %#v", matches) + } + + if scopePathMatches("", "main.go") { + t.Fatal("empty path matched") + } + if !scopePathMatches("internal/**", "internal/cli/app.go") { + t.Fatal("recursive scope path did not match") + } + + challenge := DirectionEvent{ID: "challenge", Kind: "challenge", Status: "open", Scope: EventScope{Global: true}} + resolution := DirectionEvent{ID: "resolution", Kind: "challenge_resolution", Challenges: "challenge", Scope: EventScope{Global: true}} + conflict := DirectionEvent{ID: "conflict", Conflict: &protocol.MaterializationConflict{ParentEventID: "evt_parent"}, Scope: EventScope{Global: true}} + reviewDirection := DirectionEvent{ID: "review", Kind: "review_direction", Scope: EventScope{Global: true}} + ordered := prioritizedEvents([]DirectionEvent{reviewDirection, challenge, resolution, conflict}, "", "", nil) + if ordered[0].ID != "conflict" { + t.Fatalf("conflict should sort first: %#v", ordered) + } + if isOpenChallenge(challenge, map[string]DirectionEvent{"challenge": resolution}) { + t.Fatal("resolved challenge reported open") + } +} + +func TestStorageHelperCoverage(t *testing.T) { + events := []DirectionEvent{ + {ID: "active", Status: StatusActive, Durability: ""}, + {ID: "open", Status: "open", Durability: DurabilityLive}, + {ID: "accepted", Status: "accepted", Durability: DurabilityCandidate}, + {ID: "rejected", Status: "rejected", Durability: DurabilityDurable}, + {ID: "expired", Status: StatusExpired}, + {ID: "discarded", Status: StatusDiscarded}, + {ID: "superseded", Status: StatusSuperseded}, + {ID: "unknown", Status: "mystery"}, + } + if normalizeStatus("") != StatusActive || normalizeDurability("") != DurabilityDurable { + t.Fatal("normalization defaults changed") + } + active := filterActiveEvents(events) + if len(active) != 4 { + t.Fatalf("active events = %d, want 4", len(active)) + } + counts := eventDurabilityCounts(active) + if counts[DurabilityDurable] != 2 || counts[DurabilityCandidate] != 1 || counts[DurabilityLive] != 1 { + t.Fatalf("counts = %#v", counts) + } +} + +func TestGitCommonDirVariants(t *testing.T) { + chdirTemp(t) + if got, err := gitCommonDir(); err != nil || got != "" { + t.Fatalf("no git common dir = %q err=%v", got, err) + } + + if err := os.WriteFile(".git", []byte("not a gitdir file"), 0o644); err != nil { + t.Fatal(err) + } + if got, err := gitCommonDir(); err != nil || got != "" { + t.Fatalf("plain .git file common dir = %q err=%v", got, err) + } + + if err := os.Remove(".git"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(".gitdir", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(".git", []byte("gitdir: .gitdir\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := gitCommonDir() + if err != nil { + t.Fatal(err) + } + if got != filepath.Join(mustGetwd(), ".gitdir") { + t.Fatalf("gitdir common dir = %q", got) + } + + common := filepath.Join(mustGetwd(), "common") + if err := os.WriteFile(filepath.Join(".gitdir", "commondir"), []byte("../common\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err = gitCommonDir() + if err != nil { + t.Fatal(err) + } + if got != common { + t.Fatalf("commondir = %q, want %q", got, common) + } +} + +func TestMarshalGraph(t *testing.T) { + raw := marshalGraph(protocol.Graph{Root: protocol.NodeRef{Kind: "record", ID: "rec-1"}}) + if !json.Valid(raw) { + t.Fatalf("invalid graph JSON: %s", raw) + } +} diff --git a/internal/cli/output_prompt_test.go b/internal/cli/output_prompt_test.go new file mode 100644 index 0000000..2094f0a --- /dev/null +++ b/internal/cli/output_prompt_test.go @@ -0,0 +1,143 @@ +package cli + +import ( + "errors" + "os" + "testing" +) + +func TestRenderedAndTypedErrors(t *testing.T) { + cause := errors.New("boom") + rendered := renderedError{cause: cause} + if rendered.Error() != "boom" || !errors.Is(rendered, cause) || !IsRenderedError(rendered) { + t.Fatalf("rendered error not wrapping cause: %v", rendered) + } + + typed := typedError("custom_code", "custom message", map[string]any{"id": "1"}) + if typed.Error() != "custom message" || errorCode(typed) != "custom_code" || errorDetails(typed)["id"] != "1" { + t.Fatalf("typed error helpers failed: %v", typed) + } +} + +func TestExtractOutputFormatVariantsAndErrors(t *testing.T) { + format, clean, err := extractOutputFormat([]string{"--format=json", "version"}) + if err != nil || format != "json" || len(clean) != 1 || clean[0] != "version" { + t.Fatalf("format=%q clean=%v err=%v", format, clean, err) + } + + format, clean, err = extractOutputFormat([]string{"--format", "human", "version"}) + if err != nil || format != "human" || len(clean) != 1 || clean[0] != "version" { + t.Fatalf("format=%q clean=%v err=%v", format, clean, err) + } + + for _, args := range [][]string{{"--format"}, {"--format=xml"}} { + if _, _, err := extractOutputFormat(args); err == nil { + t.Fatalf("extractOutputFormat(%v) succeeded", args) + } + } +} + +func TestRunJSONOutputFallbackAndErrorEnvelope(t *testing.T) { + output := captureStdout(t, func() { + if err := Run([]string{"--json"}); err != nil { + t.Fatal(err) + } + }) + assertContains(t, output, `"command":"help"`) + assertContains(t, output, `"message":"Fabric`) + + output = captureStdout(t, func() { + err := Run([]string{"--format=json", "version", "extra"}) + if err == nil || !IsRenderedError(err) { + t.Fatalf("err = %v", err) + } + }) + assertContains(t, output, `"ok":false`) + assertContains(t, output, `"code":"internal_error"`) +} + +func TestErrorCodeClassifiesMessages(t *testing.T) { + cases := map[string]string{ + "not found": "not_found", + "unknown thread": "not_found", + "conflict happened": "conflict", + "immutable mismatch": "conflict", + "requires value": "invalid_argument", + "must be set": "invalid_argument", + "something else": "internal_error", + } + for message, want := range cases { + if got := errorCode(errors.New(message)); got != want { + t.Fatalf("errorCode(%q) = %q, want %q", message, got, want) + } + } +} + +func TestVersionAndCapabilitiesRejectArguments(t *testing.T) { + if err := runVersion([]string{"extra"}); err == nil { + t.Fatal("runVersion accepted arguments") + } + if err := runCapabilities([]string{"extra"}); err == nil { + t.Fatal("runCapabilities accepted arguments") + } +} + +func TestPromptDurabilityChoicesAndReadError(t *testing.T) { + for input, want := range map[string]string{ + "y\n": DurabilityDurable, + "later\n": DurabilityCandidate, + "something\n": DurabilityLive, + } { + got := withStdin(t, input, func() string { + value, err := promptDurability() + if err != nil { + t.Fatal(err) + } + return value + }) + if got != want { + t.Fatalf("promptDurability(%q) = %q, want %q", input, got, want) + } + } + + oldStdin := os.Stdin + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + os.Stdin = reader + defer func() { + os.Stdin = oldStdin + }() + value, err := promptDurability() + if err != nil || value != DurabilityLive { + t.Fatalf("promptDurability read error value=%q err=%v", value, err) + } +} + +func withStdin(t *testing.T, input string, fn func() string) string { + t.Helper() + oldStdin := os.Stdin + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if _, err := writer.WriteString(input); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + os.Stdin = reader + defer func() { + os.Stdin = oldStdin + reader.Close() + }() + return fn() +} diff --git a/internal/cli/relation_graph_test.go b/internal/cli/relation_graph_test.go new file mode 100644 index 0000000..9ee3d5d --- /dev/null +++ b/internal/cli/relation_graph_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/lutefd/fabric/protocol" +) + +func TestRelationCommandValidationBranches(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + for _, args := range [][]string{ + {"relation"}, + {"relation", "remove"}, + {"relation", "add", "--type", "unknown", "--from", "record:a", "--to", "record:b"}, + {"relation", "add", "--type", "informed_by", "--from", "record", "--to", "record:b"}, + {"relation", "add", "--type", "informed_by", "--from", "record:a", "--to", "record:"}, + {"relation", "add", "--type", "informed_by", "--from", "record:a", "--to", "record:b", "--candidate", "--durable", "--reason", "x"}, + {"relation", "add", "--type", "informed_by", "--from", "record:a", "--to", "record:b", "--durable"}, + {"relation", "add", "--type", "supersedes", "--from", "action:a", "--to", "record:b"}, + } { + if err := Run(args); err == nil { + t.Fatalf("Run(%v) succeeded", args) + } + } +} + +func TestRelationCommandRecordsLiveCandidateAndDurableRelations(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + + mustRun(t, "relation", "add", "--type", "informed_by", "--from", "action:codex:msg-1", "--to", "thread:thread-a", "--from-url", "https://example.invalid/from", "--to-url", "https://example.invalid/to") + mustRun(t, "relation", "add", "--type", "implements", "--from", "commit:abc", "--to", "record:rec-1", "--candidate") + mustRun(t, "relation", "add", "--type", "derived_from", "--from", "record:rec-1", "--to", "message:m1", "--durable", "--reason", "stable provenance") + + relations, err := loadRelations() + if err != nil { + t.Fatal(err) + } + if len(relations) != 3 { + t.Fatalf("relations = %#v", relations) + } + if relations[0].RelationID > relations[1].RelationID { + t.Fatalf("relations not sorted: %#v", relations) + } +} + +func TestSupersedesTrustValidationBranches(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "note", "--candidate", "--global", "Human direction") + humanID := recordIDAt(t, 0) + if err := validateSupersedesTrust(protocol.NodeRef{Kind: "record", ID: "missing"}, protocol.NodeRef{Kind: "record", ID: humanID}); err == nil { + t.Fatal("missing record accepted for supersedes") + } + if trustRank("repository_reviewed") <= trustRank("human_confirmed") || trustRank("unknown") != 0 { + t.Fatal("trust rank ordering changed") + } +} + +func TestExplainGraphValidationAndEmptyOutput(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if err := Run([]string{"explain", "--node", "record:rec-1", "--direction", "sideways"}); err == nil { + t.Fatal("invalid graph direction accepted") + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: "rec-1"}, "", []string{protocol.RelationInformedBy}, 0); err != nil { + t.Fatal(err) + } + output := captureStdout(t, func() { + printGraph(protocol.Graph{Root: protocol.NodeRef{Kind: "record", ID: "rec-1"}}) + }) + assertContains(t, output, "No provenance relations found.") +} + +func TestExplainGraphIncludesReceiptAndNodeDetails(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "source-thread", "--issue", "FAB-1") + mustRun(t, "note", "--candidate", "--thread", "source-thread", "Shared direction") + recordID := recordIDAt(t, 0) + mustRun(t, "thread", "start", "--id", "target-thread", "--issue", "FAB-1") + mustRun(t, "sync", "--thread", "target-thread") + + projections, err := loadRuntimeEvents(runtimeProjections) + if err != nil { + t.Fatal(err) + } + if len(projections) == 0 { + t.Fatal("sync did not create a projection") + } + projectionID := "" + for _, event := range projections { + var payload protocol.ProjectionCreated + if err := json.Unmarshal(event.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload.Projection.ThreadID == "target-thread" && len(payload.Projection.RecordIDs) != 0 { + projectionID = payload.Projection.ProjectionID + } + } + if projectionID == "" { + t.Fatal("target projection not found") + } + mustRun(t, "context", "acknowledge", "--projection", projectionID, "--state", "exposed", "--provider", "codex") + + graph, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", nil, 3) + if err != nil { + t.Fatal(err) + } + if len(graph.Relations) == 0 || len(graph.RelationDetails) == 0 || len(graph.NodeDetails) == 0 { + t.Fatalf("graph was not enriched: %#v", graph) + } + output := captureStdout(t, func() { printGraph(graph) }) + assertContains(t, output, "availability, not proof of influence") + assertContains(t, output, "Shared direction") +} + +func TestPrintGraphShowsRelationReasonAndRecordConflict(t *testing.T) { + graph := protocol.Graph{ + Root: protocol.NodeRef{Kind: "record", ID: "rec-1"}, + Relations: []protocol.Relation{{ + RelationID: "rel-1", Type: protocol.RelationInformedBy, + From: protocol.NodeRef{Kind: "record", ID: "rec-1"}, To: protocol.NodeRef{Kind: "message", ID: "m1"}, + Reason: "because", + }}, + RelationDetails: []protocol.RelationDetail{{RelationID: "rel-1", Actor: protocol.ActorRef{Kind: "human"}, Trust: protocol.TrustClaim{Level: "human_confirmed"}}}, + NodeDetails: []protocol.NodeDetail{{Ref: protocol.NodeRef{Kind: "record", ID: "rec-1"}, Record: &protocol.RecordNodeDetail{ + Record: protocol.Record{RecordID: "rec-1", Text: "direction", Status: "active", Reason: "rationale"}, + Trust: protocol.TrustClaim{Level: "human_confirmed"}, HeadEventID: "evt-1", HeadActor: protocol.ActorRef{Kind: "human"}, HeadTrust: protocol.TrustClaim{Level: "human_confirmed"}, + Conflict: &protocol.MaterializationConflict{Message: "competing children"}, + }}}, + } + output := captureStdout(t, func() { printGraph(graph) }) + for _, want := range []string{"reason: because", "asserted by: human", "rationale", "latest revision", "conflict: competing children"} { + if !strings.Contains(output, want) { + t.Fatalf("printGraph output missing %q:\n%s", want, output) + } + } +} diff --git a/internal/cli/storage_runtime_test.go b/internal/cli/storage_runtime_test.go new file mode 100644 index 0000000..379dfd2 --- /dev/null +++ b/internal/cli/storage_runtime_test.go @@ -0,0 +1,1036 @@ +package cli + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/lutefd/fabric/internal/core" + filestore "github.com/lutefd/fabric/internal/store" + "github.com/lutefd/fabric/protocol" +) + +func TestStorageRuntimeBehavior(t *testing.T) { + t.Run("lock paths and current-thread read errors", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if err := os.WriteFile("file-parent", []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := withFileLock("file-parent/lock", func() error { return nil }); err == nil { + t.Fatal("withFileLock with file parent returned nil error") + } + + if err := os.Mkdir("lockdir", 0o755); err != nil { + t.Fatal(err) + } + if err := withFileLock("lockdir", func() error { return nil }); err == nil { + t.Fatal("withFileLock with directory lock path returned nil error") + } + + lockPath := filepath.Join(t.TempDir(), "lock") + locked := false + if err := withFileLock(lockPath, func() error { locked = true; return nil }); err != nil { + t.Fatalf("withFileLock success path: %v", err) + } + if !locked { + t.Fatal("withFileLock did not execute fn") + } + + if err := os.Mkdir(currentThreadPath, 0o755); err != nil { + t.Fatal(err) + } + if _, err := loadCurrentThreadID(); err == nil { + t.Fatal("loadCurrentThreadID with directory path returned nil error") + } + + localLock, err := ledgerLockPath() + if err != nil { + t.Fatalf("ledgerLockPath outside git: %v", err) + } + if localLock != filepath.Join(filepath.Dir(currentThreadPath), "lock") { + t.Fatalf("ledgerLockPath = %q, want local lock path", localLock) + } + + if err := os.Mkdir("gitdir", 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join("gitdir", "commondir"), 0o755); err != nil { + t.Fatal(err) + } + gitDir, err := filepath.Abs("gitdir") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(".git", []byte("gitdir:"+gitDir), 0o644); err != nil { + t.Fatal(err) + } + if _, err := gitCommonDir(); err == nil { + t.Fatal("gitCommonDir with commondir as directory returned nil error") + } + if err := withLedgerLock(func() error { return nil }); err == nil { + t.Fatal("withLedgerLock with commondir as directory returned nil error") + } + }) + + t.Run("runtime kind and envelope validation", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if got := runtimeKindForEvent("unknown"); got != "" { + t.Fatalf("runtimeKindForEvent(unknown) = %q, want empty", got) + } + + if err := appendRuntimeEnvelope(runtimeThreads, protocol.EventEnvelope{}); err == nil { + t.Fatal("appendRuntimeEnvelope accepted invalid envelope") + } + + envelope, err := protocol.NewEnvelope(protocol.EventThreadStarted, + protocol.ActorRef{Kind: "agent", ID: "thread-a"}, + protocol.TrustClaim{Level: "agent_asserted"}, + protocol.ThreadEvent{Thread: protocol.Thread{ + ThreadID: "thread-a", + CreatedAt: time.Now().Format(time.RFC3339Nano), + UpdatedAt: time.Now().Format(time.RFC3339Nano), + Scope: protocol.Scope{Global: true}, + }}) + if err != nil { + t.Fatal(err) + } + if err := appendRuntimeEnvelope(runtimeProjections, envelope); err == nil { + t.Fatal("appendRuntimeEnvelope accepted kind mismatch") + } + + if err := saveRuntimeThread(ThreadRecord{ThreadID: "thread-a"}, protocol.EventProjectionCreated); err == nil { + t.Fatal("saveRuntimeThread with wrong event type returned nil error") + } + }) + + t.Run("revision delivery branches", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if allRevisionsDelivered(DirectionEvent{}, nil) { + t.Fatal("allRevisionsDelivered with empty IDs should be false") + } + + evt1, err := protocol.NewEventID() + if err != nil { + t.Fatal(err) + } + evt2, err := protocol.NewEventID() + if err != nil { + t.Fatal(err) + } + rec1, err := protocol.NewRecordID() + if err != nil { + t.Fatal(err) + } + + if !allRevisionsDelivered(DirectionEvent{HeadEventID: evt1}, map[string]bool{evt1: true}) { + t.Fatal("allRevisionsDelivered should be true when head delivered") + } + if allRevisionsDelivered(DirectionEvent{HeadEventID: evt1}, map[string]bool{evt2: true}) { + t.Fatal("allRevisionsDelivered should be false when head missing") + } + + if err := writeTestReceipt(t, "thread-a", []string{evt1}, []string{rec1}); err != nil { + t.Fatal(err) + } + + delivered, records, err := deliveredForThread("thread-a") + if err != nil { + t.Fatal(err) + } + if !delivered[evt1] { + t.Fatal("deliveredForThread did not include event id") + } + if !records[rec1] { + t.Fatal("deliveredForThread did not include record id") + } + + thread := ThreadRecord{ThreadID: "thread-a", Issue: "FAB-1"} + events := []DirectionEvent{ + {ID: rec1, HeadEventID: evt1, Status: core.StatusActive, Scope: core.EventScope{Issue: "FAB-1"}}, + {ID: "rec-2", HeadEventID: evt2, Status: core.StatusActive, Scope: core.EventScope{Issue: "FAB-1"}}, + {ID: "rec-3", HeadEventID: evt2, Status: core.StatusActive, Scope: core.EventScope{Issue: "FAB-2"}}, + } + matches, err := relevantUndelivered(events, thread) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].ID != "rec-2" { + t.Fatalf("relevantUndelivered = %#v, want rec-2 only", matches) + } + + if err := os.WriteFile(runtimeReceiptPath(t), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := deliveredForThread("thread-a"); err == nil { + t.Fatal("deliveredForThread accepted malformed receipts") + } + }) + + t.Run("stale and seen classification", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + evt1, err := protocol.NewEventID() + if err != nil { + t.Fatal(err) + } + evt2, err := protocol.NewEventID() + if err != nil { + t.Fatal(err) + } + rec1, err := protocol.NewRecordID() + if err != nil { + t.Fatal(err) + } + + if err := writeTestReceipt(t, "thread-a", []string{evt1}, nil); err != nil { + t.Fatal(err) + } + + threads := map[string]ThreadRecord{ + "thread-a": {ThreadID: "thread-a", Issue: "FAB-1"}, + "thread-b": {ThreadID: "thread-b", Issue: "FAB-2"}, + } + + inactive := DirectionEvent{Status: core.StatusExpired} + seen, stale, err := seenAndStaleFromReceipts(inactive, threads) + if err != nil { + t.Fatal(err) + } + if len(seen) != 0 || len(stale) != 0 { + t.Fatalf("inactive event produced seen/stale: %v %v", seen, stale) + } + + activeSeen := DirectionEvent{ID: rec1, HeadEventID: evt1, Status: core.StatusActive, Scope: core.EventScope{Issue: "FAB-1"}} + seen, stale, err = seenAndStaleFromReceipts(activeSeen, threads) + if err != nil { + t.Fatal(err) + } + if len(seen) != 1 || seen[0] != "thread-a" || len(stale) != 0 { + t.Fatalf("seen = %v, stale = %v, want [thread-a] []", seen, stale) + } + + activeStale := DirectionEvent{ID: rec1, HeadEventID: evt2, Status: core.StatusActive, Scope: core.EventScope{Issue: "FAB-1"}} + seen, stale, err = seenAndStaleFromReceipts(activeStale, threads) + if err != nil { + t.Fatal(err) + } + if len(seen) != 0 || len(stale) != 1 || stale[0] != "thread-a" { + t.Fatalf("seen = %v, stale = %v, want [] [thread-a]", seen, stale) + } + }) + + t.Run("projection and receipt store errors", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + + if _, err := loadProjection("prj-missing"); err == nil { + t.Fatal("loadProjection missing returned nil error") + } + + if err := os.WriteFile(filepath.Join(root, runtimeProjections), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if _, err := createProjection("sync", "thread-a", protocol.Scope{Global: true}, nil, false); err == nil { + t.Fatal("createProjection with file projection dir returned nil error") + } + + if err := os.WriteFile(filepath.Join(root, runtimeReceipts), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + projectionID, err := protocol.NewProjectionID() + if err != nil { + t.Fatal(err) + } + if _, err := recordProjectionReceipt(protocol.Projection{ProjectionID: projectionID, ThreadID: "thread-a"}, protocol.ReceiptDelivered, "codex"); err == nil { + t.Fatal("recordProjectionReceipt with file receipts dir returned nil error") + } + + if err := os.WriteFile(filepath.Join(root, runtimeThreads), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadRuntimeEvents(runtimeThreads); err == nil { + t.Fatal("loadRuntimeEvents with file threads dir returned nil error") + } + if _, err := loadRuntimeThreads(); err == nil { + t.Fatal("loadRuntimeThreads with file threads dir returned nil error") + } + }) +} + +func writeTestReceipt(t *testing.T, threadID string, eventIDs, recordIDs []string) error { + t.Helper() + receiptID, err := protocol.NewReceiptID() + if err != nil { + return err + } + projectionID, err := protocol.NewProjectionID() + if err != nil { + return err + } + receipt := protocol.Receipt{ + ReceiptID: receiptID, + ProjectionID: projectionID, + ThreadID: threadID, + State: protocol.ReceiptDelivered, + OccurredAt: time.Now().Format(time.RFC3339Nano), + EventIDs: eventIDs, + RecordIDs: recordIDs, + } + payload := protocol.ReceiptRecorded{Receipt: receipt} + envelope, err := protocol.NewEnvelope(protocol.EventReceiptRecorded, + protocol.ActorRef{Kind: "agent", ID: threadID}, + protocol.TrustClaim{Level: "agent_asserted", Basis: "test"}, payload) + if err != nil { + return err + } + return appendRuntimeEnvelope(runtimeReceipts, envelope) +} + +func runtimeReceiptPath(t *testing.T) string { + t.Helper() + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + return filepath.Join(root, runtimeReceipts, "corrupt.json") +} + +func TestRelationStorageBranches(t *testing.T) { + t.Run("appendRelation and parseNodeSpec branches", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if err := os.WriteFile(".git", []byte("x"), 0); err != nil { + t.Fatal(err) + } + if err := appendRelation(protocol.Relation{}, DurabilityLive, protocol.ActorRef{Kind: "agent"}, protocol.TrustClaim{Level: "agent_asserted"}); err == nil { + t.Fatal("appendRelation with unreadable .git returned nil error") + } + + if _, err := parseNodeSpec("kind:provider:"); err == nil { + t.Fatal("parseNodeSpec accepted empty third part") + } + if got := trustRank("unknown"); got != 0 { + t.Fatalf("trustRank(unknown) = %d, want 0", got) + } + }) + + t.Run("loadRelations and enrich error branches", func(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + mustRun(t, "note", "--candidate", "--thread", "thread-a", "direction") + + if err := os.WriteFile(ledgerEventsPath+"/corrupt.json", []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadRelations(); err == nil { + t.Fatal("loadRelations accepted corrupt ledger") + } + if err := os.Remove(ledgerEventsPath + "/corrupt.json"); err != nil { + t.Fatal(err) + } + + if _, err := loadRelations(); err != nil { + t.Fatalf("loadRelations after cleanup: %v", err) + } + + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeReceipts, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadRelations(); err == nil { + t.Fatal("loadRelations accepted corrupt receipts") + } + if err := os.Remove(filepath.Join(root, runtimeReceipts, "bad.json")); err != nil { + t.Fatal(err) + } + + recordID := recordIDAt(t, 0) + if err := validateSupersedesTrust(protocol.NodeRef{Kind: "record", ID: recordID}, protocol.NodeRef{Kind: "record", ID: recordID}); err != nil { + t.Fatalf("validateSupersedesTrust same record: %v", err) + } + + if err := os.WriteFile(ledgerEventsPath+"/corrupt.json", []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if err := validateSupersedesTrust(protocol.NodeRef{Kind: "record", ID: recordID}, protocol.NodeRef{Kind: "record", ID: recordID}); err == nil { + t.Fatal("validateSupersedesTrust accepted corrupt ledger") + } + + if err := os.Remove(ledgerEventsPath + "/corrupt.json"); err != nil { + t.Fatal(err) + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err != nil { + t.Fatalf("explainGraph: %v", err) + } + + if err := os.WriteFile(filepath.Join(root, runtimeProjections, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err == nil { + t.Fatal("explainGraph accepted corrupt projections") + } + }) +} + +func TestIDGeneratorErrorsInRuntimeStore(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + idErr := errors.New("id generation failed") + + previousProjectionID := newProjectionID + newProjectionID = func() (string, error) { return "", idErr } + defer func() { newProjectionID = previousProjectionID }() + + if _, err := createProjection("sync", "thread-a", protocol.Scope{Global: true}, nil, false); !errors.Is(err, idErr) { + t.Fatalf("createProjection id err = %v", err) + } + newProjectionID = previousProjectionID + + projectionID, err := protocol.NewProjectionID() + if err != nil { + t.Fatal(err) + } + previousReceiptID := newReceiptID + newReceiptID = func() (string, error) { return "", idErr } + defer func() { newReceiptID = previousReceiptID }() + + if _, err := recordProjectionReceipt(protocol.Projection{ProjectionID: projectionID, ThreadID: "thread-a"}, protocol.ReceiptDelivered, "codex"); !errors.Is(err, idErr) { + t.Fatalf("recordProjectionReceipt id err = %v", err) + } +} + +func TestIsActiveEventAndPromoteBranches(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "note", "--durable", "--issue", "FAB-1", "Durable direction") + mustRun(t, "note", "--live", "--issue", "FAB-1", "Live direction") + + events, err := loadEvents() + if err != nil { + t.Fatal(err) + } + var durableID, liveID string + for _, event := range events { + if event.Durability == core.DurabilityDurable { + durableID = event.ID + } else if event.Durability == core.DurabilityLive { + liveID = event.ID + } + } + if durableID == "" || liveID == "" { + t.Fatal("failed to find durable and live records") + } + + for _, status := range []string{"open", "accepted", "rejected"} { + event := DirectionEvent{Status: status} + if !isActiveEvent(event) { + t.Fatalf("isActiveEvent(%q) = false", status) + } + } + + if _, err := promoteEvent(durableID, "reason"); err == nil { + t.Fatal("promote durable returned nil error") + } + if _, err := promoteEvent(liveID, ""); err == nil { + t.Fatal("promote without reason returned nil error") + } + if _, err := promoteEvent("rec-missing", "reason"); err == nil { + t.Fatal("promote missing returned nil error") + } +} + +func TestLoadRuntimeThreadsOrderingAndReceiptUnmarshal(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if err := saveRuntimeThread(ThreadRecord{ThreadID: "thread-a", Issue: "FAB-1"}, protocol.EventThreadStarted); err != nil { + t.Fatal(err) + } + threads, err := loadRuntimeThreads() + if err != nil { + t.Fatal(err) + } + if threads["thread-a"].Issue != "FAB-1" { + t.Fatalf("loadRuntimeThreads = %#v", threads) + } + + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, runtimeThreads), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeThreads, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadRuntimeThreads(); err == nil { + t.Fatal("loadRuntimeThreads accepted corrupt json") + } + + if err := os.MkdirAll(filepath.Join(root, runtimeReceipts), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeReceipts, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadReceipts(); err == nil { + t.Fatal("loadReceipts accepted corrupt json") + } +} + +func TestInstallRootAgentsFileBranches(t *testing.T) { + chdirTemp(t) + block := "\nblock\n\n" + + if err := installRootAgentsFile("missing.txt", block); err != nil { + t.Fatalf("installRootAgentsFile missing: %v", err) + } + if got := mustRead(t, "missing.txt"); got != block { + t.Fatalf("missing file content = %q", got) + } + + if err := os.WriteFile("existing.txt", []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := installRootAgentsFile("existing.txt", block); err != nil { + t.Fatalf("installRootAgentsFile existing: %v", err) + } + got := mustRead(t, "existing.txt") + if !strings.Contains(got, "hello") || !strings.Contains(got, "block") { + t.Fatalf("existing file content = %q", got) + } + + if err := os.WriteFile("block.txt", []byte("before\n"+block+"after\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := installRootAgentsFile("block.txt", "\nnew\n\n"); err != nil { + t.Fatalf("installRootAgentsFile replace: %v", err) + } + got = mustRead(t, "block.txt") + if !strings.Contains(got, "new") || strings.Contains(got, "block") { + t.Fatalf("replace content = %q", got) + } +} + +func TestIsActiveEventEmptyStatus(t *testing.T) { + if !isActiveEvent(DirectionEvent{}) { + t.Fatal("empty status should be active") + } +} + +func TestPromoteTrustRankAndLedgerLoadError(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + lowTrust := DirectionEvent{ + Kind: "note", + CreatedAt: nowString(), + Durability: core.DurabilityCandidate, + Scope: core.EventScope{Repo: repoName(), Issue: "FAB-1", Global: true}, + Source: core.EventSource{Type: "agent"}, + Text: "low trust", + Confidence: "agent_asserted", + TTL: "until_issue_closed", + } + if err := appendEvent(&lowTrust); err != nil { + t.Fatal(err) + } + if _, err := promoteEvent(lowTrust.ID, "reason"); err == nil { + t.Fatal("promote low-trust event returned nil error") + } + + if err := os.WriteFile(ledgerEventsPath+"/corrupt.json", []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := promoteEvent(lowTrust.ID, "reason"); err == nil { + t.Fatal("promote with corrupt ledger returned nil error") + } +} + +func TestInstallRootAgentsFileEmptyAndNoNewline(t *testing.T) { + chdirTemp(t) + block := "\nblock\n\n" + + if err := os.WriteFile("empty.txt", []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + if err := installRootAgentsFile("empty.txt", block); err != nil { + t.Fatalf("installRootAgentsFile empty: %v", err) + } + got := mustRead(t, "empty.txt") + if got != block { + t.Fatalf("empty file content = %q, want %q", got, block) + } + + if err := os.WriteFile("nonewline.txt", []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if err := installRootAgentsFile("nonewline.txt", block); err != nil { + t.Fatalf("installRootAgentsFile no newline: %v", err) + } + got = mustRead(t, "nonewline.txt") + if !strings.Contains(got, "hello\n\n") || !strings.Contains(got, "block") { + t.Fatalf("no newline content = %q", got) + } +} + +func TestGitCommonDirFileBranch(t *testing.T) { + chdirTemp(t) + if err := os.WriteFile(".git", []byte("not a gitdir file"), 0o644); err != nil { + t.Fatal(err) + } + if got, err := gitCommonDir(); err != nil || got != "" { + t.Fatalf("gitCommonDir file without gitdir = %q, %v", got, err) + } +} + +func TestLedgerHealthBranches(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + report, err := ledgerHealth() + if err != nil { + t.Fatalf("ledgerHealth: %v", err) + } + if !report.DurableLedgerOK { + t.Fatalf("ledgerHealth durable not ok: %s", report.DurableLedgerError) + } + if report.SharedMirrorOK { + t.Fatal("ledgerHealth shared mirror should not be ok outside git") + } + + if err := os.WriteFile(ledgerEventsPath+"/corrupt.json", []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + report, err = ledgerHealth() + if err != nil { + t.Fatal(err) + } + if report.DurableLedgerOK { + t.Fatal("ledgerHealth accepted corrupt ledger") + } + if err := os.Remove(ledgerEventsPath + "/corrupt.json"); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(".git", []byte("gitdir:"+filepath.Join(mustGetwd(), "gitdir")), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir("gitdir", 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join("gitdir", "commondir"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := ledgerHealth(); err == nil { + t.Fatal("ledgerHealth with broken git returned nil error") + } +} + +func TestSeenStaleReceiptLoadError(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + evt1, err := protocol.NewEventID() + if err != nil { + t.Fatal(err) + } + if err := writeTestReceipt(t, "thread-a", []string{evt1}, nil); err != nil { + t.Fatal(err) + } + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeReceipts, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + + threads := map[string]ThreadRecord{"thread-a": {ThreadID: "thread-a", Issue: "FAB-1"}} + event := DirectionEvent{HeadEventID: evt1, Status: core.StatusActive, Scope: core.EventScope{Issue: "FAB-1"}} + if _, _, err := seenAndStaleFromReceipts(event, threads); err == nil { + t.Fatal("seenAndStaleFromReceipts accepted corrupt receipts") + } +} + +func TestLoadRuntimeThreadsUpdateOrdering(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + old := ThreadRecord{ThreadID: "thread-a", Issue: "FAB-1", CreatedAt: "2020-01-01T00:00:00Z", UpdatedAt: "2020-01-01T00:00:00Z"} + new := ThreadRecord{ThreadID: "thread-a", Issue: "FAB-2", CreatedAt: time.Now().Format(time.RFC3339Nano), UpdatedAt: time.Now().Format(time.RFC3339Nano)} + if err := saveRuntimeThread(old, protocol.EventThreadStarted); err != nil { + t.Fatal(err) + } + if err := saveRuntimeThread(new, protocol.EventThreadScopeChanged); err != nil { + t.Fatal(err) + } + threads, err := loadRuntimeThreads() + if err != nil { + t.Fatal(err) + } + if threads["thread-a"].Issue != "FAB-2" { + t.Fatalf("loadRuntimeThreads did not apply newer update: %#v", threads["thread-a"]) + } + + older := ThreadRecord{ThreadID: "thread-a", Issue: "FAB-3", CreatedAt: "2019-01-01T00:00:00Z", UpdatedAt: "2019-01-01T00:00:00Z"} + if err := saveRuntimeThread(older, protocol.EventThreadScopeChanged); err != nil { + t.Fatal(err) + } + threads, err = loadRuntimeThreads() + if err != nil { + t.Fatal(err) + } + if threads["thread-a"].Issue != "FAB-2" { + t.Fatalf("loadRuntimeThreads applied older update: %#v", threads["thread-a"]) + } +} + +func TestRelationCommandRunErrorBranches(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if err := Run([]string{"relation", "add", "--unknown"}); err == nil { + t.Fatal("relation add accepted unknown flag") + } + + if err := os.WriteFile(".git", []byte("gitdir:"+filepath.Join(mustGetwd(), "gitdir")), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir("gitdir", 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join("gitdir", "commondir"), 0o755); err != nil { + t.Fatal(err) + } + if err := Run([]string{"relation", "add", "--type", "informed_by", "--from", "record:rec-1", "--to", "record:rec-2"}); err == nil { + t.Fatal("relation add with broken git returned nil error") + } +} + +func TestBrokenGitPropagatesToRuntimeAndRepository(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if err := os.WriteFile(".git", []byte("gitdir:"+filepath.Join(mustGetwd(), "gitdir")), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir("gitdir", 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join("gitdir", "commondir"), 0o755); err != nil { + t.Fatal(err) + } + + if _, err := sharedRuntimeRoot(); err == nil { + t.Fatal("sharedRuntimeRoot with broken git returned nil error") + } + if _, err := sharedRuntimePath(runtimeThreads); err == nil { + t.Fatal("sharedRuntimePath with broken git returned nil error") + } + if _, err := runtimeFileStore(); err == nil { + t.Fatal("runtimeFileStore with broken git returned nil error") + } + if _, err := loadRuntimeEvents(runtimeThreads); err == nil { + t.Fatal("loadRuntimeEvents with broken git returned nil error") + } + if err := appendRuntimeEnvelope(runtimeThreads, protocol.EventEnvelope{}); err == nil { + t.Fatal("appendRuntimeEnvelope with broken git returned nil error") + } + if err := saveRuntimeThread(ThreadRecord{ThreadID: "thread-a"}, protocol.EventThreadStarted); err == nil { + t.Fatal("saveRuntimeThread with broken git returned nil error") + } + if _, err := createProjection("sync", "thread-a", protocol.Scope{Global: true}, nil, false); err == nil { + t.Fatal("createProjection with broken git returned nil error") + } + projectionID, err := protocol.NewProjectionID() + if err != nil { + t.Fatal(err) + } + if _, err := recordProjectionReceipt(protocol.Projection{ProjectionID: projectionID, ThreadID: "thread-a"}, protocol.ReceiptDelivered, "codex"); err == nil { + t.Fatal("recordProjectionReceipt with broken git returned nil error") + } + + if _, err := directionRepository(); err == nil { + t.Fatal("directionRepository with broken git returned nil error") + } + if err := appendDirection(&DirectionEvent{}); err == nil { + t.Fatal("appendDirection with broken git returned nil error") + } + if _, err := appendDirectionState(DirectionEvent{}, DirectionEvent{}, "reason"); err == nil { + t.Fatal("appendDirectionState with broken git returned nil error") + } + if _, _, err := loadDirectionsUnlocked(); err == nil { + t.Fatal("loadDirectionsUnlocked with broken git returned nil error") + } + if _, _, err := loadProtocolEventsUnlocked(); err == nil { + t.Fatal("loadProtocolEventsUnlocked with broken git returned nil error") + } +} + +func TestRuntimeKindAndTrustRankBranches(t *testing.T) { + if got := runtimeKindForEvent(protocol.EventRelationCreated); got != filestore.RuntimeRelations { + t.Fatalf("runtimeKindForEvent(relation.created) = %q, want %q", got, filestore.RuntimeRelations) + } + if got := trustRank("tool_verified"); got != 2 { + t.Fatalf("trustRank(tool_verified) = %d, want 2", got) + } +} + +func TestLedgerHealthSharedDirLoadError(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + if err := os.Mkdir(".git", 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(".git", "fabric"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".git", "fabric", "events"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + report, err := ledgerHealth() + if err != nil { + t.Fatalf("ledgerHealth: %v", err) + } + if report.SharedMirrorOK { + t.Fatal("ledgerHealth shared mirror should not be ok when events is a file") + } +} + +func TestRelationGraphWithCorruptRuntimeData(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + mustRun(t, "note", "--candidate", "--thread", "thread-a", "direction") + recordID := recordIDAt(t, 0) + + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Join(root, runtimeReceipts), 0o755); err != nil { + t.Fatal(err) + } + receiptEnv, err := protocol.NewEnvelope(protocol.EventReceiptRecorded, + protocol.ActorRef{Kind: "agent", ID: "thread-a"}, + protocol.TrustClaim{Level: "agent_asserted", Basis: "test"}, + protocol.ReceiptRecorded{Receipt: protocol.Receipt{ + ReceiptID: "invalid-id", + ProjectionID: "prj-invalid", + ThreadID: "thread-a", + State: protocol.ReceiptDelivered, + OccurredAt: time.Now().Format(time.RFC3339Nano), + }}) + if err != nil { + t.Fatal(err) + } + receiptData, err := json.Marshal(receiptEnv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeReceipts, "bad.json"), receiptData, 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadRelations(); err == nil { + t.Fatal("loadRelations accepted invalid receipt ID") + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err == nil { + t.Fatal("explainGraph accepted invalid receipt ID") + } + + if err := os.Remove(filepath.Join(root, runtimeReceipts, "bad.json")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeReceipts, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err == nil { + t.Fatal("explainGraph accepted corrupt receipts") + } + + if err := os.Remove(filepath.Join(root, runtimeReceipts, "bad.json")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeProjections, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err == nil { + t.Fatal("explainGraph accepted corrupt projections") + } + + if err := os.Remove(filepath.Join(root, runtimeProjections, "bad.json")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeThreads, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err == nil { + t.Fatal("explainGraph accepted corrupt threads") + } +} + +func TestInstallRootAgentsFileRemainingBranches(t *testing.T) { + chdirTemp(t) + block := "\nblock\n\n" + + if err := os.WriteFile("empty.txt", []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := installRootAgentsFile("empty.txt", block); err != nil { + t.Fatalf("installRootAgentsFile empty: %v", err) + } + if got := mustRead(t, "empty.txt"); got != block { + t.Fatalf("empty file content = %q, want %q", got, block) + } + + if err := os.WriteFile("nonewline.txt", []byte("before\n"+block+"after"), 0o644); err != nil { + t.Fatal(err) + } + if err := installRootAgentsFile("nonewline.txt", "\nnew\n\n"); err != nil { + t.Fatalf("installRootAgentsFile replace no newline: %v", err) + } + got := mustRead(t, "nonewline.txt") + if !strings.HasSuffix(got, "\n") || strings.Contains(got, "block") { + t.Fatalf("replace no newline content = %q", got) + } + + if err := os.Mkdir("dirpath.txt", 0o755); err != nil { + t.Fatal(err) + } + if err := installRootAgentsFile("dirpath.txt", block); err == nil { + t.Fatal("installRootAgentsFile with directory path returned nil error") + } +} + +func TestLedgerHealthMismatchDetection(t *testing.T) { + chdirTemp(t) + if err := os.Mkdir(".git", 0o755); err != nil { + t.Fatal(err) + } + mustRun(t, "init") + mustRun(t, "note", "--durable", "--issue", "FAB-1", "Durable direction") + + shared, err := sharedEventDir() + if err != nil { + t.Fatal(err) + } + if shared == "" { + t.Fatal("shared event dir is empty despite .git") + } + + events, _, err := filestore.Load(ledgerEventsPath) + if err != nil { + t.Fatal(err) + } + if len(events) == 0 { + t.Fatal("no durable events found") + } + localEventID := events[0].EventID + + entries, err := os.ReadDir(shared) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if err := os.Remove(filepath.Join(shared, entry.Name())); err != nil { + t.Fatal(err) + } + } + + report, err := ledgerHealth() + if err != nil { + t.Fatalf("ledgerHealth: %v", err) + } + if len(report.DurableSharedMismatches) != 1 || !strings.Contains(report.DurableSharedMismatches[0], localEventID) { + t.Fatalf("ledgerHealth mismatches = %v, want event %s", report.DurableSharedMismatches, localEventID) + } +} + +func TestRelevantUndeliveredReceiptError(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + + evt1, err := protocol.NewEventID() + if err != nil { + t.Fatal(err) + } + if err := writeTestReceipt(t, "thread-a", []string{evt1}, nil); err != nil { + t.Fatal(err) + } + + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeReceipts, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + + thread := ThreadRecord{ThreadID: "thread-a", Issue: "FAB-1"} + event := DirectionEvent{HeadEventID: evt1, Status: core.StatusActive, Scope: core.EventScope{Issue: "FAB-1"}} + if _, err := relevantUndelivered([]DirectionEvent{event}, thread); err == nil { + t.Fatal("relevantUndelivered accepted corrupt receipts") + } +} + +func TestExplainAndEnrichGraphLedgerErrorBranches(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + mustRun(t, "note", "--candidate", "--thread", "thread-a", "direction") + recordID := recordIDAt(t, 0) + + if err := os.WriteFile(ledgerEventsPath+"/corrupt.json", []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err == nil { + t.Fatal("explainGraph accepted corrupt ledger") + } + + if err := os.Remove(ledgerEventsPath + "/corrupt.json"); err != nil { + t.Fatal(err) + } + + root, err := sharedRuntimeRoot() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, runtimeThreads), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, runtimeThreads, "bad.json"), []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := explainGraph(protocol.NodeRef{Kind: "record", ID: recordID}, "outgoing", []string{protocol.RelationInformedBy}, 1); err == nil { + t.Fatal("explainGraph accepted corrupt threads for enrichGraph") + } +}