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" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "init", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-06-07", "--title", "The Black Cabin", "--remote", }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } key := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07")) obj, ok := fake.Objects[key] if !ok { t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects) } if !strings.Contains(string(obj.Data), `session_id: "2026-06-07"`) || !strings.Contains(string(obj.Data), "prefix: audio/") { t.Fatalf("remote session data = %q", string(obj.Data)) } if storeInitCalls != 1 { t.Fatalf("object store init calls = %d, want 1", storeInitCalls) } } func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) accessKeyEnv := "NARRATIO_TEST_VALIDATE_OBJECT_KEY_ID" secretKeyEnv := "NARRATIO_TEST_VALIDATE_OBJECT_SECRET" restoreEnvAfterTest(t, accessKeyEnv, secretKeyEnv) secretsDir := t.TempDir() mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-key-id\n") mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret\n") addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv) if err := os.WriteFile(sessionPath, []byte(`session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `), 0o644); err != nil { t.Fatalf("write session: %v", err) } fake := &storage.FakeBackend{} audioKey := artifacts.S3PromotedArtifactKey(artifacts.S3AudioPrefix(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "audio/"), "alice.flac") fake.SeedObject(storage.FakeObject{Key: audioKey, Data: []byte("audio")}) origStoreFn := newObjectStoreFromConfigFn newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { if os.Getenv(accessKeyEnv) != "test-key-id" || os.Getenv(secretKeyEnv) != "test-secret" { return nil, fmt.Errorf("secrets were not loaded before object store init") } return fake, nil } t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn }) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "validate", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "OK audio") { t.Fatalf("stdout = %q, want OK audio", stdout.String()) } } func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "locks", "add", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--reason", "manual edit", "narratio.transcript.trimmed", }, &stdout, &stderr) if code != 0 { t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } key := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")) obj, ok := fake.Objects[key] if !ok { t.Fatalf("remote locks key %q not uploaded", key) } if !strings.Contains(string(obj.Data), "source: narratio.transcript.trimmed") || !strings.Contains(string(obj.Data), "reason: manual edit") { t.Fatalf("lock store data = %q", string(obj.Data)) } stdout.Reset() stderr.Reset() code = Execute([]string{ "locks", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", }, &stdout, &stderr) if code != 0 { t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "- narratio.transcript.trimmed origin=remote") { t.Fatalf("stdout = %q, want remote lock", stdout.String()) } stdout.Reset() stderr.Reset() code = Execute([]string{ "locks", "remove", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "narratio.transcript.trimmed", }, &stdout, &stderr) if code != 0 { t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String()) } store, err := config.LoadArchiveLockStoreBytes("locks.yml", fake.Objects[key].Data, nil) if err != nil { t.Fatalf("LoadArchiveLockStoreBytes() error = %v", err) } if len(store.Locks) != 0 { t.Fatalf("locks after remove = %#v, want empty", store.Locks) } if storeInitCalls != 3 { t.Fatalf("object store init calls = %d, want 3", storeInitCalls) } } func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "locks", "add", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--reason", "first", "narratio.transcript.trimmed", }, &stdout, &stderr) if code != 0 { t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } stdout.Reset() stderr.Reset() code = Execute([]string{ "locks", "add", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--reason", "second", "narratio.transcript.trimmed", }, &stdout, &stderr) if code == 0 { t.Fatal("duplicate locks add exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "pass --force to update") { t.Fatalf("stderr = %q, want force guidance", stderr.String()) } stdout.Reset() stderr.Reset() code = Execute([]string{ "locks", "add", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--reason", "second", "--force", "narratio.transcript.trimmed", }, &stdout, &stderr) if code != 0 { t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } key := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")) if !strings.Contains(string(fake.Objects[key].Data), "reason: second") { t.Fatalf("lock store data = %q, want updated reason", string(fake.Objects[key].Data)) } } func TestExecuteLocksRequireSessionID(t *testing.T) { tests := []struct { name string args []string want string }{ {"list", []string{"locks"}, "locks: --session-id is required"}, {"add", []string{"locks", "add", "narratio.transcript.trimmed"}, "locks add: --session-id is required"}, {"remove", []string{"locks", "remove", "narratio.transcript.trimmed"}, "locks remove: --session-id is required"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(tt.args, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), tt.want) { t.Fatalf("stderr = %q, want %q", stderr.String(), tt.want) } }) } } func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addStaticArchiveLockToPipelineConfig(t, pipelinePath, "narratio.transcript.trimmed") fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "locks", "add", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "narratio.transcript.trimmed", }, &stdout, &stderr) if code == 0 { t.Fatal("locks add static lock exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "locked by pipeline config") { t.Fatalf("stderr = %q, want static lock error", stderr.String()) } stdout.Reset() stderr.Reset() code = Execute([]string{ "locks", "remove", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "narratio.transcript.trimmed", }, &stdout, &stderr) if code == 0 { t.Fatal("locks remove static lock exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "locked by pipeline config") { t.Fatalf("stderr = %q, want static lock error", stderr.String()) } } func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) { tests := []string{"lock", "unlock"} for _, cmd := range tests { t.Run(cmd, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{cmd, "narratio.transcript.trimmed"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), `unknown command: "`+cmd+`"`) { t.Fatalf("stderr = %q, want unknown command", stderr.String()) } }) } } func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} trimmedKey := artifacts.S3PromotedArtifactKey( artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/trimmed.json", ) fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)}) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "artifacts", "list", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--remote", }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "narratio.transcript.trimmed remote=promoted") { t.Fatalf("stdout = %q, want promoted remote availability", stdout.String()) } } func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) trimmedKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/trimmed.json") lockKey := artifacts.S3SessionLocksKey(sessionPrefix) fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")}) fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)}) fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.trimmed\n reason: remote review\n")}) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "status", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } out := stdout.String() for _, want := range []string{ "Remote outputs:", "Built-in:", "Configured:", "Previous-session:", "Promoted:", "narratio.transcript.trimmed locked remote=promoted", "narratio.transcript.merged remote=missing", } { if !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) } } } func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{ExistsErr: fmt.Errorf("exists failed")} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "status", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } out := stdout.String() if !strings.Contains(out, "Remote archive: missing or unavailable:") { t.Fatalf("stdout = %q, want remote archive unavailable state", out) } if !strings.Contains(out, "Remote outputs:") || !strings.Contains(out, "narratio.transcript.trimmed remote=error") { t.Fatalf("stdout = %q, want remote output error state", out) } if !strings.Contains(out, "Archive locks: error:") { t.Fatalf("stdout = %q, want archive locks error", out) } } func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidArchiveConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} lockKey := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")) fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.trimmed\n reason: remote review\n")}) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} { // The archive stage only checks the manifest statuses and source files. _ = stageName } mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "trimmed.json"), `{"segments":[]}`) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "archive"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } promotedKey := artifacts.S3PromotedArtifactKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/trimmed.json") if _, ok := fake.Objects[promotedKey]; ok { t.Fatalf("locked promoted key %q was uploaded", promotedKey) } } func writeValidArchiveConfigFiles(t *testing.T, workspaceRoot string) (string, string, string) { t.Helper() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) data, err := os.ReadFile(pipelinePath) if err != nil { t.Fatalf("read pipeline: %v", err) } updated := strings.Replace(string(data), "upload_run: false", "upload_run: true", 1) if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil { t.Fatalf("write pipeline: %v", err) } ctx := context.Background() cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{}) if err != nil { t.Fatalf("LoadWithSessionOptions() error = %v", err) } store := &manifest.LocalStore{} m := manifest.New("2026-05-03", nowUTC()) m.Campaign = "sample-campaign" m.RunID = "20260521T160000Z-test" for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} { m.MarkStageSucceeded(name, nowUTC(), nil) } path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) if err := store.Save(ctx, path, m); err != nil { t.Fatalf("save manifest: %v", err) } runManifestPath := artifacts.SessionRunManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, m.RunID) if err := os.MkdirAll(filepath.Dir(runManifestPath), 0o755); err != nil { t.Fatalf("mkdir run manifest: %v", err) } if err := os.WriteFile(runManifestPath, []byte("{}\n"), 0o644); err != nil { t.Fatalf("write run manifest: %v", err) } return pipelinePath, campaignPath, sessionPath } func addStaticArchiveLockToPipelineConfig(t *testing.T, pipelinePath, source string) { t.Helper() data, err := os.ReadFile(pipelinePath) if err != nil { t.Fatalf("read pipeline: %v", err) } updated := strings.Replace( string(data), "archive:\n enabled: true\n upload_run: false\n", "archive:\n enabled: true\n upload_run: false\n locks:\n - source: "+source+"\n reason: static review\n", 1, ) if updated == string(data) { t.Fatalf("archive section not found in pipeline config") } if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil { t.Fatalf("write pipeline: %v", err) } }