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", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--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", "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", "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_id: explicit-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml players_file: ./players.yml party_file: ./party.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") mustWriteTestFile(t, filepath.Join(explicitDir, "players.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "party.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", "2026-06-07", "--config", defaultPipeline, "--campaign-file", explicitCampaign, "--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...) config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")} t.Cleanup(func() { config.DefaultPipelineConfigSearchPaths = origPipelineDefaults }) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "init", "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", "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", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--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", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--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", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--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", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--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", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--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", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--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.S3PublishedOutputKey(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", "2026-05-03", "--config", pipelinePath, "--campaign-file", 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()) } if !strings.Contains(stdout.String(), "OK inputs players:") || !strings.Contains(stdout.String(), "OK inputs party:") { t.Fatalf("stdout = %q, want players and party input findings", 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{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "manual edit", }, &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{ "session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &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{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String()) } store, err := config.LoadPublishLockStoreBytes("locks.yml", fake.Objects[key].Data, nil, nil) if err != nil { t.Fatalf("LoadPublishLockStoreBytes() 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{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "first", }, &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{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "second", }, &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{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "second", "--force", }, &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{"session", "locks"}, "locks: session_id is required"}, {"add", []string{"session", "locks", "add", "narratio.transcript.final_trimmed"}, "locks add: expected session_id and source id"}, {"remove", []string{"session", "locks", "remove", "narratio.transcript.final_trimmed"}, "locks remove: expected session_id and source id"}, } 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 TestExecuteLocksMutationRejectsSessionIDMismatch(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) tests := []struct { name string args []string }{ { name: "add mismatch", args: []string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--session-id", "2026-05-04", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, }, { name: "remove mismatch", args: []string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--session-id", "2026-05-04", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, }, } 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(), "does not match expected session id") { t.Fatalf("stderr = %q, want session-id mismatch guidance", stderr.String()) } }) } } func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addStaticPublishLockToPipelineConfig(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{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &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{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &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...) config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} t.Cleanup(func() { config.DefaultPipelineConfigSearchPaths = origPipelineDefaults }) _ = campaignPath } 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 TestExecuteArtifactsListRemoteReportsPublishedAvailability(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addPublishOutputsToPipeline(t, pipelinePath, ` outputs: - source: narratio.transcript.final_trimmed dest: transcripts/final.trimmed.json required: true `) fake := &storage.FakeBackend{} trimmedKey := artifacts.S3PublishedOutputKey( 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{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", 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=published") { t.Fatalf("stdout = %q, want published remote availability", stdout.String()) } } func TestExecuteArtifactsListRemoteUsesPublishOutputDestinations(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) addPublishOutputsToPipeline(t, pipelinePath, ` outputs: - 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.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json"), Data: []byte(`{"segments":[]}`)}) fake.SeedObject(storage.FakeObject{Key: artifacts.S3PublishedOutputKey(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{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", 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=published", "narratio.bounds.session dest=transcripts/bounds.json remote=published", } { 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) addPublishOutputsToPipeline(t, pipelinePath, ` outputs: - 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.ResolveCurrentStateKeys(sessionPrefix) trimmedKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json") fullKey := artifacts.S3PublishedOutputKey(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{ "session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &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:", "Published:", "narratio.transcript.final_trimmed locked", "narratio.transcript.final_trimmed locked remote=published", "narratio.transcript.final dest=transcripts/full.json remote=published", "Stable input players:", "Stable input party:", } { 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) addPublishOutputsToPipeline(t, pipelinePath, ` outputs: - 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{ "session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &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 publish: missing or unavailable:") { t.Fatalf("stdout = %q, want remote publish 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, "Publish locks: error:") { t.Fatalf("stdout = %q, want publish locks error", out) } } func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(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{ "session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "Remote publish: missing or unavailable: remote current run pointer missing") { t.Fatalf("stdout = %q, want missing remote current-state line", stdout.String()) } } func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) replaceInFileOrFatal(t, pipelinePath, "source: narratio.artifact.session_recap", "source: narratio.previous_session.artifact.session_recap") replaceInFileOrFatal(t, sessionPath, "session_id: 2026-05-03\n", "session_id: 2026-05-03\nprevious_session_id: 2026-04-26\n") fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "Previous-session artifacts: unavailable: remote current run pointer missing") { t.Fatalf("stdout = %q, want previous readiness unavailable line", stdout.String()) } } func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) replaceInFileOrFatal(t, pipelinePath, "source: narratio.artifact.session_recap", "source: narratio.previous_session.artifact.session_recap") replaceInFileOrFatal(t, sessionPath, "session_id: 2026-05-03\n", "session_id: 2026-05-03\nprevious_session_id: 2026-04-26\n") fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "validate", "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(stdout.String(), "ERROR previous") { t.Fatalf("stdout = %q, want previous finding error", stdout.String()) } if !strings.Contains(stdout.String(), "remote current run pointer missing") { t.Fatalf("stdout = %q, want missing run pointer finding", stdout.String()) } if !strings.Contains(stderr.String(), "validation error(s)") { t.Fatalf("stderr = %q, want finding error summary", stderr.String()) } } func TestExecutePublishLoadsRemoteLocks(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidPublishRunConfigFiles(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", "extract", "render", "analyze"} { // The publish stage only checks the manifest statuses and source files. _ = stageName } mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.md"), "# final\n") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.md"), "# final trimmed\n") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "publish", "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()) } publishedKey := artifacts.S3PublishedOutputKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/final.trimmed.json") if _, ok := fake.Objects[publishedKey]; ok { t.Fatalf("locked published key %q was uploaded", publishedKey) } } func addPublishOutputsToPipeline(t *testing.T, pipelinePath, publishYAML 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"+publishYAML, 1) if updated == string(data) { t.Fatalf("pipeline %q did not contain publish upload_run marker", pipelinePath) } if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil { t.Fatalf("write pipeline: %v", err) } } func replaceInFileOrFatal(t *testing.T, path, old, new string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } updated := strings.Replace(string(data), old, new, 1) if updated == string(data) { t.Fatalf("%s did not contain %q", path, old) } if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } func writeValidPublishRunConfigFiles(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", "extract", "render", "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 addStaticPublishLockToPipelineConfig(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), "publish:\n enabled: true\n upload_run: false\n", "publish:\n enabled: true\n upload_run: false\n locks:\n - source: "+source+"\n reason: static review\n", 1, ) if updated == string(data) { t.Fatalf("publish section not found in pipeline config") } if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil { t.Fatalf("write pipeline: %v", err) } }