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 TestExecuteSessionInitRemoteUsesDefaultConfigDiscovery(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath) 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", "--session-id", "2026-06-07", "--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")) if _, ok := fake.Objects[key]; !ok { t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects) } if storeInitCalls != 1 { t.Fatalf("object store init calls = %d, want 1", storeInitCalls) } } func TestExecuteSessionInitLocalUsesDefaultConfigDiscovery(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath) outputPath := filepath.Join(t.TempDir(), "session.yml") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "init", "--session-id", "2026-06-07", "--output", outputPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read generated session: %v", err) } if !strings.Contains(string(data), `session_id: "2026-06-07"`) || !strings.Contains(string(data), "prefix: audio/") { t.Fatalf("generated session = %q", string(data)) } } func TestExecuteSessionInitExplicitConfigWinsOverDefaults(t *testing.T) { workspaceRoot := t.TempDir() defaultPipeline, defaultCampaign, _ := writeValidConfigFiles(t, workspaceRoot) withDefaultPipelineCampaignConfigs(t, defaultPipeline, defaultCampaign) explicitDir := t.TempDir() explicitCampaign := filepath.Join(explicitDir, "campaign.yml") if err := os.WriteFile(explicitCampaign, []byte(`campaign: explicit-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `), 0o644); err != nil { t.Fatalf("write explicit campaign: %v", err) } mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n") 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", defaultPipeline, "--campaign", explicitCampaign, "--session-id", "2026-06-07", "--remote", }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } explicitKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "explicit-campaign", "2026-06-07")) if _, ok := fake.Objects[explicitKey]; !ok { t.Fatalf("explicit campaign remote key %q not uploaded; objects=%v", explicitKey, fake.Objects) } defaultKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07")) if _, ok := fake.Objects[defaultKey]; ok { t.Fatalf("default campaign key %q uploaded despite explicit campaign override", defaultKey) } } func TestExecuteSessionInitRequiresSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "init", "--remote"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "session init: --session-id is required") { t.Fatalf("stderr = %q, want session-id required error", stderr.String()) } } func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) { origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")} config.DefaultCampaignConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-campaign.yml")} t.Cleanup(func() { config.DefaultPipelineConfigSearchPaths = origPipelineDefaults config.DefaultCampaignConfigSearchPaths = origCampaignDefaults }) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "init", "--session-id", "2026-06-07", "--remote"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "session init: no pipeline config path provided and no default pipeline config found; searched:") { t.Fatalf("stderr = %q, want default pipeline searched-path error", stderr.String()) } } func TestExecuteSessionInitRemoteLoadsSecretsBeforeObjectStoreInit(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath) accessKeyEnv := "NARRATIO_TEST_SESSION_INIT_OBJECT_KEY_ID" secretKeyEnv := "NARRATIO_TEST_SESSION_INIT_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) fake := &storage.FakeBackend{} 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", "init", "--session-id", "2026-06-07", "--remote"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } } func TestExecuteSessionInitLocalRendersCampaignTemplate(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}" previous_session_id: "{{ previous_session_id }}" date: "{{ date }}" title: "{{ title }}" inputs: audio_s3: prefix: "{{ audio_s3_prefix }}" `) outputPath := 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", "--previous-session-id", "2026-05-31", "--date", "2026-06-07", "--title", "The Black Cabin", "--audio-s3-prefix", "audio/", "--output", outputPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read generated session: %v", err) } got := string(data) for _, want := range []string{ `session_id: "2026-06-07"`, `previous_session_id: "2026-05-31"`, `date: "2026-06-07"`, `title: "The Black Cabin"`, `prefix: "audio/"`, } { if !strings.Contains(got, want) { t.Fatalf("generated session = %q, want %q", got, want) } } if strings.Contains(got, "{{") { t.Fatalf("generated session still contains template placeholder: %q", got) } } func TestExecuteSessionInitRemoteRendersCampaignTemplate(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}" inputs: audio_s3: prefix: audio/ `) 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", "--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), "{{") || !strings.Contains(string(obj.Data), `session_id: "2026-06-07"`) { t.Fatalf("remote session data = %q, want rendered concrete session", string(obj.Data)) } } func TestExecuteSessionInitTemplatePathIsCampaignRelative(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) templateDir := filepath.Join(filepath.Dir(campaignPath), "templates") if err := os.MkdirAll(templateDir, 0o755); err != nil { t.Fatalf("mkdir template dir: %v", err) } templatePath := filepath.Join(templateDir, "session.template.yml") if err := os.WriteFile(templatePath, []byte(`session_id: "{{ session_id }}" inputs: audio_dir: ./audio `), 0o644); err != nil { t.Fatalf("write session template: %v", err) } addSessionTemplateToCampaign(t, campaignPath, "./templates/session.template.yml") outputPath := 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", "--output", outputPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read generated session: %v", err) } if !strings.Contains(string(data), `session_id: "2026-06-07"`) { t.Fatalf("generated session = %q, want campaign-relative template output", string(data)) } } func TestExecuteSessionInitTemplateMissingVariableFails(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}" date: "{{ date }}" inputs: audio_s3: prefix: audio/ `) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "init", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-06-07", "--remote", }, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "missing required template variable value(s): date") { t.Fatalf("stderr = %q, want missing date variable", stderr.String()) } } func TestExecuteSessionInitTemplateUnusedFlagFails(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}" inputs: audio_s3: prefix: audio/ `) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "init", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-06-07", "--title", "Unused Title", "--remote", }, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "unused template variable value(s): title") { t.Fatalf("stderr = %q, want unused title variable", stderr.String()) } } func TestExecuteSessionInitTemplateStrictDecodeFailure(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}" unknown: true inputs: audio_s3: prefix: audio/ `) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "init", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-06-07", "--remote", }, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "strict decode failed") { t.Fatalf("stderr = %q, want strict decode error", stderr.String()) } } 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.final_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.final_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.final_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.final_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.final_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.final_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.final_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.final_trimmed"}, "locks add: --session-id is required"}, {"remove", []string{"locks", "remove", "narratio.transcript.final_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.final_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.final_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.final_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.final_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 withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) { t.Helper() origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} config.DefaultCampaignConfigSearchPaths = []string{campaignPath} t.Cleanup(func() { config.DefaultPipelineConfigSearchPaths = origPipelineDefaults config.DefaultCampaignConfigSearchPaths = origCampaignDefaults }) } func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) { t.Helper() templatePath := filepath.Join(filepath.Dir(campaignPath), "session.template.yml") if err := os.WriteFile(templatePath, []byte(templateYAML), 0o644); err != nil { t.Fatalf("write session template: %v", err) } addSessionTemplateToCampaign(t, campaignPath, "./session.template.yml") } func addSessionTemplateToCampaign(t *testing.T, campaignPath, templateFile string) { t.Helper() data, err := os.ReadFile(campaignPath) if err != nil { t.Fatalf("read campaign config: %v", err) } if strings.Contains(string(data), "session_template_file:") { t.Fatalf("campaign config already has session_template_file: %q", string(data)) } updated := "session_template_file: " + templateFile + "\n" + string(data) if err := os.WriteFile(campaignPath, []byte(updated), 0o644); err != nil { t.Fatalf("write campaign config: %v", err) } } func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addArchivePromotionsToPipeline(t, pipelinePath, ` promote_artifacts: - source: narratio.transcript.final_trimmed dest: transcripts/final.trimmed.json required: true `) fake := &storage.FakeBackend{} trimmedKey := artifacts.S3PromotedArtifactKey( artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/final.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.final_trimmed remote=promoted") { t.Fatalf("stdout = %q, want promoted remote availability", stdout.String()) } } func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addArchivePromotionsToPipeline(t, pipelinePath, ` promote_artifacts: - source: narratio.transcript.final dest: transcripts/full.json required: true - source: narratio.bounds.session dest: transcripts/bounds.json required: true `) fake := &storage.FakeBackend{} sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") fake.SeedObject(storage.FakeObject{Key: artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/full.json"), Data: []byte(`{"segments":[]}`)}) fake.SeedObject(storage.FakeObject{Key: artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/bounds.json"), Data: []byte(`{}`)}) 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()) } out := stdout.String() for _, unwanted := range []string{ "narratio.transcript.final remote=missing", "narratio.bounds.session remote=missing", } { if strings.Contains(out, unwanted) { t.Fatalf("stdout = %q, did not want catalog remote marker %q", out, unwanted) } } for _, want := range []string{ "narratio.transcript.final dest=transcripts/full.json remote=promoted", "narratio.bounds.session dest=transcripts/bounds.json remote=promoted", } { if !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) } } } func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addArchivePromotionsToPipeline(t, pipelinePath, ` promote_artifacts: - source: narratio.transcript.final_trimmed dest: transcripts/final.trimmed.json required: true - source: narratio.transcript.final dest: transcripts/full.json required: true `) fake := &storage.FakeBackend{} sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) trimmedKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/final.trimmed.json") fullKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/full.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: fullKey, Data: []byte(`{"segments":[]}`)}) fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_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.final_trimmed locked", "narratio.transcript.final_trimmed locked remote=promoted", "narratio.transcript.final dest=transcripts/full.json remote=promoted", } { if !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) } } if strings.Contains(out, "narratio.transcript.base remote=missing") { t.Fatalf("stdout = %q, did not want catalog remote marker", out) } } func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addArchivePromotionsToPipeline(t, pipelinePath, ` promote_artifacts: - source: narratio.transcript.final_trimmed dest: transcripts/final.trimmed.json required: true `) 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.final_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.final_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", "final.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/final.trimmed.json") if _, ok := fake.Objects[promotedKey]; ok { t.Fatalf("locked promoted key %q was uploaded", promotedKey) } } func addArchivePromotionsToPipeline(t *testing.T, pipelinePath, archiveYAML string) { t.Helper() data, err := os.ReadFile(pipelinePath) if err != nil { t.Fatalf("read pipeline: %v", err) } updated := strings.Replace(string(data), " upload_run: false\n", " upload_run: false\n"+archiveYAML, 1) if updated == string(data) { t.Fatalf("pipeline %q did not contain archive upload_run marker", pipelinePath) } if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil { t.Fatalf("write pipeline: %v", err) } } 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) } }