Files
narratio/internal/previouscache/previouscache_test.go

437 lines
19 KiB
Go

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 TestResolveUsesExactCommittedDeclaration(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
seedCommittedPreviousState(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"artifacts/session_recap.md"}), []artifacts.RemoteArtifact{committedPublishedArtifactForTest("narratio.artifact.session_recap", "artifacts/session_recap.md", []byte("# recap\n"), "recap-generation")})
store.ListErr = context.Canceled
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 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)
}
pointerKey := artifacts.S3CurrentCommitPointerKey(artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID))
if strings.Join(store.ExistsKeys, ",") != pointerKey {
t.Fatalf("Exists keys = %#v, want only current pointer %q", store.ExistsKeys, pointerKey)
}
}
func TestResolveUsesCarriedDeclarationFromEarlierRun(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
declaration := committedPublishedArtifactForTest("narratio.artifact.session_recap", "artifacts/session_recap.md", []byte("# recap\n"), "recap-generation")
declaration.DestinationKey = strings.Replace(declaration.DestinationKey, "/runs/previous-run/", "/runs/origin-run/", 1)
exactKey := seedCommittedPreviousState(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil), []artifacts.RemoteArtifact{declaration})
plan, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
record := findRecord(plan.Records, InputKindArtifact)
if record == nil || record.RemoteKey != exactKey {
t.Fatalf("artifact record = %#v, want exact carried key %q", record, exactKey)
}
}
func TestResolveDerivesLocalPathFromCommittedDestination(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
cfg.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{Enabled: true, OutputPath: "configured/ignored.md"}
store := &storage.FakeBackend{}
seedCommittedPreviousState(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil), []artifacts.RemoteArtifact{committedPublishedArtifactForTest("narratio.artifact.session_recap", "artifacts/session_recap.md", []byte("# recap\n"), "recap-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 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 TestPublishedOutputRelativePathRejectsUnsafeDestination(t *testing.T) {
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-04-26")
_, err := publishedOutputRelativePath(sessionPrefix, artifacts.S3RunPrefix(sessionPrefix, "previous-run")+"artifacts/session_recap.md")
if err == nil || !strings.Contains(err.Error(), "outside its immutable run published-output prefix") {
t.Fatalf("publishedOutputRelativePath() error = %v, want prefix rejection", err)
}
_, err = publishedOutputRelativePath(sessionPrefix, artifacts.S3RunPrefix(sessionPrefix, "previous-run")+"published/../session_recap.md")
if err == nil || !strings.Contains(err.Error(), "clean relative object key") {
t.Fatalf("publishedOutputRelativePath() error = %v, want unsafe destination rejection", err)
}
}
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/history/recap-v2.txt" {
t.Fatalf("local relative path = %q, want committed-destination 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" is not declared`) {
t.Fatalf("Resolve() error = %v, want source-mapping absence", err)
}
}
func TestResolveRejectsUndeclaredPublishedCandidatesWithoutProbing(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(), "is not declared by the selected remote commit") {
t.Fatalf("Resolve() error = %v, want undeclared-candidate 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 TestResolveSkipsMissingOptionalPreviousArtifact(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
plan, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: false},
}, store)
if err != nil {
t.Fatalf("Resolve() 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 TestResolveMissingRequiredPreviousArtifactFails(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
_, 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" is not declared`) {
t.Fatalf("Resolve() error = %v, want required missing error", err)
}
}
func TestResolveValidatesPreviousManifestIdentity(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 := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}, store)
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("Resolve() error = %v, want identity validation error", err)
}
}
func TestResolveMissingCurrentRunPointerSkipsOptionalRequirements(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
plan, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: false},
}, store)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if strings.Join(plan.SkippedMissing, ",") != "session_recap" {
t.Fatalf("SkippedMissing = %#v, want session_recap", plan.SkippedMissing)
}
}
func TestResolveMissingCurrentRunPointerFailsRequiredRequirements(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
_, err := Resolve(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 commit pointer missing`) {
t.Fatalf("Resolve() error = %v, want required missing commit pointer error", err)
}
}
func TestResolveMissingCurrentCommitPointerSkipsOptionalRequirements(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
plan, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: false},
}, store)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if strings.Join(plan.SkippedMissing, ",") != "session_recap" {
t.Fatalf("SkippedMissing = %#v, want session_recap", plan.SkippedMissing)
}
}
func TestResolveMissingCurrentCommitPointerFailsRequiredRequirements(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
_, err := Resolve(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 commit pointer missing`) {
t.Fatalf("Resolve() error = %v, want required missing commit pointer 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()
data, err := marshalManifestForPreviousCacheTest(m)
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
manifestKey := artifacts.S3RunSessionManifestKey(prefix, m.RunID)
manifestGeneration := "manifest-generation"
commit := artifacts.RemoteCommitManifest{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: m.RunID, Artifacts: []artifacts.RemoteArtifact{{Type: artifacts.RemoteArtifactTypeSessionManifest, Source: "session.manifest", DestinationKey: manifestKey, SHA256: sha256ForPreviousCacheTest(data), Size: int64(len(data)), Generation: manifestGeneration}}}
commitData, err := artifacts.EncodeRemoteCommitManifest(commit)
if err != nil {
t.Fatalf("encode previous commit: %v", err)
}
commitKey := artifacts.S3RunCommitKey(prefix, m.RunID)
pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: m.RunID, CommitKey: commitKey, CommitSHA256: sha256ForPreviousCacheTest(commitData), CommitSize: int64(len(commitData)), CommitGeneration: "commit-generation"})
if err != nil {
t.Fatalf("encode previous pointer: %v", err)
}
store.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(prefix), Data: pointerData})
store.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData, ETag: "commit-generation"})
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data, ETag: manifestGeneration})
}
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[:])
}