package app import ( "bytes" "context" "fmt" "os" "path/filepath" "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 TestExecuteRestoreHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "restore", "--help"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0", code) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } out := stdout.String() if !strings.Contains(out, "Usage: narratio session restore ") { t.Fatalf("stdout = %q, want restore usage", out) } if !strings.Contains(out, "--include-audio") { t.Fatalf("stdout = %q, want --include-audio flag", out) } if !strings.Contains(out, "--campaign") { t.Fatalf("stdout = %q, want --campaign flag", out) } } func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn origPlanFn := buildRestorePlanFn origExecuteFn := executeRestorePlanFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn buildRestorePlanFn = origPlanFn executeRestorePlanFn = origExecuteFn }) newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return &storage.FakeBackend{}, nil } discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { return &RemoteCurrentState{ SessionID: "2026-05-03", Campaign: "sample-campaign", RunID: "20260519T010203Z-a1b2c3d4", }, nil } buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { return &RestorePlan{ Actions: []RestoreAction{ { Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json", Reason: "local file missing", }, }, DownloadCount: 1, }, nil } workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{ "session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run", "--force", "--include-audio", }, &stdout, &stderr, ) if code != 0 { t.Fatalf("exit code = %d, want 0 for --dry-run restore planning; stderr=%q", code, stderr.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } outText := stdout.String() if !strings.Contains(outText, "Restore plan for sample-campaign/2026-05-03") { t.Fatalf("stdout = %q, want restore plan summary", outText) } if !strings.Contains(outText, "Would download: 1") { t.Fatalf("stdout = %q, want plan count output", outText) } if !strings.Contains(outText, "Would download: manifest.json") { t.Fatalf("stdout = %q, want action output", outText) } manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err) } reportPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "reports", "restore-latest.json") if _, err := os.Stat(reportPath); !os.IsNotExist(err) { t.Fatalf("restore report should not be written during dry-run; stat err=%v", err) } } func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "restore: unexpected positional arguments") { t.Fatalf("stderr = %q, want positional-args failure", stderr.String()) } } func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn }) workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "no remote object store backend is configured") { t.Fatalf("stderr = %q, want storage backend preflight failure", stderr.String()) } } func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn }) newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return &storage.FakeBackend{}, nil } discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { return nil, fmt.Errorf("remote current run pointer missing: %q", "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt") } workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "remote current run pointer missing") { t.Fatalf("stderr = %q, want discovery error context", stderr.String()) } } func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn origPlanFn := buildRestorePlanFn origExecuteFn := executeRestorePlanFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn buildRestorePlanFn = origPlanFn executeRestorePlanFn = origExecuteFn }) const accessKeyEnv = "OBJECT_STORAGE_KEY_ID" const secretKeyEnv = "OBJECT_STORAGE_KEY" restoreEnv := func(name string) { value, exists := os.LookupEnv(name) _ = os.Unsetenv(name) t.Cleanup(func() { if exists { _ = os.Setenv(name, value) return } _ = os.Unsetenv(name) }) } restoreEnv(accessKeyEnv) restoreEnv(secretKeyEnv) workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) secretsDir := filepath.Join(t.TempDir(), "secrets") mustWriteSecretFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-access-key-id\n") mustWriteSecretFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret-key\n") f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatalf("open pipeline config for append: %v", err) } defer f.Close() if _, err := f.WriteString("\nsecrets:\n env_dir: " + secretsDir + "\n"); err != nil { t.Fatalf("append secrets config: %v", err) } storeInitCalled := false newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { storeInitCalled = true gotID, okID := os.LookupEnv(accessKeyEnv) if !okID || gotID != "test-access-key-id" { return nil, fmt.Errorf("missing or unexpected %s: %q (set=%t)", accessKeyEnv, gotID, okID) } gotSecret, okSecret := os.LookupEnv(secretKeyEnv) if !okSecret || gotSecret != "test-secret-key" { return nil, fmt.Errorf("missing or unexpected %s: %q (set=%t)", secretKeyEnv, gotSecret, okSecret) } return &storage.FakeBackend{}, nil } discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { return &RemoteCurrentState{ SessionID: "2026-05-03", Campaign: "sample-campaign", RunID: "20260519T010203Z-a1b2c3d4", }, nil } buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { return &RestorePlan{ Actions: []RestoreAction{ { Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json", Reason: "local file missing", }, }, DownloadCount: 1, }, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{ "session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run", }, &stdout, &stderr, ) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !storeInitCalled { t.Fatal("expected object store initialization to be called") } } func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn origPlanFn := buildRestorePlanFn origExecuteFn := executeRestorePlanFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn buildRestorePlanFn = origPlanFn executeRestorePlanFn = origExecuteFn }) newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return &storage.FakeBackend{}, nil } discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { return &RemoteCurrentState{ SessionID: "2026-05-03", Campaign: "sample-campaign", RunID: "20260519T010203Z-a1b2c3d4", }, nil } buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { return &RestorePlan{ Actions: []RestoreAction{ {Kind: RestoreActionConflict, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs"}, }, ConflictCount: 1, }, nil } workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty on conflict failure", stdout.String()) } if !strings.Contains(stderr.String(), "restore conflict: 1 conflicting path(s); rerun with --force to overwrite") { t.Fatalf("stderr = %q, want conflict failure", stderr.String()) } if strings.Contains(stderr.String(), "phase 4: restore execution") { t.Fatalf("stderr = %q, should fail before phase-4 NYI boundary", stderr.String()) } } func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn origPlanFn := buildRestorePlanFn origExecuteFn := executeRestorePlanFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn buildRestorePlanFn = origPlanFn executeRestorePlanFn = origExecuteFn }) newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return &storage.FakeBackend{}, nil } discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { return &RemoteCurrentState{ SessionID: "2026-05-03", Campaign: "sample-campaign", RunID: "20260519T010203Z-a1b2c3d4", }, nil } buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { return &RestorePlan{ Actions: []RestoreAction{ {Kind: RestoreActionDownload, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs; overwrite with --force"}, }, DownloadCount: 1, }, nil } executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) { return &RestoreExecutionResult{DownloadedCount: 1}, nil } workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "Restored session state for sample-campaign/2026-05-03") { t.Fatalf("stdout = %q, want completion summary", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string, string) { t.Helper() dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") campaignPath := writeAppTestCampaignConfig(t, dir) sessionPath := filepath.Join(dir, "session.yml") pipelineYAML := `workspace: root: ` + workspaceRoot + ` whisperx: transcribe_url: https://example.com/transcribe ` sessionYAML := `session_id: 2026-05-03 campaign: sample-campaign inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline config: %v", err) } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session config: %v", err) } mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n") mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "players.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") return pipelinePath, campaignPath, sessionPath }