1460 lines
54 KiB
Go
1460 lines
54 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
|
"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
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "init", "2026-06-07",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--title", "The Black Cabin",
|
|
"--remote",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "init", "2026-06-07",
|
|
"--remote",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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
|
|
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"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")
|
|
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "init", "2026-06-07",
|
|
"--config", defaultPipeline,
|
|
"--campaign-file", explicitCampaign,
|
|
"--remote",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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()
|
|
mustWriteSecretFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-key-id\n")
|
|
mustWriteSecretFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret\n")
|
|
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
deps := commandDependencies{newObjectStore: 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
|
|
}}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr, deps, executePlan)
|
|
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
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "init", "2026-06-07",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--remote",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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()
|
|
mustWriteSecretFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-key-id\n")
|
|
mustWriteSecretFile(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")})
|
|
deps := commandDependencies{newObjectStore: 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
|
|
}}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr, deps, executePlan)
|
|
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 TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
campaignBytes, err := os.ReadFile(campaignPath)
|
|
if err != nil {
|
|
t.Fatalf("read campaign: %v", err)
|
|
}
|
|
campaignYAML := strings.Replace(string(campaignBytes), " party_file: ./party.yml\n", " party_file: ./party.yml\n spell_catalog_file: ./spells.json\n", 1)
|
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
|
t.Fatalf("write campaign: %v", err)
|
|
}
|
|
spellPath := filepath.Join(filepath.Dir(campaignPath), "spells.json")
|
|
mustWriteTestFile(t, spellPath, "{\"spells\":[]}\n")
|
|
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
commonArgs := []string{
|
|
"2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
validateArgs := append([]string{"session", "validate"}, commonArgs...)
|
|
if code := executeWithDependencies(validateArgs, &stdout, &stderr, deps, executePlan); code != 0 {
|
|
t.Fatalf("validate exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "OK inputs spell_catalog: "+spellPath) {
|
|
t.Fatalf("validate stdout = %q, want spell catalog finding", stdout.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
statusArgs := append([]string{"session", "status"}, commonArgs...)
|
|
if code := executeWithDependencies(statusArgs, &stdout, &stderr, deps, executePlan); code != 0 {
|
|
t.Fatalf("status exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Stable input spell_catalog: "+spellPath) {
|
|
t.Fatalf("status stdout = %q, want spell catalog inventory", stdout.String())
|
|
}
|
|
|
|
if err := os.Remove(spellPath); err != nil {
|
|
t.Fatalf("remove spell catalog: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := executeWithDependencies(validateArgs, &stdout, &stderr, deps, executePlan); code == 0 {
|
|
t.Fatalf("validate missing spell catalog exit code = 0; stdout=%q", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "ERROR inputs spell_catalog missing:") {
|
|
t.Fatalf("validate stdout = %q, want missing spell catalog finding", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteLocksAddListAndRemoveUseIndependentObjects(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--reason", "manual edit",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code != 0 {
|
|
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
key, err := artifacts.RemoteLockKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "narratio.transcript.final_trimmed")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obj, ok := fake.Objects[key]
|
|
if !ok {
|
|
t.Fatalf("remote locks key %q not uploaded", key)
|
|
}
|
|
lock, err := artifacts.DecodeRemoteLock(obj.Data)
|
|
if err != nil || lock.Source != "narratio.transcript.final_trimmed" || lock.Reason != "manual edit" {
|
|
t.Fatalf("lock object = %#v, %v", lock, err)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = executeWithDependencies([]string{
|
|
"session", "locks", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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 published reason=manual edit created_at=") {
|
|
t.Fatalf("stdout = %q, want remote lock", stdout.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = executeWithDependencies([]string{
|
|
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code != 0 {
|
|
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if _, exists := fake.Objects[key]; exists {
|
|
t.Fatalf("lock object %q remains after remove", key)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = executeWithDependencies([]string{
|
|
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code != 0 || !strings.Contains(stdout.String(), "was not locked") {
|
|
t.Fatalf("idempotent remove exit code = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
if storeInitCalls != 4 {
|
|
t.Fatalf("object store init calls = %d, want 4", storeInitCalls)
|
|
}
|
|
}
|
|
|
|
func TestExecuteLocksListIsSortedAndRejectsMalformedObjects(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
|
seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "second")
|
|
seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final", "first")
|
|
args := []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := executeWithDependencies(args, &stdout, &stderr, deps, executePlan); code != 0 {
|
|
t.Fatalf("locks list exit code = %d; stderr=%q", code, stderr.String())
|
|
}
|
|
first := strings.Index(stdout.String(), "narratio.transcript.final ")
|
|
second := strings.Index(stdout.String(), "narratio.transcript.final_trimmed ")
|
|
if first < 0 || second < 0 || first >= second {
|
|
t.Fatalf("locks list is not source sorted: %q", stdout.String())
|
|
}
|
|
|
|
badKey, err := artifacts.RemoteLockKey(sessionPrefix, "narratio.transcript.base")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake.SeedObject(storage.FakeObject{Key: badKey, Data: []byte(`{"format_version":1}`)})
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := executeWithDependencies(args, &stdout, &stderr, deps, executePlan); code == 0 {
|
|
t.Fatalf("malformed lock list exit code = 0; stdout=%q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--reason", "first",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code != 0 {
|
|
t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = executeWithDependencies([]string{
|
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--reason", "second",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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 = executeWithDependencies([]string{
|
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--reason", "second",
|
|
"--force",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code != 0 {
|
|
t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
key, err := artifacts.RemoteLockKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "narratio.transcript.final_trimmed")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lock, err := artifacts.DecodeRemoteLock(fake.Objects[key].Data)
|
|
if err != nil || lock.Reason != "second" {
|
|
t.Fatalf("lock object = %#v, %v; want updated reason", lock, err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteLocksForceReportsConcurrentReplacement(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
|
key := seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "first")
|
|
replacement, err := artifacts.NewRemoteLock("narratio.transcript.final_trimmed", "concurrent", time.Date(2026, 5, 20, 1, 2, 3, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replacementData, err := artifacts.EncodeRemoteLock(replacement)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake.UploadHook = func(call storage.FakeUploadCall) error {
|
|
if call.Key == key {
|
|
fake.SeedObject(storage.FakeObject{Key: key, Data: replacementData})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--force",
|
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code == 0 || !strings.Contains(stderr.String(), "changed concurrently; retry") {
|
|
t.Fatalf("forced conflict exit code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
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
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
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 := executeWithDependencies(tt.args, &stdout, &stderr, deps, executePlan)
|
|
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 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 seedOperatorCommittedOutputs(t *testing.T, fake *storage.FakeBackend, outputs map[string]string) {
|
|
t.Helper()
|
|
const runID = "20260519T010203Z-a1b2c3d4"
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
|
manifestKey := artifacts.S3RunSessionManifestKey(sessionPrefix, runID)
|
|
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
|
|
declarations := []artifacts.RemoteArtifact{
|
|
remoteRestoreArtifact(fake, artifacts.RemoteArtifactTypeSessionManifest, "session.manifest", manifestKey),
|
|
}
|
|
sources := make([]string, 0, len(outputs))
|
|
for source := range outputs {
|
|
sources = append(sources, source)
|
|
}
|
|
sort.Strings(sources)
|
|
for _, source := range sources {
|
|
key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, runID), "published/"+outputs[source])
|
|
fake.SeedObject(storage.FakeObject{Key: key, Data: []byte(`{"available":true}`)})
|
|
declarations = append(declarations, remoteRestoreArtifact(fake, artifacts.RemoteArtifactTypePublishedOutput, source, key))
|
|
}
|
|
commit := artifacts.RemoteCommitManifest{
|
|
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
|
Campaign: "sample-campaign",
|
|
SessionID: "2026-05-03",
|
|
RunID: runID,
|
|
Artifacts: declarations,
|
|
}
|
|
commitData, err := artifacts.EncodeRemoteCommitManifest(commit)
|
|
if err != nil {
|
|
t.Fatalf("encode operator remote commit: %v", err)
|
|
}
|
|
commitKey := artifacts.S3RunCommitKey(sessionPrefix, runID)
|
|
fake.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData})
|
|
commitObject := fake.Objects[commitKey]
|
|
pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{
|
|
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
|
Campaign: "sample-campaign",
|
|
SessionID: "2026-05-03",
|
|
RunID: runID,
|
|
CommitKey: commitKey,
|
|
CommitSHA256: restoreCommitSHA256(commitData),
|
|
CommitSize: int64(len(commitData)),
|
|
CommitGeneration: commitObject.ETag,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("encode operator current pointer: %v", err)
|
|
}
|
|
fake.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(sessionPrefix), Data: pointerData})
|
|
}
|
|
|
|
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{}
|
|
seedOperatorCommittedOutputs(t, fake, map[string]string{
|
|
"narratio.transcript.final_trimmed": "transcripts/final.trimmed.json",
|
|
})
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "artifacts", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--remote",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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 TestExecuteArtifactsListReportsExtractionLifecycleWithoutPayload(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
addExtractionOutputToPipeline(t, pipelinePath)
|
|
addPublishOutputsToPipeline(t, pipelinePath, `
|
|
outputs:
|
|
- source: narratio.extraction.encounters
|
|
dest: artifacts/encounters.json
|
|
required: true
|
|
`)
|
|
lanePath := writeOperatorExtractionManifest(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
seedOperatorCommittedOutputs(t, fake, map[string]string{
|
|
"narratio.extraction.encounters": "artifacts/encounters.json",
|
|
})
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "artifacts", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--remote",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
out := stdout.String()
|
|
for _, want := range []string{
|
|
"Extraction:",
|
|
"narratio.extraction.encounters planned available provenance=manifest.current_extract_run",
|
|
"narratio.extraction.encounters dest=artifacts/encounters.json remote=published",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("stdout = %q, want %q", out, want)
|
|
}
|
|
}
|
|
if strings.Contains(out, "DO_NOT_PRINT") {
|
|
t.Fatalf("operator output exposed extraction payload: %q", out)
|
|
}
|
|
|
|
if err := os.Remove(lanePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = executeWithDependencies([]string{
|
|
"session", "artifacts", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
if code != 0 {
|
|
t.Fatalf("unavailable exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "narratio.extraction.encounters planned unavailable") {
|
|
t.Fatalf("stdout = %q, want unavailable extraction state", 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{}
|
|
seedOperatorCommittedOutputs(t, fake, map[string]string{
|
|
"narratio.transcript.final": "transcripts/full.json",
|
|
"narratio.bounds.session": "transcripts/bounds.json",
|
|
})
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "artifacts", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--remote",
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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")
|
|
seedOperatorCommittedOutputs(t, fake, map[string]string{
|
|
"narratio.transcript.final_trimmed": "transcripts/final.trimmed.json",
|
|
"narratio.transcript.final": "transcripts/full.json",
|
|
})
|
|
seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "remote review")
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "status", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "status", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "status", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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 commit 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
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "status", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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 commit pointer missing") {
|
|
t.Fatalf("stdout = %q, want previous readiness unavailable line", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteStatusDetectsMissingRequiredPreviousArtifact(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")
|
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
|
}
|
|
fake := &storage.FakeBackend{}
|
|
seedRestorePreviousCurrentManifestOnly(t, fake, cfg)
|
|
var storeInitCalls int
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "status", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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 required previous-session artifact "session_recap" is not declared`) {
|
|
t.Fatalf("stdout = %q, want missing required previous artifact", 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
|
|
deps := fakeCommandDependencies(fake, &storeInitCalls)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := executeWithDependencies([]string{
|
|
"session", "validate", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
}, &stdout, &stderr, deps, executePlan)
|
|
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 commit pointer missing") {
|
|
t.Fatalf("stdout = %q, want missing commit pointer finding", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "validation error(s)") {
|
|
t.Fatalf("stderr = %q, want finding error summary", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecutePublishIgnoresRetiredSharedLockObject(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidPublishRunConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
|
lockKey := sessionPrefix + "locks.yml"
|
|
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")})
|
|
|
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
|
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")
|
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
|
m.Campaign = "sample-campaign"
|
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "extract", "analyze"} {
|
|
m.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
|
|
}
|
|
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(workRoot, "transcripts", "final.trimmed.json"))
|
|
finalMarkdown := filepath.Join(workRoot, "transcripts", "final.md")
|
|
trimmedMarkdown := filepath.Join(workRoot, "transcripts", "final.trimmed.md")
|
|
finalChecksum, err := artifacts.SHA256File(finalMarkdown)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
trimmedChecksum, err := artifacts.SHA256File(trimmedMarkdown)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.MarkStageSucceeded("render", time.Now().UTC(), []manifest.ArtifactRecord{
|
|
{Kind: artifacts.TranscriptOutputKindFinalMarkdown, SourceID: artifacts.ArtifactTranscriptFinalMarkdown, LocalPath: finalMarkdown, ProducerRunID: "render-run", Checksum: finalChecksum},
|
|
{Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, SourceID: artifacts.ArtifactTranscriptFinalTrimmedMarkdown, LocalPath: trimmedMarkdown, ProducerRunID: "render-run", Checksum: trimmedChecksum},
|
|
})
|
|
if err := (&manifest.LocalStore{}).Save(context.Background(), filepath.Join(workRoot, "manifest.json"), m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var storeInitCalls int
|
|
err = runStageWithPipelineExecutor(
|
|
context.Background(),
|
|
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"},
|
|
&stdout,
|
|
func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
|
return executePlanWithDependencies(ctx, cfg, plan, opts, invocationDependencies{
|
|
newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
storeInitCalls++
|
|
return fake, nil
|
|
},
|
|
})
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("runStageWithPipelineExecutor() error = %v", err)
|
|
}
|
|
if storeInitCalls != 1 {
|
|
t.Fatalf("object store initialization calls = %d, want 1", storeInitCalls)
|
|
}
|
|
current, err := artifacts.LoadCurrentState(context.Background(), fake, sessionPrefix, artifacts.CurrentStateValidation{ValidateRunID: true})
|
|
if err != nil {
|
|
t.Fatalf("LoadCurrentState() error = %v", err)
|
|
}
|
|
found := false
|
|
for _, declaration := range current.Commit.Artifacts {
|
|
if declaration.Type == artifacts.RemoteArtifactTypePublishedOutput && declaration.Source == "narratio.transcript.final_trimmed" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("retired shared lock object affected publication")
|
|
}
|
|
}
|
|
|
|
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 addExtractionOutputToPipeline(t *testing.T, pipelinePath string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(pipelinePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data = append(data, []byte(`notarius:
|
|
enabled: true
|
|
config_path: notarius.yml
|
|
pipeline_id: campaign.extract
|
|
outputs:
|
|
encounters:
|
|
lane_id: encounters
|
|
media_type: application/json
|
|
schema_id: encounters
|
|
schema_version: "1"
|
|
module_key: encounters
|
|
`)...)
|
|
if err := os.WriteFile(pipelinePath, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("{}\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func writeOperatorExtractionManifest(t *testing.T, workspaceRoot string) string {
|
|
t.Helper()
|
|
paths := artifacts.NewLocalStore(workspaceRoot).SessionPathsFor("sample-campaign", "2026-05-03")
|
|
bundleRoot := filepath.Join(paths.ArtifactsDir, "notarius", "extract-run-1")
|
|
lanePath := filepath.Join(bundleRoot, "lanes", "encounters.json")
|
|
indexPath := filepath.Join(bundleRoot, "index.json")
|
|
trimmedPath := filepath.Join(paths.Root, filepath.FromSlash(artifacts.TranscriptPathFinalTrimmed))
|
|
mustWriteTestFile(t, lanePath, `{"secret":"DO_NOT_PRINT"}`)
|
|
mustWriteTestFile(t, indexPath, `{"lanes":[]}`)
|
|
mustWriteTestFile(t, trimmedPath, `{"segments":[]}`)
|
|
laneChecksum, err := artifacts.SHA256File(lanePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
indexChecksum, err := artifacts.SHA256File(indexPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
|
m.Campaign = "sample-campaign"
|
|
trimmedChecksum, err := artifacts.SHA256File(trimmedPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.Stages["trim"] = &manifest.StageRecord{
|
|
Name: "trim", Status: manifest.StatusSucceeded,
|
|
Outputs: []manifest.ArtifactRecord{{
|
|
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Checksum: trimmedChecksum,
|
|
}},
|
|
}
|
|
input, err := artifacts.ResolveExtractionInputIdentity(paths, m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.Stages["extract"] = &manifest.StageRecord{
|
|
Name: "extract", Status: manifest.StatusSucceeded,
|
|
Metadata: map[string]any{
|
|
"narratio_run_id": "extract-run-1", "bundle_root": bundleRoot,
|
|
"receipt": map[string]any{"run_id": "notarius-run-1", "pipeline_id": "campaign.extract"},
|
|
"direct_input": input.Metadata(),
|
|
},
|
|
Outputs: []manifest.ArtifactRecord{
|
|
{
|
|
Kind: "notarius_lane", SourceID: artifacts.ExtractionArtifactSourceID("encounters"), LocalPath: lanePath,
|
|
ProducerRunID: "extract-run-1", Checksum: laneChecksum,
|
|
Contract: &artifactmodel.ContractMetadata{MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1", ModuleKey: "encounters"},
|
|
ExternalProvenance: &artifactmodel.ExternalProvenance{System: "notarius", RunID: "notarius-run-1", PipelineID: "campaign.extract", ArtifactID: "encounters"},
|
|
},
|
|
{Kind: "notarius_index", LocalPath: indexPath, ProducerRunID: "extract-run-1", Checksum: indexChecksum},
|
|
},
|
|
}
|
|
if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return lanePath
|
|
}
|
|
|
|
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 seedOperatorRemoteLock(t *testing.T, fake *storage.FakeBackend, sessionPrefix, source, reason string) string {
|
|
t.Helper()
|
|
lock, err := artifacts.NewRemoteLock(source, reason, time.Date(2026, 5, 19, 1, 2, 3, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("NewRemoteLock() error = %v", err)
|
|
}
|
|
data, err := artifacts.EncodeRemoteLock(lock)
|
|
if err != nil {
|
|
t.Fatalf("EncodeRemoteLock() error = %v", err)
|
|
}
|
|
key, err := artifacts.RemoteLockKey(sessionPrefix, source)
|
|
if err != nil {
|
|
t.Fatalf("RemoteLockKey() error = %v", err)
|
|
}
|
|
fake.SeedObject(storage.FakeObject{Key: key, Data: data})
|
|
return key
|
|
}
|
|
|
|
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)
|
|
}
|
|
return pipelinePath, campaignPath, sessionPath
|
|
}
|