package manifest import ( "bytes" "context" "os" "path/filepath" "reflect" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" ) func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) records := map[string]AnalyzeArtifactRecord{ "session_recap": currentAnalyzeArtifactRecord("session_recap", now), "quest_log": { Key: "quest_log", Status: AnalyzeArtifactStale, FingerprintVersion: AnalyzeFingerprintContractVersion, Fingerprint: strings.Repeat("2", 64), Dependencies: []string{"session_recap"}, ProducerRunID: "run-stale", UpdatedAt: now, }, "player_handout": { Key: "player_handout", Status: AnalyzeArtifactMissing, Dependencies: []string{"session_recap"}, ProducerRunID: "run-missing", UpdatedAt: now, }, "npc_digest": { Key: "npc_digest", Status: AnalyzeArtifactFailed, ProducerRunID: "run-failed", UpdatedAt: now, Error: "scriptorium validation failed", Logs: []string{"runs/run-failed/logs/npc-digest.stderr.log"}, }, "gm_notes": { Key: "gm_notes", Status: AnalyzeArtifactUnselected, ProducerRunID: "run-unselected", UpdatedAt: now, }, } records["session_recap"] = func() AnalyzeArtifactRecord { record := records["session_recap"] record.Dependencies = []string{"quest_log", "gm_notes"} record.Family = "character_meta" record.CharacterID = "arannis" return record }() m := New("session", now) m.Stages["analyze"] = &StageRecord{ Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, AnalyzeStateVersion: AnalyzeStateContractVersion, AnalyzeArtifacts: records, } path := filepath.Join(t.TempDir(), "manifest.json") store := &LocalStore{} if err := store.Save(context.Background(), path, m); err != nil { t.Fatalf("Save() error = %v", err) } loaded, err := store.Load(context.Background(), path) if err != nil { t.Fatalf("Load() error = %v", err) } analyze := loaded.Stages["analyze"] if !analyze.HasVersionedAnalyzeState() { t.Fatal("round-tripped analyze record lacks versioned state") } for key, want := range records { got, ok := analyze.AnalyzeArtifacts[key] if !ok || got.Status != want.Status { t.Fatalf("artifact %q = %#v, want status %q", key, got, want.Status) } } if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) { t.Fatalf("canonical dependencies = %#v", got) } if got := analyze.AnalyzeArtifacts["session_recap"]; got.Family != "character_meta" || got.CharacterID != "arannis" { t.Fatalf("family provenance = %#v", got) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile() error = %v", err) } orderedKeys := []string{"gm_notes", "npc_digest", "player_handout", "quest_log", "session_recap"} previous := -1 for _, key := range orderedKeys { position := bytes.Index(data, []byte(`"`+key+`": {`)) if position <= previous { t.Fatalf("map key %q position = %d after %d; JSON is not canonical:\n%s", key, position, previous, data) } previous = position } } func TestRunManifestAnalyzeArtifactStateRoundTrip(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) run := NewRun("session", "campaign", "run-123", true, []string{"analyze"}, now) run.Stages["analyze"] = &RunStageRecord{ Name: "analyze", Action: RunStageActionRun, Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, AnalyzeStateVersion: AnalyzeStateContractVersion, AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{ "session_recap": currentAnalyzeArtifactRecord("session_recap", now), }, } path := filepath.Join(t.TempDir(), "run.json") store := &LocalStore{} if err := store.SaveRun(context.Background(), path, run); err != nil { t.Fatalf("SaveRun() error = %v", err) } loaded, err := store.LoadRun(context.Background(), path) if err != nil { t.Fatalf("LoadRun() error = %v", err) } got := loaded.Stages["analyze"] if got.AnalyzeStateVersion != AnalyzeStateContractVersion || got.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent { t.Fatalf("run analyze state = %#v", got) } } func TestValidateAnalyzeArtifactCollectionRejectsMalformedState(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) valid := currentAnalyzeArtifactRecord("session_recap", now) tests := []struct { name string key string mutate func(*AnalyzeArtifactRecord) want string }{ {name: "malformed map key", key: "Session Recap", want: "map key must match"}, {name: "record key mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Key = "quest_log" }, want: "does not match map key"}, {name: "unsupported status", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = "ready" }, want: "unsupported status"}, {name: "unsupported fingerprint version", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.FingerprintVersion = 99 }, want: "unsupported fingerprint version"}, {name: "bad fingerprint", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Fingerprint = "not-a-digest" }, want: "fingerprint must be"}, {name: "duplicate dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"quest_log", "quest_log"} }, want: "duplicate dependency"}, {name: "malformed dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"Quest Log"} }, want: "dependencies[0]"}, {name: "missing current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output = nil }, want: "requires output"}, {name: "bad current size", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.OutputSize = 0 }, want: "positive output_size"}, {name: "bad current source", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.SourceID = "narratio.artifact.other" }, want: "source_id"}, {name: "unsafe current path", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.LocalPath = "../recap.md" }, want: "local_path is unsafe"}, {name: "missing current contract", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Contract = nil }, want: "output contract"}, {name: "bad current checksum", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("G", 64) }, want: "checksum must be"}, {name: "producer mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"}, {name: "non-current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactStale }, want: "forbids output"}, {name: "failed without error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactFailed; r.Output = nil; r.OutputSize = 0 }, want: "requires error"}, {name: "current with error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Error = "unexpected" }, want: "forbids error"}, {name: "oversized error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactFailed r.Output = nil r.OutputSize = 0 r.Error = strings.Repeat("x", maxAnalyzeArtifactErrorLength+1) }, want: "error exceeds"}, {name: "bad producer run", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id"}, {name: "missing update time", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.UpdatedAt = time.Time{} }, want: "updated_at"}, {name: "oversized log collection", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Logs = make([]string, maxAnalyzeArtifactListEntries+1) }, want: "logs exceeds"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { record := valid record.Output = cloneArtifactRecord(valid.Output) if test.mutate != nil { test.mutate(&record) } err := ValidateAnalyzeArtifactCollection(AnalyzeStateContractVersion, map[string]AnalyzeArtifactRecord{test.key: record}) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want %q", err, test.want) } }) } if err := ValidateAnalyzeArtifactCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version") { t.Fatalf("unsupported version error = %v", err) } } func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) store := &LocalStore{} legacy := `{ "session_id": "session", "created_at": "2026-05-03T10:00:00Z", "updated_at": "2026-05-03T10:01:00Z", "stages": { "analyze": { "name": "analyze", "status": "succeeded", "created_at": "2026-05-03T10:00:00Z", "updated_at": "2026-05-03T10:01:00Z", "outputs": [{"kind":"session_recap","local_path":"artifacts/session_recap.md"}] } } }` loaded, err := store.LoadReader(context.Background(), strings.NewReader(legacy)) if err != nil { t.Fatalf("LoadReader(legacy) error = %v", err) } if loaded.Stages["analyze"].HasVersionedAnalyzeState() { t.Fatal("legacy aggregate outputs became current per-artifact evidence") } path := filepath.Join(t.TempDir(), "legacy.json") if err := store.Save(context.Background(), path, loaded); err != nil { t.Fatalf("Save(legacy) error = %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(legacy) error = %v", err) } if bytes.Contains(data, []byte("analyze_state_version")) || bytes.Contains(data, []byte("analyze_artifacts")) || bytes.Contains(data, []byte("fingerprint")) { t.Fatalf("legacy state gained fabricated evidence:\n%s", data) } empty := New("session", now) empty.Stages["analyze"] = &StageRecord{ Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, AnalyzeStateVersion: AnalyzeStateContractVersion, } emptyPath := filepath.Join(t.TempDir(), "empty.json") if err := store.Save(context.Background(), emptyPath, empty); err != nil { t.Fatalf("Save(versioned empty state) error = %v", err) } emptyLoaded, err := store.Load(context.Background(), emptyPath) if err != nil { t.Fatalf("Load(versioned empty state) error = %v", err) } if !emptyLoaded.Stages["analyze"].HasVersionedAnalyzeState() || len(emptyLoaded.Stages["analyze"].AnalyzeArtifacts) != 0 { t.Fatalf("versioned empty state = %#v", emptyLoaded.Stages["analyze"]) } m := New("session", now) m.Stages["render"] = &StageRecord{ Name: "render", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, AnalyzeStateVersion: AnalyzeStateContractVersion, } if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "bad-owner.json"), m); err == nil || !strings.Contains(err.Error(), "cannot contain analyze-owned state") { t.Fatalf("non-analyze ownership error = %v", err) } m.Stages = map[string]*StageRecord{"analyze": { Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{}, }} if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "missing-version.json"), m); err == nil || !strings.Contains(err.Error(), "without a state version") { t.Fatalf("missing version error = %v", err) } } func TestAnalyzeArtifactStateSurvivesAggregateLifecycleClearing(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) for _, transition := range []struct { name string apply func(*Manifest) }{ {name: "running", apply: func(m *Manifest) { m.MarkStageRunning("analyze", now.Add(time.Minute)) }}, {name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("analyze", now.Add(time.Minute), "aggregate failed") }}, {name: "skipped", apply: func(m *Manifest) { m.MarkStageSkipped("analyze", now.Add(time.Minute), "disabled") }}, } { t.Run(transition.name, func(t *testing.T) { m := New("session", now) m.Stages["analyze"] = &StageRecord{ Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, Outputs: []ArtifactRecord{{Kind: "legacy", LocalPath: "artifacts/legacy.md"}}, Logs: []string{"aggregate.log"}, GeneratedConfigs: []string{"aggregate.yml"}, Metadata: map[string]any{"aggregate": true}, AnalyzeStateVersion: AnalyzeStateContractVersion, AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{ "session_recap": currentAnalyzeArtifactRecord("session_recap", now), }, } transition.apply(m) stage := m.Stages["analyze"] if len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.GeneratedConfigs) != 0 || len(stage.Metadata) != 0 { t.Fatalf("aggregate details survived %s: %#v", transition.name, stage) } if !stage.HasVersionedAnalyzeState() || stage.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent { t.Fatalf("per-artifact state was cleared by %s: %#v", transition.name, stage) } }) } } func currentAnalyzeArtifactRecord(key string, now time.Time) AnalyzeArtifactRecord { runID := "run-" + strings.ReplaceAll(key, "_", "-") return AnalyzeArtifactRecord{ Key: key, Status: AnalyzeArtifactCurrent, FingerprintVersion: AnalyzeFingerprintContractVersion, Fingerprint: strings.Repeat("1", 64), Output: &ArtifactRecord{ Kind: key, SourceID: "narratio.artifact." + key, LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md", ProducerRunID: runID, Checksum: strings.Repeat("a", 64), Contract: &artifactmodel.ContractMetadata{ MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1", }, ExternalProvenance: &artifactmodel.ExternalProvenance{ System: "scriptorium", PipelineID: "campaign", ArtifactID: key, }, }, OutputSize: 42, ProducerRunID: runID, UpdatedAt: now, Scriptorium: &AnalyzeArtifactProvenance{ PromptID: key, ProfileID: "default", CommandMode: "artifact", }, Logs: []string{"runs/" + runID + "/logs/" + key + ".log"}, GeneratedConfigs: []string{"runs/" + runID + "/config/" + key + ".yml"}, } }