package notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// recorder is a minimal fake notify server: it remembers every request
// it saw and answers with whatever statuses the test primes.
type recorder struct {
reqs []recordedReq
statuses map[string]int // method+" "+path -> status to answer once, then 200
}
type recordedReq struct {
method, path string
// query is the raw query string, which is where notify#303's
// onConflict mode lives — the difference between "create if absent"
// and "overwrite whatever the operator edited".
query string
body map[string]any
}
func (r *recorder) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var body map[string]any
json.NewDecoder(req.Body).Decode(&body)
r.reqs = append(r.reqs, recordedReq{method: req.Method, path: req.URL.Path,
query: req.URL.RawQuery, body: body})
key := req.Method + " " + req.URL.Path
if status, ok := r.statuses[key]; ok {
w.WriteHeader(status)
return
}
w.WriteHeader(http.StatusOK)
}
func serverWith(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *recorder) {
t.Helper()
rec := &recorder{statuses: map[string]int{}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rec.ServeHTTP(w, req)
if handler != nil {
handler(w, req)
}
}))
t.Cleanup(srv.Close)
return srv, rec
}
func TestEnsureSubscriber(t *testing.T) {
srv, rec := serverWith(t, nil)
c := New(srv.URL, "k")
if err := c.EnsureSubscriber(context.Background(), "alice"); err != nil {
t.Fatal(err)
}
if len(rec.reqs) != 1 || rec.reqs[0].path != "/v1/subscribers" {
t.Fatalf("requests = %+v", rec.reqs)
}
if rec.reqs[0].body["subscriberId"] != "alice" {
t.Fatalf("body = %+v", rec.reqs[0].body)
}
}
func TestEnsureSubscriberNilClient(t *testing.T) {
var c *Client
if err := c.EnsureSubscriber(context.Background(), "alice"); err != nil {
t.Fatalf("nil client must no-op: %v", err)
}
c = New("", "k")
if err := c.EnsureSubscriber(context.Background(), "alice"); err != nil {
t.Fatalf("disabled client must no-op: %v", err)
}
}
func TestEnsureSubscriberAlreadyExists(t *testing.T) {
rec := &recorder{statuses: map[string]int{"POST /v1/subscribers": http.StatusConflict}}
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
c := New(srv.URL, "k")
if err := c.EnsureSubscriber(context.Background(), "alice"); err != nil {
t.Fatalf("409 must be treated as success: %v", err)
}
}
func TestEnsureSubscriberRealFailure(t *testing.T) {
rec := &recorder{statuses: map[string]int{"POST /v1/subscribers": http.StatusInternalServerError}}
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
c := New(srv.URL, "k")
if err := c.EnsureSubscriber(context.Background(), "alice"); err == nil {
t.Fatal("a real 500 must be reported")
}
}
func TestEnsureTopic(t *testing.T) {
srv, rec := serverWith(t, nil)
c := New(srv.URL, "k")
if err := c.EnsureTopic(context.Background(), "repo-quarry", "quarry activity"); err != nil {
t.Fatal(err)
}
if len(rec.reqs) != 1 || rec.reqs[0].path != "/v1/topics" ||
rec.reqs[0].body["key"] != "repo-quarry" || rec.reqs[0].body["name"] != "quarry activity" {
t.Fatalf("requests = %+v", rec.reqs)
}
}
func TestAddTopicMember(t *testing.T) {
srv, rec := serverWith(t, nil)
c := New(srv.URL, "k")
if err := c.AddTopicMember(context.Background(), "repo-quarry", "alice"); err != nil {
t.Fatal(err)
}
if len(rec.reqs) != 1 || rec.reqs[0].path != "/v1/topics/repo-quarry/subscribers" ||
rec.reqs[0].body["subscriberId"] != "alice" {
t.Fatalf("requests = %+v", rec.reqs)
}
}
func TestAddTopicMemberAlreadyExists(t *testing.T) {
rec := &recorder{statuses: map[string]int{"POST /v1/topics/repo-quarry/subscribers": http.StatusConflict}}
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
c := New(srv.URL, "k")
if err := c.AddTopicMember(context.Background(), "repo-quarry", "alice"); err != nil {
t.Fatalf("409 must be treated as success: %v", err)
}
}
func TestRemoveTopicMember(t *testing.T) {
rec := &recorder{statuses: map[string]int{}}
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
c := New(srv.URL, "k")
if err := c.RemoveTopicMember(context.Background(), "repo-quarry", "alice"); err != nil {
t.Fatal(err)
}
if len(rec.reqs) != 1 || rec.reqs[0].method != "DELETE" ||
rec.reqs[0].path != "/v1/topics/repo-quarry/subscribers/alice" {
t.Fatalf("requests = %+v", rec.reqs)
}
}
func TestRemoveTopicMemberAbsentIsSuccess(t *testing.T) {
rec := &recorder{statuses: map[string]int{"DELETE /v1/topics/repo-quarry/subscribers/alice": http.StatusNotFound}}
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
c := New(srv.URL, "k")
if err := c.RemoveTopicMember(context.Background(), "repo-quarry", "alice"); err != nil {
t.Fatalf("404 on remove must be treated as success: %v", err)
}
}
func TestEnsureWorkflowUpsertsWithKeep(t *testing.T) {
rec := &recorder{statuses: map[string]int{
"PUT /v1/workflows/quarry-mention": http.StatusCreated}}
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
c := New(srv.URL, "k")
def := WorkflowDef{Name: "quarry-mention", TriggerIdentifier: "quarry-mention",
Steps: []WorkflowStep{{StepID: "S1", Type: "in_app", ControlValues: map[string]any{"subject": "s", "body": "b"}}}}
if err := c.EnsureWorkflow(context.Background(), def); err != nil {
t.Fatal(err)
}
if len(rec.reqs) != 1 {
t.Fatalf("expected exactly one request, got %+v", rec.reqs)
}
r := rec.reqs[0]
if r.method != "PUT" || r.path != "/v1/workflows/quarry-mention" {
t.Fatalf("request = %+v", r)
}
// `keep` and never `update`: an operator's hand-edited copy must
// survive the next startup (RFC 0011 §7).
if r.query != "onConflict=keep" {
t.Fatalf("query = %q, want onConflict=keep", r.query)
}
}
// A slug that is already there answers 200 and was left alone. That is
// the end state this call asks for, so it is success and not a retry.
func TestEnsureWorkflowPresentIsSuccess(t *testing.T) {
rec := &recorder{statuses: map[string]int{
"PUT /v1/workflows/quarry-mention": http.StatusOK}}
srv := httptest.NewServer(rec)
t.Cleanup(srv.Close)
c := New(srv.URL, "k")
def := WorkflowDef{Name: "quarry-mention", TriggerIdentifier: "quarry-mention"}
if err := c.EnsureWorkflow(context.Background(), def); err != nil {
t.Fatalf("a kept workflow must be success: %v", err)
}
if len(rec.reqs) != 1 || rec.reqs[0].method != "PUT" {
t.Fatalf("requests = %+v", rec.reqs)
}
}
func TestEnsureWorkflowNilClient(t *testing.T) {
var c *Client
if err := c.EnsureWorkflow(context.Background(), WorkflowDef{TriggerIdentifier: "x"}); err != nil {
t.Fatalf("nil client must no-op: %v", err)
}
}
// The routing table of RFC 0011 §6, asserted as step TYPES in order —
// the shape notify runs, not the copy, which is an operator's to edit.
func TestWorkflowsCoverTheSixNames(t *testing.T) {
want := map[string][]string{
"quarry-mention": {"in_app", "push"},
"quarry-forum-reply": {"in_app", "push"},
"quarry-landing": {"in_app", "push"},
// A digest BEFORE the in_app step: the inbox row is the batch.
"quarry-activity": {"digest", "in_app"},
"quarry-error": {"in_app"},
"quarry-slow": {"in_app"},
}
seen := map[string]bool{}
for _, wf := range Workflows() {
steps, ok := want[wf.Name]
if !ok {
t.Fatalf("unexpected workflow %q", wf.Name)
}
seen[wf.Name] = true
if wf.TriggerIdentifier != wf.Name {
t.Fatalf("%s: triggerIdentifier %q must match name", wf.Name, wf.TriggerIdentifier)
}
if len(wf.Steps) != len(steps) {
t.Fatalf("%s: steps = %+v, want %v", wf.Name, wf.Steps, steps)
}
ids := map[string]bool{}
for i, s := range wf.Steps {
if s.Type != steps[i] {
t.Fatalf("%s: step %d is %q, want %q", wf.Name, i, s.Type, steps[i])
}
if s.StepID == "" || ids[s.StepID] {
t.Fatalf("%s: step ids must be present and distinct: %+v", wf.Name, wf.Steps)
}
ids[s.StepID] = true
}
}
for name := range want {
if !seen[name] {
t.Fatalf("workflow %q missing from Workflows()", name)
}
}
}
// A digest's amount must serialize as a JSON NUMBER: notify's
// steptype.sleepConfig unmarshals it into an int64, so a quoted "15"
// makes the step fail to compile and the whole workflow never runs.
func TestDigestAmountIsANumber(t *testing.T) {
for _, wf := range Workflows() {
for _, s := range wf.Steps {
if s.Type != "digest" {
continue
}
b, err := json.Marshal(s.ControlValues)
if err != nil {
t.Fatal(err)
}
var cv struct {
Amount int64 `json:"amount"`
Unit string `json:"unit"`
Type string `json:"type"`
}
if err := json.Unmarshal(b, &cv); err != nil {
t.Fatalf("%s: digest controlValues do not decode as notify reads them: %v (%s)", wf.Name, err, b)
}
if cv.Amount != 15 || cv.Unit != "minutes" || cv.Type != "regular" {
t.Fatalf("%s: digest window = %+v", wf.Name, cv)
}
}
}
}
// The push steps carry the link as data_json, which notify validates as
// a JSON object whose every value is a string literal (steptype.push).
func TestPushStepsCarryALinkAsStringData(t *testing.T) {
found := 0
for _, wf := range Workflows() {
for _, s := range wf.Steps {
if s.Type != "push" {
continue
}
found++
raw, _ := s.ControlValues["data_json"].(string)
var data map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("%s: data_json is not a JSON object: %q", wf.Name, raw)
}
for k, v := range data {
if len(v) == 0 || v[0] != '"' {
t.Fatalf("%s: data_json[%q] must be a string literal, got %s", wf.Name, k, v)
}
}
if _, ok := data["url"]; !ok {
t.Fatalf("%s: push data carries no url for sw.js to open: %q", wf.Name, raw)
}
}
}
if found != 3 {
t.Fatalf("push steps = %d, want 3 (mention, reply, landing)", found)
}
}