package notify import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/cdnutter/playlink/internal/store" ) type got struct { path string header http.Header body string } func server(t *testing.T, status int) (*httptest.Server, chan got) { t.Helper() ch := make(chan got, 10) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) ch <- got{path: r.URL.Path, header: r.Header, body: string(b)} w.WriteHeader(status) _, _ = w.Write([]byte("nope")) })) t.Cleanup(srv.Close) return srv, ch } func newNotifier(t *testing.T) *Notifier { t.Helper() st, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.DB.Close() }) return New(st, slog.New(slog.NewTextHandler(io.Discard, nil))) } var ev = Event{Kind: EventReview, PairID: 3, Pair: "Music", Title: "New songs to review", Message: "2 songs need review.", Path: "#/pair/3/review"} func TestPayloads(t *testing.T) { n := newNotifier(t) srv, ch := server(t, 200) pushoverURL = srv.URL + "/pushover" ctx := context.Background() base := "https://playlink.lan" link := "https://playlink.lan/#/pair/3/review" check := func(tg Target, f func(g got)) { t.Helper() if err := n.send(ctx, tg, base, ev); err != nil { t.Fatalf("%s: %v", tg.Type, err) } f(<-ch) } check(Target{Type: TypeDiscord, URL: srv.URL + "/discord"}, func(g got) { var b struct { Embeds []struct{ Title, Description, URL string } } json.Unmarshal([]byte(g.body), &b) if len(b.Embeds) != 1 || b.Embeds[0].Title != "Music: New songs to review" || b.Embeds[0].URL != link || b.Embeds[0].Description != ev.Message { t.Fatalf("discord: %s", g.body) } }) check(Target{Type: TypePushover, Token: "app", User: "me"}, func(g got) { v, _ := url.ParseQuery(g.body) if g.path != "/pushover" || v.Get("token") != "app" || v.Get("user") != "me" || v.Get("url") != link || v.Get("message") != ev.Message { t.Fatalf("pushover: %s %s", g.path, g.body) } }) check(Target{Type: TypeNtfy, URL: srv.URL + "/playlink", Token: "tk"}, func(g got) { if g.path != "/playlink" || g.body != ev.Message || g.header.Get("Title") != "Music: New songs to review" || g.header.Get("Click") != link || g.header.Get("Authorization") != "Bearer tk" { t.Fatalf("ntfy: %v %s", g.header, g.body) } }) check(Target{Type: TypeGotify, URL: srv.URL + "/", Token: "gk"}, func(g got) { if g.path != "/message" || g.header.Get("X-Gotify-Key") != "gk" || !strings.Contains(g.body, `"click":{"url":"`+link+`"}`) { t.Fatalf("gotify: %s %v %s", g.path, g.header, g.body) } }) check(Target{Type: TypeWebhook, URL: srv.URL + "/hook"}, func(g got) { var b map[string]any json.Unmarshal([]byte(g.body), &b) if b["event"] != "review" || b["pair"] != "Music" || b["url"] != link || b["pair_id"] != float64(3) { t.Fatalf("webhook: %s", g.body) } }) } func TestErrorsAreReported(t *testing.T) { n := newNotifier(t) srv, _ := server(t, 401) err := n.Test(context.Background(), Target{Type: TypeDiscord, URL: srv.URL}, "") if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "nope") { t.Fatalf("err = %v", err) } } func TestConfigValidationAndEvents(t *testing.T) { n := newNotifier(t) ctx := context.Background() if c := n.Config(ctx); !c.OnReview || !c.OnLinked || !c.OnCleanup || c.Targets == nil { t.Fatalf("defaults: %+v", c) } for _, bad := range []Target{ {Type: TypeDiscord, URL: "discord.com/x"}, {Type: TypePushover, Token: "a"}, {Type: TypeGotify, URL: "http://g"}, {Type: "smoke-signal"}, } { if err := n.SetConfig(ctx, Config{Targets: []Target{bad}}); err == nil { t.Fatalf("accepted %+v", bad) } } if err := n.SetConfig(ctx, Config{BaseURL: "playlink.lan"}); err == nil { t.Fatal("accepted a base URL without a scheme") } // Only enabled targets, and only events that are turned on. srv, ch := server(t, 200) c := Config{OnReview: false, OnLinked: true, BaseURL: "http://p/ ", Targets: []Target{ {ID: "a", Type: TypeWebhook, Name: " on ", Enabled: true, URL: srv.URL + "/on"}, {ID: "b", Type: TypeWebhook, Enabled: false, URL: srv.URL + "/off"}, }} if err := n.SetConfig(ctx, c); err != nil { t.Fatal(err) } if saved := n.Config(ctx); saved.BaseURL != "http://p" || saved.Targets[0].Name != "on" { t.Fatalf("not normalized: %+v", saved) } n.Notify(ctx, ev) // review is off n.Notify(ctx, Event{Kind: EventLinked, Title: "t", Message: "m"}) select { case g := <-ch: if g.path != "/on" || !strings.Contains(g.body, `"event":"linked"`) { t.Fatalf("sent %s %s", g.path, g.body) } case <-time.After(5 * time.Second): t.Fatal("nothing sent") } select { case g := <-ch: t.Fatalf("unexpected second message to %s: %s", g.path, g.body) case <-time.After(200 * time.Millisecond): } }