436 lines
16 KiB
Go
436 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
)
|
|
|
|
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
|
inputs:
|
|
audio_s3:
|
|
prefix: audio/
|
|
`)
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if storeInitCalls != 1 {
|
|
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
|
}
|
|
if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
|
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
|
}
|
|
if _, ok := fake.Objects[remoteKey]; !ok {
|
|
t.Fatalf("remote session key %q was not seeded", remoteKey)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sessionYAML string
|
|
command []string
|
|
configureRun func()
|
|
wantSuccessful bool
|
|
}{
|
|
{
|
|
name: "success",
|
|
sessionYAML: `session_id: 2026-05-03
|
|
inputs:
|
|
audio_s3:
|
|
prefix: audio/
|
|
`,
|
|
command: []string{"session", "plan", "2026-05-03"},
|
|
wantSuccessful: true,
|
|
},
|
|
{
|
|
name: "validation failure",
|
|
sessionYAML: `session_id: 2026-05-03
|
|
`,
|
|
command: []string{"session", "plan", "2026-05-03"},
|
|
},
|
|
{
|
|
name: "load failure",
|
|
sessionYAML: `session_id: 2026-05-03
|
|
unknown: true
|
|
`,
|
|
command: []string{"session", "plan", "2026-05-03"},
|
|
},
|
|
{
|
|
name: "adapter failure",
|
|
sessionYAML: `session_id: 2026-05-03
|
|
inputs:
|
|
audio_s3:
|
|
prefix: audio/
|
|
`,
|
|
command: []string{"run", "2026-05-03"},
|
|
configureRun: func() {
|
|
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
|
return nil, errors.New("adapter failed")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "cancellation",
|
|
sessionYAML: `session_id: 2026-05-03
|
|
inputs:
|
|
audio_s3:
|
|
prefix: audio/
|
|
`,
|
|
command: []string{"run", "2026-05-03"},
|
|
configureRun: func() {
|
|
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
|
return nil, context.Canceled
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
seedRemoteSessionConfig(t, fake, "2026-05-03", tt.sessionYAML)
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var downloadedPath string
|
|
captureRemoteSessionTempPath(t, &downloadedPath)
|
|
if tt.configureRun != nil {
|
|
origExecuteStagesFn := executeStagesFn
|
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
|
tt.configureRun()
|
|
}
|
|
|
|
args := append(append([]string(nil), tt.command...), "--config", pipelinePath, "--campaign-file", campaignPath)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(args, &stdout, &stderr)
|
|
if tt.wantSuccessful && code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if !tt.wantSuccessful && code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if downloadedPath == "" {
|
|
t.Fatal("remote session download path was not captured")
|
|
}
|
|
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteSessionConfigCloseIsIdempotent(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
|
inputs:
|
|
audio_s3:
|
|
prefix: audio/
|
|
`)
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var downloadedPath string
|
|
captureRemoteSessionTempPath(t, &downloadedPath)
|
|
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, "", config.SessionLoadOptions{SessionID: "2026-05-03"})
|
|
if err != nil {
|
|
t.Fatalf("loadCommandConfig() error = %v", err)
|
|
}
|
|
if err := loaded.Close(); err != nil {
|
|
t.Fatalf("first Close() error = %v", err)
|
|
}
|
|
if err := loaded.Close(); err != nil {
|
|
t.Fatalf("second Close() error = %v", err)
|
|
}
|
|
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
|
|
}
|
|
}
|
|
|
|
func captureRemoteSessionTempPath(t *testing.T, destination *string) {
|
|
t.Helper()
|
|
original := downloadObjectToTempFn
|
|
downloadObjectToTempFn = func(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) {
|
|
path, err := original(ctx, store, key, pattern)
|
|
if err == nil {
|
|
*destination = path
|
|
}
|
|
return path, err
|
|
}
|
|
t.Cleanup(func() { downloadObjectToTempFn = original })
|
|
}
|
|
|
|
func TestExecuteRemoteSessionFallbackLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
accessKeyEnv := "NARRATIO_TEST_REMOTE_SESSION_KEY_ID"
|
|
secretKeyEnv := "NARRATIO_TEST_REMOTE_SESSION_SECRET"
|
|
restoreEnvAfterTest(t, accessKeyEnv, secretKeyEnv)
|
|
secretsDir := t.TempDir()
|
|
mustWriteSecretFile(t, filepath.Join(secretsDir, accessKeyEnv), "remote-session-key-id\n")
|
|
mustWriteSecretFile(t, filepath.Join(secretsDir, secretKeyEnv), "remote-session-secret\n")
|
|
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
|
inputs:
|
|
audio_s3:
|
|
prefix: audio/
|
|
`)
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...)
|
|
config.DefaultSessionConfigSearchPaths = []string{filepath.Join(t.TempDir(), "session.yml")}
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
if os.Getenv(accessKeyEnv) != "remote-session-key-id" || os.Getenv(secretKeyEnv) != "remote-session-secret" {
|
|
return nil, fmt.Errorf("secrets were not loaded before remote session object store init")
|
|
}
|
|
return fake, nil
|
|
}
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
config.DefaultSessionConfigSearchPaths = origSessionDefaults
|
|
})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "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 storeInitCalls != 0 {
|
|
t.Fatalf("object store init calls = %d, want 0", storeInitCalls)
|
|
}
|
|
}
|
|
|
|
func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(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", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if storeInitCalls != 0 {
|
|
t.Fatalf("object store init calls = %d, want 0", storeInitCalls)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
var storeInitCalls int
|
|
missingSessionPath := filepath.Join(t.TempDir(), "session.yml")
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{missingSessionPath})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "remote session") || !strings.Contains(stderr.String(), "session.yml") || !strings.Contains(stderr.String(), "not found") {
|
|
t.Fatalf("stderr = %q, want remote session not found context", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), missingSessionPath) {
|
|
t.Fatalf("stderr = %q, want local searched path", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, &storage.FakeBackend{}, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "plan: session_id is required") {
|
|
t.Fatalf("stderr = %q, want session_id guidance", stderr.String())
|
|
}
|
|
if storeInitCalls != 0 {
|
|
t.Fatalf("object store init calls = %d, want 0", storeInitCalls)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...)
|
|
config.DefaultSessionConfigSearchPaths = []string{filepath.Join(t.TempDir(), "session.yml")}
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
return nil, errors.New("storage unavailable")
|
|
}
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
config.DefaultSessionConfigSearchPaths = origSessionDefaults
|
|
})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "storage unavailable") || !strings.Contains(stderr.String(), "remote session") {
|
|
t.Fatalf("stderr = %q, want remote storage context", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-03\nunknown: true\n")
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &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 context", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRemoteSessionTemplateFailsConcreteSessionCheck(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
|
|
inputs:
|
|
audio_s3:
|
|
prefix: audio/
|
|
`)
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "session.yml must be concrete") || !strings.Contains(stderr.String(), "run narratio session init") {
|
|
t.Fatalf("stderr = %q, want concrete session guidance", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRemoteSessionMismatchFails(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-04\ninputs:\n audio_s3:\n prefix: audio/\n")
|
|
var storeInitCalls int
|
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &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_id mismatch", stderr.String())
|
|
}
|
|
}
|
|
|
|
func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) {
|
|
t.Helper()
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...)
|
|
config.DefaultSessionConfigSearchPaths = append([]string(nil), sessionDefaults...)
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
if storeInitCalls != nil {
|
|
(*storeInitCalls)++
|
|
}
|
|
return fake, nil
|
|
}
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
config.DefaultSessionConfigSearchPaths = origSessionDefaults
|
|
})
|
|
}
|
|
|
|
func seedRemoteSessionConfig(t *testing.T, fake *storage.FakeBackend, sessionID, content string) string {
|
|
t.Helper()
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", sessionID)
|
|
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
|
fake.SeedObject(storage.FakeObject{
|
|
Key: remoteKey,
|
|
Data: []byte(content),
|
|
ETag: "remote-session-etag",
|
|
})
|
|
return remoteKey
|
|
}
|
|
|
|
func addSecretsToPipelineConfig(t *testing.T, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv string) {
|
|
t.Helper()
|
|
pipelineData, err := os.ReadFile(pipelinePath)
|
|
if err != nil {
|
|
t.Fatalf("read pipeline: %v", err)
|
|
}
|
|
pipelineYAML := strings.Replace(
|
|
string(pipelineData),
|
|
"storage:\n backend: s3\n s3:\n bucket: test-bucket\n",
|
|
"storage:\n backend: s3\n s3:\n bucket: test-bucket\n access_key_id_env: "+accessKeyEnv+"\n secret_access_key_env: "+secretKeyEnv+"\nsecrets:\n env_dir: "+secretsDir+"\n",
|
|
1,
|
|
)
|
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatalf("write pipeline: %v", err)
|
|
}
|
|
}
|