package notify import ( "bytes" "log" "strings" "sync" "testing" ) // R-329 — `app_start_failed` was emitted with severity "warn", which the hub coerces to "info" and // then drops. These tests pin the ROUTING the fixed severity produces, from the dispatcher's side. // // THE LAYER. The emitter's word is pinned in the controller (AST walk). This pins the CONSEQUENCE at // the hub: with the correct severity the OPERATOR is emailed and the CUSTOMER is not, unless the // customer opted in. Asserting only the controller's string would be case #9's mistake — mechanism // pinned, consequence unpinned — and this is the half a customer actually experiences. // // RED-PROOF (observed, see REPORT.md): pass "warn" instead of "warning" in Scenario A and it fails // with `operator was NOT emailed` — the exact live defect, reproduced in a unit test. // SCENARIO A — an app goes down and the customer has NOT opted in. // The operator must be emailed; the customer must not. func TestR329_ScenarioA_OperatorMailedCustomerNot(t *testing.T) { st := opOnlyStore(t) // A customer with an address and the DEFAULT set — app_start_failed deliberately absent. if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"backup_failed", "disk_warning"}, 6); err != nil { t.Fatal(err) } rec := &sentTo{} d := opOnlyDispatcher(t, st, rec) d.ProcessEvent("c1", "app_start_failed", "warning", "Telepített alkalmazás nem fut: BookStack", `{"stack_name":"bookstack"}`, "controller") var gotOperator, gotCustomer bool for _, to := range rec.to { switch to { case "operator@felhom.eu": gotOperator = true case "customer@example.com": gotCustomer = true } } if !gotOperator { t.Errorf("operator was NOT emailed for app_start_failed (severity \"warning\") — this is the "+ "R-329 defect: a severity outside {info,warning,error,critical} is coerced to \"info\" "+ "and dropped by severityNotifies, reaching nobody. sent=%v", rec.to) } if gotCustomer { t.Errorf("the CUSTOMER was emailed although app_start_failed is not in their enabled events "+ "— the operator ruled this OFF by default. sent=%v", rec.to) } } // SCENARIO B — the customer HAS opted in. Both legs deliver, and the customer's copy must carry the // hub's HUNGARIAN template, not raw English. That is the v0.78.0 defect the registers exist to stop. // // This is also the POSITIVE CONTROL for Scenario A's absence claim: it proves the customer leg can // deliver at all for this event type, so "the customer was not emailed" in A means the gate held, // not that the path is broken. func TestR329_ScenarioB_OptedInCustomerGetsTheHungarianMessage(t *testing.T) { st := opOnlyStore(t) if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"backup_failed", "app_start_failed"}, 6); err != nil { t.Fatal(err) } type mail struct{ to, subject, body string } var sent []mail d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, discardLogger()) d.sendEmailFn = func(to, subject, body string, _ map[string]string) error { sent = append(sent, mail{to, subject, body}) return nil } d.ProcessEvent("c1", "app_start_failed", "warning", "Telepített alkalmazás nem fut: BookStack", `{"stack_name":"bookstack"}`, "controller") var customer *mail var gotOperator bool for i := range sent { if sent[i].to == "customer@example.com" { customer = &sent[i] } if sent[i].to == "operator@felhom.eu" { gotOperator = true } } if !gotOperator { t.Errorf("operator not emailed when the customer opted in — both legs must deliver. sent=%v", sent) } if customer == nil { t.Fatalf("the customer opted in and was NOT emailed — the toggle would be a lie. sent=%v", sent) } // The Hungarian template, not the raw English/controller string. want := customerMessages["app_start_failed"] if want == "" { t.Fatal("app_start_failed has no customerMessages entry — a customer-switchable event with " + "no Hungarian copy sends a household raw internal text (the v0.78.0 defect)") } if !strings.Contains(customer.body+customer.subject, want) { t.Errorf("the customer's mail does not carry the Hungarian template %q.\n subject: %q\n body: %q", want, customer.subject, customer.body) } } // The two registers must stay as they are: app_start_failed is customer-reachable BY CONFIGURATION, // which is precisely what makes the toggle honest. func TestR329_AppStartFailedIsNotOperatorOnly(t *testing.T) { if operatorOnlyEvents["app_start_failed"] { t.Fatal("app_start_failed is in operatorOnlyEvents — the customer toggle added in controller " + "v0.223.0 would then be visible, switchable and STRUCTURALLY INCAPABLE of delivering. " + "A toggle that cannot do what it says is worse than no toggle.") } if customerMessages["app_start_failed"] == "" { t.Fatal("app_start_failed has no customerMessages entry — see above") } } // SCENARIO H's dispatcher half: an unrecognized severity must be LOGGED, not silently dropped. This // branch is reachable from the hub's own monitor checkers, which call ProcessEvent directly and never // pass the API handler's coercion — which is why it was KEPT rather than deleted as dead. func TestR329_UnrecognizedSeverityIsLoggedNotSilent(t *testing.T) { st := opOnlyStore(t) if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"backup_failed"}, 6); err != nil { t.Fatal(err) } buf := &lineBuf{} d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, buf.logger()) d.sendEmailFn = func(to, _, _ string, _ map[string]string) error { t.Errorf("an unrecognized severity was ROUTED to %s — it must not be", to) return nil } // The hub-internal path: straight into ProcessEvent, bypassing the handler. d.ProcessEvent("c1", "backup_failed", "warn", "msg", "{}", "hub") out := buf.String() if !strings.Contains(out, "unrecognized severity") { t.Errorf("a bad severity from a hub-internal producer vanished without a word: %q", out) } if !strings.Contains(out, `"warn"`) { t.Errorf("the log line does not name the offending value, so nobody can fix it: %q", out) } } // lineBuf is a tiny concurrency-safe log sink — the dispatcher logs from the calling goroutine here, // but ProcessEvent is documented as goroutine-safe and the hub calls it with `go`. type lineBuf struct { mu sync.Mutex buf bytes.Buffer } func (b *lineBuf) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *lineBuf) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } func (b *lineBuf) logger() *log.Logger { return log.New(b, "", 0) }