package app import ( "context" "path/filepath" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" ) func TestRestorePlanDefaultScope(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`)) seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n")) seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio")) seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}")) seedRestoreObject(store, current.SessionPrefix+"logs/archive.log", []byte("log")) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"artifacts/session_recap.md", "manifest.json", "transcripts/full.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } if plan.DownloadCount != 3 || plan.SkipSameCount != 0 || plan.ConflictCount != 0 { t.Fatalf("counts = download=%d skip_same=%d conflict=%d, want 3/0/0", plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount) } } func TestRestorePlanIncludeAudio(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio")) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"audio/alice.flac", "manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanExistingAudioUsesSizeWithoutRemoteChecksumDownload(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio")) sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "local") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if len(store.Downloads) != 0 { t.Fatalf("downloads = %d, want no remote checksum download for audio", len(store.Downloads)) } actionByRel := map[string]RestoreAction{} for _, action := range plan.Actions { actionByRel[action.LocalRelativePath] = action } audioAction := actionByRel["audio/alice.flac"] if audioAction.Kind != RestoreActionSkipSame { t.Fatalf("audio action kind = %q, want %q", audioAction.Kind, RestoreActionSkipSame) } } func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, true) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`)) seedRestoreObject(store, current.SessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n")) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanMissingOptionalPreviousCacheSkipsArtifact(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, false) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrentManifestOnly(t, store, cfg) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } got := actionRelPaths(plan.Actions) want := []string{"manifest.json", "previous/manifest.json"} if !reflect.DeepEqual(got, want) { t.Fatalf("action local paths = %#v, want %#v", got, want) } } func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, true) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrentManifestOnly(t, store, cfg) _, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err == nil || !strings.Contains(err.Error(), "required previous-session artifact") { t.Fatalf("buildRestorePlan() error = %v, want required previous artifact failure", err) } } func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) { cfg := restorePlanConfig(t) configureRestorePlanPreviousRequirement(cfg, true) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n") sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if plan.ConflictCount != 1 { t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount) } plan, err = buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true}) if err != nil { t.Fatalf("buildRestorePlan(force) error = %v", err) } if plan.ConflictCount != 0 { t.Fatalf("force ConflictCount = %d, want 0", plan.ConflictCount) } } func TestRestorePlanClassifiesSameAndConflict(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`)) seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n")) sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`) mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } if plan.SkipSameCount != 1 { t.Fatalf("SkipSameCount = %d, want 1", plan.SkipSameCount) } if plan.ConflictCount != 1 { t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount) } actionByRel := map[string]RestoreAction{} for _, action := range plan.Actions { actionByRel[action.LocalRelativePath] = action } if actionByRel["transcripts/full.json"].Kind != RestoreActionSkipSame { t.Fatalf("transcripts/full.json kind = %q, want %q", actionByRel["transcripts/full.json"].Kind, RestoreActionSkipSame) } if actionByRel["artifacts/session_recap.md"].Kind != RestoreActionConflict { t.Fatalf("artifacts/session_recap.md kind = %q, want %q", actionByRel["artifacts/session_recap.md"].Kind, RestoreActionConflict) } } func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n")) sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n") plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) } actionByRel := map[string]RestoreAction{} for _, action := range plan.Actions { actionByRel[action.LocalRelativePath] = action } recap := actionByRel["artifacts/session_recap.md"] if recap.Kind != RestoreActionDownload { t.Fatalf("artifacts/session_recap.md kind = %q, want %q", recap.Kind, RestoreActionDownload) } if plan.ConflictCount != 0 { t.Fatalf("ConflictCount = %d, want 0", plan.ConflictCount) } } func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) seedRestoreObject(store, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad")) _, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "escapes session scope") { t.Fatalf("error = %v, want traversal safety failure", err) } } func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) { store.SeedObject(storage.FakeObject{Key: key, Data: data}) } func actionRelPaths(actions []RestoreAction) []string { out := make([]string, 0, len(actions)) for _, action := range actions { out = append(out, action.LocalRelativePath) } return out } func restorePlanConfig(t *testing.T) *config.Config { t.Helper() workspaceRoot := t.TempDir() return &config.Config{ Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: workspaceRoot}, Storage: config.StorageConfig{S3: &config.StorageS3Config{ Bucket: "test-bucket", RootPrefix: "dnd", }}, }, Session: &config.SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", }, } } func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool) { cfg.Session.PreviousSessionID = "2026-04-26" cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{ Artifacts: map[string]config.ScriptoriumArtifactConfig{ "session_recap": { Enabled: true, OutputPath: "artifacts/session_recap.md", Inputs: map[string]config.ScriptoriumInputConfig{ "previous_recap": { Source: "narratio.previous_session.artifact.session_recap", Required: required, }, }, }, }, } } func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState { t.Helper() sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix) return &RemoteCurrentState{ Bucket: "test-bucket", SessionPrefix: sessionPrefix, CurrentManifestKey: manifestKey, CurrentRunIDKey: runIDKey, RunID: "20260519T010203Z-a1b2c3d4", SessionID: cfg.Session.SessionID, Campaign: cfg.Session.Campaign, } } func TestWriteRestorePlan(t *testing.T) { current := &RemoteCurrentState{Campaign: "sample-campaign", SessionID: "2026-05-03", RunID: "r-1"} plan := &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "k", Reason: "local file missing"}}, DownloadCount: 1} var out strings.Builder if err := writeRestorePlan(&out, current, plan, RestorePlanOptions{DryRun: true}); err != nil { t.Fatalf("writeRestorePlan() error = %v", err) } text := out.String() if !strings.Contains(text, "restore plan: session sample-campaign/2026-05-03 run=r-1") { t.Fatalf("output = %q, want plan summary", text) } if !strings.Contains(text, "download manifest.json <- k") { t.Fatalf("output = %q, want action line", text) } }