297 lines
10 KiB
Go
297 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
)
|
|
|
|
func TestExecuteRestoreHelp(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute([]string{"restore", "--help"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0", code)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "Usage: narratio restore") {
|
|
t.Fatalf("stdout = %q, want restore usage", out)
|
|
}
|
|
if !strings.Contains(out, "--include-audio") {
|
|
t.Fatalf("stdout = %q, want --include-audio flag", out)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
|
origPlanFn := buildRestorePlanFn
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
|
buildRestorePlanFn = origPlanFn
|
|
})
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
return &storage.FakeBackend{}, nil
|
|
}
|
|
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
|
|
return &RemoteCurrentState{
|
|
SessionID: "2026-05-03",
|
|
Campaign: "sample-campaign",
|
|
RunID: "20260519T010203Z-a1b2c3d4",
|
|
}, nil
|
|
}
|
|
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
|
|
return &RestorePlan{
|
|
Actions: []RestoreAction{
|
|
{
|
|
Kind: RestoreActionDownload,
|
|
LocalRelativePath: "manifest.json",
|
|
RemoteKey: "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json",
|
|
Reason: "local file missing",
|
|
},
|
|
},
|
|
DownloadCount: 1,
|
|
}, nil
|
|
}
|
|
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{
|
|
"restore",
|
|
"--config", pipelinePath,
|
|
"--session", sessionPath,
|
|
"--session-id", "2026-05-03",
|
|
"--dry-run",
|
|
"--force",
|
|
"--include-audio",
|
|
},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0 for --dry-run restore planning; stderr=%q", code, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
outText := stdout.String()
|
|
if !strings.Contains(outText, "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
|
t.Fatalf("stdout = %q, want restore plan summary", outText)
|
|
}
|
|
if !strings.Contains(outText, "download manifest.json <- dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json") {
|
|
t.Fatalf("stdout = %q, want action output", outText)
|
|
}
|
|
|
|
manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
|
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
|
|
t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "extra"}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "restore: unexpected positional arguments") {
|
|
t.Fatalf("stderr = %q, want positional-args failure", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
|
})
|
|
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "no remote object store backend is configured") {
|
|
t.Fatalf("stderr = %q, want storage backend preflight failure", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
|
})
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
return &storage.FakeBackend{}, nil
|
|
}
|
|
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
|
|
return nil, fmt.Errorf("remote current run pointer missing: %q", "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt")
|
|
}
|
|
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "remote current run pointer missing") {
|
|
t.Fatalf("stderr = %q, want discovery error context", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
|
origPlanFn := buildRestorePlanFn
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
|
buildRestorePlanFn = origPlanFn
|
|
})
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
return &storage.FakeBackend{}, nil
|
|
}
|
|
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
|
|
return &RemoteCurrentState{
|
|
SessionID: "2026-05-03",
|
|
Campaign: "sample-campaign",
|
|
RunID: "20260519T010203Z-a1b2c3d4",
|
|
}, nil
|
|
}
|
|
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
|
|
return &RestorePlan{
|
|
Actions: []RestoreAction{
|
|
{Kind: RestoreActionConflict, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs"},
|
|
},
|
|
ConflictCount: 1,
|
|
}, nil
|
|
}
|
|
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "plan has 1 conflicting path(s)") {
|
|
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
|
}
|
|
if strings.Contains(stderr.String(), "phase 4: restore execution") {
|
|
t.Fatalf("stderr = %q, should fail before phase-4 NYI boundary", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreNonDryRunForceStillStopsAtNYI(t *testing.T) {
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
|
origPlanFn := buildRestorePlanFn
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
|
buildRestorePlanFn = origPlanFn
|
|
})
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
return &storage.FakeBackend{}, nil
|
|
}
|
|
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
|
|
return &RemoteCurrentState{
|
|
SessionID: "2026-05-03",
|
|
Campaign: "sample-campaign",
|
|
RunID: "20260519T010203Z-a1b2c3d4",
|
|
}, nil
|
|
}
|
|
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
|
|
return &RestorePlan{
|
|
Actions: []RestoreAction{
|
|
{Kind: RestoreActionDownload, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs; overwrite with --force"},
|
|
},
|
|
DownloadCount: 1,
|
|
}, nil
|
|
}
|
|
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero at phase-4 execution boundary")
|
|
}
|
|
if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "not yet implemented (phase 4: restore execution)") {
|
|
t.Fatalf("stderr = %q, want phase-4 NYI marker", stderr.String())
|
|
}
|
|
}
|
|
|
|
func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
|
sessionPath := filepath.Join(dir, "session.yml")
|
|
|
|
pipelineYAML := `workspace:
|
|
root: ` + workspaceRoot + `
|
|
whisperx:
|
|
transcribe_url: https://example.com/transcribe
|
|
`
|
|
sessionYAML := `session_id: 2026-05-03
|
|
campaign: sample-campaign
|
|
inputs:
|
|
audio_dir: ./audio
|
|
speakers_file: ./speakers.yml
|
|
autocorrect_file: ./autocorrect.yml
|
|
glossary_file: ./glossary.yml
|
|
`
|
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatalf("write pipeline config: %v", err)
|
|
}
|
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
|
t.Fatalf("write session config: %v", err)
|
|
}
|
|
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
|
|
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
|
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
|
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
|
|
|
return pipelinePath, sessionPath
|
|
}
|