v0.309.0: the notification page shows three group switches; details and cooldown under Részletek (R-388 slice 1, decision 200)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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) {
|
||||
want := map[string]string{
|
||||
settings.NotifGroupMachine: settings.NotifGroupCustom,
|
||||
settings.NotifGroupApps: settings.NotifGroupCustom,
|
||||
settings.NotifGroupHandled: settings.NotifGroupCustom,
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user