package app import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var capturedSessionID string origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) { capturedSessionID = cfg.Session.SessionID return &RunSummary{ SessionID: cfg.Session.SessionID, ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"prepare"}, }, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03") { t.Fatalf("stdout = %q, want run summary", stdout.String()) } if capturedSessionID != "2026-05-03" { t.Fatalf("captured session = %q, want positional session id", capturedSessionID) } } func TestExecutePositionalSessionIDMismatchFails(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "run", "2026-05-04", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "session_id mismatch") { t.Fatalf("stderr = %q, want session mismatch", stderr.String()) } } func TestExecuteSessionIDFlagMismatchFails(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"session", "status", "2026-05-03", "--session-id", "2026-05-04"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "does not match expected session id") { t.Fatalf("stderr = %q, want positional/flag mismatch", stderr.String()) } } func TestExecuteSessionIDFlagAcceptedWithoutPositional(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "status", "--session-id", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "Session: 2026-05-03") { t.Fatalf("stdout = %q, want status output", stdout.String()) } } func TestExecuteRemoteSessionFallbackUsesPositionalSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) remoteKey := seedRemoteSessionConfig(t, fake, "2026-06-07", `session_id: 2026-06-07 inputs: audio_s3: prefix: audio/ `) origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) { return &RunSummary{ SessionID: cfg.Session.SessionID, ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"prepare"}, }, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "run", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } downloaded := false for _, call := range fake.Downloads { if call.Key == remoteKey { downloaded = true break } } if !downloaded { t.Fatalf("remote session %q was not downloaded; downloads=%v", remoteKey, fake.Downloads) } if storeInitCalls == 0 { t.Fatal("object store was not initialized") } } func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) tests := []struct { name string args []string wantStage string wantForce bool }{ { name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantStage: "prepare", wantForce: false, }, { name: "analyze", args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, wantStage: "analyze", wantForce: true, }, { name: "publish", args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, wantStage: "publish", wantForce: true, }, { name: "run-stage", args: []string{"run-stage", "publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, wantStage: "publish", wantForce: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var capturedStages []string var capturedForce bool var capturedArtifacts []string origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { for _, s := range stages { capturedStages = append(capturedStages, s.Name()) } capturedForce = opts.Force capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...) return &RunSummary{ SessionID: "2026-05-03", ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{tt.wantStage}, }, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(tt.args, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if len(capturedStages) == 0 || capturedStages[0] != tt.wantStage { t.Fatalf("captured stages = %#v, want first %q", capturedStages, tt.wantStage) } if capturedForce != tt.wantForce { t.Fatalf("captured force = %t, want %t", capturedForce, tt.wantForce) } if tt.name == "analyze" || tt.name == "publish" || tt.name == "run-stage" { if strings.Join(capturedArtifacts, ",") != "session_recap" { t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts) } } }) } } func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix) fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")}) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) tests := []struct { name string args []string want string }{ { name: "validate", args: []string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "OK config", }, { name: "status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "Session: 2026-05-03", }, { name: "plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "narratio session plan: workdir prepared", }, { name: "artifacts", args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "Built-in:", }, { name: "locks", args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "Publish locks:", }, } 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.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), tt.want) { t.Fatalf("stdout = %q, want %q", stdout.String(), tt.want) } }) } } func TestExecuteSessionInitAcceptsPositionalSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) 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 positional session id", string(data)) } } func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "review", }, &stdout, &stderr) if code != 0 { t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } key := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")) if !strings.Contains(string(fake.Objects[key].Data), "reason: review") { t.Fatalf("lock store data = %q, want reason", string(fake.Objects[key].Data)) } stdout.Reset() stderr.Reset() code = Execute([]string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String()) } store, err := config.LoadPublishLockStoreBytes("locks.yml", fake.Objects[key].Data, nil, nil) if err != nil { t.Fatalf("LoadPublishLockStoreBytes() error = %v", err) } if len(store.Locks) != 0 { t.Fatalf("locks after remove = %#v, want empty", store.Locks) } } func TestExecuteCleanAcceptsPositionalSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) workDir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") spoolDir := artifacts.SessionSpoolDir(filepath.Join(workspaceRoot, "spool"), "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workDir, "manifest.json"), "{}") mustWriteTestFile(t, filepath.Join(spoolDir, "run-1", "audio", "alice.flac"), "audio") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } cleanAssertMissing(t, workDir) cleanAssertMissing(t, spoolDir) }