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(`]*)>`) 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, `egyedi`) { 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, `
`)] 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) } }