Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions frontend/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
/* FlowFi push notification service worker. */
/* FlowFi push notification service worker.
* Behavior is exercised by frontend/__tests__/sw.test.ts via a worker harness that
* evaluates this file against a mocked ServiceWorkerGlobalScope. Keep handlers
* registered via self.addEventListener and avoid module-level side effects. */

self.addEventListener('install', () => self.skipWaiting());

Expand All @@ -10,9 +13,12 @@ self.addEventListener('push', (event) => {
let payload = { title: 'FlowFi', body: 'You have a new stream update.', url: '/' };
if (event.data) {
try {
payload = { ...payload, ...event.data.json() };
const parsed = event.data.json();
if (parsed && typeof parsed === 'object') {
payload = { ...payload, ...parsed };
}
} catch {
payload.body = event.data.text();
payload = { ...payload, body: event.data.text() };
}
}

Expand All @@ -30,15 +36,16 @@ self.addEventListener('push', (event) => {

self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = (event.notification.data && event.notification.data.url) || '/';
const data = event.notification.data || {};
const target = data.url || '/';

event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if ('focus' in client) {
client.navigate(target);
if (typeof client.navigate === 'function') client.navigate(target);
return client.focus();
}
}
Expand Down
179 changes: 177 additions & 2 deletions frontend/src/__tests__/notifications.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const { subscribeToPush, unsubscribeFromPush, getExistingSubscription } = vi.hoisted(() => ({
subscribeToPush: vi.fn(async () => ({} as PushSubscription)),
subscribeToPush: vi.fn(async () => ({}) as PushSubscription),
unsubscribeFromPush: vi.fn(async () => undefined),
getExistingSubscription: vi.fn(async () => null as PushSubscription | null),
}));
Expand Down Expand Up @@ -34,6 +36,7 @@
const bytes = urlBase64ToUint8Array('a-_w');
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBeGreaterThan(0);
expect(Array.from(urlBase64ToUint8Array('-_8'))).toEqual(Array.from(urlBase64ToUint8Array('+/8=')));
});
});

Expand Down Expand Up @@ -95,3 +98,175 @@
);
});
});

describe('public/sw.js service worker', () => {
type Listener = (event: unknown) => void;
type PushEventLike = {
data?: { json?: () => unknown; text?: () => string } | null;
waitUntil: ReturnType<typeof vi.fn>;
settle: () => Promise<unknown>;
};
type NotificationClickEventLike = {
notification: { close: ReturnType<typeof vi.fn>; data: { url: string } };
waitUntil: ReturnType<typeof vi.fn>;
settle: () => Promise<unknown>;
};
type ClientLike = {
url?: string;
focus: ReturnType<typeof vi.fn>;
navigate: ReturnType<typeof vi.fn>;
};

const source = readFileSync(resolve(__dirname, '../../public/sw.js'), 'utf8');

let listeners: Record<string, Listener>;
let self: {
addEventListener: (type: string, fn: Listener) => void;
registration: { showNotification: ReturnType<typeof vi.fn> };
skipWaiting: ReturnType<typeof vi.fn>;
clients: unknown;
};
let clients: { matchAll: ReturnType<typeof vi.fn>; openWindow: ReturnType<typeof vi.fn> };
let showNotification: ReturnType<typeof vi.fn>;

// Runs the actual service-worker script inside a function scope whose
// `self`, `clients`, and `registration` globals are controlled by the test.
function loadWorker() {
listeners = {};
showNotification = vi.fn(async () => undefined);
clients = {
matchAll: vi.fn(async () => []),
openWindow: vi.fn(async () => undefined),
};
self = {
addEventListener: (type: string, fn: Listener) => {
listeners[type] = fn;
},
registration: { showNotification },
skipWaiting: vi.fn(),
clients,
};
new Function('self', 'clients', 'registration', source)(

Check failure on line 149 in frontend/src/__tests__/notifications.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend CI

src/__tests__/notifications.test.tsx > public/sw.js service worker > push event > handles a push event whose data has no json or text methods

TypeError: event.data.text is not a function ❯ Object.eval src/__tests__/notifications.test.tsx:149:5 ❯ src/__tests__/notifications.test.tsx:222:17
self,
clients,
self.registration,
);
}

function makeEvent<T extends Record<string, unknown>>(init: T) {
const pending: Promise<unknown>[] = [];
return {
...init,
waitUntil: vi.fn((p: Promise<unknown>) => {
pending.push(p);
}),
settle: () => Promise.all(pending),
};
}

beforeEach(() => {
loadWorker();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('push event', () => {
it('registers a push listener', () => {
expect(listeners.push).toBeTypeOf('function');
});

it('shows a notification from valid JSON payload', async () => {
const event: PushEventLike = makeEvent({
data: {
json: () => ({ title: 'Stream paid', body: 'You received 5 XLM' }),
text: () => '',
},
});
listeners.push(event);
expect(event.waitUntil).toHaveBeenCalledTimes(1);
await event.settle();
expect(showNotification).toHaveBeenCalledTimes(1);
const [title, options] = showNotification.mock.calls[0];
expect(title).toBe('Stream paid');
expect(options.body).toBe('You received 5 XLM');
});

it('falls back to text when payload is not valid JSON', async () => {
const event: PushEventLike = makeEvent({
data: {
json: () => {
throw new SyntaxError('bad json');
},
text: () => 'plain text ping',
},
});
listeners.push(event);
await event.settle();
expect(showNotification).toHaveBeenCalledTimes(1);
const [, options] = showNotification.mock.calls[0];
expect(options.body).toBe('plain text ping');
});

it('shows a default notification when push has no payload', async () => {
const event: PushEventLike = makeEvent({ data: null });
listeners.push(event);
expect(event.waitUntil).toHaveBeenCalledTimes(1);
await event.settle();
expect(showNotification).toHaveBeenCalledTimes(1);
});

it('handles a push event whose data has no json or text methods', async () => {
const event: PushEventLike = makeEvent({ data: {} });
listeners.push(event);
expect(event.waitUntil).toHaveBeenCalledTimes(1);
await event.settle();
expect(showNotification).toHaveBeenCalledTimes(1);
});
});

describe('notificationclick event', () => {
it('focuses and navigates an existing window instead of opening a new one', async () => {
const existing: ClientLike = {
url: 'https://app.test/dashboard',
focus: vi.fn(async () => existing),
navigate: vi.fn(async () => existing),
};
clients.matchAll.mockResolvedValueOnce([existing]);
const event: NotificationClickEventLike = makeEvent({
notification: { close: vi.fn(), data: { url: '/streams/1' } },
});
listeners.notificationclick(event);
expect(event.notification.close).toHaveBeenCalled();
expect(event.waitUntil).toHaveBeenCalledTimes(1);
await event.settle();
expect(existing.focus).toHaveBeenCalled();
expect(existing.navigate).toHaveBeenCalledWith('/streams/1');
expect(clients.openWindow).not.toHaveBeenCalled();
});

it('opens a new window when no client window exists', async () => {
clients.matchAll.mockResolvedValueOnce([]);
const event: NotificationClickEventLike = makeEvent({
notification: { close: vi.fn(), data: { url: '/streams/2' } },
});
listeners.notificationclick(event);
expect(event.notification.close).toHaveBeenCalled();
expect(event.waitUntil).toHaveBeenCalledTimes(1);
await event.settle();
expect(clients.openWindow).toHaveBeenCalledWith('/streams/2');
});

it('closes the notification and waits on the click handling promise', async () => {
clients.matchAll.mockResolvedValueOnce([]);
const event: NotificationClickEventLike = makeEvent({
notification: { close: vi.fn(), data: { url: '/streams/3' } },
});
listeners.notificationclick(event);
expect(event.notification.close).toHaveBeenCalledTimes(1);
expect(event.waitUntil).toHaveBeenCalledWith(expect.any(Promise));
await event.settle();
});
});
});
Loading
Loading