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
gates / gates (push) Successful in 1m55s
This commit is contained in:
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ¬ify.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 = ¬ify.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: ¬ify.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: ¬ify.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 {
|
||||
|
||||
Reference in New Issue
Block a user