package previouscache import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestBuildPlanResolvesPublishedArtifactFromPreviousManifest(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"artifacts/session_recap.md"})) previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")}) plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) if err != nil { t.Fatalf("BuildPlan() error = %v", err) } if plan.PreviousRunID != "previous-run" { t.Fatalf("PreviousRunID = %q, want previous-run", plan.PreviousRunID) } got := recordRelPaths(plan.Records) want := []string{"previous/artifacts/session_recap.md", "previous/manifest.json"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("record rel paths = %#v, want %#v", got, want) } } func TestBuildPlanFallsBackToConfiguredOutputPath(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil)) previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")}) plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) if err != nil { t.Fatalf("BuildPlan() error = %v", err) } if len(plan.Records) != 2 { t.Fatalf("records len = %d, want 2", len(plan.Records)) } if plan.Records[0].LocalRelativePath != "previous/artifacts/session_recap.md" { t.Fatalf("artifact local relative path = %q", plan.Records[0].LocalRelativePath) } } func TestResolveUsesCommittedSourceMappingForCustomDestination(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} previous := previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil) customKey := seedCommittedPreviousState(t, store, cfg, previous, []artifacts.RemoteArtifact{ committedPublishedArtifactForTest("narratio.artifact.session_recap", "history/recap-v2.txt", []byte("# recap\n"), "recap-generation"), committedPublishedArtifactForTest("narratio.artifact.player_handout", "other/recap-v2.txt", []byte("handout"), "handout-generation"), }) plan, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store) if err != nil { t.Fatalf("Resolve() error = %v", err) } if len(plan.Records) != 2 { t.Fatalf("records = %#v, want manifest and artifact", plan.Records) } if manifestRecord := findRecord(plan.Records, InputKindManifest); manifestRecord == nil || len(manifestRecord.VerifiedContent) == 0 { t.Fatalf("manifest record = %#v, want retained verified content", manifestRecord) } artifact := findRecord(plan.Records, InputKindArtifact) if artifact == nil { t.Fatal("artifact record is missing") } if artifact.RemoteKey != customKey { t.Fatalf("remote key = %q, want exact configured source destination %q", artifact.RemoteKey, customKey) } if artifact.LocalRelativePath != "previous/artifacts/session_recap.md" { t.Fatalf("local relative path = %q, want source-local cache path", artifact.LocalRelativePath) } } func TestResolveDoesNotUseCommittedBasenameFallback(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} previous := previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil) seedCommittedPreviousState(t, store, cfg, previous, []artifacts.RemoteArtifact{ committedPublishedArtifactForTest("narratio.artifact.player_handout", "history/session_recap.md", []byte("wrong source"), "handout-generation"), }) _, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store) if err == nil || !strings.Contains(err.Error(), `required previous-session artifact "session_recap" object missing`) { t.Fatalf("Resolve() error = %v, want source-mapping absence", err) } } func TestResolveRejectsAmbiguousLegacyCandidates(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"archive/session_recap.md"})) prefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) store.SeedObject(storage.FakeObject{Key: prefix + "artifacts/session_recap.md", Data: []byte("source candidate")}) store.SeedObject(storage.FakeObject{Key: prefix + "archive/session_recap.md", Data: []byte("published candidate")}) _, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store) if err == nil || !strings.Contains(err.Error(), "matches multiple published candidates") { t.Fatalf("Resolve() error = %v, want legacy ambiguity error", err) } } func TestResolveHonorsCancellation(t *testing.T) { cfg, paths := previousCacheTestConfig(t) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := Resolve(ctx, cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, &storage.FakeBackend{}) if err == nil || !strings.Contains(err.Error(), "context canceled") { t.Fatalf("Resolve() error = %v, want cancellation", err) } } func TestBuildPlanSkipsMissingOptionalPreviousArtifact(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil)) plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: false}, }, store) if err != nil { t.Fatalf("BuildPlan() error = %v", err) } if strings.Join(plan.SkippedMissing, ",") != "session_recap" { t.Fatalf("SkippedMissing = %#v, want session_recap", plan.SkippedMissing) } if len(plan.Records) != 1 || plan.Records[0].Kind != InputKindManifest { t.Fatalf("records = %#v, want manifest only", plan.Records) } } func TestBuildPlanMissingRequiredPreviousArtifactFails(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil)) _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) if err == nil || !strings.Contains(err.Error(), `required previous-session artifact "session_recap" object missing`) { t.Fatalf("BuildPlan() error = %v, want required missing error", err) } } func TestBuildPlanValidatesPreviousManifestIdentity(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} manifest := previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil) manifest.SessionID = "wrong-session" seedPreviousCurrent(t, store, cfg, manifest) _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) if err == nil || !strings.Contains(err.Error(), "does not match expected session_id") { t.Fatalf("BuildPlan() error = %v, want identity validation error", err) } } func TestBuildPlanMissingCurrentRunPointerSkipsOptionalRequirements(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: false}, }, store) if err != nil { t.Fatalf("BuildPlan() error = %v", err) } if strings.Join(plan.SkippedMissing, ",") != "session_recap" { t.Fatalf("SkippedMissing = %#v, want session_recap", plan.SkippedMissing) } } func TestBuildPlanMissingCurrentRunPointerFailsRequiredRequirements(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current run pointer missing`) { t.Fatalf("BuildPlan() error = %v, want required missing run pointer error", err) } } func TestBuildPlanMissingCurrentManifestSkipsOptionalRequirements(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) _, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix) store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")}) plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: false}, }, store) if err != nil { t.Fatalf("BuildPlan() error = %v", err) } if strings.Join(plan.SkippedMissing, ",") != "session_recap" { t.Fatalf("SkippedMissing = %#v, want session_recap", plan.SkippedMissing) } } func TestBuildPlanMissingCurrentManifestFailsRequiredRequirements(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) _, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix) store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")}) _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current manifest missing`) { t.Fatalf("BuildPlan() error = %v, want required missing manifest error", err) } } func previousCacheTestConfig(t *testing.T) (*config.Config, artifacts.SessionPaths) { t.Helper() workspaceRoot := t.TempDir() cfg := &config.Config{ Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: workspaceRoot}, Storage: config.StorageConfig{S3: &config.StorageS3Config{ Bucket: "test-bucket", RootPrefix: "dnd", }}, Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{ "session_recap": { Enabled: true, OutputPath: "artifacts/session_recap.md", }, }}, }, Session: &config.SessionConfig{ Campaign: "sample-campaign", SessionID: "2026-05-03", PreviousSessionID: "2026-04-26", }, } return cfg, artifacts.NewLocalStore(workspaceRoot).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) } func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest) { t.Helper() previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix) store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")}) data, err := marshalManifestForPreviousCacheTest(m) if err != nil { t.Fatalf("marshal manifest: %v", err) } store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data}) } func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, publishedPaths []string) *manifest.Manifest { t.Helper() m := manifest.New(cfg.Session.PreviousSessionID, time.Date(2026, 4, 26, 10, 0, 0, 0, time.UTC)) m.Campaign = cfg.Session.Campaign m.RunID = "previous-run" m.LocalWorkDir = filepath.Join("/var/lib/narratio/work", cfg.Session.Campaign, cfg.Session.PreviousSessionID, "runs", m.RunID) if strings.TrimSpace(rel) != "" { m.MarkStageSucceeded("analyze", time.Date(2026, 4, 26, 10, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{ {SourceID: "narratio.artifact.session_recap", LocalPath: filepath.Join(filepath.Dir(filepath.Dir(m.LocalWorkDir)), filepath.FromSlash(rel))}, }) } if publishedPaths != nil { if m.Stages["publish"] == nil { m.MarkStageSucceeded("publish", time.Date(2026, 4, 26, 10, 2, 0, 0, time.UTC), nil) } m.Stages["publish"].Metadata = map[string]any{"published_paths": publishedPaths} } return m } func marshalManifestForPreviousCacheTest(m *manifest.Manifest) ([]byte, error) { data, err := json.Marshal(m) if err != nil { return nil, err } return append(data, '\n'), nil } func recordRelPaths(records []Record) []string { out := make([]string, 0, len(records)) for _, record := range records { out = append(out, record.LocalRelativePath) } return out } func findRecord(records []Record, kind string) *Record { for index := range records { if records[index].Kind == kind { return &records[index] } } return nil } func seedCommittedPreviousState(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest, published []artifacts.RemoteArtifact) string { t.Helper() prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) runID := "previous-run" manifestKey := artifacts.S3RunSessionManifestKey(prefix, runID) manifestData, err := marshalManifestForPreviousCacheTest(m) if err != nil { t.Fatalf("marshal previous manifest: %v", err) } manifestGeneration := "manifest-generation" commit := artifacts.RemoteCommitManifest{ FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: runID, Artifacts: append([]artifacts.RemoteArtifact{{ Type: artifacts.RemoteArtifactTypeSessionManifest, Source: "session.manifest", DestinationKey: manifestKey, SHA256: sha256ForPreviousCacheTest(manifestData), Size: int64(len(manifestData)), Generation: manifestGeneration, }}, published...), } commitData, err := artifacts.EncodeRemoteCommitManifest(commit) if err != nil { t.Fatalf("encode committed previous state: %v", err) } commitKey := artifacts.S3RunCommitKey(prefix, runID) commitGeneration := "commit-generation" pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{ FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: runID, CommitKey: commitKey, CommitSHA256: sha256ForPreviousCacheTest(commitData), CommitSize: int64(len(commitData)), CommitGeneration: commitGeneration, }) if err != nil { t.Fatalf("encode committed previous pointer: %v", err) } store.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(prefix), Data: pointerData, ETag: "pointer-generation"}) store.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData, ETag: commitGeneration}) store.SeedObject(storage.FakeObject{Key: manifestKey, Data: manifestData, ETag: manifestGeneration}) for _, artifact := range published { store.SeedObject(storage.FakeObject{Key: artifact.DestinationKey, Data: publishedDataForTest(artifact.Source), ETag: artifact.Generation}) } return published[0].DestinationKey } func committedPublishedArtifactForTest(source, destination string, data []byte, generation string) artifacts.RemoteArtifact { return artifacts.RemoteArtifact{ Type: artifacts.RemoteArtifactTypePublishedOutput, Source: source, DestinationKey: "dnd/campaigns/sample-campaign/sessions/2026-04-26/runs/previous-run/published/" + destination, SHA256: sha256ForPreviousCacheTest(data), Size: int64(len(data)), Generation: generation, } } func publishedDataForTest(source string) []byte { if source == "narratio.artifact.session_recap" { return []byte("# recap\n") } if source == "narratio.artifact.player_handout" { return []byte("handout") } return []byte("published") } func sha256ForPreviousCacheTest(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) }