package pbs import ( "context" "errors" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // fakeReporter builds a LiveSnapshotReporter with an injected snapshot-lister seam and a targets // closure, so no live PBS is needed. listFn is keyed by datastore; targetsErr forces a targets- // resolution failure. The Target.Client is a throwaway &Client{} the seam never dereferences. func fakeReporter(store *SnapshotStore, datastores []string, targetsErr error, listFn func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error)) *LiveSnapshotReporter { targets := func(ctx context.Context) ([]Target, error) { if targetsErr != nil { return nil, targetsErr } out := make([]Target, 0, len(datastores)) for _, ds := range datastores { out = append(out, Target{Datastore: ds, Client: &Client{}}) } return out, nil } r := NewLiveSnapshotReporter(targets, store, 2*time.Second, nil) r.listSnapshots = func(ctx context.Context, t Target) ([]hub.PBSSnapshot, error) { return listFn(ctx, t.Datastore) } return r } func snap(ds, backupID, backupTime string) hub.PBSSnapshot { return hub.PBSSnapshot{Namespace: "root", BackupType: "ct", BackupID: backupID, BackupTime: backupTime} } // T1 (load-bearing): coord present with NO prior verify. A fresh store (verify never ran) + a live // lister returning 2 snapshots → PBSSnapshots returns both, and feeding them through // BuildDRRecipeHostHalf yields a non-nil pbs whose latest_snapshot_id is the LATER snapshot. func TestLiveReporter_CoordPresentWithoutPriorVerify(t *testing.T) { store := NewSnapshotStore() // empty — simulates a just-restarted daemon, no verify yet r := fakeReporter(store, []string{"felhom-spike"}, nil, func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) { return []hub.PBSSnapshot{ snap(ds, "9201", "2026-06-13T20:00:00Z"), snap(ds, "9201", "2026-06-16T17:00:00Z"), // latest }, nil }) got := r.PBSSnapshots(context.Background()) if len(got) != 2 { t.Fatalf("want 2 live snapshots, got %d", len(got)) } // The whole point: a recipe built from the live read carries the pbs coord. h := hub.BuildDRRecipeHostHalf(nil, []hub.StorageTarget{{Name: "felhom-pbs", Type: hub.StorageTypePBS, Content: "backup"}}, got) if h.PBS == nil { t.Fatal("pbs coord absent despite a reachable PBS — the gap this fixes") } if h.PBS.RepoID != "felhom-pbs" || h.PBS.Namespace != "root" || h.PBS.LatestSnapshotID != "9201" { t.Errorf("pbs coord = %+v, want felhom-pbs/root/9201", h.PBS) } // COMPANION (pre-fix): the bare SnapshotStore (no live read) with an empty store omits pbs. bare := NewSnapshotStore() h2 := hub.BuildDRRecipeHostHalf(nil, []hub.StorageTarget{{Name: "felhom-pbs", Type: hub.StorageTypePBS, Content: "backup"}}, bare.PBSSnapshots(context.Background())) if h2.PBS != nil { t.Fatal("companion sanity: the bare store should yield NO pbs coord (proves the live read is load-bearing)") } } // T2: live error → last-known-good fallback (and the store is NOT clobbered). func TestLiveReporter_ErrorFallsBackToLastKnownGood(t *testing.T) { store := NewSnapshotStore() store.Record("D", []hub.PBSSnapshot{snap("D", "9001", "2026-06-10T00:00:00Z")}) r := fakeReporter(store, []string{"D"}, nil, func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) { return nil, errors.New("pbs unreachable") }) got := r.PBSSnapshots(context.Background()) if len(got) != 1 || got[0].BackupID != "9001" { t.Fatalf("want the last-known-good snapshot on live error, got %+v", got) } // The store must still hold the prior set (a failed live read must not wipe it). if lkg := store.Get("D"); len(lkg) != 1 || lkg[0].BackupID != "9001" { t.Errorf("live error clobbered the store: %+v", lkg) } // COMPANION (pre-fix): without the fallback branch (return empty on error) the result is empty. noFallback := func(ctx context.Context) []hub.PBSSnapshot { targets, _ := r.targets(ctx) out := []hub.PBSSnapshot{} for _, tg := range targets { if _, err := r.listSnapshots(ctx, tg); err != nil { continue // the mutation: drop the LKG-append branch } } return out } if len(noFallback(context.Background())) != 0 { t.Fatal("companion sanity: the no-fallback variant should return empty") } } // T3: a successful response updates the store (last-known-good warm for a later error). func TestLiveReporter_SuccessUpdatesStore(t *testing.T) { store := NewSnapshotStore() // empty r := fakeReporter(store, []string{"D"}, nil, func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) { return []hub.PBSSnapshot{snap("D", "9201", "2026-06-16T00:00:00Z")}, nil }) _ = r.PBSSnapshots(context.Background()) if lkg := store.Get("D"); len(lkg) != 1 || lkg[0].BackupID != "9201" { t.Errorf("store not warmed by a successful live read: %+v", lkg) } } // T4: targets-resolution error → the full last-known-good aggregate (not empty). func TestLiveReporter_TargetsErrorReturnsAggregate(t *testing.T) { store := NewSnapshotStore() store.Record("D1", []hub.PBSSnapshot{snap("D1", "1", "2026-06-10T00:00:00Z")}) store.Record("D2", []hub.PBSSnapshot{snap("D2", "2", "2026-06-11T00:00:00Z")}) r := fakeReporter(store, nil, errors.New("ListStorage failed"), func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) { t.Fatal("listSnapshots must not be called when targets fail to resolve") return nil, nil }) got := r.PBSSnapshots(context.Background()) if len(got) != 2 { t.Fatalf("want the 2-snapshot aggregate on a targets-resolution error, got %d (%+v)", len(got), got) } } // T5: bounded. A lister that blocks until ctx is done must return within ~timeout (the child-ctx // deadline) and fall back, not hang. A short timeout keeps the test fast + deterministic. func TestLiveReporter_BoundedByTimeout(t *testing.T) { store := NewSnapshotStore() store.Record("D", []hub.PBSSnapshot{snap("D", "9001", "2026-06-10T00:00:00Z")}) targets := func(ctx context.Context) ([]Target, error) { return []Target{{Datastore: "D", Client: &Client{}}}, nil } r := NewLiveSnapshotReporter(targets, store, 100*time.Millisecond, nil) r.listSnapshots = func(ctx context.Context, t Target) ([]hub.PBSSnapshot, error) { <-ctx.Done() // block until the child-ctx deadline fires return nil, ctx.Err() } done := make(chan []hub.PBSSnapshot, 1) go func() { done <- r.PBSSnapshots(context.Background()) }() select { case got := <-done: // Fell back to last-known-good rather than hanging. if len(got) != 1 || got[0].BackupID != "9001" { t.Errorf("want last-known-good after the deadline, got %+v", got) } case <-time.After(2 * time.Second): t.Fatal("PBSSnapshots hung past the timeout — not bounded by the child ctx") } } // T6: an empty-but-successful response is authoritative — it OVERWRITES a prior non-empty set. // (Documents the chosen semantics: a real empty datastore must be reflected, not masked by stale LKG.) func TestLiveReporter_EmptySuccessIsAuthoritative(t *testing.T) { store := NewSnapshotStore() store.Record("D", []hub.PBSSnapshot{snap("D", "9001", "2026-06-10T00:00:00Z")}) r := fakeReporter(store, []string{"D"}, nil, func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) { return []hub.PBSSnapshot{}, nil // success, but the datastore is now empty }) got := r.PBSSnapshots(context.Background()) if len(got) != 0 { t.Errorf("empty-but-successful should yield an empty result, got %+v", got) } if lkg := store.Get("D"); len(lkg) != 0 { t.Errorf("empty success should overwrite the store to empty, got %+v", lkg) } }