package notify
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// GET /v1/workflows is the only place the ULID a message carries can be
// turned back into the trigger identifier quarry named the workflow by
// (notify's workflowDTO: id, name, triggerIdentifier).
func TestWorkflowsListsIDAndTriggerIdentifier(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/workflows" {
t.Errorf("path = %q", r.URL.Path)
}
w.Write([]byte(`[{"id":"01J0AAAA","name":"quarry mention","triggerIdentifier":"quarry-mention"}]`))
}))
t.Cleanup(srv.Close)
got, err := New(srv.URL, "k").Workflows(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "01J0AAAA" || got[0].TriggerIdentifier != "quarry-mention" {
t.Fatalf("workflows = %+v", got)
}
}
// The envelope form is read too, for decodeMessages' reason.
func TestWorkflowsReadsAnEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"01J0BBBB","triggerIdentifier":"quarry-landing"}]}`))
}))
t.Cleanup(srv.Close)
got, err := New(srv.URL, "k").Workflows(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].TriggerIdentifier != "quarry-landing" {
t.Fatalf("workflows = %+v", got)
}
}
// A message's WorkflowID is a ULID. The map turns it into the trigger
// identifier, caches it, and refreshes at most once for a whole page of
// unknown ids.
func TestWorkflowNamesResolvesAndCaches(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Write([]byte(`[{"id":"01J0AAAA","triggerIdentifier":"quarry-mention"},
{"id":"01J0BBBB","triggerIdentifier":"quarry-forum-reply"}]`))
}))
t.Cleanup(srv.Close)
names := NewWorkflowNames(New(srv.URL, "k"))
ctx := context.Background()
got := names.Identifiers(ctx, []string{"01J0AAAA", "01J0BBBB"})
if got["01J0AAAA"] != "quarry-mention" || got["01J0BBBB"] != "quarry-forum-reply" {
t.Fatalf("identifiers = %+v", got)
}
if n := atomic.LoadInt32(&hits); n != 1 {
t.Fatalf("a page of unknown ids must cost ONE refresh, got %d", n)
}
// A second page of the same ids is served from the cache.
names.Identifiers(ctx, []string{"01J0AAAA"})
if n := atomic.LoadInt32(&hits); n != 1 {
t.Fatalf("refreshes = %d, want the cache to answer", n)
}
// And the reverse direction, for a preference payload keyed by ULID.
if id := names.ID(ctx, "quarry-forum-reply"); id != "01J0BBBB" {
t.Fatalf("ID(quarry-forum-reply) = %q", id)
}
}
// notify being down must never block a page: an unresolvable id falls
// back to itself, and the failure is not retried on every row.
func TestWorkflowNamesFallsBackWhenNotifyIsDown(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
names := NewWorkflowNames(New(srv.URL, "k"))
ctx := context.Background()
if got := names.Identifiers(ctx, []string{"01J0AAAA"})["01J0AAAA"]; got != "01J0AAAA" {
t.Fatalf("fallback = %q, want the id verbatim", got)
}
names.Identifiers(ctx, []string{"01J0CCCC"})
if n := atomic.LoadInt32(&hits); n != 1 {
t.Fatalf("a failed refresh must be rate-limited, got %d attempts", n)
}
}
// A companion that does not answer the preferences pair — 404 because
// the subscriber is unknown there, 405 because something else is
// mounted at the path — is not a failure the caller should raise. It is
// "quarry does not know what notify holds", which a settings page shows
// as UNKNOWN rather than as a default it just made up.
func TestUnansweredPreferencesReportErrRouteMissing(t *testing.T) {
for _, code := range []int{http.StatusNotFound, http.StatusMethodNotAllowed} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
}))
c := New(srv.URL, "k")
err := c.SetPreferences(context.Background(), "dan",
map[string]map[string]bool{"quarry-mention": {"push": false}}, nil)
if !errors.Is(err, ErrRouteMissing) {
t.Errorf("set preferences on %d: err = %v", code, err)
}
if _, err := c.Preferences(context.Background(), "dan"); !errors.Is(err, ErrRouteMissing) {
t.Errorf("get preferences on %d: err = %v", code, err)
}
srv.Close()
}
}
// An inbox row that leads nowhere is dead text. Every in_app step
// carries the redirect notify stores as extras.redirect.url — which is
// where Message.Link reads a row's destination from.
func TestEveryInAppStepCarriesARedirect(t *testing.T) {
seen := 0
for _, wf := range Workflows() {
for _, st := range wf.Steps {
if st.Type != "in_app" {
continue
}
seen++
rd, ok := st.ControlValues["redirect"].(map[string]any)
if !ok {
t.Errorf("FAIL: %s step %s has no redirect", wf.Name, st.StepID)
continue
}
if url, _ := rd["url"].(string); url != "{{payload.link}}" {
t.Errorf("FAIL: %s step %s redirects to %q, not the trigger's link",
wf.Name, st.StepID, url)
}
}
}
if seen == 0 {
t.Fatal("FAIL: no in_app step was checked")
}
t.Logf("ok: %d in_app steps carry the trigger's link", seen)
}
// A push notification says what it is about in its title. notify's push
// editor authors "title" while its delivery envelope renders "subject"
// (notify#334), so every push step carries both — otherwise the title
// arrives empty and every notification reads the same.
func TestEveryPushStepTitlesItself(t *testing.T) {
seen := 0
for _, wf := range Workflows() {
for _, st := range wf.Steps {
if st.Type != "push" {
continue
}
seen++
title, _ := st.ControlValues["title"].(string)
subject, _ := st.ControlValues["subject"].(string)
if title == "" || subject != title {
t.Errorf("FAIL: %s step %s titles as %q/%q — both spellings must carry it",
wf.Name, st.StepID, title, subject)
}
if body, _ := st.ControlValues["body"].(string); body == "" {
t.Errorf("FAIL: %s step %s has no body", wf.Name, st.StepID)
}
}
}
if seen == 0 {
t.Fatal("FAIL: no push step was checked")
}
t.Logf("ok: %d push steps carry a title in both spellings", seen)
}
// A digest that batches every repository together answers no question
// anybody has, and copy that reads "1 updates" for the commonest case
// is worse than no digest at all.
func TestActivityDigestIsPerRepoAndCounts(t *testing.T) {
var digest, row WorkflowStep
for _, wf := range Workflows() {
if wf.Name != "quarry-activity" {
continue
}
for _, st := range wf.Steps {
switch st.Type {
case "digest":
digest = st
case "in_app":
row = st
}
}
}
if digest.Type != "digest" || row.Type != "in_app" {
t.Fatal("FAIL: quarry-activity is no longer a digest into an inbox row")
}
// Rooted: notify resolves a single-segment path to "" and merges
// every event into one bucket without saying so.
if key, _ := digest.ControlValues["digestKey"].(string); key != "payload.repo" {
t.Errorf("FAIL: the window groups by %q, want payload.repo", key)
}
// Two hours by default: often enough to be news, rare enough that a
// busy repository is not why somebody mutes the forge.
if amount, _ := digest.ControlValues["amount"].(int); amount != 2 {
t.Errorf("FAIL: the window is %v long", digest.ControlValues["amount"])
}
if unit, _ := digest.ControlValues["unit"].(string); unit != "hours" {
t.Errorf("FAIL: the window is measured in %q", unit)
}
subject, _ := row.ControlValues["subject"].(string)
body, _ := row.ControlValues["body"].(string)
// The batch says how many and which; the single event still reads
// as itself.
for _, want := range []string{"digest.count", "digest.events.[1]", "payload.summary"} {
if !strings.Contains(subject, want) {
t.Errorf("FAIL: the subject cannot tell a batch from one event: missing %q", want)
}
}
if !strings.Contains(body, "each digest.events") {
t.Error("FAIL: the batch body does not list what happened")
}
t.Log("ok: activity digests per repository and says how many")
}
// Every family carries an email step: plain text with the subject, the
// body and the trigger's link, and — for the ones a person answers — the
// reply opt-in that makes notify mint a Reply-To. A digest is not
// answered and must not promise it. Nothing outside the four families
// mails anyone.
func TestEmailStepsMatchTheFamilies(t *testing.T) {
want := map[string]bool{"quarry-mention": true, "quarry-forum-reply": true,
"quarry-landing": true, "quarry-activity": false}
if fams := EmailFamilies(); len(fams) != len(want) {
t.Fatalf("FAIL: EmailFamilies lists %d, want %d", len(fams), len(want))
}
seen := map[string]bool{}
for _, wf := range Workflows() {
for _, st := range wf.Steps {
if st.Type != "email" {
continue
}
reply, listed := want[wf.Name]
if !listed {
t.Errorf("FAIL: %s has an email step but is not a family", wf.Name)
continue
}
seen[wf.Name] = true
subject, _ := st.ControlValues["subject"].(string)
body, _ := st.ControlValues["body"].(string)
if subject == "" || body == "" {
t.Errorf("FAIL: %s email step lacks subject or body", wf.Name)
}
if !strings.Contains(body, ".link}}") {
t.Errorf("FAIL: %s email leads nowhere", wf.Name)
}
if got, _ := st.ControlValues["reply"].(bool); got != reply {
t.Errorf("FAIL: %s email reply opt-in is %v, want %v", wf.Name, got, reply)
}
}
}
for name := range want {
if !seen[name] {
t.Errorf("FAIL: %s has no email step", name)
}
}
t.Logf("ok: %d families carry an email step", len(seen))
}
// Every email step carries the card: the per-event ones read it from the
// payload, and the digest's is the card itself with Handlebars in it. The
// reply hint must agree with the step's own reply flag.
func TestEveryEmailStepCarriesTheCard(t *testing.T) {
for _, def := range Workflows() {
for _, st := range def.Steps {
if st.Type != "email" {
continue
}
got, _ := st.ControlValues["htmlBody"].(string)
id := def.TriggerIdentifier
if id != "quarry-activity" {
if got != `{{raw "payload.html"}}` {
t.Errorf("%s: htmlBody %q is not the raw payload.html", id, got)
}
continue
}
for _, want := range []string{"<!doctype html>", `class="q-card"`, "{{#each digest.events}}",
`href="{{payload.link}}"`, "{{payload.repo}}", "@media (prefers-color-scheme: dark)"} {
if !strings.Contains(got, want) {
t.Errorf("%s: htmlBody lacks %q", id, want)
}
}
if strings.Contains(got, "q.invalid") {
t.Errorf("%s: a placeholder survived", id)
}
reply, _ := st.ControlValues["reply"].(bool)
if hint := strings.Contains(got, "reply to this email"); hint != reply {
t.Errorf("%s: reply hint %v, step reply %v", id, hint, reply)
}
}
}
}