Skip to content
quarry Artifact [effafb6741]
Login

Artifact [effafb6741]

Artifact effafb6741f521eb14f10b2fd341e879fc03056ade9db3c1e1a56c5ece21c50c:


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}}", `{{raw "payload.mark"}}`, `src="{{payload.origin}}/apple-touch-icon.png"`, `src="{{payload.origin}}/wordmark.png"`, "@media (prefers-color-scheme: dark)"} {
				if !strings.Contains(got, want) {
					t.Errorf("%s: htmlBody lacks %q", id, want)
				}
			}
			if !strings.Contains(got, "background:#f4f4f4;") || strings.Contains(got, "f3ecda") {
				t.Errorf("%s: page wash is not the neutral grey", id)
			}
			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)
			}
		}
	}
}