f21e7caed1
gates / gates (push) Successful in 7s
Found by reading the first REAL digest, not by design. Every app row ended with the same usage clause the mail already prints once on its own Filesystem line. On a two-app box that is untidy; down a list of a dozen it is the same forty characters twelve times, pushing the part that DIFFERS off a phone screen at 07:00 — the only moment this mail has to work. The reserve's refusal message is authored for a single-app alert where naming the filesystem is right, so the message is unchanged; the digest trims the duplicate when rendering. trimRepeatedUsage removes ONLY an exact "— <target path>:" suffix, so an unrelated reason is untouched and a reason that is nothing but the usage clause is left alone rather than emptied. Also updates TestRecoveryUnitCaptureFailed_NeverReachesTheCustomer, which required the OPERATOR to be emailed a per-app capture failure. That was correct when the event was the only signal and is wrong now that it is the record and the digest is the notification. Its customer-safety claim is unchanged and is why the test still exists; the operator assertion is inverted with the reasoning written in place, and R-158's guarantee is shown to have MOVED, not weakened.
143 lines
6.6 KiB
Go
143 lines
6.6 KiB
Go
package notify
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func discardLogger() *log.Logger { return log.New(io.Discard, "", 0) }
|
|
|
|
// R-158 / R-167 Scenario G — the two new signals of decision D-c sit on OPPOSITE sides of the
|
|
// operator/customer line, and each is tested through the real dispatch path rather than by reading
|
|
// the register.
|
|
//
|
|
// D-c's rule, restated: a customer can free space, delete files or add a drive, so a FILL WARNING is
|
|
// theirs. A customer can do nothing about a recovery-unit capture failure, so it is not.
|
|
|
|
// The operator half. Run under the BREAKING configuration — the customer has the event explicitly
|
|
// enabled and has an email address — because that is the only configuration in which a missing
|
|
// operatorOnlyEvents entry is visible. This is the v0.78.0 defect, demonstrated rather than argued.
|
|
func TestRecoveryUnitCaptureFailed_NeverReachesTheCustomer(t *testing.T) {
|
|
st := opOnlyStore(t)
|
|
if err := st.SaveNotificationPrefs("c1", "customer@example.com",
|
|
[]string{"recovery_unit_capture_failed", "backup_failed"}, 6); err != nil {
|
|
t.Fatalf("SaveNotificationPrefs: %v", err)
|
|
}
|
|
|
|
rec := &sentTo{}
|
|
d := opOnlyDispatcher(t, st, rec)
|
|
d.ProcessEvent("c1", "recovery_unit_capture_failed", "error",
|
|
`Recovery unit capture FAILED for "immich" — the app has no fresh local (Tier-1) backup`,
|
|
`{"app":"immich","used_percent":100,"space_known":true}`, "controller")
|
|
|
|
for _, to := range rec.to {
|
|
if to == "customer@example.com" {
|
|
t.Fatalf("a customer was emailed the OPERATOR-ONLY recovery_unit_capture_failed (%s) — "+
|
|
"enabled_events must not be able to opt in to a failure they cannot act on", to)
|
|
}
|
|
}
|
|
|
|
// R-182 CHANGED WHAT THIS ASSERTS, DELIBERATELY, AND THE OLD ASSERTION IS WORTH KEEPING IN VIEW.
|
|
//
|
|
// Until 2026-08-03 this test required the OPERATOR to be e-mailed here, on the grounds that "the
|
|
// alert is the whole point of R-158". That was right when this event was the only signal, and it
|
|
// is wrong now: measured, nine of these arrived at the hub and two were mailed, because the
|
|
// operator cooldown key carries no app identifier — so as an alarm it told the operator about one
|
|
// app and threw the rest away.
|
|
//
|
|
// The type is now RECORD-ONLY: written down every time, never mailed. R-158's guarantee — the
|
|
// operator learns WHICH app failed and WHY — is not weakened, it MOVED: the per-run digest
|
|
// `backup_run_failures` carries every failed app in one mail, and is pinned by
|
|
// backup_run_digest_test.go. The customer safety claim below is untouched and is the reason this
|
|
// test still exists.
|
|
for _, to := range rec.to {
|
|
if to == "operator@felhom.eu" {
|
|
t.Fatal("the operator was e-mailed a PER-APP capture failure — this type is the record " +
|
|
"now, not the alarm. One mail per failing app on a full disk is a dozen mails, which " +
|
|
"is the volume problem the operator ruled against; the digest is the notification")
|
|
}
|
|
}
|
|
|
|
// The RECORD must exist, always. It is what makes the digest trustworthy: if the digest is ever
|
|
// lost, delayed or suppressed, the failures are still individually written down. An absent row is
|
|
// equally consistent with "correctly not mailed" and "the dispatcher never ran" — the positive
|
|
// observable is the row itself (standing rule 3).
|
|
logs, err := st.GetRecentNotifications("c1", 20)
|
|
if err != nil {
|
|
t.Fatalf("GetRecentNotifications: %v", err)
|
|
}
|
|
found := false
|
|
for _, l := range logs {
|
|
if l.EventType == "recovery_unit_capture_failed" && l.Status == "recorded" &&
|
|
strings.Contains(l.ErrorMessage, "record-only") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("the per-app failure left no 'recorded' row — a failure that is neither mailed nor "+
|
|
"written down is exactly the 2026-08-03 defect, rebuilt; got %d row(s)", len(logs))
|
|
}
|
|
}
|
|
|
|
// The customer half. The fill warning MUST be delivered, and it must render the controller's own
|
|
// Hungarian text — which names the drive and the free space — rather than a generic template or the
|
|
// raw English.
|
|
func TestDiskFillWarning_ReachesTheCustomerInHungarianWithItsNumbers(t *testing.T) {
|
|
st := opOnlyStore(t)
|
|
if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"disk_warning"}, 6); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The exact shape the controller sends from v0.191.0.
|
|
const hun = `A(z) „Fotók" tároló 87%-osan megtelt — 4,2 GB szabad hely maradt. ` +
|
|
`Szabadíts fel helyet (törölj felesleges fájlokat, vagy csatlakoztass új meghajtót), ` +
|
|
`különben a biztonsági mentések hamarosan meghiúsulnak.`
|
|
|
|
var bodies []string
|
|
d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, discardLogger())
|
|
d.sendEmailFn = func(to, subject, body string, headers map[string]string) error {
|
|
if to == "customer@example.com" {
|
|
bodies = append(bodies, subject+"\n"+body)
|
|
}
|
|
return nil
|
|
}
|
|
d.ProcessEvent("c1", "disk_warning", "warning", hun,
|
|
`{"label":"Fotók","used_percent":87,"avail_gb":4.2}`, "controller")
|
|
|
|
if len(bodies) == 0 {
|
|
t.Fatal("the customer was NOT warned that a disk is filling — this is the customer half of " +
|
|
"decision D-c, and nothing reached them")
|
|
}
|
|
got := strings.Join(bodies, "\n")
|
|
|
|
// The controller's dynamic text must survive. A customerMessages entry for disk_warning would
|
|
// OVERRIDE it (templates.go prefers the entry) and discard the drive name and the free space,
|
|
// leaving the customer with "A lemezterület 90% felett van" and nothing to act on — which is
|
|
// exactly why v0.191.0 removes those two entries.
|
|
if !strings.Contains(got, "Fotók") {
|
|
t.Fatalf("the customer email does not name the drive — a generic customerMessages entry has "+
|
|
"discarded the controller's dynamic text (templates.go priority). Got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "4,2 GB") {
|
|
t.Fatalf("the customer email does not carry the free-space figure. Got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "Szabadíts fel helyet") {
|
|
t.Fatalf("the customer email does not tell the customer what to DO. Got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// The generic entries must STAY REMOVED. A well-meaning re-add would silently re-break the test
|
|
// above's guarantee for every future reader — this pins the deletion itself.
|
|
func TestDiskFillTypesHaveNoGenericCustomerMessage(t *testing.T) {
|
|
for _, et := range []string{"disk_warning", "disk_critical"} {
|
|
if msg, ok := customerMessages[et]; ok {
|
|
t.Fatalf("customerMessages[%q] = %q — a static entry OVERRIDES the controller's dynamic "+
|
|
"Hungarian text (FormatCustomerEmail prefers the entry), discarding the drive name "+
|
|
"and the free-space figure the customer needs. Same reason offbox_enlarge_blocked "+
|
|
"and disk_health_degraded deliberately have none", et, msg)
|
|
}
|
|
}
|
|
}
|