package app import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "sort" "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 TestCommittedRestorePlanUsesOnlyDeclaredObjects(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte(`{"segments":[1]}`), }) fake.SeedObject(storage.FakeObject{Key: current.SessionPrefix + "artifacts/stale.md", Data: []byte("stale")}) fake.SeedObject(storage.FakeObject{Key: artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, "20260519T010204Z-e5f6a7b8"), "transcripts/other.json"), Data: []byte("other")}) plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"manifest.json", "transcripts/full.json"} if len(got) != len(want) { t.Fatalf("action paths = %#v, want %#v", got, want) } for index := range want { if got[index] != want[index] { t.Fatalf("action paths = %#v, want %#v", got, want) } } } func TestCommittedRestoreDownloadsCarriedOutputFromDeclaredRun(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("carried\n"), }) declaration := ¤t.Commit.Artifacts[1] oldKey := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, "20260512T010203Z-11223344"), "published/transcripts/full.json") fake.SeedObject(storage.FakeObject{Key: oldKey, Data: []byte("carried\n")}) oldObject := fake.Objects[oldKey] declaration.DestinationKey = oldKey declaration.Generation = oldObject.ETag if err := current.Commit.ValidateForSessionPrefix(current.SessionPrefix); err != nil { t.Fatalf("carried commit validation: %v", err) } plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } action := restoreActionForPath(t, plan, "transcripts/full.json") if action.RemoteKey != oldKey { t.Fatalf("restore remote key = %q, want exact carried key %q", action.RemoteKey, oldKey) } if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err != nil { t.Fatalf("executeRestorePlan() error = %v", err) } if got := fakeDownloadCount(fake, oldKey); got != 1 { t.Fatalf("carried object downloads = %d, want one", got) } } func TestCommittedRestoreReusesVerifiedManifestCandidate(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", nil) plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err != nil { t.Fatalf("executeRestorePlan() error = %v", err) } if got := fakeReadCount(fake, current.CurrentManifestKey); got != 1 { t.Fatalf("manifest reads = %d, want one discovery read reused by restore", got) } if got := fakeDownloadCount(fake, current.CurrentManifestKey); got != 0 { t.Fatalf("manifest downloads = %d, want no temporary download", got) } } func TestCommittedRestoreRejectsChangedGenerationForVerifiedManifestCandidate(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", nil) plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } fake.SeedObject(storage.FakeObject{Key: current.CurrentManifestKey, Data: []byte("changed manifest"), ETag: "changed-generation"}) _, err = executeRestorePlan(context.Background(), cfg, current, plan, nil, fake) if err == nil || !strings.Contains(err.Error(), "generation mismatch") { t.Fatalf("executeRestorePlan() error = %v, want generation mismatch", err) } if got := fakeReadCount(fake, current.CurrentManifestKey); got != 1 { t.Fatalf("manifest reads = %d, want no second transfer for rejected candidate", got) } } func TestCommittedStatusReportsOnlyDeclaredPublishedOutputs(t *testing.T) { cfg := restorePlanConfig(t) cfg.Pipeline.Publish = &config.PublishConfig{Outputs: []config.PublishOutputRule{{ Source: "narratio.transcript.final_trimmed", Dest: "transcripts/full.json", }}} fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("declared\n"), }) current.Commit.Artifacts[1].Source = "narratio.transcript.final_trimmed" fake.SeedObject(storage.FakeObject{Key: current.SessionPrefix + "transcripts/full.json", Data: []byte("mutable stale copy\n")}) availability := remotePublishedOutputAvailabilityForCurrent(context.Background(), cfg, fake, nil, current) key := publishedOutputRemoteStateKey("narratio.transcript.final_trimmed", "transcripts/full.json") if availability[key] != "remote=published" { t.Fatalf("availability = %#v, want committed published output", availability) } } func TestCommittedStatusReportsCarriedOutputOriginAndChecksExactKey(t *testing.T) { cfg := restorePlanConfig(t) cfg.Pipeline.Publish = &config.PublishConfig{Outputs: []config.PublishOutputRule{{ Source: "narratio.transcript.final_trimmed", Dest: "transcripts/full.json", }}} fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("carried\n"), }) const originRun = "20260512T010203Z-11223344" oldKey := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, originRun), "published/transcripts/original.json") fake.SeedObject(storage.FakeObject{Key: oldKey, Data: []byte("carried\n")}) current.Commit.Artifacts[1].Source = "narratio.transcript.final_trimmed" current.Commit.Artifacts[1].DestinationKey = oldKey current.Manifest.Stages["publish"] = &manifest.StageRecord{ Name: "publish", Status: manifest.StatusSucceeded, Metadata: map[string]any{"publish_output_outcomes": []any{map[string]any{ "source": "narratio.transcript.final_trimmed", "status": "carried_locked", }}}, } availability := remotePublishedOutputAvailabilityForCurrent(context.Background(), cfg, fake, nil, current) key := publishedOutputRemoteStateKey("narratio.transcript.final_trimmed", "transcripts/full.json") want := "outcome=carried_locked remote=published declared_dest=transcripts/original.json declared_key=" + oldKey + " origin_run=" + originRun if availability[key] != want { t.Fatalf("availability = %#v, want %q", availability, want) } if len(fake.ExistsKeys) == 0 || fake.ExistsKeys[len(fake.ExistsKeys)-1] != oldKey { t.Fatalf("exists keys = %#v, want final exact declaration key %q", fake.ExistsKeys, oldKey) } var rendered bytes.Buffer writePublishedOutputLine(&rendered, cfg.Pipeline.Publish.Outputs[0], nil, nil, availability) for _, fragment := range []string{"dest=transcripts/full.json", "declared_dest=transcripts/original.json", "declared_key=" + oldKey, "origin_run=" + originRun} { if !strings.Contains(rendered.String(), fragment) { t.Fatalf("rendered output = %q, want %q", rendered.String(), fragment) } } } func TestCommittedPublishOutputOutcomesRecognizesAllStates(t *testing.T) { statuses := []string{"published", "carried_locked", "locked_unpublished", "optional_missing", "unselected"} items := make([]any, 0, len(statuses)) for _, status := range statuses { items = append(items, map[string]any{"source": "source." + status, "status": status}) } m := &manifest.Manifest{Stages: map[string]*manifest.StageRecord{ "publish": {Metadata: map[string]any{"publish_output_outcomes": items}}, }} got := committedPublishOutputOutcomes(m) for _, status := range statuses { if got["source."+status] != status { t.Fatalf("outcomes = %#v, missing %q", got, status) } } } func TestCommittedRestoreKeepsSelectedSnapshotWhenPointerChanges(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} first := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("from first commit\n"), }) plan, err := buildRestorePlan(context.Background(), cfg, first, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } _ = seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010204Z-e5f6a7b8", map[string][]byte{ "transcripts/full.json": []byte("from second commit\n"), }) if _, err := executeRestorePlan(context.Background(), cfg, first, plan, nil, fake); err != nil { t.Fatalf("executeRestorePlan() error = %v", err) } root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustReadEquals(t, filepath.Join(root, "transcripts", "full.json"), "from first commit\n") restored, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(root, "manifest.json")) if err != nil { t.Fatalf("load restored manifest: %v", err) } if restored.RunID != first.RunID { t.Fatalf("restored run id = %q, want %q", restored.RunID, first.RunID) } } func TestCommittedRestoreRejectsChangedDeclaredObjectBeforeManifestInstall(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("committed bytes\n"), }) plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "published/transcripts/full.json") fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("changed bytes\n")}) if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err == nil { t.Fatal("executeRestorePlan() error = nil, want committed-object verification failure") } root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) { t.Fatalf("manifest should not be installed after failed restore; stat err=%v", err) } } func TestCommittedRestoreRejectsChangedDeclaredObjectGeneration(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("committed bytes\n"), }) plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "published/transcripts/full.json") fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("committed bytes\n"), ETag: "replacement-generation"}) if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err == nil { t.Fatal("executeRestorePlan() error = nil, want generation verification failure") } root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) { t.Fatalf("manifest should not be installed after failed restore; stat err=%v", err) } } func TestCommittedRestoreRejectsChangedAudioBeforeInstall(t *testing.T) { tests := []struct { name string data []byte generation string wantError string }{ {name: "content", data: []byte("tampered-audio"), wantError: "checksum mismatch"}, {name: "generation", data: []byte("original-audio"), generation: "replacement-generation", wantError: "generation mismatch"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { cfg := restorePlanConfig(t) cfg.Pipeline.Spool.Root = filepath.Join(t.TempDir(), "spool") cacheDisabled := false cfg.Pipeline.Cache.S3Audio = &cacheDisabled fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "audio/alice.flac": []byte("original-audio"), }) plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{IncludeAudio: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "published/audio/alice.flac") generation := test.generation if generation == "" { generation = fake.Objects[key].ETag } fake.SeedObject(storage.FakeObject{Key: key, Data: test.data, ETag: generation}) root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) destination := filepath.Join(root, "audio", "alice.flac") if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { t.Fatalf("MkdirAll(audio directory) error = %v", err) } if err := os.WriteFile(destination, []byte("local-audio-remains"), 0o644); err != nil { t.Fatalf("WriteFile(existing audio) error = %v", err) } _, err = executeRestorePlan(context.Background(), cfg, current, plan, nil, fake) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("executeRestorePlan() error = %v, want %q", err, test.wantError) } mustReadEquals(t, destination, "local-audio-remains") if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) { t.Fatalf("manifest should not be installed after failed audio restore; stat err=%v", err) } }) } } func TestCommittedRestoreRejectsMissingDeclaredObjectBeforeManifestInstall(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("committed bytes\n"), }) plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } missingKey := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "published/transcripts/full.json") fake.DownloadHook = func(call storage.FakeDownloadCall) error { if call.Key == missingKey { return os.ErrNotExist } return nil } if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err == nil { t.Fatal("executeRestorePlan() error = nil, want missing-object failure") } root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) { t.Fatalf("manifest should not be installed after failed restore; stat err=%v", err) } } func TestCommittedRestoreForceRetainsDirectoryConflict(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ "transcripts/full.json": []byte("committed bytes\n"), }) root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) if err := os.MkdirAll(filepath.Join(root, "transcripts", "full.json"), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{Force: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if plan.ConflictCount != 1 { t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount) } for _, action := range plan.Actions { if action.LocalRelativePath == "transcripts/full.json" && action.ConflictKind != RestoreConflictDirectory { t.Fatalf("ConflictKind = %q, want %q", action.ConflictKind, RestoreConflictDirectory) } } } func seedCommittedRestoreSnapshot(t *testing.T, cfg *config.Config, fake *storage.FakeBackend, runID string, outputs map[string][]byte) *RemoteCurrentState { t.Helper() sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) remoteManifest := manifest.New(cfg.Session.SessionID, time.Now().UTC()) remoteManifest.Campaign = cfg.Session.Campaign remoteManifest.RunID = runID manifestData, err := json.Marshal(remoteManifest) if err != nil { t.Fatalf("marshal manifest: %v", err) } manifestData = append(manifestData, '\n') manifestKey := artifacts.S3RunSessionManifestKey(sessionPrefix, runID) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: manifestData}) artifactsByKey := []artifacts.RemoteArtifact{remoteRestoreArtifact(fake, artifacts.RemoteArtifactTypeSessionManifest, "session.manifest", manifestKey)} paths := make([]string, 0, len(outputs)) for relative := range outputs { paths = append(paths, relative) } sort.Strings(paths) for _, relative := range paths { key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, runID), "published/"+relative) fake.SeedObject(storage.FakeObject{Key: key, Data: outputs[relative]}) sourceKey := strings.NewReplacer("/", "_", ".", "_", "-", "_").Replace(relative) artifactsByKey = append(artifactsByKey, remoteRestoreArtifact(fake, artifacts.RemoteArtifactTypePublishedOutput, artifacts.ConfiguredArtifactSourceID(sourceKey), key)) } commit := artifacts.RemoteCommitManifest{ FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.SessionID, RunID: runID, Artifacts: artifactsByKey, } commitData, err := artifacts.EncodeRemoteCommitManifest(commit) if err != nil { t.Fatalf("encode remote commit: %v", err) } commitKey := artifacts.S3RunCommitKey(sessionPrefix, runID) fake.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData}) commitObject := fake.Objects[commitKey] pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{ FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.SessionID, RunID: runID, CommitKey: commitKey, CommitSHA256: restoreCommitSHA256(commitData), CommitSize: int64(len(commitData)), CommitGeneration: commitObject.ETag, }) if err != nil { t.Fatalf("encode current pointer: %v", err) } fake.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(sessionPrefix), Data: pointerData}) current, err := discoverRemoteCurrentState(context.Background(), cfg, fake) if err != nil { t.Fatalf("discoverRemoteCurrentState() error = %v", err) } return current } func remoteRestoreArtifact(fake *storage.FakeBackend, artifactType artifacts.RemoteArtifactType, source, key string) artifacts.RemoteArtifact { object := fake.Objects[key] return artifacts.RemoteArtifact{ Type: artifactType, Source: source, DestinationKey: key, SHA256: restoreCommitSHA256(object.Data), Size: int64(len(object.Data)), Generation: object.ETag, } } func fakeReadCount(fake *storage.FakeBackend, key string) int { count := 0 for _, call := range fake.Reads { if call.Key == key { count++ } } return count } func restoreCommitSHA256(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) }