265 lines
10 KiB
Go
265 lines
10 KiB
Go
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))
|
|
}
|
|
}
|