package api import ( "encoding/json" "net/http" "strings" "testing" ) // R-1199 reopened (2026-10-22): a push that is not the household's own save never overwrites a list the household // saved (demo-felhom's 13-event list became controller 0.338.0's default at 07:54:55 UTC); a box reads the household's // list instead (GET /preferences, self-scoped). // RED-PROOF: with the `!household && existing.HouseholdSaved` refusal removed from handleSavePreferences, A and A-legacy // fail (the default replaces the household's list) — seen failing 2026-10-22. var householdList = []string{"backup_failed", "disk_warning", "node_down"} func eventsOf(t *testing.T, h *Handler, key string) (saved bool, events []string) { t.Helper() rr := do(h, http.MethodGet, "/preferences?customer_id=c1", key, "") if rr.Code != http.StatusOK { t.Fatalf("GET /preferences = %d %s", rr.Code, rr.Body.String()) } var out struct { Exists bool `json:"exists"` HouseholdSaved bool `json:"household_saved"` EnabledEvents []string `json:"enabled_events"` } if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatalf("decode: %v", err) } return out.HouseholdSaved, out.EnabledEvents } // A: the household saved its list (on an earlier box); this box pushes its default → the hub keeps the household's. func TestR1199Reopened_A_ADefaultNeverOverwritesTheHouseholdsList(t *testing.T) { h, st := newEventTestHandler(t) if err := st.SaveNotificationPrefsAs("c1", "family@example.hu", householdList, 6, true); err != nil { t.Fatal(err) } rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","enabled_events":["backup_failed","disk_warning","node_down","storage_disconnected","crossdrive_failed"],"cooldown_hours":6,"source":"default"}`) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"kept"`) { t.Fatalf("a default push over a household list: %d %s, want 200 kept", rr.Code, rr.Body.String()) } p, _ := st.GetNotificationPrefs("c1") if len(p.EnabledEvents) != 3 || !p.HouseholdSaved || p.Email != "family@example.hu" { t.Fatalf("the household's list was changed: %+v", p) } } // A-legacy: controller 0.338.0 sends no `source`; its start-up default push (no address, no clear) is not the // household's either — the exact push that overwrote demo-felhom's list. func TestR1199Reopened_A_The0338DefaultPushIsKeptOutToo(t *testing.T) { h, st := newEventTestHandler(t) st.SaveNotificationPrefsAs("c1", "family@example.hu", householdList, 6, true) rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","enabled_events":["backup_failed"],"cooldown_hours":6}`) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"kept"`) { t.Fatalf("0.338.0's push: %d %s, want kept", rr.Code, rr.Body.String()) } if p, _ := st.GetNotificationPrefs("c1"); len(p.EnabledEvents) != 3 { t.Fatalf("0.338.0's default push changed the household's list: %+v", p.EnabledEvents) } } // B: no household ever saved (the row was seeded at the claim) → the box's default is applied, still not marked. func TestR1199Reopened_B_ASeededListTakesTheBoxsDefault(t *testing.T) { h, st := newEventTestHandler(t) if ok, err := st.SeedNotificationPrefs("c1", "family@example.hu", []string{"node_down"}); err != nil || !ok { t.Fatalf("seed: %v %v", ok, err) } rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","enabled_events":["backup_failed","disk_warning"],"cooldown_hours":6,"source":"default"}`) if rr.Code != http.StatusOK || strings.Contains(rr.Body.String(), "kept") { t.Fatalf("default over a seed: %d %s, want applied", rr.Code, rr.Body.String()) } saved, ev := eventsOf(t, h, "ckey") if saved || len(ev) != 2 { t.Fatalf("after the default: household_saved=%t events=%v, want false + the 2 defaults", saved, ev) } if p, _ := st.GetNotificationPrefs("c1"); p.Email != "family@example.hu" { t.Fatal("the seeded address must stay (no-clobber guard)") } } // C: the household saves on the box → applied and marked; a later default does not undo it. func TestR1199Reopened_C_AHouseholdSaveIsAppliedAndMarked(t *testing.T) { h, st := newEventTestHandler(t) st.SeedNotificationPrefs("c1", "family@example.hu", []string{"node_down"}) if rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","enabled_events":["backup_failed"],"cooldown_hours":6,"source":"household"}`); rr.Code != http.StatusOK { t.Fatalf("household save: %d", rr.Code) } if saved, ev := eventsOf(t, h, "ckey"); !saved || len(ev) != 1 { t.Fatalf("after the household's save: %t %v", saved, ev) } do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","enabled_events":["node_down","disk_warning"],"source":"default"}`) if _, ev := eventsOf(t, h, "ckey"); len(ev) != 1 || ev[0] != "backup_failed" { t.Fatalf("a default after the household's save changed it: %v", ev) } // An older controller's push WITH an address is the household's (only a saved list carries one). do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"new@example.hu","enabled_events":["node_down"]}`) if p, _ := st.GetNotificationPrefs("c1"); p.Email != "new@example.hu" || len(p.EnabledEvents) != 1 || p.EnabledEvents[0] != "node_down" { t.Fatalf("an older controller's address push must still apply: %+v", p) } } // The read is self-scoped: another household's key is refused; no row → exists false. func TestR1199Reopened_TheReadIsSelfScoped(t *testing.T) { h, st := newTwoCustomerHandler(t) if rr := do(h, http.MethodGet, "/preferences?customer_id=c1", "c2key", ""); rr.Code != http.StatusForbidden { t.Fatalf("c2 reading c1: %d, want 403", rr.Code) } if rr := do(h, http.MethodGet, "/preferences?customer_id=c1", "", ""); rr.Code != http.StatusUnauthorized { t.Fatalf("no key: %d, want 401", rr.Code) } rr := do(h, http.MethodGet, "/preferences?customer_id=c2", "c2key", "") if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"exists":false`) { t.Fatalf("c2 has no row: %d %s", rr.Code, rr.Body.String()) } _ = st }