Skip to content
Merged
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
3 changes: 1 addition & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@anthropic-ai/sdk": "^0.128.0",
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af",
"@profullstack/emailer": "^1.0.3",
"@profullstack/notifications": "0.1.0",
"@profullstack/stack": "^0.1.3",
"@profullstack/x402-gateway": "^0.7.0",
"@serwist/next": "^9.5.0",
Expand All @@ -38,7 +39,6 @@
"react-type-animation": "^3.2.0",
"serwist": "^9.5.0",
"tailwind-merge": "^2.6.0",
"web-push": "^3.6.7",
"zod": "^3.24.0"
},
"devDependencies": {
Expand All @@ -51,7 +51,6 @@
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/web-push": "^3.6.4",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.0",
"postcss": "^8.4.49",
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/app/api/push/vapid-public-key/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { vapidKeysFromEnv, vapidPublicKeyResponse } from '@profullstack/notifications/server';

/**
* GET /api/push/vapid-public-key — the VAPID public key, served at runtime.
*
* The browser used to get it from NEXT_PUBLIC_VAPID_PUBLIC_KEY compiled into
* the bundle; when the Docker build did not have it, every browser was told
* "Push notifications are not supported". Fetching it from here cannot drift
* from the key the server signs with.
*/
export const dynamic = 'force-dynamic';

export function GET() {
return vapidPublicKeyResponse(vapidKeysFromEnv(process.env));
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockUnsubscribe = vi.fn().mockResolvedValue(true);
vi.mock('@/hooks/usePushNotifications', () => ({
usePushNotifications: vi.fn(() => ({
isSupported: true,
unsupportedReason: null,
permission: 'default' as NotificationPermission,
isSubscribed: false,
isLoading: false,
Expand All @@ -31,6 +32,7 @@ describe('NotificationPreferences', () => {
it('should show unsupported message when push is not available', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: false,
unsupportedReason: null,
permission: 'default',
isSubscribed: false,
isLoading: false,
Expand All @@ -43,9 +45,27 @@ describe('NotificationPreferences', () => {
expect(screen.getByText(/not supported/i)).toBeInTheDocument();
});

it('shows the specific reason push is unavailable', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: false,
unsupportedReason: 'On iPhone and iPad, add this site to your Home Screen.',
permission: 'default',
isSubscribed: false,
isLoading: false,
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
});

render(<NotificationPreferences />);

expect(screen.getByText(/add this site to your Home Screen/i)).toBeInTheDocument();
expect(screen.queryByText(/not supported/i)).not.toBeInTheDocument();
});

it('should show enable button when not subscribed', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'default',
isSubscribed: false,
isLoading: false,
Expand All @@ -62,6 +82,7 @@ describe('NotificationPreferences', () => {
it('should show disable button when subscribed', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'granted',
isSubscribed: true,
isLoading: false,
Expand All @@ -78,6 +99,7 @@ describe('NotificationPreferences', () => {
it('should call subscribe when enable button is clicked', async () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'default',
isSubscribed: false,
isLoading: false,
Expand All @@ -97,6 +119,7 @@ describe('NotificationPreferences', () => {
it('should call unsubscribe when disable button is clicked', async () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'granted',
isSubscribed: true,
isLoading: false,
Expand All @@ -116,6 +139,7 @@ describe('NotificationPreferences', () => {
it('should show notification blocked message when permission is denied', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'denied',
isSubscribed: false,
isLoading: false,
Expand All @@ -132,6 +156,7 @@ describe('NotificationPreferences', () => {
it('should disable button while loading', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'default',
isSubscribed: false,
isLoading: true,
Expand All @@ -151,6 +176,7 @@ describe('NotificationPreferences', () => {
it('should show event toggles when subscribed', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'granted',
isSubscribed: true,
isLoading: false,
Expand All @@ -170,6 +196,7 @@ describe('NotificationPreferences', () => {
it('should not show event toggles when not subscribed', () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'default',
isSubscribed: false,
isLoading: false,
Expand All @@ -186,6 +213,7 @@ describe('NotificationPreferences', () => {
it('should load preferences from server on mount', async () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'granted',
isSubscribed: true,
isLoading: false,
Expand Down Expand Up @@ -219,6 +247,7 @@ describe('NotificationPreferences', () => {
it('should save preference when toggle is clicked', async () => {
vi.mocked(usePushNotifications).mockReturnValue({
isSupported: true,
unsupportedReason: null,
permission: 'granted',
isSubscribed: true,
isLoading: false,
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/notifications/NotificationPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,15 @@ function Toggle({
}

export function NotificationPreferences() {
const { isSupported, permission, isSubscribed, isLoading, subscribe, unsubscribe } =
usePushNotifications();
const {
isSupported,
unsupportedReason,
permission,
isSubscribed,
isLoading,
subscribe,
unsubscribe,
} = usePushNotifications();

const [preferences, setPreferences] = useState<NotificationPrefs>(DEFAULT_PREFS);
const [saving, setSaving] = useState(false);
Expand Down Expand Up @@ -157,7 +164,7 @@ export function NotificationPreferences() {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="text-sm text-yellow-800">
Push notifications are not supported in this browser.
{unsupportedReason ?? 'Push notifications are not supported in this browser.'}
</p>
</div>
);
Expand Down
118 changes: 61 additions & 57 deletions apps/web/src/hooks/usePushNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';

// Set VAPID key before module load (vi.hoisted runs before imports)
vi.hoisted(() => {
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY =
'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkOs-qy7505aFNGpOTN_4Bz8T5RA8ZjqO1QjAPGeZs';
const mocks = vi.hoisted(() => {
class PushError extends Error {
reason: string;
constructor(reason: string, message = reason) {
super(message);
this.reason = reason;
}
}
return {
PushError,
pushSupport: vi.fn(),
getSubscription: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
};
});

vi.mock('@profullstack/notifications/client', () => mocks);

import { usePushNotifications } from './usePushNotifications';

const mockSubscription = {
const subscriptionJson = {
endpoint: 'https://fcm.googleapis.com/fcm/send/test-endpoint',
toJSON: () => ({
endpoint: 'https://fcm.googleapis.com/fcm/send/test-endpoint',
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
}),
unsubscribe: vi.fn().mockResolvedValue(true),
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
};

const mockPushManager = {
getSubscription: vi.fn().mockResolvedValue(null),
subscribe: vi.fn().mockResolvedValue(mockSubscription),
};
const mockSubscription = { endpoint: subscriptionJson.endpoint };

const mockRegistration = {
pushManager: mockPushManager,
};
const SUPPORTED = { supported: true, reason: null, message: null, permission: 'default' };

describe('usePushNotifications', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPushManager.getSubscription.mockResolvedValue(null);
mockPushManager.subscribe.mockResolvedValue(mockSubscription);
mockSubscription.unsubscribe.mockResolvedValue(true);

// Mock browser APIs
Object.defineProperty(window, 'Notification', {
value: {
permission: 'default' as NotificationPermission,
requestPermission: vi.fn().mockResolvedValue('granted'),
},
writable: true,
configurable: true,
});

Object.defineProperty(navigator, 'serviceWorker', {
value: {
ready: Promise.resolve(mockRegistration),
},
writable: true,
configurable: true,
});

// Ensure PushManager exists
Object.defineProperty(window, 'PushManager', {
value: vi.fn(),
writable: true,
configurable: true,
});
mocks.pushSupport.mockReturnValue(SUPPORTED);
mocks.getSubscription.mockResolvedValue(null);
mocks.unsubscribe.mockResolvedValue(true);
// The package hands the new subscription to `save`; PairUX's save POSTs it.
mocks.subscribe.mockImplementation(
async (opts: { save: (json: typeof subscriptionJson) => Promise<void> }) => {
await opts.save(subscriptionJson);
return mockSubscription;
}
);

global.fetch = vi.fn().mockResolvedValue({
ok: true,
Expand All @@ -69,17 +55,22 @@ describe('usePushNotifications', () => {
const { result } = renderHook(() => usePushNotifications());

expect(result.current.isSupported).toBe(true);
expect(result.current.unsupportedReason).toBeNull();
expect(result.current.isLoading).toBe(false);
});

it('should detect unsupported browser', () => {
// Must delete the property so 'PushManager' in window returns false
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).PushManager;
it('should detect unsupported browser and say why', () => {
mocks.pushSupport.mockReturnValue({
supported: false,
reason: 'ios-needs-install',
message: 'On iPhone and iPad, add this site to your Home Screen.',
permission: 'unsupported',
});

const { result } = renderHook(() => usePushNotifications());

expect(result.current.isSupported).toBe(false);
expect(result.current.unsupportedReason).toMatch(/Home Screen/);
});

it('should start as not subscribed', () => {
Expand All @@ -89,7 +80,7 @@ describe('usePushNotifications', () => {
});

it('should detect existing subscription on mount', async () => {
mockPushManager.getSubscription.mockResolvedValueOnce(mockSubscription);
mocks.getSubscription.mockResolvedValueOnce(mockSubscription);

const { result } = renderHook(() => usePushNotifications());

Expand All @@ -100,7 +91,7 @@ describe('usePushNotifications', () => {
expect(result.current.isSubscribed).toBe(true);
});

it('should subscribe successfully', async () => {
it('should subscribe with the runtime VAPID key endpoint', async () => {
const { result } = renderHook(() => usePushNotifications());

let success = false;
Expand All @@ -110,15 +101,21 @@ describe('usePushNotifications', () => {

expect(success).toBe(true);
expect(result.current.isSubscribed).toBe(true);
expect(mocks.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
vapidKeyUrl: '/api/push/vapid-public-key',
serviceWorkerUrl: '/sw.js',
})
);
expect(global.fetch).toHaveBeenCalledWith(
'/api/push/subscribe',
expect.objectContaining({ method: 'POST' })
);
});

it('should return false when permission is denied', async () => {
(window.Notification.requestPermission as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
'denied'
mocks.subscribe.mockRejectedValueOnce(
new mocks.PushError('denied', 'Notifications are blocked for this site.')
);

const { result } = renderHook(() => usePushNotifications());
Expand All @@ -130,11 +127,16 @@ describe('usePushNotifications', () => {

expect(success).toBe(false);
expect(result.current.isSubscribed).toBe(false);
expect(result.current.permission).toBe('denied');
});

it('should return false when not supported', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).PushManager;
mocks.pushSupport.mockReturnValue({
supported: false,
reason: 'no-push-manager',
message: 'This browser does not support push notifications.',
permission: 'unsupported',
});

const { result } = renderHook(() => usePushNotifications());

Expand All @@ -144,10 +146,11 @@ describe('usePushNotifications', () => {
});

expect(success).toBe(false);
expect(mocks.subscribe).not.toHaveBeenCalled();
});

it('should unsubscribe successfully', async () => {
mockPushManager.getSubscription.mockResolvedValueOnce(mockSubscription);
mocks.getSubscription.mockResolvedValue(mockSubscription);

const { result } = renderHook(() => usePushNotifications());

Expand All @@ -164,7 +167,7 @@ describe('usePushNotifications', () => {

expect(success).toBe(true);
expect(result.current.isSubscribed).toBe(false);
expect(mockSubscription.unsubscribe).toHaveBeenCalled();
expect(mocks.unsubscribe).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith(
'/api/push/unsubscribe',
expect.objectContaining({ method: 'POST' })
Expand All @@ -180,6 +183,7 @@ describe('usePushNotifications', () => {
});

expect(success).toBe(true);
expect(global.fetch).not.toHaveBeenCalledWith('/api/push/unsubscribe', expect.anything());
});

it('should include participantId when provided', async () => {
Expand Down
Loading
Loading