package api import ( "encoding/json" "go/ast" "go/parser" "go/token" "io" "log" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-552: an interrupted-restore notice was cleared only by a NEW restore of the same app, so a // household that answered it by REMOVING the app kept a „Megszakadt visszaállítás" card about an app // that no longer exists — for ever, because the notice is persisted. The consequence asserted: after // the removal path's clear, the notice is gone from the page's list AND from the persisted record (a // fresh Manager loading the file sees none), while another app's notice stays. // COMPANION RED-PROOF: drop the clearInterruptedRestoreNotice call from removeStack → the AST check // fails; make ClearInterruptedRestore a no-op → the notice is still listed. func TestR552_RemoveClearsTheInterruptedRestoreNotice(t *testing.T) { dir := t.TempDir() cfg := &config.Config{} cfg.Paths.DataDir = dir lg := log.New(io.Discard, "", 0) sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } recPath := filepath.Join(dir, "restore-status.json") // The record two stops left behind: "otherapp" already carries a notice, "homebox" was running. seed := map[string]any{ "running": true, "op": "restore", "stack": "homebox", "interrupted": map[string]any{"otherapp": map[string]any{"op": "restore", "stack": "otherapp", "interrupted": true}}, } b, _ := json.Marshal(seed) if err := os.WriteFile(recPath, b, 0o600); err != nil { t.Fatal(err) } load := func() *backup.Manager { bm := backup.NewManager(cfg, sett, lg) bm.SetRestoreRecordPath(recPath) bm.LoadRestoreRecord() return bm } bm := load() if _, ok := bm.InterruptedRestore("homebox"); !ok { t.Fatal("setup: no interrupted notice for homebox") } r := &Router{backupMgr: bm, logger: lg} r.clearInterruptedRestoreNotice("homebox") if _, ok := bm.InterruptedRestore("homebox"); ok { t.Errorf("R-552: the removed app's interrupted-restore notice is still listed") } if _, ok := bm.InterruptedRestore("otherapp"); !ok { t.Errorf("another app's notice was cleared by this app's removal") } again := load() if _, ok := again.InterruptedRestore("homebox"); ok { t.Errorf("R-552: the notice comes back after a restart — the clear was not persisted") } fset := token.NewFileSet() f, err := parser.ParseFile(fset, "router.go", nil, 0) if err != nil { t.Fatal(err) } called := false for _, d := range f.Decls { if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil { ast.Inspect(fn.Body, func(n ast.Node) bool { if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "clearInterruptedRestoreNotice" { called = true } return true }) } } if !called { t.Errorf("R-552: removeStack does not call clearInterruptedRestoreNotice") } }