R-1199 reopened: a box with no list of its own reads the household's from the hub and adopts it, pushes its default once only when no household saved one; every push names its source; an empty saved list stays empty; R-1216 (D28 = A): the install page's swap sentence only when host swap is really short (shared case table); unreleased
gates / gates (push) Successful in 1m55s

This commit is contained in:
2026-10-22 12:13:26 +02:00
parent 08fa440d04
commit add310c80a
13 changed files with 512 additions and 61 deletions
+3 -9
View File
@@ -1889,15 +1889,9 @@ func main() {
// Sync notification preferences to hub on startup (handles hub DB rebuild recovery)
if notifier.IsEnabled() {
go func() {
prefs := sett.GetNotificationPrefs()
// R-922: a household's clear is pushed too, so a clear the hub missed reaches it at the next start.
// R-1199: a box whose household never saved its choice pushes the default list its page shows (with no
// address — the hub keeps the stored one, its no-clobber guard).
if prefsSyncAtStartup(prefs, sett.NotificationPrefsSaved(), logger) {
if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
logger.Printf("[WARN] Failed to sync notification preferences on startup: %v", err)
}
}
// R-922 (a saved list's address or clear is re-pushed) and R-1199 reopened (a box with no list of its own
// adopts the household's from the hub, or pushes its default once) — prefs_sync_wiring.go.
startupPrefsSync(notifier, sett, logger)
}()
}
+78 -20
View File
@@ -3,28 +3,86 @@ package main
import (
"log"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-1199 (rehearsal 2026-10-20; mechanism read the night of 2026-10-21, `audits/night-2026-10-21/P5-checks/`): a fresh
// box shows the household its DEFAULT notification list ticked (disk warning, drive disconnected, second-drive failure
// …), but pushed nothing to the hub until the household saved the page — and the hub mails the household only by its
// OWN stored list (`processCustomer`), which for Tester4 was a list saved two days before that box existed. So the
// household got one alarm of nine. The box's page is the household's choice (`08` „the household decides" — the three
// groups); the hub follows it. Decided by CC unattended — operator may reverse.
// R-1199 (rehearsal 2026-10-20) and R-1199 REOPENED (2026-10-22). A fresh box shows the household its DEFAULT
// notification list, but the hub mails only by its OWN stored list. v0.338.0 pushed that default at every start when
// nothing was saved on the box — and on a REBUILT box that overwrote the list the household had saved on its old box
// (demo-felhom, 07:54:55 UTC 2026-10-22: its own 13 events became the 17 defaults), again at every start, because the
// push created no local save. `08` „the household decides".
//
// prefsSyncAtStartup: push at start when the household saved an address or a clear (R-922, unchanged), or when it never
// saved a choice at all (the default list, pushed with no address — the hub's no-clobber guard keeps the stored one).
// Pinned by TestR1199_*.
func prefsSyncAtStartup(prefs *settings.NotificationPrefs, saved bool, logger *log.Logger) bool {
if prefs.SyncOnStartup() {
return true
}
if !saved && prefs != nil {
if logger != nil {
logger.Printf("[INFO] notification prefs: the household never saved its choice — pushing the default list the page shows, so the hub mails by it (R-1199)")
}
return true
}
return false
// Now, at start:
// - a list saved on this box: as before (R-922 — re-pushed when it carries an address or a clear), as the household's;
// - nothing saved here: READ the hub's list first. One the household saved (on any box) is ADOPTED as this box's
// list — nothing is pushed; otherwise the box's default is pushed once, as a default, and saved here, so the next
// start sends nothing. A hub that cannot be read (an older hub, no network) gets nothing; the next start asks again.
//
// The hub refuses a default over a household's list on its own side too (felhom.eu hub household_prefs.go), which also
// stops a box still on v0.338.0. Pinned by TestR1199Reopened_* (prefs_sync_wiring_test.go).
type prefsHub interface {
FetchPreferences() (*notify.HubPrefs, error)
SyncPreferencesAs(source, email string, enabledEvents []string, cooldownHours int, emailCleared bool) (string, error)
}
type prefsStore interface {
GetNotificationPrefs() *settings.NotificationPrefs
NotificationPrefsSaved() bool
SetNotificationPrefs(*settings.NotificationPrefs) error
}
// startupPrefsSync runs the start-up step above and returns what it did (for the log and the tests): "pushed",
// "adopted", "default-pushed", "default-kept", "nothing", "no-hub" or "error".
func startupPrefsSync(hub prefsHub, st prefsStore, logger *log.Logger) string {
logf := func(f string, a ...any) {
if logger != nil {
logger.Printf(f, a...)
}
}
prefs := st.GetNotificationPrefs()
if st.NotificationPrefsSaved() {
if !prefs.SyncOnStartup() {
return "nothing"
}
if _, err := hub.SyncPreferencesAs(notify.PrefsSourceHousehold, prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
logf("[WARN] Failed to sync notification preferences on startup: %v", err)
return "error"
}
return "pushed"
}
hp, err := hub.FetchPreferences()
if err != nil {
logf("[WARN] notification prefs: no list saved on this box and the hub's could not be read (%v) — nothing pushed; asked again at the next start (R-1199)", err)
return "no-hub"
}
if hp.Exists && hp.HouseholdSaved {
adopted := &settings.NotificationPrefs{Email: hp.Email, EnabledEvents: hp.EnabledEvents, CooldownHours: hp.CooldownHours}
if adopted.EnabledEvents == nil {
adopted.EnabledEvents = []string{}
}
if err := st.SetNotificationPrefs(adopted); err != nil {
logf("[ERROR] notification prefs: adopting the household's list from the hub failed: %v", err)
return "error"
}
logf("[INFO] notification prefs: adopted the household's saved list from the hub (%d events, address set=%t) — nothing pushed (R-1199)", len(adopted.EnabledEvents), adopted.Email != "")
return "adopted"
}
status, err := hub.SyncPreferencesAs(notify.PrefsSourceDefault, "", prefs.EnabledEvents, prefs.CooldownHours, false)
if err != nil {
logf("[WARN] notification prefs: pushing the default list failed: %v", err)
return "error"
}
if status == "kept" {
logf("[INFO] notification prefs: the hub kept the household's own list over this box's default — adopted at the next start (R-1199)")
return "default-kept"
}
local := &settings.NotificationPrefs{EnabledEvents: prefs.EnabledEvents, CooldownHours: prefs.CooldownHours}
if err := st.SetNotificationPrefs(local); err != nil {
logf("[ERROR] notification prefs: saving the pushed default on this box failed: %v", err)
return "error"
}
logf("[INFO] notification prefs: no household list anywhere — pushed this box's default once and saved it here (R-1199)")
return "default-pushed"
}
@@ -1,28 +1,137 @@
package main
import (
"errors"
"io"
"log"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-1199: a box whose household never saved its notification choice pushes the default list at start; a saved choice
// without an address (and no clear) is still not pushed (R-922's rule unchanged); a saved address is pushed.
// RED-PROOF: with the `!saved` branch removed the first case fails — the hub keeps mailing by a list the page never showed.
func TestR1199_ANeverSavedChoiceIsPushedAtStart(t *testing.T) {
def := &settings.NotificationPrefs{EnabledEvents: settings.DefaultEnabledEvents, CooldownHours: 6}
if !prefsSyncAtStartup(def, false, nil) {
t.Fatal("a box with no saved choice must push the default list its page shows")
// R-1199 REOPENED (2026-10-22) — the brief's four cases, against the real settings file and a hub that behaves like
// hub ≥ 0.165.0 (it keeps a household's list over a default push).
// RED-PROOF: with v0.338.0's rule restored (no hub read: a box with nothing saved pushes its default at every start)
// case A fails (the default reaches the hub) and case D fails (a second push) — seen failing 2026-10-22.
type fakeHub struct {
stored *notify.HubPrefs
readErr error
pushes []string // the source of every push
lastEvents []string
}
func (f *fakeHub) FetchPreferences() (*notify.HubPrefs, error) {
if f.readErr != nil {
return nil, f.readErr
}
if prefsSyncAtStartup(&settings.NotificationPrefs{EnabledEvents: []string{"backup_failed"}}, true, nil) {
t.Fatal("a saved choice with no address and no clear stays local (R-922 unchanged)")
if f.stored == nil {
return &notify.HubPrefs{Exists: false}, nil
}
if !prefsSyncAtStartup(&settings.NotificationPrefs{Email: "x@example.invalid", EnabledEvents: []string{"backup_failed"}}, true, nil) {
t.Fatal("a saved address is pushed, as before")
cp := *f.stored
return &cp, nil
}
func (f *fakeHub) SyncPreferencesAs(source, email string, ev []string, cd int, cleared bool) (string, error) {
f.pushes = append(f.pushes, source)
if source != notify.PrefsSourceHousehold && f.stored != nil && f.stored.HouseholdSaved {
return "kept", nil
}
if f.stored == nil {
f.stored = &notify.HubPrefs{Exists: true}
}
f.stored.EnabledEvents, f.lastEvents = ev, ev
if email != "" {
f.stored.Email = email
}
if source == notify.PrefsSourceHousehold {
f.stored.HouseholdSaved = true
}
return "ok", nil
}
func newSettings(t *testing.T) *settings.Settings {
t.Helper()
s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
return s
}
var familyList = []string{"backup_failed", "disk_warning", "node_down"}
// A: the household saved on an earlier box; this box has no local save → nothing reaches the hub, the box adopts the
// list and its page (GetNotificationPrefs) shows it.
func TestR1199Reopened_A_ARebuiltBoxAdoptsTheHouseholdsList(t *testing.T) {
hub := &fakeHub{stored: &notify.HubPrefs{Exists: true, HouseholdSaved: true, Email: "family@example.hu", EnabledEvents: familyList, CooldownHours: 12}}
st := newSettings(t)
if got := startupPrefsSync(hub, st, nil); got != "adopted" {
t.Fatalf("startup = %q, want adopted", got)
}
if len(hub.pushes) != 0 {
t.Fatalf("a box with no list of its own pushed %v over the household's list", hub.pushes)
}
p := st.GetNotificationPrefs()
if !st.NotificationPrefsSaved() || len(p.EnabledEvents) != 3 || p.Email != "family@example.hu" || p.CooldownHours != 12 {
t.Fatalf("the page must show the household's list: %+v", p)
}
// D after A: the next start re-pushes the adopted list as the household's (it carries an address, R-922) — the
// same list, never the default.
startupPrefsSync(hub, st, nil)
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceHousehold || len(hub.lastEvents) != 3 {
t.Fatalf("after adoption: pushes %v events %v", hub.pushes, hub.lastEvents)
}
}
// The default list the page shows carries the three events the rehearsal household did not get (the consequence).
// B: no household ever saved (a seeded row, or none) → the default goes once, is saved here, and the hub mails by it.
func TestR1199Reopened_B_NoHouseholdListAnywhereTakesTheDefaultOnce(t *testing.T) {
for _, stored := range []*notify.HubPrefs{nil, {Exists: true, Email: "seed@example.hu", EnabledEvents: []string{"node_down"}}} {
hub := &fakeHub{stored: stored}
st := newSettings(t)
if got := startupPrefsSync(hub, st, nil); got != "default-pushed" {
t.Fatalf("startup = %q, want default-pushed", got)
}
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceDefault || len(hub.stored.EnabledEvents) != len(settings.DefaultEnabledEvents) {
t.Fatalf("the default must reach the hub once, as a default: %v %v", hub.pushes, hub.stored.EnabledEvents)
}
if !st.NotificationPrefsSaved() {
t.Fatal("the pushed default must be saved on the box")
}
// D: a restart sends nothing (a local save exists, with no address).
if got := startupPrefsSync(hub, st, nil); got != "nothing" || len(hub.pushes) != 1 {
t.Fatalf("restart after B: %q, pushes %v — want nothing", got, hub.pushes)
}
}
}
// C: the household saves on the box → the page save pushes as the household's (web handler, unchanged path); at start
// a saved list with an address is re-pushed as the household's (R-922).
func TestR1199Reopened_C_ASavedListIsTheHouseholds(t *testing.T) {
hub := &fakeHub{stored: &notify.HubPrefs{Exists: true, HouseholdSaved: true, EnabledEvents: familyList}}
st := newSettings(t)
st.SetNotificationPrefs(&settings.NotificationPrefs{Email: "new@example.hu", EnabledEvents: []string{"node_down"}})
if got := startupPrefsSync(hub, st, nil); got != "pushed" || hub.pushes[0] != notify.PrefsSourceHousehold {
t.Fatalf("a saved list: %q %v", got, hub.pushes)
}
if len(hub.stored.EnabledEvents) != 1 {
t.Fatalf("the household's new choice must reach the hub: %v", hub.stored.EnabledEvents)
}
}
// A hub that cannot be read gets nothing (never a blind default); a hub that keeps its list over a race is not
// overwritten and the box saves nothing (it adopts at the next start).
func TestR1199Reopened_AnUnreadableHubGetsNothing(t *testing.T) {
hub := &fakeHub{readErr: errors.New("hub answered HTTP 404")}
st := newSettings(t)
if got := startupPrefsSync(hub, st, nil); got != "no-hub" || len(hub.pushes) != 0 || st.NotificationPrefsSaved() {
t.Fatalf("unreadable hub: %q pushes %v saved %t", got, hub.pushes, st.NotificationPrefsSaved())
}
}
// The default list the page shows carries the three events the rehearsal household did not get (kept from R-1199).
func TestR1199_TheDefaultListHoldsTheAlarmsTheHouseholdMissed(t *testing.T) {
want := map[string]bool{"disk_warning": false, "storage_disconnected": false, "crossdrive_failed": false}
for _, e := range settings.DefaultEnabledEvents {