Answer push_off before no_subscriptions and give every iOS browser the Home Screen copy (Feature 19)
CI / ci (push) Successful in 6m17s
CI / ci (pull_request) Successful in 7m26s

SendTest now reads the plan_ready push preference before the stored
subscriptions, so with push off and no device subscribed the test-push
endpoint answers 409 push_off rather than 409 no_subscriptions.

Onboarding step 3's unsupported result now gives the Home Screen guidance
in any iOS browser, not only Safari. isIos in InstallHint.tsx is the one
check for iPhone, iPod and iPad; isIosSafari builds on it and InstallHint
itself stays Safari-only.

Task f89279b8-b92c-4d9f-a6c2-fd87fccda509

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:17:58 -04:00
co-authored by Claude Opus 5.5
parent 875a0faaae
commit 3ce0935b52
7 changed files with 88 additions and 22 deletions
+16 -5
View File
@@ -115,12 +115,23 @@ type TestSent struct {
// 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 no subscription it is ErrNoSubscriptions, and with push
// switched off for plan_ready it is notify.ErrPushOff; neither queues
// anything. Delivery happens after SendTest returns, with the outbox's
// retries.
// 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) {
subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx)
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)
}
+17
View File
@@ -306,3 +306,20 @@ func TestSendTestWithPushOff(t *testing.T) {
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)
}
}
+4 -3
View File
@@ -89,9 +89,10 @@ 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 no subscription
// it is 409 no_subscriptions, and with push switched off for plan_ready
// 409 push_off; neither sends anything. It never posts to Google Chat.
// 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())
+14 -1
View File
@@ -291,8 +291,21 @@ func TestTestPushAPI(t *testing.T) {
svc, st, _ := newTestPushWith(t)
router := newPushRouter(t, svc)
// With no device subscribed there is nothing to test.
// 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)
}