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, nil); 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}}, nil)
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)
}
}