1106 lines
40 KiB
Go
1106 lines
40 KiB
Go
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
|
|
`), 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", "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())
|
|
}
|
|
}
|
|
|
|
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)
|
|
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",
|
|
} {
|
|
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", "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", "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)
|
|
}
|
|
}
|