822d039344
gates / gates (push) Successful in 6m40s
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
50 lines
2.5 KiB
Go
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)
|
|
}
|
|
}
|