Skip to content
quarry Artifact [a85e202156]
Login

Artifact [a85e202156]

Artifact a85e202156d57dedd09bbe24a202bc430005eee2176fb1dc4f37144eff3b8c40:


package notify

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

// call is one request the Phase 3 fake saw, with its query and decoded body.
type call struct {
	method, path, query string
	body                map[string]any
}

func phase3Server(t *testing.T, h func(w http.ResponseWriter, r *http.Request)) (*httptest.Server, *[]call) {
	t.Helper()
	var seen []call
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var body map[string]any
		if b, _ := io.ReadAll(r.Body); len(b) > 0 {
			json.Unmarshal(b, &body)
		}
		seen = append(seen, call{r.Method, r.URL.Path, r.URL.RawQuery, body})
		h(w, r)
	}))
	t.Cleanup(srv.Close)
	return srv, &seen
}

// MarkMessages by id posts notify's bulk route (notify#300) with the
// ids and the target state, never the per-message routes: one call, one
// transaction, whatever the page selected.
func TestMarkMessagesByID(t *testing.T) {
	srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	if err := New(srv.URL, "k").MarkMessages(context.Background(), "dan", []string{"m1", "m2"}, false, "read"); err != nil {
		t.Fatal(err)
	}
	c := (*seen)[0]
	if c.method != "POST" || c.path != "/v1/messages/mark" {
		t.Fatalf("route = %s %s", c.method, c.path)
	}
	if c.body["subscriberId"] != "dan" || c.body["as"] != "read" {
		t.Fatalf("body = %+v", c.body)
	}
	ids, _ := c.body["ids"].([]any)
	if len(ids) != 2 || ids[0] != "m1" {
		t.Fatalf("ids = %+v", c.body["ids"])
	}
	if _, ok := c.body["all"]; ok {
		t.Fatalf("all must be absent when ids are named: %+v", c.body)
	}
}

// "all" is the whole-inbox form: no ids, all:true. A page that cannot
// say "clear everything" leaves people scrolling to zero by hand.
func TestMarkMessagesAll(t *testing.T) {
	srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	if err := New(srv.URL, "k").MarkMessages(context.Background(), "dan", nil, true, "archived"); err != nil {
		t.Fatal(err)
	}
	c := (*seen)[0]
	if c.body["all"] != true || c.body["as"] != "archived" {
		t.Fatalf("body = %+v", c.body)
	}
	if _, ok := c.body["ids"]; ok {
		t.Fatalf("ids must be absent on an all-mark: %+v", c.body)
	}
}

// Messages decodes notify#301's envelope: the page, the cursor for the
// next one, and the two counts a badge reads.
func TestMessagesEnvelope(t *testing.T) {
	srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"data":[{"ID":"m1","Subject":"s","WorkflowID":"quarry-mention",
			"CreatedAt":"2026-08-16T00:00:00Z"}],"next":"m0","unreadCount":3,"unseenCount":4}`))
	})
	msgs, next, unread, unseen, err := New(srv.URL, "k").Messages(context.Background(), "dan", "m9", 20)
	if err != nil {
		t.Fatal(err)
	}
	if len(msgs) != 1 || msgs[0].ID != "m1" || msgs[0].WorkflowID != "quarry-mention" {
		t.Fatalf("msgs = %+v", msgs)
	}
	if next != "m0" || unread != 3 || unseen != 4 {
		t.Fatalf("next=%q unread=%d unseen=%d", next, unread, unseen)
	}
	q := (*seen)[0].query
	for _, want := range []string{"subscriberId=dan", "channel=in_app", "limit=20", "before=m9"} {
		if !contains(q, want) {
			t.Errorf("query %q missing %q", q, want)
		}
	}
}

// Until notify#301 lands, /v1/messages still answers a bare array. That
// body must page correctly rather than decode-error the whole inbox —
// the quarry half ships before the notify half.
func TestMessagesBareArrayFallback(t *testing.T) {
	srv, _ := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`  [{"ID":"m1","Subject":"s","Read":false},{"ID":"m2","Read":true}]`))
	})
	msgs, next, unread, unseen, err := New(srv.URL, "k").Messages(context.Background(), "dan", "", 0)
	if err != nil {
		t.Fatal(err)
	}
	if len(msgs) != 2 || next != "" {
		t.Fatalf("msgs=%+v next=%q", msgs, next)
	}
	// Counted here, since the old body carries no counts at all.
	if unread != 1 || unseen != 2 {
		t.Fatalf("unread=%d unseen=%d", unread, unseen)
	}
}

func TestUnreadCount(t *testing.T) {
	srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"count":7}`))
	})
	n, err := New(srv.URL, "k").UnreadCount(context.Background(), "dan")
	if err != nil {
		t.Fatal(err)
	}
	if n != 7 {
		t.Fatalf("count = %d", n)
	}
	c := (*seen)[0]
	if c.path != "/v1/messages/count" || !contains(c.query, "unread=true") {
		t.Fatalf("route = %s?%s", c.path, c.query)
	}
}

// notify#301's count route may not exist yet. A 404 there must degrade
// to counting the page, not report "you have nothing".
func TestUnreadCountFallsBackWhenRouteIsAbsent(t *testing.T) {
	srv, _ := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/v1/messages/count" {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		w.Write([]byte(`[{"ID":"m1","Read":false},{"ID":"m2","Read":false},{"ID":"m3","Read":true}]`))
	})
	n, err := New(srv.URL, "k").UnreadCount(context.Background(), "dan")
	if err != nil {
		t.Fatal(err)
	}
	if n != 2 {
		t.Fatalf("count = %d, want 2", n)
	}
}

func TestWebPushKey(t *testing.T) {
	srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"publicKey":"BK-base64url"}`))
	})
	key, err := New(srv.URL, "k").WebPushKey(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if key != "BK-base64url" {
		t.Fatalf("key = %q", key)
	}
	if (*seen)[0].path != "/v1/integrations/webpush/key" {
		t.Fatalf("path = %q", (*seen)[0].path)
	}
}

func TestPutWebPush(t *testing.T) {
	srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	sub := PushSubscription{Endpoint: "https://push.example/xyz",
		Keys: PushKeys{P256dh: "p", Auth: "a"}}
	if err := New(srv.URL, "k").PutWebPush(context.Background(), "dan", sub); err != nil {
		t.Fatal(err)
	}
	c := (*seen)[0]
	if c.method != "PUT" || c.path != "/v1/subscribers/dan/credentials" {
		t.Fatalf("route = %s %s", c.method, c.path)
	}
	if c.body["providerId"] != "webpush" {
		t.Fatalf("providerId = %v", c.body["providerId"])
	}
	creds, _ := c.body["credentials"].(map[string]any)
	if creds["endpoint"] != "https://push.example/xyz" {
		t.Fatalf("credentials = %+v", creds)
	}
	keys, _ := creds["keys"].(map[string]any)
	if keys["p256dh"] != "p" || keys["auth"] != "a" {
		t.Fatalf("keys = %+v", keys)
	}
}

// Every Phase 3 call on a disabled client is a no-op, like every other
// call on this thin edge: a forge with no --notify has no inbox, not an
// error surface.
func TestPhase3DisabledClientIsQuiet(t *testing.T) {
	c := New("", "")
	ctx := context.Background()
	if err := c.MarkMessages(ctx, "dan", []string{"m1"}, false, "read"); err != nil {
		t.Error(err)
	}
	if msgs, _, _, _, err := c.Messages(ctx, "dan", "", 0); err != nil || msgs != nil {
		t.Error(msgs, err)
	}
	if n, err := c.UnreadCount(ctx, "dan"); err != nil || n != 0 {
		t.Error(n, err)
	}
	if k, err := c.WebPushKey(ctx); err != nil || k != "" {
		t.Error(k, err)
	}
	if err := c.PutWebPush(ctx, "dan", PushSubscription{Endpoint: "e"}); err != nil {
		t.Error(err)
	}
	if err := c.DeleteWebPush(ctx, "dan", "e"); err != nil {
		t.Error(err)
	}
	if p, err := c.Preferences(ctx, "dan"); err != nil || p.Workflows != nil {
		t.Error(p, err)
	}
	if err := c.SetPreferences(ctx, "dan", nil, nil); err != nil {
		t.Error(err)
	}
}

func contains(hay, needle string) bool {
	for i := 0; i+len(needle) <= len(hay); i++ {
		if hay[i:i+len(needle)] == needle {
			return true
		}
	}
	return false
}