Files
narratio/internal/app/operator_helpers_test.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
}