package notify
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"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}})
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)
}