package web import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // ── R-397 / R-400 — the button that posted to nothing ──────────────────────────────────────────── // // `debug.html:83` has carried a „Restic integritás" button posting to /api/debug/backup/integrity // since it was added. Verified 2026-08-30: `handleDebugAPI` had no such case, so pressing it did // nothing at all — no error, no result, no log line. Seventh instance of built-but-never-wired in this // project, and filed as R-400 in its own right rather than disappearing inside this change. func newDebugServer(t *testing.T, cb *DebugCallbacks) *Server { t.Helper() cfg := &config.Config{} cfg.Paths.DataDir = t.TempDir() return &Server{cfg: cfg, logger: log.New(io.Discard, "", 0), debugCallbacks: cb} } // postIntegrity returns the response, the ENVELOPE, and the nested `data` payload. The two are // separate on purpose: `ok` at the envelope level means "the request was handled", while the payload's // own `ok` means "the store passed". Conflating them is how an unreachable store would read as a // failed check. func postIntegrity(t *testing.T, s *Server) (*httptest.ResponseRecorder, map[string]interface{}, map[string]interface{}) { t.Helper() req := httptest.NewRequest(http.MethodPost, "/api/debug/backup/integrity", nil) w := httptest.NewRecorder() s.handleDebugAPI(w, req) var env map[string]interface{} _ = json.Unmarshal(w.Body.Bytes(), &env) data, _ := env["data"].(map[string]interface{}) if data == nil { data = map[string]interface{}{} } return w, env, data } func TestR397_DebugRouteExists(t *testing.T) { var called bool s := newDebugServer(t, &DebugCallbacks{ RunIntegrityCheck: func(force bool) backup.IntegrityResult { called = true if !force { t.Error("the hand-run did not force — a button that respects due-ness does nothing on " + "six days out of seven, which is indistinguishable from the button being dead") } return backup.IntegrityResult{OK: true} }, }) w, env, _ := postIntegrity(t, s) if !called { t.Fatal("POST /api/debug/backup/integrity was not dispatched — this is the defect: the button " + "has posted here since it was added and nothing answered") } if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } if ok, _ := env["ok"].(bool); !ok { t.Errorf("a passing check did not report success: %v", env) } } func TestR397_DebugRunStillHonoursTheRunningFlag(t *testing.T) { // THE POINT. "The operator asked for it" is precisely the reasoning that would bypass the // single-writer flag and let the check meet a live prune's lock. force skips due-ness and NOTHING // else; the skip must still surface as a skip. s := newDebugServer(t, &DebugCallbacks{ RunIntegrityCheck: func(bool) backup.IntegrityResult { return backup.IntegrityResult{Skipped: true, SkipReason: "a backup or restore is already running"} }, }) _, env, data := postIntegrity(t, s) msg, _ := env["message"].(string) if !strings.Contains(msg, "Kihagyva") { t.Fatalf("a hand-run during a live operation did not report a skip: %v", env) } if skipped, _ := data["skipped"].(bool); !skipped { t.Errorf("the payload did not carry skipped=true: %v", data) } if ok, _ := data["ok"].(bool); ok { t.Error("a skip was reported as a passing CHECK — nothing was checked") } } func TestR397_DebugRunSeparatesUnreachableFromDamage(t *testing.T) { // "I could not look" and "I looked and it is broken" must not read the same on the operator's // screen either — the same rule the notifier path obeys. s := newDebugServer(t, &DebugCallbacks{ RunIntegrityCheck: func(bool) backup.IntegrityResult { return backup.IntegrityResult{Unreachable: true} }, }) _, env, data := postIntegrity(t, s) // The REQUEST succeeded (we handled it) but the CHECK did not pass — and must not read as damage. if ok, _ := env["ok"].(bool); !ok { t.Error("an unreachable store was reported as a FAILED check — nothing was looked at, and " + "telling an operator their store is damaged when it was merely unreachable is the alarm " + "that trains them to ignore the real one") } if unreach, _ := data["unreachable"].(bool); !unreach { t.Errorf("the payload did not distinguish unreachable: %v", data) } if ok, _ := data["ok"].(bool); ok { t.Error("an unreachable store reported a passing check") } } func TestR397_DebugRouteUnwiredSaysSo(t *testing.T) { // The nil-callback case must be loud, not silently successful — that is the failure shape the // button itself had. s := newDebugServer(t, &DebugCallbacks{}) w, _, _ := postIntegrity(t, s) if w.Code != http.StatusNotImplemented { t.Fatalf("an unwired callback returned %d, want 501", w.Code) } } // TestR359_MonitoringPageNoLongerPromisesASundayJob — §9 rule 9: the source line is ASCII (no // accents), so this asserts on ASCII fragments and carries both controls. func TestR359_MonitoringPageNoLongerPromisesASundayJob(t *testing.T) { const src = "controller/internal/web/handlers.go" _ = src // POSITIVE CONTROL: the label the row is about is still present in the code under test. if !strings.Contains(monitoringIntegritySchedule, "Hetente") { t.Fatalf("positive control failed — the schedule string does not mention a weekly cadence: %q", monitoringIntegritySchedule) } // NEGATIVE CONTROL + the assertion: it no longer names a weekday, because the job is due-ness based // and a box that was off on Sunday is checked on Monday. if strings.Contains(strings.ToLower(monitoringIntegritySchedule), "vasarnap") { t.Fatalf("the monitoring page still promises a Sunday job, which is not what was built: %q", monitoringIntegritySchedule) } }