Files
felhom-controller/controller/internal/web/r487_removed_row_test.go
T
admin d698ce343b
gates / gates (push) Successful in 14s
controller v0.242.0: a removed app is listed with its kept backup; five small ones (R-487 R-491 R-490 R-489 R-476 R-456)
R-487: the local backup lists are keyed on the drives, not on what is
deployed — a removed app whose unit was kept is listed with the restore
that reinstalls it, the picker answers for it, and the restore opens the
unit where it sits. R-491: a removal clears the app's update hold.
R-490: /api/system/info reaches the API router and reads the default
storage path. R-489: volumes_removed is the real before/after difference,
[] when none. R-476: a Tier-2 copy is dated by its data, not its manifest.
R-456: the boot-orphan rule is pinned. Every fix red-proofed.
2026-09-13 22:50:18 +02:00

112 lines
4.6 KiB
Go

package web
import (
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-487 — a removed app whose unit was kept renders on the backups page with the restore that brings
// it back; a page without such rows carries none of it (the negative control).
func TestR487_BackupsPageListsARemovedAppWithItsRestore(t *testing.T) {
data := splitTestData()
data["AppBackupRows"] = []AppBackupRow{
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
{StackName: "gone-app", DisplayName: "Gone", Removed: true, RemovedUnitTime: "2026-09-13T00:30:00Z", StorageLabel: "HDD"},
}
html := renderBackupPage(t, "backups_apps", data)
for _, want := range []string{
`name="stack_name" value="gone-app"`,
`name="snapshot_id" value="helyi"`,
`action="/backup/restore"`,
`class="layer-badge">Megtartva<`,
} {
if !strings.Contains(html, want) {
t.Errorf("removed row must render %q", want)
}
}
if strings.Count(html, `class="layer-badge">Megtartva<`) != 1 {
t.Error("the kept badge must render for the removed row only")
}
control := renderBackupPage(t, "backups_apps", splitTestData())
if strings.Contains(control, "Megtartva") || strings.Contains(control, `value="helyi"`) {
t.Error("negative control: a page with no removed app must not offer the restore")
}
}
// R-487 — the restore picker lists removed apps in their own group.
func TestR487_RestorePickerListsRemovedApps(t *testing.T) {
data := splitTestData()
data["RemovedApps"] = []backup.RemovedAppUnit{{StackName: "gone-app", DisplayName: "Gone"}}
html := renderBackupPage(t, "backups_restore", data)
if !strings.Contains(html, `<option value="gone-app" data-removed="true"`) {
t.Error("removed app must be an option in the picker")
}
if strings.Contains(renderBackupPage(t, "backups_restore", splitTestData()), "data-removed") {
t.Error("negative control: no removed apps, no group")
}
}
// r487Provider is the smallest stack provider that can say "this app is not deployed".
type r487Provider struct{ deployed []string }
func (p *r487Provider) GetStackComposePath(name string) (string, bool) {
for _, s := range p.deployed {
if s == name {
return "/stacks/" + name + "/docker-compose.yml", true
}
}
return "", false
}
func (p *r487Provider) ListDeployedStacks() []backup.StackSummary {
out := make([]backup.StackSummary, 0, len(p.deployed))
for _, s := range p.deployed {
out = append(out, backup.StackSummary{Name: s})
}
return out
}
func (p *r487Provider) GetStackHDDMounts(string) []string { return nil }
func (p *r487Provider) GetStackHDDPath(string) string { return "" }
func (p *r487Provider) GetImportRoot() string { return "" }
func (p *r487Provider) GetDockerVolumes(string) []string { return nil }
func (p *r487Provider) StopStack(string) error { return nil }
func (p *r487Provider) StartStack(string) error { return nil }
func (p *r487Provider) RefreshAndIsRunning(string) bool { return true }
func (p *r487Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
func (p *r487Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *r487Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
return nil
}
func (p *r487Provider) StartStackServices(string, []string) error { return nil }
func (p *r487Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
return nil, false
}
// R-487 — the page's row builder appends a removed app's kept unit after the deployed rows, from the
// real manager over a real drive layout (the seam is the provider, and it says only "not deployed").
func TestR487_BuildAppBackupRowsAppendsRemovedApps(t *testing.T) {
s := testServer(t)
sys := t.TempDir()
cfg := &config.Config{}
cfg.Paths.SystemDataPath = sys
mgr := backup.NewManager(cfg, s.settings, s.logger)
mgr.SetStackProvider(&r487Provider{})
s.backupMgr = mgr
unit := backup.RecoveryUnitPath(backup.NamespaceRootFor(sys, sys), "gone-app")
if err := os.MkdirAll(unit, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(backup.UnitManifestFile(unit), []byte(`{"schema_version":2,"app_name":"gone-app","display_name":"Gone","created_at":"2026-09-12T02:15:29Z"}`), 0o644); err != nil {
t.Fatal(err)
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{})
if len(rows) != 1 || rows[0].StackName != "gone-app" || !rows[0].Removed || rows[0].DisplayName != "Gone" || rows[0].RemovedUnitTime == "" {
t.Fatalf("want one removed row for gone-app, got %+v", rows)
}
}