package notify
import (
"context"
"encoding/json"
"errors"
"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)
}
}
// 410 Gone is the END STATE unsubscribe asked for: the push service
// already dropped that endpoint, so there is nothing left to do.
//
// 404 is NOT that, and reading it as success is how a device quarry
// forgot keeps being pushed to: the credential DELETE (notify#305) is
// not mounted yet, so a 404 means nobody was asked at all.
func TestDeleteWebPushTreatsGoneAsDoneButNotFound(t *testing.T) {
srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusGone)
})
if err := New(srv.URL, "k").DeleteWebPush(context.Background(), "dan", "https://push.example/xyz"); err != nil {
t.Fatalf("410: %v", err)
}
c := (*seen)[0]
if c.method != "DELETE" || c.path != "/v1/subscribers/dan/credentials/webpush" {
t.Fatalf("route = %s %s", c.method, c.path)
}
if !contains(c.query, "endpoint=https%3A%2F%2Fpush.example%2Fxyz") {
t.Fatalf("query = %q", c.query)
}
srv2, _ := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
err := New(srv2.URL, "k").DeleteWebPush(context.Background(), "dan", "https://push.example/xyz")
if !errors.Is(err, ErrRouteMissing) {
t.Fatalf("404 must report the route missing, got %v", err)
}
}
func TestPreferencesRoundTrip(t *testing.T) {
srv, seen := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
w.Write([]byte(`{"data":[{"type":"workflow","workflowId":"quarry-mention",
"channels":{"in_app":true,"push":false}}]}`))
return
}
w.WriteHeader(http.StatusOK)
})
c := New(srv.URL, "k")
prefs, err := c.Preferences(context.Background(), "dan")
if err != nil {
t.Fatal(err)
}
if len(prefs) != 1 || prefs[0].WorkflowID != "quarry-mention" || prefs[0].Channels["push"] {
t.Fatalf("prefs = %+v", prefs)
}
if err := c.SetPreferences(context.Background(), "dan", prefs); err != nil {
t.Fatal(err)
}
put := (*seen)[1]
if put.method != "PUT" || put.path != "/v1/subscribers/dan/preferences" {
t.Fatalf("route = %s %s", put.method, put.path)
}
rows, _ := put.body["preferences"].([]any)
if len(rows) != 1 {
t.Fatalf("body = %+v", put.body)
}
}
// A subscriber who has never saved a preference is not an error: notify
// has no row, quarry shows the defaults.
func TestPreferencesAbsentIsEmpty(t *testing.T) {
srv, _ := phase3Server(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
prefs, err := New(srv.URL, "k").Preferences(context.Background(), "dan")
if err != nil || len(prefs) != 0 {
t.Fatalf("prefs=%+v err=%v", prefs, err)
}
}
// 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 != nil {
t.Error(p, err)
}
if err := c.SetPreferences(ctx, "dan", 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
}