package api import ( "net/http" "testing" ) // TestSavePreferences_EmptyEmailCannotClobber (v0.71.0 F12, Scenario E): a controller push with an // empty email must preserve a stored non-empty address while still applying events + cooldown. // Companion red-proof: removing the guard in handleSavePreferences makes this fail. func TestSavePreferences_EmptyEmailCannotClobber(t *testing.T) { h, st := newEventTestHandler(t) if err := st.SaveNotificationPrefs("c1", "seeded@example.hu", []string{"node_down", "backup_failed"}, 6); err != nil { t.Fatalf("stored prefs: %v", err) } rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","enabled_events":["node_down"],"cooldown_hours":12}`) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) } prefs, err := st.GetNotificationPrefs("c1") if err != nil || prefs == nil { t.Fatalf("prefs: %+v err=%v", prefs, err) } if prefs.Email != "seeded@example.hu" { t.Fatalf("empty-email push CLOBBERED the stored address: email=%q", prefs.Email) } if len(prefs.EnabledEvents) != 1 || prefs.EnabledEvents[0] != "node_down" || prefs.CooldownHours != 12 { t.Fatalf("events/cooldown from the push must still apply: %+v", prefs) } } // TestSavePreferences_NonEmptyEmailStillUpdates: a push with a real email updates everything — // customer edits keep working (the guard must not freeze the address forever). func TestSavePreferences_NonEmptyEmailStillUpdates(t *testing.T) { h, st := newEventTestHandler(t) if err := st.SaveNotificationPrefs("c1", "old@example.hu", []string{"node_down"}, 6); err != nil { t.Fatalf("stored prefs: %v", err) } rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"new@example.hu","enabled_events":["backup_failed"],"cooldown_hours":3}`) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) } prefs, _ := st.GetNotificationPrefs("c1") if prefs.Email != "new@example.hu" || len(prefs.EnabledEvents) != 1 || prefs.EnabledEvents[0] != "backup_failed" || prefs.CooldownHours != 3 { t.Fatalf("non-empty push must update everything: %+v", prefs) } } // TestSavePreferences_EmptyEmailNoStoredRow: an empty-email push with no stored row behaves as // before (row created with empty email — the all-off case stays legitimate). func TestSavePreferences_EmptyEmailNoStoredRow(t *testing.T) { h, st := newEventTestHandler(t) rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","enabled_events":[],"cooldown_hours":6}`) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) } prefs, _ := st.GetNotificationPrefs("c1") if prefs == nil || prefs.Email != "" { t.Fatalf("all-off push must store the empty row unchanged, got %+v", prefs) } } // TestSavePreferences_DeliberateClearDeletesAddress (R-922, operator ruling 2026-10-09 option A): when the household // clears its address on the dashboard, the controller pushes `email_cleared: true` with an empty address, and the // hub DELETES the stored address. Seen on Tester 1 before the fix: the push answered 200 and the old address stayed. // Red-proof: with the `payload.EmailCleared` branch removed, this fails with email="seeded@example.hu". func TestSavePreferences_DeliberateClearDeletesAddress(t *testing.T) { h, st := newEventTestHandler(t) if err := st.SaveNotificationPrefs("c1", "seeded@example.hu", []string{"node_down"}, 6); err != nil { t.Fatalf("stored prefs: %v", err) } rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","email_cleared":true,"enabled_events":[],"cooldown_hours":6}`) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) } prefs, err := st.GetNotificationPrefs("c1") if err != nil || prefs == nil { t.Fatalf("prefs: %+v err=%v", prefs, err) } if prefs.Email != "" { t.Fatalf("a deliberate clear must delete the stored address, still stored: email=%q", prefs.Email) } } // TestSavePreferences_ClearFlagWithAddressIsAnUpdate (R-922): `email_cleared: true` beside a NON-empty address is an // ordinary update — the address in the push wins, it is never deleted. func TestSavePreferences_ClearFlagWithAddressIsAnUpdate(t *testing.T) { h, st := newEventTestHandler(t) if err := st.SaveNotificationPrefs("c1", "old@example.hu", []string{"node_down"}, 6); err != nil { t.Fatalf("stored prefs: %v", err) } rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"new@example.hu","email_cleared":true,"enabled_events":["node_down"],"cooldown_hours":6}`) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) } prefs, _ := st.GetNotificationPrefs("c1") if prefs == nil || prefs.Email != "new@example.hu" { t.Fatalf("flag + non-empty address must store the pushed address, got %+v", prefs) } } // TestSavePreferences_ClearFlagFalseKeepsAddress (R-922): an explicit `email_cleared: false` with an empty address is // NOT a clear — the F12 no-clobber guard still holds. func TestSavePreferences_ClearFlagFalseKeepsAddress(t *testing.T) { h, st := newEventTestHandler(t) if err := st.SaveNotificationPrefs("c1", "seeded@example.hu", []string{"node_down"}, 6); err != nil { t.Fatalf("stored prefs: %v", err) } rr := do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"","email_cleared":false,"enabled_events":["node_down"],"cooldown_hours":6}`) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) } prefs, _ := st.GetNotificationPrefs("c1") if prefs == nil || prefs.Email != "seeded@example.hu" { t.Fatalf("email_cleared:false with an empty address must keep the stored address, got %+v", prefs) } }