package apprise import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "drivervault/apiserver/internal/plugins" ) // ---- descriptor & config ----------------------------------------------------- func TestDescriptor(t *testing.T) { d := (&Plugin{}).Descriptor() if d.Name != "apprise" { t.Fatalf("name = %q, want apprise", d.Name) } if d.Kind != plugins.KindBuiltin { t.Fatalf("kind = %q, want builtin", d.Kind) } if d.Category != plugins.CategoryNotifications { t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryNotifications) } actions := map[string]bool{} for _, c := range d.Capabilities { actions[c.ID] = true } for _, want := range []string{"notify", "targets", "services", "status"} { if !actions[want] { t.Errorf("capability %q should be advertised", want) } } fields := map[string]plugins.ConfigField{} for _, f := range d.ConfigFields { fields[f.Key] = f } for _, k := range []string{"baseUrl", "configKey", "urls", "tag", "format", "username", "password", "timeout"} { if _, ok := fields[k]; !ok { t.Errorf("config field %q should be present", k) } } // There is no per-user cascade behind this connector: without an address it // cannot work at all, so the panel must refuse to enable it blank. if !fields["baseUrl"].Required { t.Error("baseUrl must be required — nothing else can supply it") } // Apprise URLs embed the credentials of every service they address. if !fields["urls"].Secret || !fields["password"].Secret { t.Error("urls and password must both be marked secret") } if fields["configKey"].Required || fields["urls"].Required { t.Error("the destination is either a key or URLs; neither may be required on its own") } if got := fields["format"].Type; got != "select" { t.Errorf("format type = %q, want select", got) } if got := fields["tag"].Default; got != tagAll { t.Errorf("tag default = %q, want %q", got, tagAll) } } func TestNormalizeBaseURL(t *testing.T) { cases := []struct { in, want string wantErr bool }{ {in: "", want: ""}, {in: "http://apprise:8000", want: "http://apprise:8000"}, {in: " http://apprise:8000/ ", want: "http://apprise:8000"}, {in: "https://apprise.example.com", want: "https://apprise.example.com"}, // A bare host is the common shape on an internal network. {in: "10.2.1.10:8000", want: "http://10.2.1.10:8000"}, // A reverse proxy may publish the API under a path prefix. {in: "https://home.example.com/apprise/", want: "https://home.example.com/apprise"}, {in: "ftp://apprise", wantErr: true}, {in: "http://", wantErr: true}, } for _, c := range cases { got, err := normalizeBaseURL(c.in) if c.wantErr { if err == nil { t.Errorf("normalizeBaseURL(%q) = %q, want an error", c.in, got) } continue } if err != nil { t.Errorf("normalizeBaseURL(%q): %v", c.in, err) continue } if got != c.want { t.Errorf("normalizeBaseURL(%q) = %q, want %q", c.in, got, c.want) } } } func TestInitRejectsUnusableConfigKey(t *testing.T) { p := &Plugin{} err := p.Init(context.Background(), map[string]string{ "baseUrl": "http://apprise:8000", "configKey": "not a key", }) if err == nil { t.Fatal("a config key with a space should be rejected at Init") } if !strings.Contains(err.Error(), "not usable") { t.Errorf("error should say what is wrong with the key, got: %v", err) } } func TestParseTimeoutClamps(t *testing.T) { cases := map[string]time.Duration{ "": defaultTimeout, "abc": defaultTimeout, "0": defaultTimeout, "-5": defaultTimeout, "30": 30 * time.Second, "9000": maxTimeout, } for in, want := range cases { if got := parseTimeout(in); got != want { t.Errorf("parseTimeout(%q) = %v, want %v", in, got, want) } } } func TestCountURLs(t *testing.T) { cases := map[string]int{ "": 0, " ": 0, "ntfy://topic": 1, "mailto://a@b, ntfy://t": 2, "a://one b://two c://three": 3, } for in, want := range cases { if got := countURLs(in); got != want { t.Errorf("countURLs(%q) = %d, want %d", in, got, want) } } } // ---- test server ------------------------------------------------------------- // call records one request the fake Apprise server received. type call struct { method string path string query string accept string ctype string user string pass string hasAuth bool payload map[string]any } // fakeApprise is an apprise-api stand-in: it records what it was asked and // answers from the handler the test installs. type fakeApprise struct { t *testing.T srv *httptest.Server calls []call handler func(w http.ResponseWriter, r *http.Request, c call) } func newFake(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, c call)) *fakeApprise { t.Helper() f := &fakeApprise{t: t, handler: handler} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c := call{ method: r.Method, path: r.URL.Path, query: r.URL.RawQuery, accept: r.Header.Get("Accept"), ctype: r.Header.Get("Content-Type"), } c.user, c.pass, c.hasAuth = r.BasicAuth() if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&c.payload) } f.calls = append(f.calls, c) f.handler(w, r, c) })) t.Cleanup(f.srv.Close) return f } func (f *fakeApprise) last() call { f.t.Helper() if len(f.calls) == 0 { f.t.Fatal("the plugin made no request") } return f.calls[len(f.calls)-1] } // writeJSON answers with a status and body, the way apprise-api does for a JSON // request. func writeJSON(w http.ResponseWriter, status int, body string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write([]byte(body)) } // statusOK is the /status body of a healthy default apprise-api deployment. const statusOK = `{"config_lock":false,"attach_lock":false,"stateful_enabled":true, "max_attachments":6,"attach_size":200, "status":{"persistent_storage":true,"can_write_config":true,"can_write_attach":true,"details":["OK"]}}` // newPlugin builds a plugin pointed at the fake server. func newPlugin(t *testing.T, f *fakeApprise, cfg map[string]string) *Plugin { t.Helper() if cfg == nil { cfg = map[string]string{} } cfg["baseUrl"] = f.srv.URL p := &Plugin{} if err := p.Init(context.Background(), cfg); err != nil { t.Fatalf("Init: %v", err) } return p } func invoke(t *testing.T, p *Plugin, action string, params string, into any) error { t.Helper() var raw json.RawMessage if params != "" { raw = json.RawMessage(params) } out, err := p.Invoke(context.Background(), action, raw) if err != nil { return err } if into != nil { if err := json.Unmarshal(out, into); err != nil { t.Fatalf("decoding the %s result: %v", action, err) } } return nil } // ---- notify ------------------------------------------------------------------ func TestNotifyStateful(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, `{"error":null,"details":[["INFO","2026-08-30 10:00:00,000","Sent Telegram notification."]]}`) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "tag": "ops", "format": "markdown"}) var res NotifyResult if err := invoke(t, p, "notify", `{"body":"Charge complete","title":"Kia EV6","type":"success"}`, &res); err != nil { t.Fatalf("notify: %v", err) } c := f.last() if c.method != http.MethodPost || c.path != "/notify/drivervault" { t.Fatalf("request = %s %s, want POST /notify/drivervault", c.method, c.path) } // Both headers are what make apprise-api answer in JSON instead of HTML. if !strings.Contains(c.accept, "application/json") || !strings.Contains(c.ctype, "application/json") { t.Errorf("Accept = %q, Content-Type = %q; both should be application/json", c.accept, c.ctype) } if c.hasAuth { t.Error("no Basic auth is configured, so none should be sent") } want := map[string]any{ "body": "Charge complete", "title": "Kia EV6", "type": "success", "format": "markdown", "tag": "ops", } for k, v := range want { if c.payload[k] != v { t.Errorf("payload[%q] = %v, want %v", k, c.payload[k], v) } } if _, sent := c.payload["urls"]; sent { t.Error("a stateful call must not carry URLs") } if !res.OK || res.Mode != "stateful" || res.Key != "drivervault" || res.Tag != "ops" { t.Fatalf("result = %+v", res) } if len(res.Log) != 1 || res.Log[0].Level != "INFO" || !strings.Contains(res.Log[0].Message, "Telegram") { t.Errorf("delivery log = %+v", res.Log) } if res.SentAt.IsZero() { t.Error("a delivered notification should be stamped") } } func TestNotifyStatelessUsesConfiguredURLs(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`) }) p := newPlugin(t, f, map[string]string{"urls": "ntfy://topic, mailto://user:pass@host"}) var res NotifyResult if err := invoke(t, p, "notify", `{"body":"Service due"}`, &res); err != nil { t.Fatalf("notify: %v", err) } c := f.last() if c.path != "/notify" { t.Fatalf("path = %q, want /notify (the stateless endpoint)", c.path) } if c.payload["urls"] != "ntfy://topic, mailto://user:pass@host" { t.Errorf("urls = %v, want the configured list", c.payload["urls"]) } if _, sent := c.payload["tag"]; sent { t.Error("a tag means nothing to a stateless call and should not be sent") } // Defaults fill in for what the caller left out. if c.payload["type"] != typeInfo || c.payload["format"] != formatText { t.Errorf("defaults = type %v / format %v, want info / text", c.payload["type"], c.payload["format"]) } if res.Mode != "stateless" || res.Targets != 2 { t.Errorf("result = %+v, want stateless with 2 targets", res) } if res.Key != "" { t.Errorf("a stateless result should name no key, got %q", res.Key) } } // A call that names its own URLs must not be sent to the configured key as well // — the caller chose the destination. func TestNotifyCallerURLsOverrideConfiguredKey(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault"}) var res NotifyResult if err := invoke(t, p, "notify", `{"body":"one-off","urls":"ntfy://alerts"}`, &res); err != nil { t.Fatalf("notify: %v", err) } if c := f.last(); c.path != "/notify" || c.payload["urls"] != "ntfy://alerts" { t.Fatalf("request = %s with urls %v, want the stateless endpoint with the caller's URL", c.path, c.payload["urls"]) } if res.Mode != "stateless" { t.Errorf("mode = %q, want stateless", res.Mode) } } func TestNotifyKeyParamOverridesConfiguredKey(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "urls": "ntfy://ignored"}) if err := invoke(t, p, "notify", `{"body":"hi","key":"fleet","tag":"night"}`, nil); err != nil { t.Fatalf("notify: %v", err) } c := f.last() if c.path != "/notify/fleet" { t.Fatalf("path = %q, want /notify/fleet", c.path) } if c.payload["tag"] != "night" { t.Errorf("tag = %v, want the per-call night", c.payload["tag"]) } if _, sent := c.payload["urls"]; sent { t.Error("the configured URLs must not ride along with a keyed call") } } func TestNotifyAttachAcceptsStringOrList(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`) }) p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"}) if err := invoke(t, p, "notify", `{"body":"b","attach":"https://example.com/a.png"}`, nil); err != nil { t.Fatalf("notify with one attachment: %v", err) } if got := f.last().payload["attach"]; len(got.([]any)) != 1 { t.Errorf("attach = %v, want a one-element list", got) } if err := invoke(t, p, "notify", `{"body":"b","attach":["https://example.com/a.png","https://example.com/b.pdf"]}`, nil); err != nil { t.Fatalf("notify with two attachments: %v", err) } if got := f.last().payload["attach"]; len(got.([]any)) != 2 { t.Errorf("attach = %v, want a two-element list", got) } // An empty string is no attachment at all, not an empty one. if err := invoke(t, p, "notify", `{"body":"b","attach":""}`, nil); err != nil { t.Fatalf("notify with an empty attachment: %v", err) } if _, sent := f.last().payload["attach"]; sent { t.Error("an empty attach should not be sent") } } func TestNotifyValidation(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, `{"error":null}`) }) p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"}) cases := map[string]string{ "empty body": `{"body":" "}`, "bad type": `{"body":"b","type":"urgent"}`, "bad key": `{"body":"b","key":"has space"}`, "bad params": `{"body":`, "bad attach": `{"body":"b","attach":{"url":"x"}}`, } for name, params := range cases { if err := invoke(t, p, "notify", params, nil); err == nil { t.Errorf("%s should have been rejected", name) } } if len(f.calls) != 0 { t.Errorf("nothing invalid should reach the server, got %d call(s)", len(f.calls)) } // An unrecognised format is not worth failing a notification over: Apprise // converts formats anyway, so it falls back to the configured one. if err := invoke(t, p, "notify", `{"body":"b","format":"yaml"}`, nil); err != nil { t.Fatalf("an odd format should fall back, not fail: %v", err) } if got := f.last().payload["format"]; got != formatText { t.Errorf("format = %v, want the configured %q", got, formatText) } } func TestNotifyReportsDeliveryFailure(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, statusFailedDependency, `{"error":"One or more notification could not be sent", "details":[["WARNING","2026-08-30 10:00:00,000","Failed to send Discord notification: 401"]]}`) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault"}) err := invoke(t, p, "notify", `{"body":"b"}`, nil) if err == nil { t.Fatal("a 424 means nothing was delivered and must surface as an error") } // The log line is the only place Apprise says which service refused. if !strings.Contains(err.Error(), "Discord") || !strings.Contains(err.Error(), "401") { t.Errorf("error should carry the delivery log, got: %v", err) } } func TestNotifyUnknownKeyIsExplained(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { w.WriteHeader(http.StatusNoContent) }) p := newPlugin(t, f, map[string]string{"configKey": "missing"}) err := invoke(t, p, "notify", `{"body":"b"}`, nil) if err == nil { t.Fatal("a 204 means the key holds nothing and must not read as success") } if !strings.Contains(err.Error(), "missing") { t.Errorf("error should name the key, got: %v", err) } } func TestBasicAuthIsSentWhenConfigured(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, `{"error":null}`) }) p := newPlugin(t, f, map[string]string{"urls": "ntfy://t", "username": "ops", "password": "s3cret"}) if err := invoke(t, p, "notify", `{"body":"b"}`, nil); err != nil { t.Fatalf("notify: %v", err) } c := f.last() if !c.hasAuth || c.user != "ops" || c.pass != "s3cret" { t.Errorf("Basic auth = %q/%q (present %v), want ops/s3cret", c.user, c.pass, c.hasAuth) } } // ---- targets ----------------------------------------------------------------- const targetsBody = `{"tags":["ops","night"],"urls":[ {"id":"a1","service_name":"Telegram","enabled":true,"url":"tgram://****/1234","tags":["ops"]}, {"id":"b2","service_name":"E-Mail","enabled":true,"url":"mailto://user:****@host","tags":["ops","night"]}]}` func TestTargetsForcesPrivacy(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, targetsBody) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault"}) var res TargetsResult if err := invoke(t, p, "targets", "", &res); err != nil { t.Fatalf("targets: %v", err) } c := f.last() if c.path != "/json/urls/drivervault" { t.Fatalf("path = %q, want /json/urls/drivervault", c.path) } // privacy=1 is what keeps downstream tokens on the Apprise side. if !strings.Contains(c.query, "privacy=1") { t.Errorf("query = %q, want privacy=1", c.query) } if !strings.Contains(c.query, "tag="+tagAll) { t.Errorf("query = %q, want the default tag=%s", c.query, tagAll) } if res.Count != 2 || len(res.Targets) != 2 { t.Fatalf("count = %d, want 2", res.Count) } if res.Targets[0].ServiceName != "Telegram" || res.Targets[0].ID != "a1" { t.Errorf("first target = %+v", res.Targets[0]) } if len(res.Tags) != 2 { t.Errorf("tags = %v, want both", res.Tags) } } func TestTargetsEmptyKeyIsNotAFailure(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { w.WriteHeader(http.StatusNoContent) }) p := newPlugin(t, f, map[string]string{"configKey": "fresh"}) var res TargetsResult if err := invoke(t, p, "targets", "", &res); err != nil { t.Fatalf("a key with no configuration is an empty list, not an error: %v", err) } if res.Count != 0 || res.Targets == nil { t.Errorf("result = %+v, want an empty (non-null) target list", res) } } func TestTargetsNeedsAKey(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, targetsBody) }) p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"}) if err := invoke(t, p, "targets", "", nil); err == nil { t.Fatal("targets without a key anywhere should be refused") } if len(f.calls) != 0 { t.Error("the refusal should not reach the server") } // A key given per call is enough. if err := invoke(t, p, "targets", `{"key":"fleet","tag":"night"}`, nil); err != nil { t.Fatalf("targets with a per-call key: %v", err) } if c := f.last(); c.path != "/json/urls/fleet" || !strings.Contains(c.query, "tag=night") { t.Errorf("request = %s?%s, want the per-call key and tag", c.path, c.query) } } // ---- services ---------------------------------------------------------------- const detailsBody = `{"version":"1.9.4","asset":{},"schemas":[ {"service_name":"Telegram","service_url":"https://telegram.org/","setup_url":"https://github.com/caronc/apprise/wiki/Notify_telegram", "category":"native","attachment_support":true,"protocols":null,"secure_protocols":["tgram"], "details":{"templates":["{schema}://{bot_token}"]}}, {"service_name":"Ntfy","service_url":"https://ntfy.sh/","setup_url":"", "category":"native","attachment_support":true,"protocols":["ntfy"],"secure_protocols":["ntfys"], "details":{"templates":["{schema}://{topic}"]}}]}` func TestServices(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, detailsBody) }) p := newPlugin(t, f, nil) var res ServicesResult if err := invoke(t, p, "services", "", &res); err != nil { t.Fatalf("services: %v", err) } if c := f.last(); c.path != "/details" || c.query != "" { t.Fatalf("request = %s?%s, want /details with no query", c.path, c.query) } if res.Version != "1.9.4" || res.Count != 2 { t.Fatalf("result = version %q, count %d", res.Version, res.Count) } tg := res.Services[0] if tg.Name != "Telegram" || !tg.AttachmentSupport || len(tg.SecureProtocols) != 1 { t.Errorf("Telegram = %+v", tg) } if tg.Protocols != nil { t.Errorf("a null protocol list should stay empty, got %v", tg.Protocols) } if tg.Enabled != nil { t.Error("enabled is only reported when disabled services were asked for") } // The catalogue's per-service templates are dropped: they are hundreds of KiB // and nothing here consumes them. out, _ := json.Marshal(res) if strings.Contains(string(out), "templates") { t.Error("the raw service templates should not be passed through") } if err := invoke(t, p, "services", `{"all":true}`, nil); err != nil { t.Fatalf("services all: %v", err) } if c := f.last(); !strings.Contains(c.query, "all=yes") { t.Errorf("query = %q, want all=yes", c.query) } } // ---- status ------------------------------------------------------------------ func TestStatusJSON(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, statusOK) }) p := newPlugin(t, f, nil) var st Status if err := invoke(t, p, "status", "", &st); err != nil { t.Fatalf("status: %v", err) } if !st.OK || !st.StatefulEnabled || !st.CanWriteConfig || st.MaxAttachments != 6 { t.Fatalf("status = %+v", st) } if len(st.Details) != 1 || st.Details[0] != "OK" { t.Errorf("details = %v, want [OK]", st.Details) } } // Apprise answers 417 — not 500 — when it finds a problem with itself, and the // body still says which. That is an answer, not a transport failure. func TestStatusProblemIsStillParsed(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, statusExpectationFail, `{"config_lock":false,"attach_lock":false,"stateful_enabled":true, "max_attachments":6,"attach_size":200, "status":{"persistent_storage":false,"can_write_config":false,"can_write_attach":true, "details":["CONFIG_PERMISSION_ISSUE"]}}`) }) p := newPlugin(t, f, nil) var st Status if err := invoke(t, p, "status", "", &st); err != nil { t.Fatalf("a 417 should still parse: %v", err) } if st.OK { t.Error("a permission issue is not OK") } if len(st.Details) != 1 || st.Details[0] != "CONFIG_PERMISSION_ISSUE" { t.Errorf("details = %v", st.Details) } } // A proxy that strips the Accept header, or an older release, answers /status in // plain text with the same codes. func TestStatusPlainTextFallback(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte("OK")) }) p := newPlugin(t, f, nil) var st Status if err := invoke(t, p, "status", "", &st); err != nil { t.Fatalf("plain-text status should be read, not rejected: %v", err) } if !st.OK || len(st.Details) != 1 || st.Details[0] != "OK" { t.Errorf("status = %+v", st) } } // An HTML error page from something that is not Apprise must not be mistaken for // a health line. func TestStatusRejectsNonAppriseBody(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte("Not Apprise")) }) p := newPlugin(t, f, nil) if err := invoke(t, p, "status", "", nil); err == nil { t.Fatal("an HTML page should not pass as a status") } } // ---- health ------------------------------------------------------------------ func TestHealthCheckOKWithConfigKey(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { if strings.HasPrefix(c.path, "/json/urls/") { writeJSON(w, http.StatusOK, targetsBody) return } writeJSON(w, http.StatusOK, statusOK) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault"}) h := p.HealthCheck(context.Background()) if h.Status != plugins.StatusOK { t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail) } if !strings.Contains(h.Detail, "2 target") { t.Errorf("detail should count the targets, got %q", h.Detail) } } // A reachable server whose config holds nothing to notify is degraded, not down: // the half we address works, and the missing half is the operator's config. func TestHealthCheckDegradedWhenKeyIsEmpty(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { if strings.HasPrefix(c.path, "/json/urls/") { w.WriteHeader(http.StatusNoContent) return } writeJSON(w, http.StatusOK, statusOK) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault"}) h := p.HealthCheck(context.Background()) if h.Status != plugins.StatusDegraded { t.Fatalf("status = %q, want degraded", h.Status) } if !strings.Contains(h.Detail, "no notification URLs") { t.Errorf("detail = %q", h.Detail) } } func TestHealthCheckDegradedWithNoDestination(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, statusOK) }) p := newPlugin(t, f, nil) h := p.HealthCheck(context.Background()) if h.Status != plugins.StatusDegraded { t.Fatalf("status = %q, want degraded", h.Status) } if !strings.Contains(h.Detail, "nothing to notify") { t.Errorf("detail = %q", h.Detail) } if len(f.calls) != 1 { t.Errorf("with nothing configured there is no key to read, so one call is enough; got %d", len(f.calls)) } } // A config key against a server that has stateful mode switched off can never // resolve, and saying so is more useful than an empty target list. func TestHealthCheckDegradedWhenStatefulDisabled(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, strings.Replace(statusOK, `"stateful_enabled":true`, `"stateful_enabled":false`, 1)) }) p := newPlugin(t, f, map[string]string{"configKey": "drivervault"}) h := p.HealthCheck(context.Background()) if h.Status != plugins.StatusDegraded { t.Fatalf("status = %q, want degraded", h.Status) } if !strings.Contains(h.Detail, "stateful config disabled") { t.Errorf("detail = %q", h.Detail) } } func TestHealthCheckOKStateless(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) { writeJSON(w, http.StatusOK, statusOK) }) p := newPlugin(t, f, map[string]string{"urls": "ntfy://a, ntfy://b, ntfy://c"}) h := p.HealthCheck(context.Background()) if h.Status != plugins.StatusOK { t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail) } if !strings.Contains(h.Detail, "3 configured URL") { t.Errorf("detail = %q", h.Detail) } } func TestHealthCheckDownWhenUnreachable(t *testing.T) { f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {}) p := newPlugin(t, f, nil) f.srv.Close() // the address stops answering h := p.HealthCheck(context.Background()) if h.Status != plugins.StatusDown { t.Fatalf("status = %q, want down", h.Status) } } func TestHealthCheckWithoutBaseURLIsDown(t *testing.T) { p := &Plugin{} if err := p.Init(context.Background(), map[string]string{}); err != nil { t.Fatalf("Init with no address should not fail, it should read as unhealthy: %v", err) } h := p.HealthCheck(context.Background()) if h.Status != plugins.StatusDown { t.Fatalf("status = %q, want down", h.Status) } if !strings.Contains(h.Detail, "base URL") { t.Errorf("detail should say what is missing, got %q", h.Detail) } } // ---- contract ---------------------------------------------------------------- func TestUnknownAction(t *testing.T) { p := &Plugin{} if _, err := p.Invoke(context.Background(), "explode", nil); err == nil { t.Fatal("an unknown action should be refused") } } func TestShutdownIsSafe(t *testing.T) { if err := (&Plugin{}).Shutdown(context.Background()); err != nil { t.Fatalf("Shutdown: %v", err) } } // The manager builds an instance and may never reach Init before a probe; that // must not panic on a nil client. func TestUninitializedPluginDoesNotPanic(t *testing.T) { p := &Plugin{} if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown { t.Fatalf("status = %q, want down", h.Status) } } func TestRegisteredWithTheRegistry(t *testing.T) { // Register panics on a duplicate, so a second registration under the same // name would already have failed at init. This pins the name itself. if got := (&Plugin{}).Descriptor().Name; got != "apprise" { t.Fatalf("registered name = %q", got) } }