Implement remote current-state discovery for the restore subcommand
This commit is contained in:
@@ -11,6 +11,9 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig
|
||||||
|
var discoverRemoteCurrentStateFn = discoverRemoteCurrentState
|
||||||
|
|
||||||
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
|
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
|
||||||
func Restore(ctx context.Context, args []string, out io.Writer) error {
|
func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
|
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
|
||||||
@@ -63,14 +66,23 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return fmt.Errorf("restore: %w", err)
|
return fmt.Errorf("restore: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.NewObjectStoreFromConfig(ctx, cfg)
|
objectStore, err := newObjectStoreFromConfigFn(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("restore: %w", err)
|
||||||
|
}
|
||||||
|
current, err := discoverRemoteCurrentStateFn(ctx, cfg, objectStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("restore: %w", err)
|
return fmt.Errorf("restore: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2 boundary: command wiring and preflight only.
|
// Phase 3 boundary: discovery is implemented; planning/execution is deferred.
|
||||||
_ = dryRun
|
_ = dryRun
|
||||||
_ = force
|
_ = force
|
||||||
_ = includeAudio
|
_ = includeAudio
|
||||||
return fmt.Errorf("restore: not yet implemented (phase 3: remote current-state discovery)")
|
return fmt.Errorf(
|
||||||
|
"restore: discovered remote current state for %s/%s (run %s); not yet implemented (phase 3: remote current-state discovery)",
|
||||||
|
current.Campaign,
|
||||||
|
current.SessionID,
|
||||||
|
current.RunID,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
139
internal/app/restore_discovery.go
Normal file
139
internal/app/restore_discovery.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoteCurrentState captures discovered committed remote archive state for one session.
|
||||||
|
type RemoteCurrentState struct {
|
||||||
|
Bucket string
|
||||||
|
SessionPrefix string
|
||||||
|
CurrentRunIDKey string
|
||||||
|
CurrentManifestKey string
|
||||||
|
RunID string
|
||||||
|
SessionID string
|
||||||
|
Campaign string
|
||||||
|
Manifest *manifest.Manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*RemoteCurrentState, error) {
|
||||||
|
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
||||||
|
return nil, fmt.Errorf("resolved config with pipeline/session is required")
|
||||||
|
}
|
||||||
|
if store == nil {
|
||||||
|
return nil, fmt.Errorf("remote object store is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket := artifacts.ResolveArchiveBucket(cfg, nil)
|
||||||
|
if strings.TrimSpace(bucket) == "" {
|
||||||
|
return nil, fmt.Errorf("archive bucket is required")
|
||||||
|
}
|
||||||
|
sessionPrefix, err := artifacts.ResolveArchiveSessionPrefix(cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve archive session prefix: %w", err)
|
||||||
|
}
|
||||||
|
currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
|
||||||
|
|
||||||
|
exists, err := store.Exists(ctx, currentRunIDKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("check remote current run pointer %q: %w", currentRunIDKey, err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("remote current run pointer missing: %q", currentRunIDKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
runIDPath, err := downloadObjectToTemp(ctx, store, currentRunIDKey, "narratio-restore-current-run-id-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download remote current run pointer %q: %w", currentRunIDKey, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(runIDPath) }()
|
||||||
|
|
||||||
|
runIDData, err := os.ReadFile(runIDPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read downloaded run pointer %q: %w", currentRunIDKey, err)
|
||||||
|
}
|
||||||
|
runID := strings.TrimSpace(string(runIDData))
|
||||||
|
if runID == "" {
|
||||||
|
return nil, fmt.Errorf("remote current run pointer %q is empty", currentRunIDKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err = store.Exists(ctx, currentManifestKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("check remote current manifest %q: %w", currentManifestKey, err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("remote current manifest missing: %q", currentManifestKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestPath, err := downloadObjectToTemp(ctx, store, currentManifestKey, "narratio-restore-current-manifest-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download remote current manifest %q: %w", currentManifestKey, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(manifestPath) }()
|
||||||
|
|
||||||
|
manifestStore := &manifest.LocalStore{}
|
||||||
|
remoteManifest, err := manifestStore.Load(ctx, manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("remote current manifest decode failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedSession := strings.TrimSpace(cfg.Session.SessionID)
|
||||||
|
requestedCampaign := strings.TrimSpace(cfg.Session.Campaign)
|
||||||
|
manifestSession := strings.TrimSpace(remoteManifest.SessionID)
|
||||||
|
manifestCampaign := strings.TrimSpace(remoteManifest.Campaign)
|
||||||
|
|
||||||
|
if manifestSession != requestedSession {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"remote current manifest session_id %q does not match requested session_id %q",
|
||||||
|
manifestSession,
|
||||||
|
requestedSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if manifestCampaign == "" {
|
||||||
|
return nil, fmt.Errorf("remote current manifest campaign is required")
|
||||||
|
}
|
||||||
|
if manifestCampaign != requestedCampaign {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"remote current manifest campaign %q does not match requested campaign %q",
|
||||||
|
manifestCampaign,
|
||||||
|
requestedCampaign,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RemoteCurrentState{
|
||||||
|
Bucket: bucket,
|
||||||
|
SessionPrefix: sessionPrefix,
|
||||||
|
CurrentRunIDKey: currentRunIDKey,
|
||||||
|
CurrentManifestKey: currentManifestKey,
|
||||||
|
RunID: runID,
|
||||||
|
SessionID: manifestSession,
|
||||||
|
Campaign: manifestCampaign,
|
||||||
|
Manifest: remoteManifest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadObjectToTemp(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) {
|
||||||
|
tmp, err := os.CreateTemp("", pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
path := tmp.Name()
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return "", fmt.Errorf("close temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Download(ctx, key, path); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
239
internal/app/restore_discovery_test.go
Normal file
239
internal/app/restore_discovery_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateSuccess(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
|
||||||
|
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)})
|
||||||
|
|
||||||
|
state, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverRemoteCurrentState() error = %v", err)
|
||||||
|
}
|
||||||
|
if state.RunID != "20260519T010203Z-a1b2c3d4" {
|
||||||
|
t.Fatalf("run id = %q, want 20260519T010203Z-a1b2c3d4", state.RunID)
|
||||||
|
}
|
||||||
|
if state.SessionPrefix != sessionPrefix {
|
||||||
|
t.Fatalf("session prefix = %q, want %q", state.SessionPrefix, sessionPrefix)
|
||||||
|
}
|
||||||
|
if state.CurrentRunIDKey != runIDKey {
|
||||||
|
t.Fatalf("current run id key = %q, want %q", state.CurrentRunIDKey, runIDKey)
|
||||||
|
}
|
||||||
|
if state.CurrentManifestKey != manifestKey {
|
||||||
|
t.Fatalf("current manifest key = %q, want %q", state.CurrentManifestKey, manifestKey)
|
||||||
|
}
|
||||||
|
if state.Manifest == nil {
|
||||||
|
t.Fatal("manifest is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateMissingRunPointerFails(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "remote current run pointer missing") {
|
||||||
|
t.Fatalf("error = %v, want missing run pointer failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateEmptyRunPointerFails(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
|
||||||
|
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte(" \n\t")})
|
||||||
|
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)})
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "is empty") {
|
||||||
|
t.Fatalf("error = %v, want empty run pointer failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateMissingManifestFails(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
_, _, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
|
||||||
|
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "remote current manifest missing") {
|
||||||
|
t.Fatalf("error = %v, want missing manifest failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateInvalidManifestFails(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
|
||||||
|
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: []byte("{invalid json")})
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "remote current manifest decode failed") {
|
||||||
|
t.Fatalf("error = %v, want manifest decode failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateSessionMismatchFails(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
|
||||||
|
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "wrong-session", cfg.Session.Campaign)})
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not match requested session_id") {
|
||||||
|
t.Fatalf("error = %v, want session mismatch failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateCampaignMismatchFails(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
|
||||||
|
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "wrong-campaign")})
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not match requested campaign") {
|
||||||
|
t.Fatalf("error = %v, want campaign mismatch failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateEmptyCampaignFails(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
|
||||||
|
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "")})
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "campaign is required") {
|
||||||
|
t.Fatalf("error = %v, want empty campaign failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRemoteCurrentStateUsesCurrentKeysUnderSessionPrefix(t *testing.T) {
|
||||||
|
cfg := restoreDiscoveryConfig()
|
||||||
|
sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
|
||||||
|
base := &storage.FakeBackend{}
|
||||||
|
store := &captureObjectStore{delegate: base}
|
||||||
|
|
||||||
|
base.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
base.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)})
|
||||||
|
|
||||||
|
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverRemoteCurrentState() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRunKey := fmt.Sprintf("%scurrent/run_id.txt", sessionPrefix)
|
||||||
|
expectedManifestKey := fmt.Sprintf("%scurrent/manifest.json", sessionPrefix)
|
||||||
|
if !containsString(store.existsKeys, expectedRunKey) {
|
||||||
|
t.Fatalf("exists keys = %#v, want run pointer key %q", store.existsKeys, expectedRunKey)
|
||||||
|
}
|
||||||
|
if !containsString(store.existsKeys, expectedManifestKey) {
|
||||||
|
t.Fatalf("exists keys = %#v, want manifest key %q", store.existsKeys, expectedManifestKey)
|
||||||
|
}
|
||||||
|
if !containsString(store.downloadKeys, expectedRunKey) {
|
||||||
|
t.Fatalf("download keys = %#v, want run pointer key %q", store.downloadKeys, expectedRunKey)
|
||||||
|
}
|
||||||
|
if !containsString(store.downloadKeys, expectedManifestKey) {
|
||||||
|
t.Fatalf("download keys = %#v, want manifest key %q", store.downloadKeys, expectedManifestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureObjectStore struct {
|
||||||
|
delegate storage.ObjectStore
|
||||||
|
existsKeys []string
|
||||||
|
downloadKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *captureObjectStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||||
|
return s.delegate.List(ctx, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *captureObjectStore) Download(ctx context.Context, key, localPath string) error {
|
||||||
|
s.downloadKeys = append(s.downloadKeys, key)
|
||||||
|
return s.delegate.Download(ctx, key, localPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *captureObjectStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
|
||||||
|
return s.delegate.Upload(ctx, localPath, key, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *captureObjectStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
s.existsKeys = append(s.existsKeys, key)
|
||||||
|
return s.delegate.Exists(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreDiscoveryConfig() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
Pipeline: &config.PipelineConfig{
|
||||||
|
Storage: config.StorageConfig{
|
||||||
|
S3: &config.StorageS3Config{
|
||||||
|
Bucket: "my-dnd-archive",
|
||||||
|
RootPrefix: "dnd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: &config.SessionConfig{
|
||||||
|
SessionID: "2026-05-03",
|
||||||
|
Campaign: "sample-campaign",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreDiscoveryKeys(cfg *config.Config) (sessionPrefix, manifestKey, runIDKey string) {
|
||||||
|
sessionPrefix = artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
manifestKey, runIDKey = artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
|
||||||
|
return sessionPrefix, manifestKey, runIDKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
|
||||||
|
payload := map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"campaign": campaign,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"stages": map[string]any{},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal manifest payload: %v", err)
|
||||||
|
}
|
||||||
|
return append(data, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, target string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRestoreHelp(t *testing.T) {
|
func TestExecuteRestoreHelp(t *testing.T) {
|
||||||
@@ -31,6 +35,23 @@ func TestExecuteRestoreHelp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
func TestExecuteRestoreRecognizedAndReturnsNYI(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 &RemoteCurrentState{
|
||||||
|
SessionID: "2026-05-03",
|
||||||
|
Campaign: "sample-campaign",
|
||||||
|
RunID: "20260519T010203Z-a1b2c3d4",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
@@ -50,11 +71,14 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|||||||
&stderr,
|
&stderr,
|
||||||
)
|
)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero (phase 2 NYI)")
|
t.Fatal("exit code = 0, want non-zero (phase 3 NYI boundary)")
|
||||||
}
|
}
|
||||||
errText := stderr.String()
|
errText := stderr.String()
|
||||||
if !strings.Contains(errText, "restore: not yet implemented (phase 3: remote current-state discovery)") {
|
if !strings.Contains(errText, "discovered remote current state for sample-campaign/2026-05-03 (run 20260519T010203Z-a1b2c3d4)") {
|
||||||
t.Fatalf("stderr = %q, want NYI error", errText)
|
t.Fatalf("stderr = %q, want discovery summary context", errText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errText, "not yet implemented (phase 3: remote current-state discovery)") {
|
||||||
|
t.Fatalf("stderr = %q, want phase-3 NYI marker", errText)
|
||||||
}
|
}
|
||||||
if strings.Contains(errText, "unknown command") {
|
if strings.Contains(errText, "unknown command") {
|
||||||
t.Fatalf("stderr = %q, restore should be recognized command", errText)
|
t.Fatalf("stderr = %q, restore should be recognized command", errText)
|
||||||
@@ -62,7 +86,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|||||||
|
|
||||||
manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("manifest should not be created during phase-2 restore preflight; stat err=%v", err)
|
t.Fatalf("manifest should not be created during phase-3 restore discovery; stat err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +106,13 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
|
func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
|
||||||
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newObjectStoreFromConfigFn = origStoreFn
|
||||||
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
||||||
|
})
|
||||||
|
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot)
|
pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot)
|
||||||
|
|
||||||
@@ -96,6 +127,34 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) {
|
func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user