563 lines
19 KiB
Go
563 lines
19 KiB
Go
package probenotify
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
|
)
|
|
|
|
// fakeEnv replaces every store seam of the package for one test.
|
|
type fakeEnv struct {
|
|
settings map[string]string
|
|
state map[string]string
|
|
stateErr map[string]bool
|
|
nodes []nodeInfo
|
|
channels []channel
|
|
saved []probe.Meta
|
|
}
|
|
|
|
func newFakeEnv(t *testing.T) *fakeEnv {
|
|
t.Helper()
|
|
e := &fakeEnv{
|
|
settings: map[string]string{},
|
|
state: map[string]string{},
|
|
stateErr: map[string]bool{},
|
|
}
|
|
oldSetting, oldSetSetting := settingFn, setSettingFn
|
|
oldChannels, oldLoad := channelsFn, loadNodesFn
|
|
oldGet, oldSet, oldSave := stateGetFn, stateSetFn, saveMetaFn
|
|
settingFn = func(key string) (string, bool) {
|
|
v, ok := e.settings[key]
|
|
return v, ok
|
|
}
|
|
setSettingFn = func(key, value string) error {
|
|
if value == "" {
|
|
delete(e.settings, key)
|
|
} else {
|
|
e.settings[key] = value
|
|
}
|
|
return nil
|
|
}
|
|
channelsFn = func() []channel { return e.channels }
|
|
loadNodesFn = func() ([]nodeInfo, error) { return e.nodes, nil }
|
|
stateGetFn = func(key string) (string, bool, error) {
|
|
if e.stateErr[key] {
|
|
return "", false, errors.New("store down")
|
|
}
|
|
v, ok := e.state[key]
|
|
return v, ok, nil
|
|
}
|
|
stateSetFn = func(key, value string) error {
|
|
if e.stateErr[key] {
|
|
return errors.New("store down")
|
|
}
|
|
if value == "" {
|
|
delete(e.state, key)
|
|
} else {
|
|
e.state[key] = value
|
|
}
|
|
return nil
|
|
}
|
|
saveMetaFn = func(m probe.Meta) error {
|
|
e.saved = append(e.saved, m)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() {
|
|
settingFn, setSettingFn = oldSetting, oldSetSetting
|
|
channelsFn, loadNodesFn = oldChannels, oldLoad
|
|
stateGetFn, stateSetFn, saveMetaFn = oldGet, oldSet, oldSave
|
|
})
|
|
return e
|
|
}
|
|
|
|
type fakeChannel struct {
|
|
label string
|
|
notes []Note
|
|
fail *failure
|
|
}
|
|
|
|
func (f *fakeChannel) name() string { return f.label }
|
|
func (f *fakeChannel) post(n Note) *failure {
|
|
if f.fail != nil {
|
|
return f.fail
|
|
}
|
|
f.notes = append(f.notes, n)
|
|
return nil
|
|
}
|
|
|
|
var t0 = time.Date(2026, 10, 23, 12, 0, 0, 0, time.Local)
|
|
|
|
func offlineNode(id int64, name string, lastSeen time.Time, notify bool) nodeInfo {
|
|
return nodeInfo{
|
|
id: id,
|
|
name: name,
|
|
lastSeenSec: lastSeen.Unix(),
|
|
meta: probe.Meta{AgentID: id, Notify: notify, TrafficResetDay: 1},
|
|
}
|
|
}
|
|
|
|
func TestSweepOfflineAfterGraceOnce(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.settings["notify_grace"] = "1" // 60s
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
e.nodes = []nodeInfo{offlineNode(7, "tokyo", t0.Add(-300*time.Second), true)}
|
|
w := newWatch()
|
|
|
|
if notes := runSweep(t0, w); len(notes) != 0 {
|
|
t.Fatalf("first sighting must only start the absence clock, got %v", notes)
|
|
}
|
|
if notes := runSweep(t0.Add(30*time.Second), w); len(notes) != 0 {
|
|
t.Fatalf("before grace, got %v", notes)
|
|
}
|
|
notes := runSweep(t0.Add(61*time.Second), w)
|
|
if len(notes) != 1 || notes[0].Event != "offline" || !strings.Contains(notes[0].Title, "tokyo") {
|
|
t.Fatalf("after grace want one offline note, got %v", notes)
|
|
}
|
|
if got := e.state["notify_down:7"]; got != fmt.Sprint(t0.Add(-300*time.Second).Unix()) {
|
|
t.Fatalf("offline mark = %q, want last-seen stamp", got)
|
|
}
|
|
if notes := runSweep(t0.Add(10*time.Minute), w); len(notes) != 0 {
|
|
t.Fatalf("announced mark must mute repeats, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestSweepRecoveryOnceAndRearms(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.settings["notify_grace"] = "1"
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
n := offlineNode(7, "tokyo", t0.Add(-300*time.Second), true)
|
|
e.nodes = []nodeInfo{n}
|
|
w := newWatch()
|
|
runSweep(t0, w)
|
|
runSweep(t0.Add(61*time.Second), w) // offline announced
|
|
|
|
back := t0.Add(5 * time.Minute)
|
|
n.lastSeenSec = back.Unix()
|
|
e.nodes = []nodeInfo{n}
|
|
notes := runSweep(back, w)
|
|
if len(notes) != 1 || notes[0].Event != "online" || !strings.Contains(notes[0].Message, "离线") {
|
|
t.Fatalf("want one recovery note, got %v", notes)
|
|
}
|
|
if _, ok := e.state["notify_down:7"]; ok {
|
|
t.Fatal("recovery must clear the offline mark")
|
|
}
|
|
if notes := runSweep(back.Add(time.Minute), w); len(notes) != 0 {
|
|
t.Fatalf("recovery must not repeat, got %v", notes)
|
|
}
|
|
// Down again: a fresh absence past grace announces again.
|
|
n.lastSeenSec = back.Unix()
|
|
e.nodes = []nodeInfo{n}
|
|
later := back.Add(2 * time.Hour)
|
|
if notes := runSweep(later, w); len(notes) != 0 {
|
|
t.Fatalf("first sighting of the new absence, got %v", notes)
|
|
}
|
|
notes = runSweep(later.Add(61*time.Second), w)
|
|
if len(notes) != 1 || notes[0].Event != "offline" {
|
|
t.Fatalf("second outage must announce again, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestSweepFlapUsesFlapGrace(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.settings["notify_grace"] = "1"
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
n := offlineNode(7, "tokyo", t0.Add(-300*time.Second), true)
|
|
e.nodes = []nodeInfo{n}
|
|
w := newWatch()
|
|
runSweep(t0, w)
|
|
runSweep(t0.Add(61*time.Second), w) // announced
|
|
back := t0.Add(5 * time.Minute)
|
|
n.lastSeenSec = back.Unix()
|
|
e.nodes = []nodeInfo{n}
|
|
runSweep(back, w) // recovered; returned[] = back
|
|
|
|
// Away again ten minutes after returning: inside the flap
|
|
// window, so the wait is the 30-minute flap grace, not 60s.
|
|
away := back.Add(10 * time.Minute)
|
|
n.lastSeenSec = back.Unix()
|
|
e.nodes = []nodeInfo{n}
|
|
runSweep(away, w) // first sighting of this absence
|
|
if notes := runSweep(away.Add(61*time.Second), w); len(notes) != 0 {
|
|
t.Fatalf("flapping absence must wait the flap grace, got %v", notes)
|
|
}
|
|
notes := runSweep(away.Add(flapGrace*time.Second), w)
|
|
if len(notes) != 1 || notes[0].Event != "offline" {
|
|
t.Fatalf("after flap grace want offline note, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestSweepSkipsNeverReportedAndOptedOut(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.settings["notify_grace"] = "1"
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
e.nodes = []nodeInfo{
|
|
{id: 1, name: "ghost", lastSeenSec: 0, meta: probe.Meta{AgentID: 1, Notify: true}},
|
|
offlineNode(2, "quiet", t0.Add(-time.Hour), false),
|
|
}
|
|
w := newWatch()
|
|
runSweep(t0, w)
|
|
if notes := runSweep(t0.Add(2*time.Minute), w); len(notes) != 0 {
|
|
t.Fatalf("never-reported and opted-out nodes stay silent, got %v", notes)
|
|
}
|
|
for key := range e.state {
|
|
if strings.HasPrefix(key, "notify_down:") {
|
|
t.Fatalf("no offline marks expected, got %v", e.state)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSweepUnarmedRecordsNothing(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.settings["notify_grace"] = "1"
|
|
n := offlineNode(7, "tokyo", t0.Add(-300*time.Second), true)
|
|
e.nodes = []nodeInfo{n}
|
|
w := newWatch()
|
|
runSweep(t0, w)
|
|
if notes := runSweep(t0.Add(61*time.Second), w); len(notes) != 0 {
|
|
t.Fatalf("no channels, no notes, got %v", notes)
|
|
}
|
|
if _, ok := e.state["notify_down:7"]; ok {
|
|
t.Fatal("unarmed sweep must not record the announcement")
|
|
}
|
|
// Configure a channel afterwards: the already-down node is
|
|
// reported on the next pass (its absence clock is old by now).
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
notes := runSweep(t0.Add(2*time.Minute), w)
|
|
if len(notes) != 1 || notes[0].Event != "offline" {
|
|
t.Fatalf("late-configured channel must still hear about the outage, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestSweepRestartKeepsPairing(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.settings["notify_grace"] = "1"
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
down := offlineNode(7, "tokyo", t0.Add(-300*time.Second), true)
|
|
e.nodes = []nodeInfo{down}
|
|
// The mark from before the "restart": announced already.
|
|
e.state["notify_down:7"] = fmt.Sprint(down.lastSeenSec)
|
|
w := newWatch() // fresh in-memory watch, as after a restart
|
|
runSweep(t0, w)
|
|
if notes := runSweep(t0.Add(2*time.Minute), w); len(notes) != 0 {
|
|
t.Fatalf("restart must not replay the offline alert, got %v", notes)
|
|
}
|
|
back := t0.Add(3 * time.Minute)
|
|
down.lastSeenSec = back.Unix()
|
|
e.nodes = []nodeInfo{down}
|
|
notes := runSweep(back, w)
|
|
if len(notes) != 1 || notes[0].Event != "online" {
|
|
t.Fatalf("the recovery pairing must survive the restart, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func trafficNode(id int64, name string, used, limit int64) nodeInfo {
|
|
return nodeInfo{
|
|
id: id,
|
|
name: name,
|
|
lastSeenSec: t0.Unix(),
|
|
meta: probe.Meta{
|
|
AgentID: id, TrafficLimit: limit, TrafficMode: "sum",
|
|
TrafficResetDay: 1, Notify: true,
|
|
},
|
|
traffic: probe.Traffic{MonthRx: used, MonthStart: "2026-10-01"},
|
|
hasTraffic: true,
|
|
}
|
|
}
|
|
|
|
func TestTrafficStepsPrimeThenAnnounce(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
e.nodes = []nodeInfo{trafficNode(7, "tokyo", 850, 1000)}
|
|
w := newWatch()
|
|
|
|
// Unprimed (no stored state): the crossing is recorded silently.
|
|
if notes := runSweep(t0, w); len(notes) != 0 {
|
|
t.Fatalf("first pass must prime, not announce, got %v", notes)
|
|
}
|
|
stored := map[int64]alertStep{}
|
|
if err := json.Unmarshal([]byte(e.state["traffic_alerted"]), &stored); err != nil {
|
|
t.Fatalf("state not JSON: %v", err)
|
|
}
|
|
if stored[7].Step != 80 || stored[7].Period != "2026-10-01" {
|
|
t.Fatalf("primed step = %+v", stored[7])
|
|
}
|
|
// Same step again: told already.
|
|
if notes := runSweep(t0.Add(time.Minute), w); len(notes) != 0 {
|
|
t.Fatalf("same step must not repeat, got %v", notes)
|
|
}
|
|
// Cross to 100%: the second step announces.
|
|
e.nodes = []nodeInfo{trafficNode(7, "tokyo", 1000, 1000)}
|
|
notes := runSweep(t0.Add(2*time.Minute), w)
|
|
if len(notes) != 1 || notes[0].Event != "traffic" || !strings.Contains(notes[0].Message, "100%") {
|
|
t.Fatalf("want one 100%% traffic note, got %v", notes)
|
|
}
|
|
if notes := runSweep(t0.Add(3*time.Minute), w); len(notes) != 0 {
|
|
t.Fatalf("100%% step must not repeat within the period, got %v", notes)
|
|
}
|
|
// Next period: the same usage announces again.
|
|
e.nodes = []nodeInfo{trafficNode(7, "tokyo", 900, 1000)}
|
|
nov := time.Date(2026, 11, 15, 12, 0, 0, 0, time.Local)
|
|
if notes := runSweep(nov, newWatch()); len(notes) != 1 || notes[0].Event != "traffic" {
|
|
t.Fatalf("a new period re-arms the steps, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestTrafficCorrectionRearms(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
e.state["traffic_alerted"] = `{"7":["2026-10-01",80]}`
|
|
e.nodes = []nodeInfo{trafficNode(7, "tokyo", 500, 1000)} // corrected down
|
|
w := newWatch()
|
|
if notes := runSweep(t0, w); len(notes) != 0 {
|
|
t.Fatalf("a correction announces nothing, got %v", notes)
|
|
}
|
|
stored := map[int64]alertStep{}
|
|
_ = json.Unmarshal([]byte(e.state["traffic_alerted"]), &stored)
|
|
if stored[7].Step != 0 {
|
|
t.Fatalf("the lower step must be stored, got %+v", stored[7])
|
|
}
|
|
e.nodes = []nodeInfo{trafficNode(7, "tokyo", 850, 1000)}
|
|
if notes := runSweep(t0.Add(time.Minute), w); len(notes) != 1 || notes[0].Event != "traffic" {
|
|
t.Fatalf("crossing again after a correction must announce, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestTrafficReadErrorSkipsSection(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
e.stateErr["traffic_alerted"] = true
|
|
e.nodes = []nodeInfo{trafficNode(7, "tokyo", 1000, 1000)}
|
|
if notes := runSweep(t0, newWatch()); len(notes) != 0 {
|
|
t.Fatalf("a failed state read must skip the section, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func renewNode(id int64, name string, expires string, cycle string, online bool) nodeInfo {
|
|
exp := expires
|
|
ls := t0.Add(-time.Hour).Unix()
|
|
if online {
|
|
ls = t0.Unix()
|
|
}
|
|
return nodeInfo{
|
|
id: id, name: name, lastSeenSec: ls,
|
|
meta: probe.Meta{AgentID: id, ExpiresAt: &exp, BillingCycle: cycle, TrafficResetDay: 1},
|
|
}
|
|
}
|
|
|
|
func TestHousekeepingRenewsOnlineNode(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
past := t0.AddDate(0, 0, -40).Format("2006-01-02")
|
|
e.nodes = []nodeInfo{renewNode(7, "tokyo", past, "monthly", true)}
|
|
notes := runHousekeeping(t0)
|
|
if len(e.saved) != 1 {
|
|
t.Fatalf("want one rolled meta, saved %v", e.saved)
|
|
}
|
|
want := t0.AddDate(0, 0, -40).AddDate(0, 2, 0).Format("2006-01-02")
|
|
if *e.saved[0].ExpiresAt != want {
|
|
t.Fatalf("rolled to %s, want %s (two whole cycles)", *e.saved[0].ExpiresAt, want)
|
|
}
|
|
if len(notes) != 1 || notes[0].Event != "renew" {
|
|
t.Fatalf("want one renewal note, got %v", notes)
|
|
}
|
|
// Not past any more: no second roll, no second note.
|
|
e.nodes = []nodeInfo{renewNode(7, "tokyo", *e.saved[0].ExpiresAt, "monthly", true)}
|
|
e.saved = nil
|
|
if notes := runHousekeeping(t0); len(notes) != 0 || len(e.saved) != 0 {
|
|
t.Fatalf("a future date must not roll, notes %v saved %v", notes, e.saved)
|
|
}
|
|
}
|
|
|
|
func TestHousekeepingRenewalSkipsAndGate(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
past := t0.AddDate(0, 0, -10).Format("2006-01-02")
|
|
e.nodes = []nodeInfo{
|
|
renewNode(1, "once-node", past, "once", true), // one-off plan: no cycle
|
|
renewNode(2, "away-node", past, "monthly", false), // offline: no roll
|
|
renewNode(3, "live-node", past, "monthly", true),
|
|
}
|
|
e.settings["notify_expiry"] = "0" // renewals still roll, silently
|
|
notes := runHousekeeping(t0)
|
|
if len(e.saved) != 1 || e.saved[0].AgentID != 3 {
|
|
t.Fatalf("only the online monthly node rolls, saved %v", e.saved)
|
|
}
|
|
if len(notes) != 0 {
|
|
t.Fatalf("notify_expiry=0 must mute the renewal note, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestExpiryDigestOncePerDay(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
soon := t0.AddDate(0, 0, 3).Format("2006-01-02")
|
|
e.nodes = []nodeInfo{renewNode(7, "tokyo", soon, "monthly", true)}
|
|
|
|
morning := time.Date(2026, 10, 23, 8, 0, 0, 0, time.Local)
|
|
if notes := runHousekeeping(morning); len(notes) != 0 {
|
|
t.Fatalf("before 09:00 no digest, got %v", notes)
|
|
}
|
|
if _, ok := e.state["notify_expiry_sent"]; ok {
|
|
t.Fatal("an ungated hour must not consume the day")
|
|
}
|
|
notes := runHousekeeping(t0) // 12:00
|
|
found := false
|
|
for _, n := range notes {
|
|
if n.Event == "expiry" && strings.Contains(n.Message, soon) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("want the expiry digest, got %v", notes)
|
|
}
|
|
if notes := runHousekeeping(t0.Add(time.Hour)); len(notes) != 0 {
|
|
t.Fatalf("the digest is once a day, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestExpiryDigestNeedsChannel(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
soon := t0.AddDate(0, 0, 3).Format("2006-01-02")
|
|
e.nodes = []nodeInfo{renewNode(7, "tokyo", soon, "monthly", true)}
|
|
if notes := runHousekeeping(t0); len(notes) != 0 {
|
|
t.Fatalf("no channel, no digest, got %v", notes)
|
|
}
|
|
if _, ok := e.state["notify_expiry_sent"]; ok {
|
|
t.Fatal("the day must stay unconsumed so a channel configured later still gets the digest")
|
|
}
|
|
e.channels = []channel{&fakeChannel{label: "Webhook"}}
|
|
if notes := runHousekeeping(t0); len(notes) != 1 || notes[0].Event != "expiry" {
|
|
t.Fatalf("want the digest once a channel exists, got %v", notes)
|
|
}
|
|
}
|
|
|
|
func TestCycleMonthsAndClamp(t *testing.T) {
|
|
for cycle, want := range map[string]int64{
|
|
"monthly": 1, "quarterly": 3, "semiannual": 6, "yearly": 12,
|
|
"biennial": 24, "triennial": 36, "5m": 5, "1200m": 1200,
|
|
} {
|
|
if got, ok := cycleMonths(cycle); !ok || got != want {
|
|
t.Errorf("cycleMonths(%q) = %d,%v want %d", cycle, got, ok, want)
|
|
}
|
|
}
|
|
for _, bad := range []string{"once", "", "0m", "1201m", "weekly", "m"} {
|
|
if _, ok := cycleMonths(bad); ok {
|
|
t.Errorf("cycleMonths(%q) must not renew", bad)
|
|
}
|
|
}
|
|
jan31 := time.Date(2024, 1, 31, 0, 0, 0, 0, time.Local)
|
|
if got := addMonthsClamped(jan31, 1); got.Format("2006-01-02") != "2024-02-29" {
|
|
t.Errorf("Jan 31 + 1 month = %v, want 2024-02-29 (clamped)", got)
|
|
}
|
|
}
|
|
|
|
func TestSettingError(t *testing.T) {
|
|
cases := []struct {
|
|
key, value string
|
|
wantErr bool
|
|
}{
|
|
{"notify_grace", "3", false}, {"notify_grace", "0", true},
|
|
{"notify_grace", "31", true}, {"notify_grace", "abc", true},
|
|
{"notify_traffic", "0", false}, {"notify_traffic", "100", false},
|
|
{"notify_traffic", "101", true},
|
|
{"notify_expiry", "0", false}, {"notify_expiry", "365", false},
|
|
{"notify_expiry", "366", true},
|
|
{"notify_login", "on", false}, {"notify_login", "off", false},
|
|
{"notify_login", "yes", true},
|
|
{"notify_webhook_url", "", false},
|
|
{"notify_webhook_url", "https://example.com/hook", false},
|
|
{"notify_webhook_url", "http://127.0.0.1:9/x", false},
|
|
{"notify_webhook_url", "ftp://example.com", true},
|
|
{"notify_webhook_headers", "", false},
|
|
{"notify_webhook_headers", "X-Api-Key: secret", false},
|
|
{"notify_webhook_headers", "Broken Line", true},
|
|
{"notify_webhook_headers", "Bad Name: v", true},
|
|
{"notify_webhook_body", "", false},
|
|
{"notify_webhook_body", `{"text":"{{title}}"}`, false},
|
|
{"notify_webhook_body", `{"text":{{title}}}`, true},
|
|
{"notify_webhook_body", `not json`, true},
|
|
{"notify_telegram_text", "", false},
|
|
{"notify_telegram_text", "anything {{title}}", false},
|
|
{"notify_unknown", "1", true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := SettingError(c.key, c.value); (got != "") != c.wantErr {
|
|
t.Errorf("SettingError(%q, %q) = %q, wantErr %v", c.key, c.value, got, c.wantErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderSinglePassAndEscapes(t *testing.T) {
|
|
note := Note{Event: "offline", Node: "a\"b", Title: "{{message}}", Message: "line\nline", Time: t0.Unix()}
|
|
// Single pass: the title's literal {{message}} must survive.
|
|
if got := render("{{title}}", note, "site", false); got != "{{message}}" {
|
|
t.Errorf("render single pass = %q", got)
|
|
}
|
|
got := render(`{"t":"{{title}}","m":"{{message}}","n":"{{node}}"}`, note, "site", true)
|
|
var parsed map[string]string
|
|
if err := json.Unmarshal([]byte(got), &parsed); err != nil {
|
|
t.Fatalf("rendered body not JSON: %v (%s)", err, got)
|
|
}
|
|
if parsed["m"] != "line\nline" || parsed["n"] != "a\"b" {
|
|
t.Errorf("escapes broken: %v", parsed)
|
|
}
|
|
if got := render("x{{bogus}}y", note, "site", false); got != "x{{bogus}}y" {
|
|
t.Errorf("unknown placeholder must pass through, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchShapes(t *testing.T) {
|
|
if _, ok := batch("offline", "🔴", "离线", nil); ok {
|
|
t.Error("empty batch must be no note")
|
|
}
|
|
one, _ := batch("offline", "🔴", "离线", []batchItem{{"a", "最后上报 10-23 11:00 +08:00"}})
|
|
if one.Title != "🔴 a 离线" || one.Node != "a" {
|
|
t.Errorf("single batch = %+v", one)
|
|
}
|
|
items := make([]batchItem, 25)
|
|
for i := range items {
|
|
items[i] = batchItem{fmt.Sprint("n", i), "d"}
|
|
}
|
|
many, _ := batch("offline", "🔴", "离线", items)
|
|
if !strings.Contains(many.Title, "25 台节点离线") || !strings.Contains(many.Message, "……另外 5 台") {
|
|
t.Errorf("many batch = %q / %q", many.Title, many.Message)
|
|
}
|
|
}
|
|
|
|
func TestSaveSettingsValidationAndSecrets(t *testing.T) {
|
|
e := newFakeEnv(t)
|
|
// All-or-nothing: one bad field refuses the whole write.
|
|
body := map[string]json.RawMessage{
|
|
"notifyGrace": json.RawMessage("5"),
|
|
"notifyTraffic": json.RawMessage("101"),
|
|
}
|
|
if err := SaveSettings(body); err == nil {
|
|
t.Fatal("out-of-range traffic must refuse")
|
|
}
|
|
if _, ok := e.settings["notify_grace"]; ok {
|
|
t.Fatal("a refused save must write nothing")
|
|
}
|
|
// Secrets: null leaves, string sets, "" clears.
|
|
e.settings["notify_webhook_url"] = "https://old.example/hook"
|
|
if err := SaveSettings(map[string]json.RawMessage{"notifyWebhookUrl": json.RawMessage("null")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e.settings["notify_webhook_url"] != "https://old.example/hook" {
|
|
t.Fatal("null must leave the secret")
|
|
}
|
|
if err := SaveSettings(map[string]json.RawMessage{"notifyWebhookUrl": json.RawMessage(`""`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := e.settings["notify_webhook_url"]; ok {
|
|
t.Fatal("empty string must clear the secret")
|
|
}
|
|
if err := SaveSettings(map[string]json.RawMessage{"bogus": json.RawMessage("1")}); err == nil {
|
|
t.Fatal("unknown keys are refused")
|
|
}
|
|
}
|