Skip to content
quarry Artifact [cd82537bf5]
Login

Artifact [cd82537bf5]

Artifact cd82537bf5bad2be0312d44f3ce646df76e386b4114c3cf12ddb440d1fc7b20e:


package notify

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
)

// prefsFake is notify's shipped subscriber-preferences pair: a GET that
// reports every global channel explicitly, and a PUT that MERGES what it
// is given onto what it holds. Stateful because the round trip is the
// thing under test — a fake that echoed the request back would let a
// client that writes the wrong keys pass.
type prefsFake struct {
	global    map[string]bool
	workflows map[string]map[string]bool
	sawGlobal bool
	puts      int
}

func newPrefsFake() *prefsFake {
	return &prefsFake{
		global:    map[string]bool{"in_app": true, "push": true, "email": true, "sms": true, "chat": true},
		workflows: map[string]map[string]bool{},
	}
}

func (f *prefsFake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodPut {
		f.puts++
		var in SubscriberPreferences
		if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		if in.Global != nil {
			f.sawGlobal = true
		}
		for ch, on := range in.Global {
			f.global[ch] = on
		}
		for wf, channels := range in.Workflows {
			if f.workflows[wf] == nil {
				f.workflows[wf] = map[string]bool{}
			}
			for ch, on := range channels {
				f.workflows[wf][ch] = on
			}
		}
	}
	json.NewEncoder(w).Encode(SubscriberPreferences{Global: f.global, Workflows: f.workflows})
}

// The preferences pair round-trips: what SetPreferences writes under a
// trigger identifier is what Preferences reads back, cell for cell —
// including the FALSE cells, because notify reads an absent channel as
// enabled and an unticked box that arrives as absence is a box that
// never took.
func TestPreferencesRoundTrip(t *testing.T) {
	fake := newPrefsFake()
	srv := httptest.NewServer(fake)
	t.Cleanup(srv.Close)
	c := New(srv.URL, "k")
	ctx := context.Background()

	want := map[string]map[string]bool{
		"quarry-mention":     {"in_app": true, "push": false, "email": true},
		"quarry-forum-reply": {"in_app": true, "push": false, "email": false},
	}
	if err := c.SetPreferences(ctx, "dan", want); err != nil {
		t.Fatal(err)
	}
	got, err := c.Preferences(ctx, "dan")
	if err != nil {
		t.Fatal(err)
	}
	for wf, channels := range want {
		for ch, on := range channels {
			if got.Workflows[wf][ch] != on {
				t.Fatalf("%s.%s = %v, want %v (read back %+v)", wf, ch, got.Workflows[wf][ch], on, got.Workflows)
			}
		}
	}
	// Global is notify's own surface and quarry's matrix has no cell
	// for it: writing it here would silence a channel somebody set
	// somewhere quarry cannot even show them.
	if fake.sawGlobal {
		t.Fatal("quarry wrote the global preferences section")
	}
	for _, ch := range []string{"in_app", "push", "email", "sms", "chat"} {
		if !got.Global[ch] {
			t.Fatalf("global %s came back off: %+v", ch, got.Global)
		}
	}
}

// The PUT body is exactly one section. Asserted on the literal wire
// keys, because a section named wrong is a write notify accepts and
// silently applies to nothing.
func TestSetPreferencesWritesOnlyTheWorkflowsSection(t *testing.T) {
	var body map[string]any
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&body)
		w.Write([]byte(`{"global":{},"workflows":{}}`))
	}))
	t.Cleanup(srv.Close)
	err := New(srv.URL, "k").SetPreferences(context.Background(), "dan",
		map[string]map[string]bool{"quarry-mention": {"push": false}})
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := body["global"]; ok {
		t.Fatalf("body carries a global section: %+v", body)
	}
	workflows, ok := body["workflows"].(map[string]any)
	if !ok {
		t.Fatalf("body = %+v", body)
	}
	mention, ok := workflows["quarry-mention"].(map[string]any)
	if !ok {
		t.Fatalf("workflows = %+v", workflows)
	}
	if mention["push"] != false {
		t.Fatalf("quarry-mention = %+v", mention)
	}
}

// A 404 on the preferences GET is not "everything is on". It is quarry
// having no idea what notify holds — the caller must be able to tell
// that from a read that succeeded and found nothing.
func TestPreferencesUnreadableReportsRouteMissing(t *testing.T) {
	srv, _ := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusNotFound)
	})
	_, err := New(srv.URL, "k").Preferences(context.Background(), "dan")
	if !errors.Is(err, ErrRouteMissing) {
		t.Fatalf("404 must be distinguishable from an empty read, got %v", err)
	}
}

// The per-device credential DELETE notify shipped: 204 is done, and so
// is 404 — the end state unsubscribe asked for is that this endpoint no
// longer receives, which a missing credential already is. A 5xx is NOT,
// and must stay an error so the caller keeps the row and retries.
func TestDeleteWebPushStatuses(t *testing.T) {
	for _, tc := range []struct {
		code    int
		wantErr bool
	}{
		{http.StatusNoContent, false},
		{http.StatusNotFound, false},
		{http.StatusGone, false},
		{http.StatusInternalServerError, true},
	} {
		srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(tc.code)
		})
		err := New(srv.URL, "k").DeleteWebPush(context.Background(), "dan", "https://push.example/xyz")
		if (err != nil) != tc.wantErr {
			t.Errorf("%d: err = %v, wantErr %v", tc.code, err, tc.wantErr)
		}
		c := (*seen)[0]
		if c.method != "DELETE" || c.path != "/v1/subscribers/dan/credentials/webpush" {
			t.Errorf("%d: route = %s %s", tc.code, c.method, c.path)
		}
		if !contains(c.query, "endpoint=https%3A%2F%2Fpush.example%2Fxyz") {
			t.Errorf("%d: query = %q", tc.code, c.query)
		}
		srv.Close()
	}
}

// A page whose rows carry their own trigger identifier costs NO
// workflow lookup. That is the whole point of the field: the map was a
// round trip per page spent working out what quarry already named.
func TestIdentifyPrefersTheMessageField(t *testing.T) {
	calls := 0
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls++
		w.Write([]byte(`[{"id":"01JQMENTION","triggerIdentifier":"quarry-mention"}]`))
	}))
	t.Cleanup(srv.Close)
	names := NewWorkflowNames(New(srv.URL, "k"))
	got := names.Identify(context.Background(), []Message{
		{ID: "m1", WorkflowID: "01JQMENTION", WorkflowIdentifier: "quarry-mention"},
		{ID: "m2", WorkflowID: "01JQMENTION", WorkflowIdentifier: "quarry-mention"},
	})
	if calls != 0 {
		t.Fatalf("the workflow list was asked %d times for a page that names its own workflows", calls)
	}
	if got["m1"] != "quarry-mention" || got["m2"] != "quarry-mention" {
		t.Fatalf("identifiers = %+v", got)
	}
}

// A row from an older notify carries no identifier, and the map is
// still the fallback that makes its reason quarry's own word.
func TestIdentifyFallsBackToTheMapForOlderRows(t *testing.T) {
	calls := 0
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls++
		w.Write([]byte(`[{"id":"01JQMENTION","triggerIdentifier":"quarry-mention"}]`))
	}))
	t.Cleanup(srv.Close)
	names := NewWorkflowNames(New(srv.URL, "k"))
	got := names.Identify(context.Background(), []Message{
		{ID: "m1", WorkflowID: "01JQMENTION"},
		{ID: "m2", WorkflowID: "01JQMENTION", WorkflowIdentifier: "quarry-mention"},
	})
	if calls == 0 {
		t.Fatal("a row with no identifier must still be resolved through the map")
	}
	if got["m1"] != "quarry-mention" || got["m2"] != "quarry-mention" {
		t.Fatalf("identifiers = %+v", got)
	}
}

// notify's message rows carry the identifier under its Go field name,
// like every other field on domain.Message: no json tags, so the
// encoder falls back to the identifier.
func TestMessagesDecodeTheWorkflowIdentifier(t *testing.T) {
	srv, _ := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"data":[{"ID":"m1","WorkflowID":"01JQ","WorkflowIdentifier":"quarry-mention"}],
			"next":"","unreadCount":1,"unseenCount":1}`))
	})
	msgs, _, _, _, err := New(srv.URL, "k").Messages(context.Background(), "dan", "", 0)
	if err != nil {
		t.Fatal(err)
	}
	if len(msgs) != 1 || msgs[0].WorkflowIdentifier != "quarry-mention" {
		t.Fatalf("msgs = %+v", msgs)
	}
}