Onboarding step 3 push opt-in and test-push endpoint (Feature 19) (#168)
CI / ci (push) Successful in 7m17s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #168.
This commit is contained in:
2026-10-09 17:37:53 -04:00
committed by approver_agent
parent 51e0213e92
commit 63ed5a67a8
13 changed files with 573 additions and 141 deletions
+40 -1
View File
@@ -8,6 +8,8 @@
// nothing the second time and never touches a row already stored, whatever
// its status. EmitTx does the same inside the caller's transaction, so an
// event is stored with the change it is about; the caller then calls Wake.
// EmitPush stores an event for the push channel alone, for a test of push
// delivery that must not reach Google Chat.
//
// The worker started by Start delivers due rows through the Sender
// registered for their channel. A delivered row is marked sent. A failed
@@ -103,6 +105,10 @@ type Payload struct {
// stored.
var ErrInvalidEvent = errors.New("notify: invalid event")
// ErrPushOff is returned by EmitPush when the event type's preference
// switches push off; nothing is stored.
var ErrPushOff = errors.New("notify: push is switched off for this event")
// Service emits notifications and runs the worker that delivers them.
type Service struct {
st *store.Store
@@ -153,10 +159,39 @@ func (s *Service) Emit(ctx context.Context, ev Event) error {
return nil
}
// EmitPush stores one pending push notification of ev and wakes the
// worker, as Emit does but never for Google Chat, whatever the preference
// says of it. When the preference of ev's type switches push off it is
// ErrPushOff and stores nothing. It is for a test of push delivery, which
// must not post to the household's Chat space.
func (s *Service) EmitPush(ctx context.Context, ev Event) error {
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
return s.emitTx(ctx, tx, ev, func(pref db.NotificationPref) ([]Channel, error) {
if pref.Push != 1 {
return nil, fmt.Errorf("%w: %s", ErrPushOff, ev.Type)
}
return []Channel{ChannelPush}, nil
})
})
if err != nil {
return err
}
s.Wake()
return nil
}
// EmitTx stores ev's notifications as Emit does, in tx, so they are
// committed with the change the event is about or not at all. The caller
// calls Wake once tx has committed.
func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
return s.emitTx(ctx, tx, ev, func(pref db.NotificationPref) ([]Channel, error) {
return enabledChannels(pref), nil
})
}
// emitTx stores ev in tx for each channel that channels chooses from the
// preference of ev's type.
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
if err := validate(ev); err != nil {
return err
}
@@ -177,9 +212,13 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
if err != nil {
return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err)
}
chs, err := channels(pref)
if err != nil {
return err
}
now := timestamp(s.now())
var queued []Channel
for _, ch := range enabledChannels(pref) {
for _, ch := range chs {
n, err := q.InsertNotification(ctx, db.InsertNotificationParams{
ID: store.NewID(),
EventKey: ev.Key,
+48
View File
@@ -259,6 +259,54 @@ func TestEmitIgnoresDuplicateKey(t *testing.T) {
}
}
func TestEmitPushStoresOnlyAPushRow(t *testing.T) {
ctx := context.Background()
svc, st, _ := newTestService(t)
setPref(t, st, PlanReady, true, true)
const key = "plan_ready:test-1"
if err := svc.EmitPush(ctx, planReady(key)); err != nil {
t.Fatal(err)
}
r := onlyRow(t, st, key, ChannelPush)
if r.Status != "pending" || r.NextAttemptAt.String != timestamp(start) {
t.Errorf("row = %+v, want pending, due now", r)
}
select {
case <-svc.wake:
default:
t.Error("EmitPush did not wake the worker")
}
// The same key again is ignored, as by Emit.
if err := svc.EmitPush(ctx, planReady(key)); err != nil {
t.Fatalf("duplicate EmitPush: %v", err)
}
if got := onlyRow(t, st, key, ChannelPush); got != r {
t.Fatalf("duplicate EmitPush changed the row:\n got %+v\nwant %+v", got, r)
}
}
func TestEmitPushRefusesWhenPushIsOff(t *testing.T) {
svc, st, _ := newTestService(t)
setPref(t, st, PlanReady, false, true)
const key = "plan_ready:test-1"
if err := svc.EmitPush(context.Background(), planReady(key)); !errors.Is(err, ErrPushOff) {
t.Fatalf("EmitPush = %v, want ErrPushOff", err)
}
if rows := outbox(t, st, key); len(rows) != 0 {
t.Fatalf("rows = %v, want none", rows)
}
}
func TestEmitPushRejectsInvalidEvents(t *testing.T) {
svc, _, _ := newTestService(t)
ev := planReady("plan_ready:test-1")
ev.URL = "https://evil.example/plan"
if err := svc.EmitPush(context.Background(), ev); !errors.Is(err, ErrInvalidEvent) {
t.Fatalf("EmitPush = %v, want ErrInvalidEvent", err)
}
}
func TestEmitRejectsInvalidEvents(t *testing.T) {
svc, st, _ := newTestService(t)
valid := planReady("plan_ready:2026-10-12")
+69 -4
View File
@@ -14,6 +14,7 @@ import (
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
@@ -69,22 +70,86 @@ func (e *ValidationError) Error() string {
return "invalid push subscription: " + strings.Join(parts, "; ")
}
// Service stores push subscriptions.
// Service stores push subscriptions and sends a test notification to them.
type Service struct {
st *store.Store
publicKey string
notifier *notify.Service
}
// NewService returns a Service over st handing out publicKey, the VAPID
// public key in vapid's encoding.
func NewService(st *store.Store, publicKey string) (*Service, error) {
// public key in vapid's encoding, that sends its test notification through
// notifier.
func NewService(st *store.Store, publicKey string, notifier *notify.Service) (*Service, error) {
if st == nil {
return nil, errors.New("push: nil store")
}
if _, err := vapid.ParsePublicKey(publicKey); err != nil {
return nil, fmt.Errorf("push: VAPID public key: %w", err)
}
return &Service{st: st, publicKey: publicKey}, nil
if notifier == nil {
return nil, errors.New("push: nil notifier")
}
return &Service{st: st, publicKey: publicKey, notifier: notifier}, nil
}
// The test notification SendTest sends.
const (
TestTitle = "MealQuest test notification"
TestBody = "Push works on this device. Tap to open the plan."
TestURL = "/plan"
)
// ErrNoSubscriptions is returned by SendTest when no device has turned on
// push: there is nothing to send the test to.
var ErrNoSubscriptions = errors.New("push: no device has turned on notifications")
// TestSent is a test notification queued for delivery: its event key and
// how many subscriptions were stored when it was queued.
type TestSent struct {
EventKey string `json:"event_key"`
Subscriptions int `json:"subscriptions"`
}
// SendTest queues TestTitle for every stored subscription, through the
// notification outbox as a plan_ready event whose tap opens TestURL. Its
// key, "plan_ready:test-" and a new id, is never used twice, so it is never
// taken for an event already sent. It goes to push alone, never to Google
// Chat. With push switched off for plan_ready it is notify.ErrPushOff,
// whether or not any device has turned on push; otherwise with no
// subscription it is ErrNoSubscriptions. Neither queues anything. Delivery
// happens after SendTest returns, with the outbox's retries.
func (s *Service) SendTest(ctx context.Context) (TestSent, error) {
q := db.New(s.st.Reader)
pref, err := q.GetNotificationPref(ctx, string(notify.PlanReady))
if errors.Is(err, sql.ErrNoRows) {
return TestSent{}, fmt.Errorf("push: no notification preference for %s", notify.PlanReady)
}
if err != nil {
return TestSent{}, fmt.Errorf("push: read notification preference for %s: %w", notify.PlanReady, err)
}
if pref.Push != 1 {
return TestSent{}, fmt.Errorf("%w: %s", notify.ErrPushOff, notify.PlanReady)
}
subs, err := q.ListPushSubscriptions(ctx)
if err != nil {
return TestSent{}, fmt.Errorf("push: list subscriptions: %w", err)
}
if len(subs) == 0 {
return TestSent{}, ErrNoSubscriptions
}
key := string(notify.PlanReady) + ":test-" + store.NewID()
err = s.notifier.EmitPush(ctx, notify.Event{
Type: notify.PlanReady,
Key: key,
Title: TestTitle,
Body: TestBody,
URL: TestURL,
})
if err != nil {
return TestSent{}, fmt.Errorf("push: send test notification: %w", err)
}
return TestSent{EventKey: key, Subscriptions: len(subs)}, nil
}
// PublicKey returns the VAPID public key, for applicationServerKey.
+104 -3
View File
@@ -9,6 +9,8 @@ import (
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
@@ -35,7 +37,7 @@ func newTestService(t *testing.T) (*Service, *store.Store) {
if err != nil {
t.Fatal(err)
}
svc, err := NewService(st, pair.PublicKey)
svc, err := NewService(st, pair.PublicKey, notifytest.New(t, st))
if err != nil {
t.Fatal(err)
}
@@ -86,12 +88,20 @@ func rows(t *testing.T, st *store.Store) map[string]storedRow {
func TestNewServiceRefusesBadPublicKey(t *testing.T) {
t.Parallel()
_, st := newTestService(t)
if _, err := NewService(st, "not-a-key"); err == nil {
notifier := notifytest.New(t, st)
if _, err := NewService(st, "not-a-key", notifier); err == nil {
t.Error("NewService accepted a malformed public key")
}
if _, err := NewService(nil, ""); err == nil {
if _, err := NewService(nil, "", notifier); err == nil {
t.Error("NewService accepted a nil store")
}
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
if _, err := NewService(st, pair.PublicKey, nil); err == nil {
t.Error("NewService accepted a nil notifier")
}
}
func TestSubscribeUpsertsByEndpoint(t *testing.T) {
@@ -222,3 +232,94 @@ func TestSubscribeValidates(t *testing.T) {
t.Errorf("invalid requests stored %d rows", len(got))
}
}
func TestSendTestQueuesOnePushToPlan(t *testing.T) {
t.Parallel()
svc, st := newTestService(t)
p256dh, auth := browserKeys(t)
for _, ep := range []string{endpoint, endpoint + "-2"} {
if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: ep, P256dh: p256dh, Auth: auth}); err != nil {
t.Fatal(err)
}
}
first, err := svc.SendTest(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(first.EventKey, "plan_ready:test-") || first.Subscriptions != 2 {
t.Errorf("SendTest = %+v, want a plan_ready:test- key and 2 subscriptions", first)
}
// A second test is a new event, not taken for the first.
second, err := svc.SendTest(context.Background())
if err != nil {
t.Fatal(err)
}
if second.EventKey == first.EventKey {
t.Fatalf("two tests share the key %s", first.EventKey)
}
// Seeded prefs send plan_ready to Google Chat too; the test never does.
rows := notifytest.Rows(t, st, notify.PlanReady)
if len(rows) != 2 {
t.Fatalf("rows = %+v, want one per test", rows)
}
byKey := map[string]notifytest.Row{}
for _, r := range rows {
byKey[r.Key] = r
}
for _, sent := range []TestSent{first, second} {
want := notifytest.Row{Key: sent.EventKey, Channel: notify.ChannelPush, Payload: notify.Payload{
Event: notify.PlanReady, Title: "MealQuest test notification", Body: TestBody, URL: "/plan", Tag: sent.EventKey,
}}
if got := byKey[sent.EventKey]; got != want {
t.Errorf("row of %s = %+v, want %+v", sent.EventKey, got, want)
}
}
}
func TestSendTestWithoutSubscriptions(t *testing.T) {
t.Parallel()
svc, st := newTestService(t)
if _, err := svc.SendTest(context.Background()); !errors.Is(err, ErrNoSubscriptions) {
t.Fatalf("SendTest = %v, want ErrNoSubscriptions", err)
}
if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 {
t.Fatalf("rows = %+v, want none", rows)
}
}
func TestSendTestWithPushOff(t *testing.T) {
t.Parallel()
svc, st := newTestService(t)
p256dh, auth := browserKeys(t)
if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: endpoint, P256dh: p256dh, Auth: auth}); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
t.Fatal(err)
}
if _, err := svc.SendTest(context.Background()); !errors.Is(err, notify.ErrPushOff) {
t.Fatalf("SendTest = %v, want notify.ErrPushOff", err)
}
if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 {
t.Fatalf("rows = %+v, want none", rows)
}
}
// Push off is reported before the subscriptions are looked at, so with
// both push off and no device subscribed the answer is push off.
func TestSendTestWithPushOffAndNoSubscriptions(t *testing.T) {
t.Parallel()
svc, st := newTestService(t)
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
t.Fatal(err)
}
_, err := svc.SendTest(context.Background())
if !errors.Is(err, notify.ErrPushOff) || errors.Is(err, ErrNoSubscriptions) {
t.Fatalf("SendTest = %v, want notify.ErrPushOff", err)
}
if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 {
t.Fatalf("rows = %+v, want none", rows)
}
}
+28
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
)
@@ -85,6 +86,33 @@ func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.Handler
}
}
// postTestPush answers POST /settings/notifications/test-push: it queues
// push.TestTitle for every device that turned on push, aimed at /plan, and
// answers 202 with the event key and how many subscriptions it goes to.
// Delivery follows through the notification outbox. With push switched
// off for plan_ready it is 409 push_off, whether or not any device has
// turned on push; otherwise with no subscription it is 409
// no_subscriptions. Neither sends anything. It never posts to Google Chat.
func postTestPush(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sent, err := svc.SendTest(r.Context())
switch {
case errors.Is(err, push.ErrNoSubscriptions):
writeError(w, r, logger, http.StatusConflict, "no_subscriptions",
"no device has turned on notifications, so there is nothing to send the test to")
case errors.Is(err, notify.ErrPushOff):
writeError(w, r, logger, http.StatusConflict, "push_off",
"push is switched off for New weekly plan ready in Notifications settings, so no test was sent")
case err != nil:
logger.ErrorContext(r.Context(), "send test push", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
default:
logger.InfoContext(r.Context(), "test push queued", "event_key", sent.EventKey, "subscriptions", sent.Subscriptions)
writeJSON(w, r, logger, http.StatusAccepted, sent)
}
}
}
// writePushError maps a push.Service error to its API error: an invalid
// subscription is 400 invalid_subscription, anything else 500.
func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
+60 -1
View File
@@ -14,6 +14,8 @@ import (
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
@@ -22,6 +24,7 @@ import (
const (
vapidPublicKeyPath = "/api/v1/push/vapid-public-key"
pushSubscriptionsPath = "/api/v1/push/subscriptions"
testPushPath = "/api/v1/settings/notifications/test-push"
testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk"
)
@@ -41,7 +44,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair)
t.Fatal(err)
}
st := newTestStore(t)
svc, err := push.NewService(st, pair.PublicKey)
svc, err := push.NewService(st, pair.PublicKey, notifytest.New(t, st))
if err != nil {
t.Fatal(err)
}
@@ -282,3 +285,59 @@ func mustP256dh(t *testing.T) string {
}
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes())
}
func TestTestPushAPI(t *testing.T) {
t.Parallel()
svc, st, _ := newTestPushWith(t)
router := newPushRouter(t, svc)
// With push off for plan_ready and no device subscribed, push off is
// what the test says.
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
t.Fatal(err)
}
rec, body := serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" {
t.Errorf("POST with push off and no subscriptions = %d %s, want 409 push_off", rec.Code, rec.Body)
}
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1 WHERE event = 'plan_ready'`); err != nil {
t.Fatal(err)
}
// With no device subscribed there is nothing to test.
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "no_subscriptions" {
t.Errorf("POST with no subscriptions = %d %s, want 409 no_subscriptions", rec.Code, rec.Body)
}
for _, ep := range []string{testPushEndpoint, testPushEndpoint + "-phone"} {
req := signedIn(http.MethodPost, pushSubscriptionsPath, strings.NewReader(subscriptionJSON(t, ep, "")))
if rec, _ := serve(t, router, req); rec.Code != http.StatusOK {
t.Fatalf("POST subscription = %d %s", rec.Code, rec.Body)
}
}
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
key, _ := body["event_key"].(string)
if rec.Code != http.StatusAccepted || !strings.HasPrefix(key, "plan_ready:test-") || body["subscriptions"] != float64(2) {
t.Fatalf("POST test-push = %d %s, want 202 with a plan_ready:test- key and 2 subscriptions", rec.Code, rec.Body)
}
// One push row, none for the household's Google Chat space, opening /plan.
want := []notifytest.Row{{Key: key, Channel: notify.ChannelPush, Payload: notify.Payload{
Event: notify.PlanReady, Title: "MealQuest test notification", Body: push.TestBody, URL: "/plan", Tag: key,
}}}
if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 || got[0] != want[0] {
t.Errorf("outbox = %+v, want %+v", got, want)
}
// With push off for plan_ready, the test says so and sends nothing.
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
t.Fatal(err)
}
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" {
t.Errorf("POST with push off = %d %s, want 409 push_off", rec.Code, rec.Body)
}
if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 {
t.Errorf("outbox after push off = %+v, want still one row", got)
}
}
+4 -2
View File
@@ -60,8 +60,9 @@ type RouteMounter interface {
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; pushSvc serves GET
// /api/v1/push/vapid-public-key and POST and DELETE
// /api/v1/push/subscriptions; runner serves
// /api/v1/push/vapid-public-key, POST and DELETE
// /api/v1/push/subscriptions and POST
// /api/v1/settings/notifications/test-push; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
@@ -133,6 +134,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
apiV1.Put("/settings/notifications", putNotificationSettings(logger, settings))
apiV1.Post("/settings/notifications/test-push", postTestPush(logger, pushSvc))
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))