package notify import ( "io" "log" "strings" "sync" "testing" ) // capturedMail is one seam-captured send: recipient, subject and headers. type capturedMail struct { to string subject string headers map[string]string } // captureSeam installs a capturing sendEmailFn and returns the capture slice pointer. func captureSeam(d *Dispatcher) *[]capturedMail { var mu sync.Mutex sent := &[]capturedMail{} d.sendEmailFn = func(to, subject, _ string, headers map[string]string) error { mu.Lock() defer mu.Unlock() *sent = append(*sent, capturedMail{to: to, subject: subject, headers: headers}) return nil } return sent } func mailsFor(sent []capturedMail, to string) []capturedMail { var out []capturedMail for _, m := range sent { if m.to == to { out = append(out, m) } } return out } // TestRecovery_PairedCustomerMail (Scenario A — the doodoo21 case, fixed): a customer who was // mailed node_down gets the node_recovered mail; the operator gets the ✅ mail; both are logged. func TestRecovery_PairedCustomerMail(t *testing.T) { st := newDispStore(t) if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"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) // The down edge: customer (enabled) + operator both mailed. d.ProcessEvent("c1", "node_down", "error", "No report received for 1h", "{}", "hub") if len(*sent) != 2 { t.Fatalf("down edge must mail operator + customer, got %d sends", len(*sent)) } // The recovery edge. d.ProcessEvent("c1", "node_recovered", "info", "Reports resumed (was down for 3h48m)", "{}", "hub") cust := mailsFor(*sent, "cust@example.com") if len(cust) != 2 { t.Fatalf("customer must get down + recovery, got %d customer mails", len(cust)) } recMail := cust[1] if !strings.Contains(recMail.subject, "Információ: A szerver újra elérhető.") { t.Fatalf("customer recovery subject wrong: %q", recMail.subject) } op := mailsFor(*sent, "op@felhom.eu") if len(op) != 2 { t.Fatalf("operator must get down + recovery, got %d", len(op)) } if !strings.Contains(op[1].subject, "✅") || !strings.Contains(op[1].subject, "node_recovered") { t.Fatalf("operator recovery subject must carry ✅ + node_recovered: %q", op[1].subject) } // notification_log holds sent rows on both channels for node_recovered. entries, err := st.GetRecentNotifications("c1", 10) if err != nil { t.Fatal(err) } var gotCust, gotOp bool for _, e := range entries { if e.EventType == "node_recovered" && e.Status == "sent" { switch e.Channel { case "customer": gotCust = true case "operator": gotOp = true } } } if !gotCust || !gotOp { t.Fatalf("node_recovered sent rows missing (customer=%v operator=%v)", gotCust, gotOp) } } // TestRecovery_UnpairedStaysCustomerSilent (Scenario B): no customer-channel down evidence → the // operator is mailed, the customer is NOT — even with node_recovered in enabled_events (the // pairing rule is THE customer gate; enabled_events is deliberately ignored for recovery). // Companion red-proof: removing the pairing check in processRecovery makes this fail. func TestRecovery_UnpairedStaysCustomerSilent(t *testing.T) { st := newDispStore(t) if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down", "node_recovered"}, 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) d.ProcessEvent("c1", "node_recovered", "info", "Reports resumed", "{}", "hub") if n := len(mailsFor(*sent, "cust@example.com")); n != 0 { t.Fatalf("unpaired recovery must NOT mail the customer, got %d", n) } if n := len(mailsFor(*sent, "op@felhom.eu")); n != 1 { t.Fatalf("operator must still get the recovery mail, got %d", n) } } // TestRecovery_FlapDamping (Scenario C): down mailed → recovery mailed → second down SUPPRESSED by // the customer cooldown → second recovery must NOT mail the customer (the suppressed down left no // fresh pairing evidence), and the operator recovery mail obeys the 1h per-type cooldown. func TestRecovery_FlapDamping(t *testing.T) { st := newDispStore(t) if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"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) d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub") // down #1: cust+op mailed d.ProcessEvent("c1", "node_recovered", "info", "up", "{}", "hub") // recovery #1: cust+op mailed d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub") // down #2 10min later: customer // cooldown suppresses (op too, 1h) // Clear the recovery CUSTOMER cooldown so the pairing gate — not the cooldown — decides #2: d.mu.Lock() delete(d.custCooldowns, "c1:node_recovered") d.mu.Unlock() d.ProcessEvent("c1", "node_recovered", "info", "up", "{}", "hub") // recovery #2 cust := mailsFor(*sent, "cust@example.com") if len(cust) != 2 { // down #1 + recovery #1 only t.Fatalf("second recovery must not mail the customer (no fresh pairing), customer mails=%d: %+v", len(cust), cust) } // Operator: down #1 + recovery #1; down #2 and recovery #2 suppressed by the 1h op cooldown. if op := mailsFor(*sent, "op@felhom.eu"); len(op) != 2 { t.Fatalf("operator recovery must obey the 1h cooldown, op mails=%d", len(op)) } } // TestTestEvent_BothChannels (Scenario F): a "test" event mails the customer (existing copy) AND // the operator, the operator mail carrying both priority headers; both logged. func TestTestEvent_BothChannels(t *testing.T) { st := newDispStore(t) if err := st.SaveNotificationPrefs("c1", "cust@example.com", nil, 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) d.ProcessEvent("c1", "test", "info", "", "{}", "hub") cust := mailsFor(*sent, "cust@example.com") if len(cust) != 1 || !strings.Contains(cust[0].subject, "Teszt értesítés") { t.Fatalf("customer test mail wrong: %+v", cust) } op := mailsFor(*sent, "op@felhom.eu") if len(op) != 1 || !strings.Contains(op[0].subject, "✅") || !strings.Contains(op[0].subject, "operator channel OK") { t.Fatalf("operator test mail wrong: %+v", op) } if op[0].headers["X-Priority"] != "1" || op[0].headers["Importance"] != "high" { t.Fatalf("operator test mail must carry priority headers, got %v", op[0].headers) } entries, _ := st.GetRecentNotifications("c1", 10) var chans []string for _, e := range entries { if e.EventType == "test" && e.Status == "sent" { chans = append(chans, e.Channel) } } if len(chans) != 2 { t.Fatalf("both test sends must be logged, got channels %v", chans) } } // TestTestEvent_NoPrefsRow_NoPanic: a test event for a customer WITHOUT a notification row must // not panic (the nil-prefs deref found in v0.71.0 — GetNotificationPrefs returns (nil, nil)) and // the operator copy still proves the operator channel. func TestTestEvent_NoPrefsRow_NoPanic(t *testing.T) { st := newDispStore(t) d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) sent := captureSeam(d) d.ProcessEvent("c1", "test", "info", "", "{}", "hub") // must not panic if n := len(mailsFor(*sent, "op@felhom.eu")); n != 1 { t.Fatalf("operator test copy must send even without customer prefs, got %d", n) } if n := len(*sent); n != 1 { t.Fatalf("no customer mail possible without prefs, total sends=%d", n) } } // TestPriorityHeaders (Scenario G): error/critical carry the two headers; warning/info/unknown nil. // The negative half is the red-proof target: adding headers unconditionally fails the nil cases. func TestPriorityHeaders(t *testing.T) { for _, sev := range []string{"error", "critical"} { h := priorityHeaders(sev) if h["X-Priority"] != "1" || h["Importance"] != "high" || len(h) != 2 { t.Errorf("%s: headers = %v", sev, h) } } for _, sev := range []string{"warning", "info", "", "frobnicate"} { if h := priorityHeaders(sev); h != nil { t.Errorf("%s must have NO priority headers, got %v", sev, h) } } } // TestPriorityHeaders_EndToEnd (Scenario G, seam level): an error mail carries headers on both // channels; a warning mail carries none. func TestPriorityHeaders_EndToEnd(t *testing.T) { st := newDispStore(t) if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"node_down", "node_stale"}, 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) d.ProcessEvent("c1", "node_down", "error", "down", "{}", "hub") for _, m := range *sent { if m.headers["X-Priority"] != "1" || m.headers["Importance"] != "high" { t.Fatalf("error mail to %s must carry priority headers, got %v", m.to, m.headers) } } *sent = (*sent)[:0] d.ProcessEvent("c1", "node_stale", "warning", "stale", "{}", "hub") if len(*sent) == 0 { t.Fatal("warning must still send") } for _, m := range *sent { if m.headers != nil { t.Fatalf("warning mail to %s must carry NO headers, got %v", m.to, m.headers) } } } // TestRecovery_SeverityStaysInfo guards the frozen semantics: the recovery branch must not have // widened severityNotifies — a non-recovery info event is still silent. func TestRecovery_SeverityStaysInfo(t *testing.T) { if severityNotifies("info") { t.Fatal("info must remain non-notify — recovery is an eventType branch, not a severity change") } st := newDispStore(t) if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"controller_started"}, 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) d.ProcessEvent("c1", "controller_started", "info", "started", "{}", "hub") if len(*sent) != 0 { t.Fatalf("non-recovery info must stay silent, got %d sends", len(*sent)) } }