package main import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/zenmoney" ) func TestMaxSeverity(t *testing.T) { parse := func(s string) netdataAlarms { var a netdataAlarms if err := json.Unmarshal([]byte(s), &a); err != nil { t.Fatal(err) } return a } cases := []struct{ body, want string }{ {`{"alarms":{}}`, "clear"}, {`{"alarms":{"a":{"status":"WARNING"}}}`, "warning"}, {`{"alarms":{"a":{"status":"WARNING"},"b":{"status":"CRITICAL"}}}`, "critical"}, {`{"alarms":{"a":{"status":"CLEAR"}}}`, "clear"}, } for _, c := range cases { if got := maxSeverity(parse(c.body)); got != c.want { t.Errorf("maxSeverity(%s) = %q, want %q", c.body, got, c.want) } } } func TestKumaMonitorsNamesEveryOne(t *testing.T) { cases := []struct { name string body string want map[string]string }{ {"empty body", "", map[string]string{}}, {"help line only", `# HELP monitor_status ...`, map[string]string{}}, { "one up one down", `monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, map[string]string{"web": "up", "db": "down"}, }, {"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}}, {"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}}, { "other labels do not hide the name", `monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`, map[string]string{"ci": "down"}, }, {"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := kumaMonitors([]byte(c.body)) if len(got) != len(c.want) { t.Fatalf("kumaMonitors = %v, want %v", got, c.want) } for k, v := range c.want { if got[k] != v { t.Errorf("monitor %q = %q, want %q", k, got[k], v) } } }) } } func TestKumaFactKeyCarriesTheName(t *testing.T) { if got := kumaFactKey("nexus db"); got != "service_down:nexus db" { t.Errorf("kumaFactKey = %q", got) } } func TestParseMaxHandshake(t *testing.T) { cases := []struct { out string want int64 }{ {"", 0}, {"pubkeyAAA\t0\n", 0}, // never handshaked {"pubkeyAAA\t1700000000\n", 1700000000}, {"pubkeyAAA\t1700000000\npubkeyBBB\t1700000500\n", 1700000500}, // max wins {"wg0\tpubkeyAAA\t1700000000\nwg0\tpubkeyBBB\t0\n", 1700000000}, // 'all' 3-field form } for _, c := range cases { if got := parseMaxHandshake(c.out); got != c.want { t.Errorf("parseMaxHandshake(%q) = %d, want %d", c.out, got, c.want) } } } // ---- zenmoney (Vikunja #125) ---------------------------------------------- // factCore records the facts the poller wrote and answers "no fact yet". type factCore struct { ipc.UnimplementedCoreAPI written []ipc.WriteFactReq prev map[string]string } func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) { if v, ok := c.prev[key+"|"+source]; ok { return ipc.Fact{Key: key, Source: source, Value: v}, nil } return ipc.Fact{}, ipc.ErrNoFact } func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) { c.written = append(c.written, req) return int64(len(c.written)), nil } func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if status != http.StatusOK { w.WriteHeader(status) return } w.Write(body) })) } func TestPollZenmoneyWritesMoneyFacts(t *testing.T) { body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json") if err != nil { t.Fatal(err) } srv := zenFixtureServer(t, body, http.StatusOK) defer srv.Close() zen, err := zenmoney.New("tok", srv.URL, time.Second) if err != nil { t.Fatal(err) } core := &factCore{} p := &poller{core: core, zen: zen} now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC) if err := p.pollZenmoney(context.Background(), now); err != nil { t.Fatal(err) } if len(core.written) != 2 { t.Fatalf("wrote %d facts, want today + month", len(core.written)) } for _, f := range core.written { if f.Kind != "env" || f.Source != zenmoney.Source { t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source) } if _, err := zenmoney.ParseFactValue(f.Value); err != nil { t.Errorf("fact value %q does not decode: %v", f.Value, err) } } } // A read that returns nothing for the window writes NOTHING. Silence, not a // zero: an invented 0 would be recited back to him as fact. func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) { srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK) defer srv.Close() zen, _ := zenmoney.New("tok", srv.URL, time.Second) core := &factCore{} p := &poller{core: core, zen: zen} if err := p.pollZenmoney(context.Background(), time.Now()); err != nil { t.Fatal(err) } if len(core.written) != 0 { t.Errorf("wrote %+v, want no fact at all", core.written) } } // An API failure must not overwrite the last good total either. func TestPollZenmoneyFailureWritesNothing(t *testing.T) { srv := zenFixtureServer(t, nil, http.StatusUnauthorized) defer srv.Close() zen, _ := zenmoney.New("bad", srv.URL, time.Second) core := &factCore{} p := &poller{core: core, zen: zen} if err := p.pollZenmoney(context.Background(), time.Now()); err == nil { t.Error("want the 401 reported") } if len(core.written) != 0 { t.Errorf("wrote %+v on a failed read", core.written) } } // Unchanged totals do not churn the facts table. func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) { core := &factCore{prev: map[string]string{ zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`, }} p := &poller{core: core} if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil { t.Fatal(err) } if len(core.written) != 0 { t.Errorf("wrote %+v for an unchanged value", core.written) } } // Money tracking is off unless configured: no token file, no zenmoney client, // and the poller still refuses to start with nothing at all to poll. func TestRunRequiresSomethingToPoll(t *testing.T) { err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""}) if err == nil || !strings.Contains(err.Error(), "nothing to poll") { t.Errorf("err = %v, want a 'nothing to poll' refusal", err) } } // A flag value that would kill the poller later is refused at start, before it // dials core: a non-positive interval panics time.NewTicker on the first tick, a // zero timeout means http.Client waits forever, and an empty wg command used to // index field 0 of an empty slice. func TestRunRefusesFlagsThatCrashLater(t *testing.T) { cases := []struct { name string args []string want string }{ {"zero interval", []string{"-interval", "0"}, "-interval must be positive"}, {"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"}, {"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"}, {"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...) err := run(args) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("err = %v, want %q", err, c.want) } }) } } // pollWg refuses an empty command rather than panicking on fields[0]. func TestPollWgEmptyCommand(t *testing.T) { p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""} if err := p.pollWg(context.Background()); err == nil { t.Error("want an error, got a poll that ran something") } } // A body at the cap is a truncated body, and a truncated kuma page reads as // "every monitor past the cut was deleted". Refuse it instead of parsing it. func TestGetRefusesTruncatedBody(t *testing.T) { big := strings.Repeat("x", maxBodyBytes+64) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, big) })) defer srv.Close() p := &poller{http: srv.Client()} if _, err := p.get(context.Background(), srv.URL, ""); err == nil { t.Error("want an over-size refusal, got a silently truncated body") } small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })) defer small.Close() body, err := p.get(context.Background(), small.URL, "") if err != nil || string(body) != "ok" { t.Errorf("get = %q, %v; want the whole small body", body, err) } }