563 lines
24 KiB
Go
563 lines
24 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"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 TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`))
|
|
seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
|
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
|
|
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
|
|
seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "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 stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Restored session state for sample-campaign/2026-05-03") {
|
|
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1,2,3]}`)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
|
|
reportPath := filepath.Join(sessionRoot, "reports", "restore-latest.json")
|
|
report := mustReadRestoreReport(t, reportPath)
|
|
if report.Status != "succeeded" {
|
|
t.Fatalf("report status = %q, want succeeded", report.Status)
|
|
}
|
|
if report.Execution.Downloaded != 3 {
|
|
t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded)
|
|
}
|
|
if len(report.Actions) == 0 {
|
|
t.Fatal("report actions is empty")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(sessionRoot, "audio", "alice.flac")); !os.IsNotExist(err) {
|
|
t.Fatalf("audio should not be restored by default; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreRoundTripsPublishedExtractionAndManifestMetadata(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"artifacts/encounters.json", []byte(`{"encounters":[]}`))
|
|
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
|
|
|
|
remoteManifest := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
|
remoteManifest.Campaign = cfg.Session.Campaign
|
|
remoteManifest.RunID = "20260519T010203Z-a1b2c3d4"
|
|
producerRoot := "/prior/workspace/work/sample-campaign/2026-05-03"
|
|
remoteManifest.LocalWorkDir = filepath.Join(producerRoot, "runs", remoteManifest.RunID)
|
|
remoteManifest.Stages["extract"] = &manifest.StageRecord{
|
|
Name: "extract", Status: manifest.StatusSucceeded,
|
|
Outputs: []manifest.ArtifactRecord{{
|
|
Kind: "notarius_lane", SourceID: artifacts.ExtractionArtifactSourceID("encounters"),
|
|
LocalPath: filepath.Join(producerRoot, "artifacts", "encounters.json"),
|
|
Contract: &artifactmodel.ContractMetadata{
|
|
MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1",
|
|
},
|
|
ExternalProvenance: &artifactmodel.ExternalProvenance{
|
|
System: "notarius", RunID: "notarius-run-1", PipelineID: "campaign.extract", ArtifactID: "encounters",
|
|
},
|
|
}},
|
|
}
|
|
manifestBody, err := json.Marshal(remoteManifest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedRestoreObject(fake, manifestKey, manifestBody)
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "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())
|
|
}
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "encounters.json"), `{"encounters":[]}`)
|
|
restored, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(sessionRoot, "manifest.json"))
|
|
if err != nil {
|
|
t.Fatalf("load restored manifest: %v", err)
|
|
}
|
|
lane := restored.Stages["extract"].Outputs[0]
|
|
if lane.Contract == nil || lane.Contract.SchemaID != "encounters" || lane.ExternalProvenance == nil || lane.ExternalProvenance.RunID != "notarius-run-1" {
|
|
t.Fatalf("restored extraction metadata = %#v", lane)
|
|
}
|
|
if want := filepath.Join(sessionRoot, "artifacts", "encounters.json"); lane.LocalPath != want {
|
|
t.Fatalf("rebased extraction path = %q, want %q", lane.LocalPath, want)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if !report.IncludeAudio {
|
|
t.Fatalf("report include_audio = %v, want true", report.IncludeAudio)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
audioKey := sessionPrefix + "audio/alice.flac"
|
|
seedRestoreObject(fake, audioKey, []byte("remote-audio"))
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if got := fakeDownloadCount(fake, audioKey); got != 1 {
|
|
t.Fatalf("audio downloads after first restore = %d, want 1", got)
|
|
}
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
|
|
|
if err := os.RemoveAll(sessionRoot); err != nil {
|
|
t.Fatalf("remove session root: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if got := fakeDownloadCount(fake, audioKey); got != 1 {
|
|
t.Fatalf("audio downloads after cached restore = %d, want still 1", got)
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
|
}
|
|
|
|
func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "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())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json"))
|
|
if err != nil {
|
|
t.Fatalf("read restored previous manifest: %v", err)
|
|
}
|
|
if !strings.Contains(string(previousManifestBytes), `"session_id": "2026-04-26"`) {
|
|
t.Fatalf("restored previous manifest = %q, want previous session id", string(previousManifestBytes))
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if report.Execution.Downloaded != 3 {
|
|
t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "previous/artifacts/session_recap.md") {
|
|
t.Fatalf("stdout = %q, want planned previous-cache artifact", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "may read remote data; no session files will be written") {
|
|
t.Fatalf("stdout = %q, want dry-run remote-read notice", stdout.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
if _, err := os.Stat(filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md")); !os.IsNotExist(err) {
|
|
t.Fatalf("previous artifact should not be written during dry-run; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--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(), "conflicting path") {
|
|
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if report.Status != "failed" {
|
|
t.Fatalf("report status = %q, want failed", report.Status)
|
|
}
|
|
if report.Plan.Conflicts != 1 {
|
|
t.Fatalf("report plan.conflicts = %d, want 1", report.Plan.Conflicts)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if !report.Force {
|
|
t.Fatalf("report force = %v, want true", report.Force)
|
|
}
|
|
if _, err := os.Stat(artifacts.SessionRestoreMarkerPathForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)); !os.IsNotExist(err) {
|
|
t.Fatalf("restore marker should be cleared after success; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestorePreviousCurrent(t, fake, cfg, "# remote previous recap\n")
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# remote previous recap\n")
|
|
}
|
|
|
|
func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
store := artifacts.NewLocalStore(workspaceRoot)
|
|
lock, err := store.AcquireSessionLockFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("AcquireSessionLockFor() error = %v", err)
|
|
}
|
|
defer func() { _ = store.ReleaseSessionLock(lock) }()
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
err = Restore(ctx, []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &bytes.Buffer{})
|
|
if err == nil || !strings.Contains(err.Error(), "acquire session lock") {
|
|
t.Fatalf("Restore() error = %v, want lock failure", err)
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
if _, err := os.Stat(filepath.Join(sessionRoot, "transcripts", "full.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("transcript should not be restored when lock acquisition fails; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
store := &storage.FakeBackend{}
|
|
current := seedCommittedRestoreSnapshot(t, cfg, store, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
|
"transcripts/full.json": []byte("remote-transcript"),
|
|
})
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
existing := manifest.New(cfg.Session.SessionID, nowUTC())
|
|
existing.Campaign = cfg.Session.Campaign
|
|
existingPath := filepath.Join(sessionRoot, "manifest.json")
|
|
manifestStore := &manifest.LocalStore{}
|
|
if err := manifestStore.Save(context.Background(), existingPath, existing); err != nil {
|
|
t.Fatalf("save existing local manifest: %v", err)
|
|
}
|
|
existingData, err := os.ReadFile(existingPath)
|
|
if err != nil {
|
|
t.Fatalf("read existing local manifest: %v", err)
|
|
}
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
invalidManifest := []byte("{invalid json")
|
|
foundManifest := false
|
|
manifestKey := ""
|
|
manifestGeneration := ""
|
|
for index := range plan.Actions {
|
|
if plan.Actions[index].LocalRelativePath != config.PathManifestFile {
|
|
continue
|
|
}
|
|
foundManifest = true
|
|
plan.Actions[index].VerifiedContent = invalidManifest
|
|
plan.Actions[index].SHA256 = restoreCommitSHA256(invalidManifest)
|
|
plan.Actions[index].Size = int64(len(invalidManifest))
|
|
manifestKey = plan.Actions[index].RemoteKey
|
|
manifestGeneration = plan.Actions[index].Generation
|
|
}
|
|
if !foundManifest {
|
|
t.Fatal("restore plan has no manifest action")
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: invalidManifest, ETag: manifestGeneration})
|
|
report, err := newRestoreReport(current, plan, RestorePlanOptions{Force: true})
|
|
if err != nil {
|
|
t.Fatalf("newRestoreReport() error = %v", err)
|
|
}
|
|
_, err = executeRestorePlan(context.Background(), cfg, current, plan, report, store)
|
|
if err == nil || !strings.Contains(err.Error(), "validate manifest decode") {
|
|
t.Fatalf("executeRestorePlan() error = %v, want manifest validation failure", err)
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
|
afterData, err := os.ReadFile(existingPath)
|
|
if err != nil {
|
|
t.Fatalf("read local manifest after failure: %v", err)
|
|
}
|
|
if string(afterData) != string(existingData) {
|
|
t.Fatalf("local manifest changed after failed restore; before=%q after=%q", string(existingData), string(afterData))
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestorePlanPathMismatchFails(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
plan := &RestorePlan{Actions: []RestoreAction{{
|
|
Kind: RestoreActionDownload,
|
|
RemoteKey: current.SessionPrefix + "transcripts/full.json",
|
|
LocalRelativePath: "transcripts/full.json",
|
|
LocalPath: "/tmp/escape.txt",
|
|
}}}
|
|
|
|
report, err := newRestoreReport(current, plan, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("newRestoreReport() error = %v", err)
|
|
}
|
|
_, err = executeRestorePlan(context.Background(), cfg, current, plan, report, store)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "local path mismatch") {
|
|
t.Fatalf("error = %v, want local path mismatch", err)
|
|
}
|
|
}
|
|
|
|
func mustReadRestoreReport(t *testing.T, path string) *RestoreReport {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q): %v", path, err)
|
|
}
|
|
var report RestoreReport
|
|
if err := json.Unmarshal(data, &report); err != nil {
|
|
t.Fatalf("Unmarshal restore report %q: %v", path, err)
|
|
}
|
|
return &report
|
|
}
|
|
|
|
func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore) {
|
|
t.Helper()
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
|
origPlanFn := buildRestorePlanFn
|
|
origExecuteFn := executeRestorePlanFn
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
|
buildRestorePlanFn = origPlanFn
|
|
executeRestorePlanFn = origExecuteFn
|
|
})
|
|
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
return objectStore, nil
|
|
}
|
|
discoverRemoteCurrentStateFn = discoverRemoteCurrentState
|
|
buildRestorePlanFn = buildRestorePlan
|
|
executeRestorePlanFn = executeRestorePlan
|
|
}
|
|
|
|
func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, campaignPath, sessionPath string) (*config.Config, string, string, string) {
|
|
t.Helper()
|
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
|
}
|
|
if err := config.Validate(cfg); err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
|
|
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
|
|
|
|
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
|
|
seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
|
|
|
|
return cfg, sessionPrefix, manifestKey, runIDKey
|
|
}
|
|
|
|
func appendRestoreWorkflowPreviousInputConfig(t *testing.T, pipelinePath, sessionPath string) {
|
|
t.Helper()
|
|
appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, `
|
|
scriptorium:
|
|
binary: scriptorium
|
|
artifacts:
|
|
session_recap:
|
|
enabled: true
|
|
prompt_id: dnd.session_recap
|
|
output_path: artifacts/session_recap.md
|
|
inputs:
|
|
previous_recap:
|
|
source: narratio.previous_session.artifact.session_recap
|
|
required: true
|
|
`)
|
|
appendRestoreWorkflowScriptoriumConfig(t, sessionPath, `
|
|
previous_session_id: 2026-04-26
|
|
`)
|
|
}
|
|
|
|
func seedRestorePreviousCurrent(t *testing.T, fake *storage.FakeBackend, cfg *config.Config, artifactBody string) {
|
|
t.Helper()
|
|
seedRestorePreviousCurrentManifestOnly(t, fake, cfg)
|
|
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
|
seedRestoreObject(fake, previousPrefix+"artifacts/session_recap.md", []byte(artifactBody))
|
|
}
|
|
|
|
func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBackend, cfg *config.Config) {
|
|
t.Helper()
|
|
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
|
manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix)
|
|
previousRunID := "20260426T010203Z-a1b2c3d4"
|
|
seedRestoreObject(fake, runIDKey, []byte(previousRunID+"\n"))
|
|
|
|
m := manifest.New(cfg.Session.PreviousSessionID, nowUTC())
|
|
m.Campaign = cfg.Session.Campaign
|
|
m.RunID = previousRunID
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatalf("marshal previous restore manifest: %v", err)
|
|
}
|
|
seedRestoreObject(fake, manifestKey, append(data, '\n'))
|
|
}
|
|
|
|
func mustReadEquals(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q): %v", path, err)
|
|
}
|
|
if string(data) != want {
|
|
t.Fatalf("file %q = %q, want %q", path, string(data), want)
|
|
}
|
|
}
|
|
|
|
func fakeDownloadCount(fake *storage.FakeBackend, key string) int {
|
|
count := 0
|
|
for _, call := range fake.Downloads {
|
|
if call.Key == key {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|