hub v0.71.0: paired recovery mails (F11), prefs seeding at claim + empty-email no-clobber (F12), priority headers + operator test leg (F14-light)
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user