diff --git a/api/data/database.ts b/api/data/database.ts index 241f3e6..d66d137 100644 --- a/api/data/database.ts +++ b/api/data/database.ts @@ -16,6 +16,12 @@ const dialect = new PostgresDialect({ }), }) -export const db = new Kysely({ +export let db = new Kysely({ dialect, }) + +export function resetDbInstance() { + db = new Kysely({ + dialect, + }) +} diff --git a/api/data/subscription.test.ts b/api/data/subscription.test.ts index f21dc32..0b6bbd2 100644 --- a/api/data/subscription.test.ts +++ b/api/data/subscription.test.ts @@ -1,4 +1,4 @@ -import { db } from "./database" +import { db, resetDbInstance } from "./database" import migrateToLatest, { resetAll } from "./migrate" import { createSubscription, @@ -34,9 +34,21 @@ jest.mock("./settings", () => ({ })) describe("subscriptions", () => { - beforeEach(migrateToLatest) + beforeAll(() => { + resetDbInstance() + }) - afterEach(resetAll) + beforeEach(async () => { + await migrateToLatest() + }) + + afterEach(async () => { + await resetAll() + }) + + afterAll(async () => { + await db.destroy() + }) test("createSubscription", async () => { const { id } = await createSubscription(pushSubscription1) diff --git a/app/install/EnableNotifications.tsx b/app/install/EnableNotifications.tsx index 24a792e..2841b1e 100644 --- a/app/install/EnableNotifications.tsx +++ b/app/install/EnableNotifications.tsx @@ -31,7 +31,20 @@ function EnableButton({ onSubscribe }: { onSubscribe: () => void }) { useEffect(() => { if (!canSendPush) return - subscribeToPush(urlB64ToUint8Array(pushPublicKey) as any, onSubscribe) + subscribeToPush( + urlB64ToUint8Array(pushPublicKey) as any, + async (subscription) => { + console.log("I love subscribing") + await navigator.serviceWorker.ready.then((registration) => { + console.log("Trying to subscrbie to stuff") + registration.active?.postMessage({ + type: "subscribed", + subscription: subscription.toJSON(), + }) + }) + onSubscribe() + } + ) }, [canSendPush]) return diff --git a/app/worker.test.ts b/app/worker.test.ts index 4cce979..87edf95 100644 --- a/app/worker.test.ts +++ b/app/worker.test.ts @@ -3,14 +3,22 @@ import "core-js/stable/structured-clone" import initWorker from "./worker" import Dexie, { EntityTable } from "dexie" +function createSubscription(testToken = "HI") { + return { + toJSON: () => ({ + subscription: testToken, + }), + } +} + const createSelf = () => ({ addEventListener: jest.fn(), skipWaiting: jest.fn(() => Promise.resolve()), registration: { pushManager: { - subscribe: jest.fn(() => Promise.resolve({ subscription: "HI" })), - getSubscription: jest.fn(() => Promise.resolve({ subscription: "HI" })), + subscribe: jest.fn(() => Promise.resolve(createSubscription())), + getSubscription: jest.fn(() => Promise.resolve(createSubscription())), }, }, clients: { @@ -18,10 +26,14 @@ const createSelf = () => }, }) as unknown as ServiceWorkerGlobalScope +let originalWindow: Window & typeof globalThis + describe("service worker", () => { let self: ServiceWorkerGlobalScope beforeEach(() => { + originalWindow = global.window + global.window = undefined as any indexedDB = new IDBFactory() self = createSelf() @@ -52,6 +64,7 @@ describe("service worker", () => { afterEach(() => { jest.clearAllMocks() + global.window = originalWindow }) xtest("displays push notifications", () => { @@ -87,63 +100,149 @@ describe("service worker", () => { expect(self.skipWaiting).toHaveBeenCalled() }) - test("puts push subscription if the subscription id is set", async () => { - initWorker(self) - await setSavedSubscription(5000) + describe("on subscription message", () => { + test("puts push subscription if the subscription id is set", async () => { + initWorker(self) + await setSavedSubscription(5000) - const changeHandler = getHandler("pushsubscriptionchange") + const messageHandler = getHandler("message") - const changeEvent = createEvent() - changeHandler(changeEvent) - await waitUntilCalls(changeEvent) + const messageEvent = createEvent({ + data: { + subscription: { subscription: "YO" }, + type: "subscribed", + }, + }) + messageHandler(messageEvent) + await waitUntilCalls(messageEvent) - expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ - userVisibleOnly: true, - applicationServerKey: expect.any(Uint8Array), + expect(fetch).toHaveBeenCalledWith( + `${process.env.BASE_URL}/api/subscription/5000`, + { + method: "PUT", + body: JSON.stringify({ subscription: "YO" }), + headers: { + "Content-Type": "application/json", + }, + } + ) }) - expect(fetch).toHaveBeenCalledWith( - `${process.env.BASE_URL}/api/subscription/5000`, - { - method: "PUT", - body: JSON.stringify({ subscription: "HI" }), - } - ) - }) + test("posts to subscriptions when there is no user set", async () => { + initWorker(self) - test("posts to subscriptions when there is no user set", async () => { - initWorker(self) + const messageHandler = getHandler("message") - const changeHandler = getHandler("pushsubscriptionchange") + const messageEvent = createEvent({ + data: { + subscription: { subscription: "YO" }, + type: "subscribed", + }, + }) + messageHandler(messageEvent) + await waitUntilCalls(messageEvent) - const changeEvent = createEvent() - changeHandler(changeEvent) - await waitUntilCalls(changeEvent) - - expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ - userVisibleOnly: true, - applicationServerKey: expect.any(Uint8Array), + expect(fetch).toHaveBeenCalledWith( + `${process.env.BASE_URL}/api/subscription/`, + { + method: "POST", + body: JSON.stringify({ subscription: "YO" }), + headers: { + "Content-Type": "application/json", + }, + } + ) }) - expect(fetch).toHaveBeenCalledWith( - `${process.env.BASE_URL}/api/subscription/`, - { - method: "POST", - body: JSON.stringify({ subscription: "HI" }), - } - ) + test("subscription id is saved on first subscription change", async () => { + initWorker(self) + + const messageHandler = getHandler("message") + + const messageEvent = createEvent({ + data: { + subscription: { subscription: "YO" }, + type: "subscribed", + }, + }) + messageHandler(messageEvent) + await waitUntilCalls(messageEvent) + + expect(await getSavedSubscription()).toEqual({ + id: 1, + subscriptionId: 123, + }) + }) }) - test("subscription id is saved on first subscription change", async () => { - initWorker(self) + describe("on subscription change", () => { + test("puts push subscription if the subscription id is set", async () => { + initWorker(self) + await setSavedSubscription(5000) - const changeHandler = getHandler("pushsubscriptionchange") + const changeHandler = getHandler("pushsubscriptionchange") - const changeEvent = createEvent() - changeHandler(changeEvent) - await waitUntilCalls(changeEvent) + const changeEvent = createEvent() + changeHandler(changeEvent) + await waitUntilCalls(changeEvent) - expect(await getSavedSubscription()).toEqual({ id: 1, subscriptionId: 123 }) + expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: expect.any(Uint8Array), + }) + + expect(fetch).toHaveBeenCalledWith( + `${process.env.BASE_URL}/api/subscription/5000`, + { + method: "PUT", + body: JSON.stringify({ subscription: "HI" }), + headers: { + "Content-Type": "application/json", + }, + } + ) + }) + + test("posts to subscriptions when there is no user set", async () => { + initWorker(self) + + const changeHandler = getHandler("pushsubscriptionchange") + + const changeEvent = createEvent() + changeHandler(changeEvent) + await waitUntilCalls(changeEvent) + + expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: expect.any(Uint8Array), + }) + + expect(fetch).toHaveBeenCalledWith( + `${process.env.BASE_URL}/api/subscription/`, + { + method: "POST", + body: JSON.stringify({ subscription: "HI" }), + headers: { + "Content-Type": "application/json", + }, + } + ) + }) + + test("subscription id is saved on first subscription change", async () => { + initWorker(self) + + const changeHandler = getHandler("pushsubscriptionchange") + + const changeEvent = createEvent() + changeHandler(changeEvent) + await waitUntilCalls(changeEvent) + + expect(await getSavedSubscription()).toEqual({ + id: 1, + subscriptionId: 123, + }) + }) }) function getHandler(eventName: string) { diff --git a/app/worker.ts b/app/worker.ts index a90fa1f..4f6f9a0 100644 --- a/app/worker.ts +++ b/app/worker.ts @@ -40,41 +40,51 @@ export default function start(self: ServiceWorkerGlobalScope) { // ) // }) + self.addEventListener("message", function (event) { + const waitEvent = event as ExtendableEvent + + if ("type" in event.data && event.data.type === "subscribed") { + waitEvent.waitUntil(submitSubscription(event.data.subscription)) + } + }) + self.addEventListener("pushsubscriptionchange", function (event) { const waitEvent = event as ExtendableEvent - waitEvent.waitUntil(submitSubscription(self.registration)) + waitEvent.waitUntil( + (async () => { + const applicationServerKey = urlB64ToUint8Array(pushPublicKey) + const newSubscription = await self.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey, + }) - self.registration.pushManager.getSubscription() + await submitSubscription(newSubscription.toJSON()) + })() + ) }) } -async function submitSubscription(registration: ServiceWorkerRegistration) { +async function submitSubscription(subscription: PushSubscriptionJSON) { const db = database() - const subscription = await registration.pushManager.getSubscription() - if (subscription === null) return - const existingSubscriptionId = await db.subscriptions.get(1) - const applicationServerKey = urlB64ToUint8Array(pushPublicKey) - const newSubscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: applicationServerKey, - }) - await (existingSubscriptionId === undefined - ? postSubscription(newSubscription, db) - : putSubscription(newSubscription, existingSubscriptionId.subscriptionId)) + ? postSubscription(subscription, db) + : putSubscription(subscription, existingSubscriptionId.subscriptionId)) } async function postSubscription( - subscription: PushSubscription, + subscription: PushSubscriptionJSON, db: ReturnType ) { const response = await fetch(`${process.env.BASE_URL}/api/subscription/`, { method: "POST", body: JSON.stringify(subscription), + headers: { + "Content-Type": "application/json", + }, }) db.subscriptions.put({ @@ -83,10 +93,13 @@ async function postSubscription( }) } -function putSubscription(subscription: PushSubscription, id: number) { +function putSubscription(subscription: PushSubscriptionJSON, id: number) { return fetch(`${process.env.BASE_URL}/api/subscription/${id}`, { method: "PUT", body: JSON.stringify(subscription), + headers: { + "Content-Type": "application/json", + }, }) } diff --git a/b64ToUInt8.ts b/b64ToUInt8.ts index 100edc9..2da6712 100644 --- a/b64ToUInt8.ts +++ b/b64ToUInt8.ts @@ -2,7 +2,7 @@ export default function urlB64ToUint8Array(base64String: string) { const padding = "=".repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/") - const rawData = window.atob(base64) + const rawData = atob(base64) const outputArray = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; ++i) { diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts index e4a8db6..6e3e330 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -1,5 +1,6 @@ import { test, expect, Page } from "@playwright/test" import matchPath from "./urlMatcher" +import { messageSW } from "@remix-pwa/sw" const { describe, beforeEach, skip, use } = test @@ -89,6 +90,23 @@ describe("when user is on iOS", () => { await expect(yourNotificationsHeading).toBeVisible() }) + + test.only("the subscription is submitted", async ({ page }) => { + await page.goto("/") + + await page.getByText(/Enable Notifications/).click() + + const postedMessages = await page.evaluate(() => { + return (window as any).postedMessages + }) + + expect(postedMessages).toContainEqual({ + type: "subscribed", + subscription: { + subscription: "HI", + }, + }) + }) }) }) @@ -212,20 +230,37 @@ function stubNotifications( function stubServiceWorker(page: Page) { return page.addInitScript(() => { - const serviceWorker = {} + function createSubscription(testToken = "HI") { + return { + toJSON: () => ({ + subscription: testToken, + }), + } + } + + let postedMessages: any[] = [] + + const serviceWorker = { + postMessage: (message: any) => postedMessages.push(message), + } const registration = { pushManager: { getSubscription() { - return Promise.resolve({ hi: "subscription" }) + return Promise.resolve(createSubscription()) }, subscribe(args: Parameters[0]) { - return Promise.resolve({ hi: "subscription" }) + return Promise.resolve(createSubscription()) }, }, active: serviceWorker, } + Object.defineProperty(window, "postedMessages", { + value: postedMessages, + writable: true, + }) + Object.defineProperty(navigator, "serviceWorker", { value: { ready: Promise.resolve(registration), diff --git a/vite.config.ts b/vite.config.ts index a7ebb32..3f41eab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ plugins: [ remix({ ignoredRouteFiles: ["**/*.test.*"] }), tsconfigPaths(), - remixPWA(), + remixPWA({ + buildVariables: { + "process.env.BASE_URL": process.env.BASE_URL ?? "http://localhost:5173", + }, + }), ], })