Files
felhom-controller/controller/internal/web/r388_notif_groups_test.go
T
2026-10-12 17:25:12 +02:00

305 lines
11 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-388 slice 1 (`08-alarm-ladder.md` §8.1, `09` §3 decision 200) — the notification page shows three
// group switches; the per-detector switches and the cooldown live under a „Részletek" fold.
//
// THE LAYER: the REAL render (`settingsNotificationsPageHandler`) and the REAL save
// (`settingsNotificationsHandler`) over a REAL temp-file `Settings`. `renderedForm` posts back exactly
// what a browser would — every ticked checkbox, every hidden field, the address and the cooldown — so
// the round trips below exercise what the TEMPLATE renders, not what a test assumes it renders.
var r388InputRe = regexp.MustCompile(`<input ([^>]*)>`)
var r388AttrRe = regexp.MustCompile(`([a-z]+)(?:="([^"]*)")?`)
// renderPage renders the notifications page and returns its body.
func r388RenderPage(t *testing.T, s *Server) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/settings/notifications", nil)
rr := httptest.NewRecorder()
s.settingsNotificationsPageHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("render: HTTP %d", rr.Code)
}
return rr.Body.String()
}
// renderedForm returns what a browser submits for the notifications form when the household presses
// Save without touching anything.
func renderedForm(t *testing.T, body string) url.Values {
t.Helper()
start := strings.Index(body, `action="/settings/notifications"`)
end := strings.Index(body, `action="/settings/app-email"`)
if start < 0 {
t.Fatalf("the notifications form did not render — this test would then prove nothing")
}
if end < start {
end = len(body)
}
form := url.Values{}
for _, m := range r388InputRe.FindAllStringSubmatch(body[start:end], -1) {
attrs := map[string]string{}
for _, a := range r388AttrRe.FindAllStringSubmatch(m[1], -1) {
attrs[a[1]] = a[2]
}
if _, isChecked := attrs["checked"]; attrs["type"] == "checkbox" {
if isChecked {
form.Set(attrs["name"], "on")
}
continue
}
if attrs["name"] != "" {
form.Set(attrs["name"], attrs["value"])
}
}
return form
}
func r388Seed(t *testing.T, sett *settings.Settings, events []string) {
t.Helper()
if err := sett.SetNotificationPrefs(&settings.NotificationPrefs{
Email: "seed@felhom.eu", EnabledEvents: events, CooldownHours: 6,
}); err != nil {
t.Fatal(err)
}
}
func r388Save(t *testing.T, s *Server, form url.Values) {
t.Helper()
if rr := postNotifications(t, s, form); rr.Code >= 400 {
t.Fatalf("save: HTTP %d", rr.Code)
}
}
func sortedCopy(in []string) []string {
out := append([]string(nil), in...)
sort.Strings(out)
return out
}
// Every per-detector switch the page renders belongs to exactly one group, and every group member has
// a switch — a new detector that gets a checkbox but no group fails here (the invariant stated in
// settings/notif_groups.go).
func TestR388_EveryPageSwitchIsInExactlyOneGroup(t *testing.T) {
s, _ := notifyGuardServer(t)
body := r388RenderPage(t, s)
page := map[string]bool{}
for _, m := range regexp.MustCompile(`name="event_([a-z_]+)"`).FindAllStringSubmatch(body, -1) {
page[m[1]] = true
}
if len(page) < 18 {
t.Fatalf("only %d per-detector switches rendered, want ≥18 — the probe is not reading the page", len(page))
}
member := map[string]int{}
for _, g := range settings.NotificationGroups {
for _, e := range g.Events {
member[e]++
}
}
for e := range page {
if member[e] != 1 {
t.Errorf("switch event_%s is in %d groups, want exactly 1 (`08` §8.1 table)", e, member[e])
}
}
for e := range member {
if !page[e] {
t.Errorf("group member %s has no switch on the page", e)
}
}
}
// Each group, switched on from an empty list, writes exactly its detectors; switched off again (the
// details still render them ticked), it clears them — the group switch wins over the fold.
func TestR388_EachGroupRoundTrips(t *testing.T) {
for _, g := range settings.NotificationGroups {
t.Run(g.Key, func(t *testing.T) {
s, sett := notifyGuardServer(t)
r388Seed(t, sett, []string{})
form := renderedForm(t, r388RenderPage(t, s))
if got := form.Get("group_" + g.Key + "_shown"); got != settings.NotifGroupOff {
t.Fatalf("empty list rendered group %s as %q, want off", g.Key, got)
}
form.Set("group_"+g.Key, "on")
r388Save(t, s, form)
if got := sett.GetNotificationPrefs().EnabledEvents; !reflect.DeepEqual(sortedCopy(got), sortedCopy(g.Events)) {
t.Fatalf("group %s switched on stored %v, want exactly %v", g.Key, got, g.Events)
}
form = renderedForm(t, r388RenderPage(t, s))
if form.Get("group_"+g.Key) != "on" || form.Get("group_"+g.Key+"_shown") != settings.NotifGroupOn {
t.Fatalf("group %s did not render ON after it was saved on: %v", g.Key, form)
}
form.Del("group_" + g.Key) // the household unticks the group switch
r388Save(t, s, form)
if got := sett.GetNotificationPrefs().EnabledEvents; len(got) != 0 {
t.Fatalf("group %s switched off still stores %v — the ticked details overrode the group switch", g.Key, got)
}
})
}
}
// A list that mixes a group (some on, some off) renders that group as „egyedi", and saving the page
// untouched stores the list byte-for-byte as it was — no household's saved choice changes.
func TestR388_CustomGroupSavedBackUnchanged(t *testing.T) {
cases := map[string][]string{
"defaults": append([]string(nil), settings.DefaultEnabledEvents...),
"mixed": {"disk_warning", "app_update_held", "backup_failed", "health_recovered"},
}
for name, stored := range cases {
t.Run(name, func(t *testing.T) {
s, sett := notifyGuardServer(t)
r388Seed(t, sett, stored)
before := append([]string(nil), sett.GetNotificationPrefs().EnabledEvents...)
body := r388RenderPage(t, s)
form := renderedForm(t, body)
customs := 0
for _, g := range settings.NotificationGroups {
want := settings.NotificationGroupState(before, g.Key)
if got := form.Get("group_" + g.Key + "_shown"); got != want {
t.Errorf("group %s rendered %q, want %q", g.Key, got, want)
}
if want == settings.NotifGroupCustom {
customs++
if form.Get("group_"+g.Key) == "on" {
t.Errorf("custom group %s rendered with its switch ticked", g.Key)
}
}
}
if customs == 0 {
t.Fatalf("case %s has no custom group — it would prove nothing", name)
}
// ASCII fragment of „egyedi" in the badge (positive control); its absence on an all-on page is
// checked in TestR388_FullListRendersThreeGroupsOn (negative control).
if !strings.Contains(body, `<span class="badge badge-neutral">egyedi</span>`) {
t.Errorf("no „egyedi\" badge rendered for a custom group")
}
r388Save(t, s, form)
if after := sett.GetNotificationPrefs().EnabledEvents; !reflect.DeepEqual(before, after) {
t.Errorf("an untouched save CHANGED the stored list.\n before: %v\n after: %v", before, after)
}
})
}
}
// A custom group switched ON turns on all of its detectors and leaves the other groups as they were.
func TestR388_CustomGroupSwitchedOnTakesAll(t *testing.T) {
s, sett := notifyGuardServer(t)
r388Seed(t, sett, []string{"disk_warning", "backup_failed"})
form := renderedForm(t, r388RenderPage(t, s))
form.Set("group_machine", "on")
r388Save(t, s, form)
machine, _ := settings.NotificationGroupByKey(settings.NotifGroupMachine)
want := sortedCopy(append(append([]string(nil), machine.Events...), "backup_failed"))
if got := sortedCopy(sett.GetNotificationPrefs().EnabledEvents); !reflect.DeepEqual(got, want) {
t.Fatalf("stored %v, want %v", got, want)
}
}
// Every detector on → three groups rendered on, no „egyedi" badge.
func TestR388_FullListRendersThreeGroupsOn(t *testing.T) {
s, sett := notifyGuardServer(t)
var all []string
for _, g := range settings.NotificationGroups {
all = append(all, g.Events...)
}
r388Seed(t, sett, all)
body := r388RenderPage(t, s)
form := renderedForm(t, body)
for _, g := range settings.NotificationGroups {
if form.Get("group_"+g.Key) != "on" || form.Get("group_"+g.Key+"_shown") != settings.NotifGroupOn {
t.Errorf("group %s not rendered on for the full list", g.Key)
}
}
if strings.Contains(body, "egyedi") {
t.Errorf("an all-on page shows an „egyedi\" badge")
}
}
// Today's DefaultEnabledEvents, read as groups. §8.1 and the operator's Q1 want all three ON for a new
// household; today NONE is fully on (each misses a member: storage_reconnected; app_start_failed, OFF
// by the R-329 ruling; backup_integrity_failed, crossdrive_failed, health_recovered). This slice does
// NOT change the default (the lead decides). The test pins the fact so a change to either side is seen;
// when the default is made to cover the groups, flip `want` to on.
func TestR388_DefaultListAsGroups(t *testing.T) {
// Operator Q1 (2026-10-12 evening, „as picked"): group 3 „Amit mi intézünk" is ON by default, and the
// default list completes groups 1 and 3. Group 2 stays custom: app_start_failed is OFF by the R-329
// ruling (2026-08-23). RED-PROOF: the pre-Q1 list renders all three as custom.
want := map[string]string{
settings.NotifGroupMachine: settings.NotifGroupOn,
settings.NotifGroupApps: settings.NotifGroupCustom,
settings.NotifGroupHandled: settings.NotifGroupOn,
}
for _, g := range settings.NotificationGroups {
if got := settings.NotificationGroupState(settings.DefaultEnabledEvents, g.Key); got != want[g.Key] {
t.Errorf("DefaultEnabledEvents renders group %s as %q, this test pins %q", g.Key, got, want[g.Key])
}
}
// A fresh box (no stored prefs) renders through the same path.
s, _ := notifyGuardServer(t)
form := renderedForm(t, r388RenderPage(t, s))
for _, g := range settings.NotificationGroups {
if got := form.Get("group_" + g.Key + "_shown"); got != want[g.Key] {
t.Errorf("a fresh box renders group %s as %q, want %q", g.Key, got, want[g.Key])
}
}
}
// The details fold still saves one switch on its own: all groups on, one detector unticked under
// „Részletek" → exactly that detector is gone and its group becomes custom.
func TestR388_DetailsFoldSavesAnIndividualSwitch(t *testing.T) {
s, sett := notifyGuardServer(t)
var all []string
for _, g := range settings.NotificationGroups {
all = append(all, g.Events...)
}
r388Seed(t, sett, all)
body := r388RenderPage(t, s)
open := strings.Index(body, `<details class="notif-details"`)
if open < 0 {
t.Fatalf("no „Részletek\" fold on the page")
}
fold := body[open:]
fold = fold[:strings.Index(fold, `</details>`)]
for _, needle := range []string{`name="event_disk_warning"`, `name="cooldown_hours"`} {
if !strings.Contains(fold, needle) {
t.Errorf("%s is not inside the „Részletek\" fold", needle)
}
}
form := renderedForm(t, body)
form.Del("event_disk_warning")
form.Set("cooldown_hours", "12")
r388Save(t, s, form)
got := sett.GetNotificationPrefs()
var want []string
for _, e := range all {
if e != "disk_warning" {
want = append(want, e)
}
}
if !reflect.DeepEqual(sortedCopy(got.EnabledEvents), sortedCopy(want)) {
t.Fatalf("stored %v, want all but disk_warning", got.EnabledEvents)
}
if got.CooldownHours != 12 {
t.Errorf("cooldown under the fold not saved: %d", got.CooldownHours)
}
if st := got.GroupState(settings.NotifGroupMachine); st != settings.NotifGroupCustom {
t.Errorf("machine group after one detector off = %q, want custom", st)
}
}