package backup import ( "encoding/json" "log" "os" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // r487Provider knows exactly which stacks are deployed: GetStackComposePath answers ok for those and // only those, which is the question ListRestorePoints and the restore ask. type r487Provider struct{ floorProvider } func (p *r487Provider) GetStackComposePath(name string) (string, bool) { for _, s := range p.stacks { if s == name { return filepath.Join(p.dir, "stacks", name, "docker-compose.yml"), true } } return "", false } func r487Manager(t *testing.T, sysPath string, deployed ...string) (*Manager, *settings.Settings) { t.Helper() sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(os.Stderr, "", 0)) if err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.SystemDataPath = sysPath m := NewManager(cfg, sett, log.New(os.Stderr, "", 0)) m.SetStackProvider(&r487Provider{floorProvider{stacks: deployed, dir: t.TempDir()}}) return m, sett } // writeR487Unit lays down a readable unit (manifest + one dump) for stack under nsRoot. func writeR487Unit(t *testing.T, nsRoot, stack, display string, dumpAt time.Time) string { t.Helper() u := mkUnit(t, nsRoot, stack) man := RecoveryManifest{SchemaVersion: 2, AppName: stack, DisplayName: display, CreatedAt: "2026-09-12T02:15:29Z"} b, _ := json.Marshal(man) if err := os.WriteFile(UnitManifestFile(u), b, 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(UnitDBDumpDir(u), 0o755); err != nil { t.Fatal(err) } dump := filepath.Join(UnitDBDumpDir(u), stack+".sql") if err := os.WriteFile(dump, []byte("-- dump"), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(dump, dumpAt, dumpAt); err != nil { t.Fatal(err) } // the manifest is the OLDER artifact, as on a real box: it moves only when the definition changes older := dumpAt.Add(-24 * time.Hour) if err := os.Chtimes(UnitManifestFile(u), older, older); err != nil { t.Fatal(err) } return u } // R-487 — a removed app's kept unit is listed; a deployed app's is not; a unit on a registered DATA // drive is found where it sits and carries that drive's label. func TestR487_ListRemovedAppUnits_ListsKeptUnitsOfUndeployedApps(t *testing.T) { sys := t.TempDir() m, sett := r487Manager(t, sys, "kept-app") sysRoot := m.namespaceRoot(sys) writeR487Unit(t, sysRoot, "kept-app", "Kept", time.Now()) writeR487Unit(t, sysRoot, "gone-app", "Gone", time.Now()) drive := t.TempDir() if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "Külső HDD", Schedulable: true}); err != nil { t.Fatal(err) } writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "On the drive", time.Now()) // a bare directory without a manifest is NOT an offer if err := os.MkdirAll(RecoveryUnitPath(sysRoot, "hollow"), 0o755); err != nil { t.Fatal(err) } got := m.ListRemovedAppUnits() if len(got) != 2 || got[0].StackName != "drive-app" || got[1].StackName != "gone-app" { t.Fatalf("want [drive-app gone-app], got %+v", got) } if got[0].DriveLabel != "Külső HDD" || got[1].DriveLabel != systemDriveLabel { t.Errorf("drive labels: %q / %q", got[0].DriveLabel, got[1].DriveLabel) } if got[1].DisplayName != "Gone" || got[0].UnitDir != RecoveryUnitPath(m.namespaceRoot(drive), "drive-app") { t.Errorf("display/unit dir: %+v", got) } for _, u := range got { if u.StackName == "kept-app" || u.StackName == "hollow" { t.Errorf("%s must not be listed", u.StackName) } } } // R-487 — the restore picker's snapshot list answers for a removed app instead of 404. func TestR487_ListRestorePointsFindsARemovedAppsUnit(t *testing.T) { sys := t.TempDir() m, _ := r487Manager(t, sys) at := time.Date(2026, 9, 13, 0, 30, 0, 0, time.UTC) writeR487Unit(t, m.namespaceRoot(sys), "gone-app", "Gone", at) pts, found := m.ListRestorePoints("gone-app") if !found || len(pts) != 1 { t.Fatalf("want found with one point, got found=%v pts=%+v", found, pts) } if pts[0].ShortID != restorePointShortID || pts[0].Tier != 1 || pts[0].Time != at.Format(time.RFC3339) { t.Errorf("point %+v", pts[0]) } if _, found := m.ListRestorePoints("never-existed"); found { t.Error("an app with no unit anywhere must still be not-found") } } // R-487 — a removed app's unit kept on a DATA drive is the one the restore opens, not the system // path the drive fallback would name. func TestR487_PrimaryUnitDirForNamesTheRemovedUnitWhereItSits(t *testing.T) { sys := t.TempDir() m, sett := r487Manager(t, sys) drive := t.TempDir() if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil { t.Fatal(err) } want := writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "D", time.Now()) if got := m.primaryUnitDirFor("drive-app"); got != want { t.Errorf("unit dir: got %s want %s", got, want) } }