200 lines
7.7 KiB
Go
200 lines
7.7 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/archive.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 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},
|
|
},
|
|
Session: &config.SessionConfig{
|
|
SessionID: "2026-05-03",
|
|
Campaign: "sample-campaign",
|
|
},
|
|
}
|
|
}
|
|
|
|
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
|
|
t.Helper()
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
|
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(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)
|
|
}
|
|
}
|