366 lines
14 KiB
Go
366 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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 TestRestorePlanDefaultScope(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
|
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
|
seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
|
|
seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log"))
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"artifacts/session_recap.md", "manifest.json", "transcripts/full.json"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
if plan.DownloadCount != 3 || plan.SkipSameCount != 0 || plan.ConflictCount != 0 {
|
|
t.Fatalf("counts = download=%d skip_same=%d conflict=%d, want 3/0/0", plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanIncludeAudio(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"audio/alice.flac", "manifest.json"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanExistingAudioRequiresVerifiedReplacement(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "local")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
if len(store.Downloads) != 0 {
|
|
t.Fatalf("downloads = %d, want no remote checksum download for audio", len(store.Downloads))
|
|
}
|
|
actionByRel := map[string]RestoreAction{}
|
|
for _, action := range plan.Actions {
|
|
actionByRel[action.LocalRelativePath] = action
|
|
}
|
|
audioAction := actionByRel["audio/alice.flac"]
|
|
if audioAction.Kind != RestoreActionConflict {
|
|
t.Fatalf("audio action kind = %q, want %q", audioAction.Kind, RestoreActionConflict)
|
|
}
|
|
|
|
forcedPlan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true, Force: true})
|
|
if err != nil {
|
|
t.Fatalf("forced buildRestorePlan() error = %v", err)
|
|
}
|
|
for _, action := range forcedPlan.Actions {
|
|
if action.LocalRelativePath == "audio/alice.flac" && action.Kind != RestoreActionDownload {
|
|
t.Fatalf("forced audio action kind = %q, want %q", action.Kind, RestoreActionDownload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, true)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"manifest.json"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanMissingOptionalPreviousCacheSkipsArtifact(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, false)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"manifest.json", "previous/manifest.json"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, true)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
|
|
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "required previous-session artifact") {
|
|
t.Fatalf("buildRestorePlan() error = %v, want required previous artifact failure", err)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, true)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n")
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
if plan.ConflictCount != 1 {
|
|
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
|
|
}
|
|
|
|
plan, err = buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan(force) error = %v", err)
|
|
}
|
|
if plan.ConflictCount != 0 {
|
|
t.Fatalf("force ConflictCount = %d, want 0", plan.ConflictCount)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
if plan.SkipSameCount != 1 {
|
|
t.Fatalf("SkipSameCount = %d, want 1", plan.SkipSameCount)
|
|
}
|
|
if plan.ConflictCount != 1 {
|
|
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
|
|
}
|
|
|
|
actionByRel := map[string]RestoreAction{}
|
|
for _, action := range plan.Actions {
|
|
actionByRel[action.LocalRelativePath] = action
|
|
}
|
|
if actionByRel["transcripts/full.json"].Kind != RestoreActionSkipSame {
|
|
t.Fatalf("transcripts/full.json kind = %q, want %q", actionByRel["transcripts/full.json"].Kind, RestoreActionSkipSame)
|
|
}
|
|
if actionByRel["artifacts/session_recap.md"].Kind != RestoreActionConflict {
|
|
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", actionByRel["artifacts/session_recap.md"].Kind, RestoreActionConflict)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
actionByRel := map[string]RestoreAction{}
|
|
for _, action := range plan.Actions {
|
|
actionByRel[action.LocalRelativePath] = action
|
|
}
|
|
recap := actionByRel["artifacts/session_recap.md"]
|
|
if recap.Kind != RestoreActionDownload {
|
|
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", recap.Kind, RestoreActionDownload)
|
|
}
|
|
if plan.ConflictCount != 0 {
|
|
t.Fatalf("ConflictCount = %d, want 0", plan.ConflictCount)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad"))
|
|
|
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "escapes session scope") {
|
|
t.Fatalf("error = %v, want traversal safety failure", err)
|
|
}
|
|
}
|
|
|
|
func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) {
|
|
store.SeedObject(storage.FakeObject{Key: key, Data: data})
|
|
}
|
|
|
|
func actionRelPaths(actions []RestoreAction) []string {
|
|
out := make([]string, 0, len(actions))
|
|
for _, action := range actions {
|
|
out = append(out, action.LocalRelativePath)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func restorePlanConfig(t *testing.T) *config.Config {
|
|
t.Helper()
|
|
workspaceRoot := t.TempDir()
|
|
return &config.Config{
|
|
Pipeline: &config.PipelineConfig{
|
|
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
|
|
Storage: config.StorageConfig{S3: &config.StorageS3Config{
|
|
Bucket: "test-bucket",
|
|
RootPrefix: "dnd",
|
|
}},
|
|
},
|
|
Session: &config.SessionConfig{
|
|
SessionID: "2026-05-03",
|
|
Campaign: "sample-campaign",
|
|
},
|
|
}
|
|
}
|
|
|
|
func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool) {
|
|
cfg.Session.PreviousSessionID = "2026-04-26"
|
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
|
Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
|
"session_recap": {
|
|
Enabled: true,
|
|
PromptID: "dnd.session_recap",
|
|
OutputPath: "artifacts/session_recap.md",
|
|
Inputs: map[string]config.ScriptoriumInputConfig{
|
|
"previous_recap": {
|
|
Source: "narratio.previous_session.artifact.session_recap",
|
|
Required: required,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
|
|
t.Helper()
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
|
manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
|
|
return &RemoteCurrentState{
|
|
Bucket: "test-bucket",
|
|
SessionPrefix: sessionPrefix,
|
|
CurrentManifestKey: manifestKey,
|
|
CurrentRunIDKey: runIDKey,
|
|
RunID: "20260519T010203Z-a1b2c3d4",
|
|
SessionID: cfg.Session.SessionID,
|
|
Campaign: cfg.Session.Campaign,
|
|
}
|
|
}
|
|
|
|
func TestWriteRestorePlan(t *testing.T) {
|
|
current := &RemoteCurrentState{Campaign: "sample-campaign", SessionID: "2026-05-03", RunID: "r-1"}
|
|
plan := &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "k", Reason: "local file missing"}}, DownloadCount: 1}
|
|
var out strings.Builder
|
|
if err := writeRestorePlan(&out, current, plan, RestorePlanOptions{DryRun: true}); err != nil {
|
|
t.Fatalf("writeRestorePlan() error = %v", err)
|
|
}
|
|
text := out.String()
|
|
if !strings.Contains(text, "restore plan: session sample-campaign/2026-05-03 run=r-1") {
|
|
t.Fatalf("output = %q, want plan summary", text)
|
|
}
|
|
if !strings.Contains(text, "download manifest.json <- k") {
|
|
t.Fatalf("output = %q, want action line", text)
|
|
}
|
|
}
|