e93e79c1bb
Implements spec #137 (spec 4 of 4 from wayfinder map #114). Closes #137. Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch. ## Summary - #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate. - #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion. - #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition. - #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing. - #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier. - #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree. `cd backend && go test ./...` green on the merged branch (8 packages). Reviewed-on: #174 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
101 lines
3.5 KiB
Go
101 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|