Files
felhom.eu/hub/internal/notify/app_repaired_after_crash_test.go
T
admin 822d039344
gates / gates (push) Successful in 6m40s
hub v0.150.0: app_repaired_after_crash — allow-listed + operator-only, counts line in the operator mail (R-1007); decision 236 recorded
The operator's half of the repair after an unclean boot (controller 0.311.0): counts only, never a
file name; warning mails, info is recorded. Registered in allowedEventTypes and operatorOnlyEvents in
the same commit, plus the per-app cooldown list; tests pin the pair, a household that enabled the type
gets nothing, and the counts line (red-proofed). Decision 236 (operator, 2026-10-14): the box asks
Immich through Immich's own job queue. R-1007 updated; the scripts' run on 9202 added as evidence.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 16:58:35 +02:00

50 lines
2.5 KiB
Go

package notify
import (
"io"
"log"
"strings"
"testing"
)
// R-1007 (v0.150.0, controller v0.311.0, `09` §3 decision 236) — app_repaired_after_crash reaches the OPERATOR and
// never the household, even a household that enabled it by name; warning mails, info is recorded only; the mail
// carries the counts as one line.
// RED-PROOF: drop it from operatorOnlyEvents → the customer gets a mail → fails.
func TestAppRepairedAfterCrash_OperatorOnlyEvenWhenTheHouseholdEnabledIt(t *testing.T) {
st := newDispStore(t)
if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"app_repaired_after_crash", "node_down"}, 6); err != nil {
t.Fatal(err)
}
d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
sent := captureSeam(d)
details := `{"app":"Immich","stack_name":"immich","boot_at":"2026-10-14T12:07:26Z","found":81,"repaired":81,"still_broken":0,"lost":1,"lost_checked":true,"duration_s":4}`
d.ProcessEvent("c1", "app_repaired_after_crash", "warning", "Immich: ...", details, "controller")
if got := mailsFor(*sent, "cust@example.com"); len(got) != 0 {
t.Fatalf("the household got %d mail(s) — the counts are the operator's; its side is the app page's note", len(got))
}
if got := len(mailsFor(*sent, "op@felhom.eu")); got != 1 {
t.Fatalf("operator mails = %d, want 1", got)
}
// info (everything repaired): recorded, not mailed.
*sent = nil
d.ProcessEvent("c1", "app_repaired_after_crash", "info", "Immich: ...", strings.Replace(details, `"lost":1`, `"lost":0`, 1), "controller")
if got := len(*sent); got != 0 {
t.Fatalf("an all-repaired (info) run mailed %d time(s)", got)
}
}
// RED-PROOF: drop the renderAppRepair call → the body lacks the counts line → fails.
func TestAppRepairedAfterCrash_OperatorMailHasTheCountsLine(t *testing.T) {
_, body := FormatOperatorEmail("c1", "app_repaired_after_crash", "warning", "msg",
`{"app":"Immich","boot_at":"2026-10-14T12:07:26Z","found":81,"repaired":80,"still_broken":1,"lost":1,"lost_checked":true,"duration_s":4}`)
want := "Repair: Immich — after the unclean boot at 2026-10-14T12:07:26Z: 81 empty file(s) found, 80 repaired, 1 still broken; originals lost: 1; 4 s"
if !strings.Contains(body, want) {
t.Fatalf("body lacks %q:\n%s", want, body)
}
_, skipped := FormatOperatorEmail("c1", "app_repaired_after_crash", "warning", "msg", `{"app":"Immich","boot_at":"b","skipped":"not proven"}`)
if !strings.Contains(skipped, "SKIPPED after the unclean boot at b: not proven") {
t.Fatalf("skipped body:\n%s", skipped)
}
}