Onboarding step 3 push opt-in and test-push endpoint (Feature 19) (#168)
CI / ci (push) Successful in 7m17s
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:
+69
-4
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user