package notify_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "bookmarkmanager/backend/internal/latest" "bookmarkmanager/backend/internal/notify" ) // TestNotifyEmbedShape pins the wire shape Discord actually reads: one embed // in the danger colour with the subject as a deep-linked title built from the // base URL, the sentence as the description, the pass time as an RFC3339 // timestamp, the machine word in the footer, and no fields grid (nor // thumbnail, nor author block) at all. The webhook is a local server, so no // test can reach Discord. func TestNotifyEmbedShape(t *testing.T) { var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if ct := r.Header.Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Errorf("decode request body: %v", err) } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() passTime := time.UnixMilli(5_000_000).UTC() c := notify.New(srv.URL, "https://bookmarks.test/") err := c.Notify(context.Background(), latest.Fault{Condition: latest.ConditionStall, Site: "comix", Since: passTime.UnixMilli()}, "sentence", "/admin/lanes") if err != nil { t.Fatalf("Notify: %v", err) } embeds, ok := body["embeds"].([]any) if !ok || len(embeds) != 1 { t.Fatalf("embeds = %#v, want exactly one embed", body["embeds"]) } embed, ok := embeds[0].(map[string]any) if !ok { t.Fatalf("embed = %#v, want an object", embeds[0]) } if color, ok := embed["color"].(float64); !ok || int(color) != 13589581 { t.Fatalf("color = %#v, want 13589581 (--danger #cf5c4d)", embed["color"]) } if url := embed["url"]; url != "https://bookmarks.test/admin/lanes" { t.Fatalf("url = %#v, want the title deep-linked off the base URL", url) } if desc := embed["description"]; desc != "sentence" { t.Fatalf("description = %#v, want the sentence", desc) } footer, ok := embed["footer"].(map[string]any) if !ok || footer["text"] != latest.ConditionStall { t.Fatalf("footer = %#v, want the machine word in the footer", embed["footer"]) } ts, ok := embed["timestamp"].(string) if !ok { t.Fatalf("timestamp = %#v, want an RFC3339 string", embed["timestamp"]) } parsed, err := time.Parse(time.RFC3339, ts) if err != nil || !parsed.Equal(passTime) { t.Fatalf("timestamp = %q, want %s (the pass time, RFC3339)", ts, passTime.Format(time.RFC3339)) } for _, banned := range []string{"fields", "thumbnail", "author"} { if _, ok := embed[banned]; ok { t.Fatalf("embed has %q, want it absent (no field grid, no thumbnail, no author block)", banned) } } } // A non-2xx answer is an error the caller logs, and the error never carries // the webhook address — a secret in the class of TOKEN_KEY, and the poller // logs every notify error. func TestNotifyNon2xxIsErrorWithoutTheAddress(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() c := notify.New(srv.URL, "https://bookmarks.test") err := c.Notify(context.Background(), latest.Fault{Condition: latest.ConditionStall, Site: "comix", Since: 5_000_000}, "sentence", "/admin/lanes") if err == nil { t.Fatal("Notify = nil, want an error for a 500") } if strings.Contains(err.Error(), srv.URL) { t.Fatalf("error %q leaks the webhook address", err) } }