tack-up-now/app/worker.test.ts

437 lines
12 KiB
TypeScript

import "fake-indexeddb/auto"
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(createSubscription())),
getSubscription: jest.fn(() => Promise.resolve(createSubscription())),
},
showNotification: jest.fn(),
},
clients: {
claim: jest.fn(() => Promise.resolve()),
openWindow: jest.fn(() => Promise.resolve()),
matchAll: jest.fn(() => Promise.resolve([])),
},
}) as unknown as ServiceWorkerGlobalScope
let originalWindow: Window & typeof globalThis
describe("service worker", () => {
let self: ServiceWorkerGlobalScope
let controlledClients: Client[]
let uncontrolledClients: Client[]
beforeEach(() => {
originalWindow = global.window
global.window = undefined as any
indexedDB = new IDBFactory()
self = createSelf()
controlledClients = []
uncontrolledClients = []
fetchMock.mockResponse((req) => {
if (
req.url.replace(/https?:\/\/[a-zA-Z0-9\.]*/, "") ===
"/api/subscription/" &&
req.method === "POST"
) {
return Promise.resolve({
body: JSON.stringify({ subscriptionId: 123 }),
})
} else if (
req.url
.replace(/https?:\/\/[a-zA-Z0-9\.]*/, "")
.startsWith("/api/subscription/") &&
req.method === "PUT"
) {
return Promise.resolve({ init: { status: 200 } })
}
return Promise.resolve({ init: { status: 500 } })
})
})
afterEach(() => {
jest.clearAllMocks()
global.window = originalWindow
})
test("displays push notifications", async () => {
initWorker(self)
const pushHandler = getHandler("push")
const pushEvent = createPushEvent(
JSON.stringify({
title: "Test title",
body: "Test text",
icon: "Test icon",
badge: "Test badge",
destination: "tackupnow.com/hi",
})
)
pushHandler(pushEvent)
await waitUntilCalls(pushEvent)
expect(self.registration.showNotification).toHaveBeenCalledWith(
"Test title",
{
body: "Test text",
icon: "Test icon",
badge: "Test badge",
data: { destination: "tackupnow.com/hi" },
}
)
})
describe("on notification click", () => {
test("the notification is closed", async () => {
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("tackupnow.com")
notificationHandler(event)
await waitUntilCalls(event)
expect(event.notification.close).toHaveBeenCalled()
})
test("opens tack up now if no clients match the destination", async () => {
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("tackupnow.com")
notificationHandler(event)
await waitUntilCalls(event)
expect(self.clients.openWindow).toHaveBeenCalledWith("tackupnow.com")
})
test("focuses first matching client", async () => {
addClient("https://tackupnow.com/place", "window", true)
const matchingClient = addClient(
"https://tackupnow.com/otherplace",
"window",
true
)
addClient("https://tackupnow.com/yetanotherotherplace", "window", true)
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("https://tackupnow.com/otherplace")
notificationHandler(event)
await waitUntilCalls(event)
expect(matchingClient.focus).toHaveBeenCalled()
})
test("focuses uncontrolled matching clients", async () => {
addClient("https://derpatious.world", "window", false)
addClient("https://tackupnow.com/1", "window", false)
const matchingClient = addClient(
"https://tackupnow.com/2",
"window",
false
)
addClient("https://tackupnow.com/controlled", "window", true)
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("https://tackupnow.com/2")
notificationHandler(event)
await waitUntilCalls(event)
expect(matchingClient.focus).toHaveBeenCalled()
})
})
test("claims on activate", async () => {
initWorker(self)
const activateHandler = getHandler("activate")
const event = createEvent()
activateHandler(event)
await waitUntilCalls(event)
expect(self.clients.claim).toHaveBeenCalled()
})
test("skips waiting on install", async () => {
initWorker(self)
const installHandler = getHandler("install")
const event = createEvent()
installHandler(event)
await waitUntilCalls(event)
expect(self.skipWaiting).toHaveBeenCalled()
})
describe("on subscription message", () => {
test("puts push subscription if the subscription id is set", async () => {
initWorker(self)
await setSavedSubscription(5000)
const messageHandler = getHandler("message")
const messageEvent = createEvent({
data: {
subscription: { subscription: "YO" },
type: "subscribed",
},
})
messageHandler(messageEvent)
await waitUntilCalls(messageEvent)
expect(fetch).toHaveBeenCalledWith(
`${process.env.BASE_URL}/api/subscription/5000`,
{
method: "PUT",
body: JSON.stringify({ subscription: "YO" }),
headers: {
"Content-Type": "application/json",
},
}
)
})
test("posts to subscriptions when there is no user set", async () => {
initWorker(self)
const messageHandler = getHandler("message")
const messageEvent = createEvent({
data: {
subscription: { subscription: "YO" },
type: "subscribed",
},
})
messageHandler(messageEvent)
await waitUntilCalls(messageEvent)
expect(fetch).toHaveBeenCalledWith(
`${process.env.BASE_URL}/api/subscription/`,
{
method: "POST",
body: JSON.stringify({ subscription: "YO" }),
headers: {
"Content-Type": "application/json",
},
}
)
})
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,
})
})
})
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 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/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 addClient(url: string, type: ClientTypes, controlled: boolean) {
const matchAllMock = self.clients.matchAll as jest.Mock
matchAllMock.mockImplementation(
(args: { type: string; includeUncontrolled?: boolean }) => {
const clientList = args.includeUncontrolled
? [...uncontrolledClients, ...controlledClients]
: controlledClients
return clientList.filter((client) => client.type === args.type)
}
)
const clientList = controlled ? controlledClients : uncontrolledClients
const client: WindowClient = {
url,
type,
focus: jest.fn(() => Promise.resolve(client)) as WindowClient["focus"],
} as WindowClient
clientList.push(client)
return client
}
function getHandler(eventName: string) {
expect(self.addEventListener).toHaveBeenCalledWith(
eventName,
expect.anything()
)
const [, handler] = (self.addEventListener as jest.Mock).mock.calls.find(
([event]: [string]) => event === eventName
)
return handler
}
})
async function getSavedSubscription() {
const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & {
subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id">
}
database.version(1).stores({ subscriptions: "id++, subscriptionId&" })
return (await database.subscriptions.toArray())[0]
}
async function setSavedSubscription(subscriptionId: number) {
const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & {
subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id">
}
database.version(1).stores({ subscriptions: "id++, subscriptionId&" })
await database.subscriptions.put({ id: 1, subscriptionId })
}
function createPushEvent(data: string) {
const pushFields = {
data: {
blob: () => new Blob([data], { type: "text/plain" }),
json: () => JSON.parse(data),
text: () => data,
arrayBuffer: () => new TextEncoder().encode(data),
},
}
return createEvent(pushFields)
}
function createNotificationEvent(destination: string): NotificationEvent & {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>>
} {
return createEvent({
notification: {
close: jest.fn(),
data: {
destination,
},
},
}) as any as NotificationEvent & {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>>
}
}
function createEvent(properties?: object) {
return {
waitUntil: jest.fn(),
...properties,
}
}
function waitUntilCalls(event: {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>>
}) {
return Promise.all(event.waitUntil.mock.calls.map(([promise]) => promise))
}