diff --git a/src/hooks/useAssignmentNotifications/useAssignmentNotifications.test.ts b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.test.ts new file mode 100644 index 0000000..d40f72a --- /dev/null +++ b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { + disableAssignmentNotifications, + enableAssignmentNotifications, + getAssignmentNotificationStatus, + isAssignmentNotificationsEnabled, + testAssignmentNotifications, +} from '@/lib/notifications/assignmentNotifications'; +import { useAssignmentNotifications } from './useAssignmentNotifications'; + +jest.mock('@/lib/notifications/assignmentNotifications', () => ({ + disableAssignmentNotifications: jest.fn(), + enableAssignmentNotifications: jest.fn(), + getAssignmentNotificationStatus: jest.fn(), + isAssignmentNotificationsEnabled: jest.fn(), + testAssignmentNotifications: jest.fn(), +})); + +const competitions = [ + { + id: 'ExampleComp2026', + }, +] as ApiCompetition[]; +const user = { + id: 8184, +} as User; + +describe('useAssignmentNotifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getAssignmentNotificationStatus).mockReturnValue('granted'); + jest.mocked(isAssignmentNotificationsEnabled).mockReturnValue(true); + jest.mocked(enableAssignmentNotifications).mockResolvedValue('granted'); + jest.mocked(disableAssignmentNotifications).mockResolvedValue(undefined); + jest.mocked(testAssignmentNotifications).mockResolvedValue(undefined); + }); + + it('exposes a test action when assignment notifications are enabled', async () => { + const { result } = renderHook(() => useAssignmentNotifications({ competitions, user })); + + expect(result.current.canTest).toBe(true); + + await act(async () => { + await result.current.test(); + }); + + await waitFor(() => { + expect(result.current.successMessage).toBe('Test notification sent.'); + }); + expect(testAssignmentNotifications).toHaveBeenCalledTimes(1); + expect(result.current.error).toBeNull(); + }); + + it('surfaces test notification errors', async () => { + jest + .mocked(testAssignmentNotifications) + .mockRejectedValue(new Error('Unable to send test notification')); + + const { result } = renderHook(() => useAssignmentNotifications({ competitions, user })); + + await act(async () => { + await result.current.test(); + }); + + await waitFor(() => { + expect(result.current.error).toBe('Unable to send test notification'); + }); + expect(result.current.successMessage).toBeNull(); + }); +}); diff --git a/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts index a16e9ab..face4f6 100644 --- a/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts +++ b/src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts @@ -5,6 +5,7 @@ import { enableAssignmentNotifications, getAssignmentNotificationStatus, isAssignmentNotificationsEnabled, + testAssignmentNotifications, } from '@/lib/notifications/assignmentNotifications'; interface UseAssignmentNotificationsParams { @@ -20,7 +21,9 @@ export function useAssignmentNotifications({ getAssignmentNotificationStatus, ); const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); const [isEnabled, setIsEnabled] = useState(isAssignmentNotificationsEnabled); useEffect(() => { @@ -39,9 +42,14 @@ export function useAssignmentNotifications({ [competitions, user], ); + const clearFeedback = useCallback(() => { + setError(null); + setSuccessMessage(null); + }, []); + const enable = useCallback(async () => { setIsSaving(true); - setError(null); + clearFeedback(); try { const nextStatus = await enableAssignmentNotifications(watches); @@ -54,11 +62,11 @@ export function useAssignmentNotifications({ } finally { setIsSaving(false); } - }, [watches]); + }, [clearFeedback, watches]); const disable = useCallback(async () => { setIsSaving(true); - setError(null); + clearFeedback(); try { await disableAssignmentNotifications(); @@ -70,16 +78,37 @@ export function useAssignmentNotifications({ } finally { setIsSaving(false); } - }, []); + }, [clearFeedback]); + + const test = useCallback(async () => { + setIsTesting(true); + clearFeedback(); + + try { + await testAssignmentNotifications(); + setSuccessMessage('Test notification sent.'); + setIsEnabled(isAssignmentNotificationsEnabled()); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to send test notification.'); + setStatus(getAssignmentNotificationStatus()); + setIsEnabled(isAssignmentNotificationsEnabled()); + } finally { + setIsTesting(false); + } + }, [clearFeedback]); return { canEnable: (status === 'default' || status === 'granted') && !isEnabled && watches.length > 0, canDisable: status === 'granted' && isEnabled, + canTest: status === 'granted' && isEnabled, enable, disable, error, + isTesting, isSaving, status, + successMessage, + test, watchCount: watches.length, }; } diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts index ebfe566..83f710c 100644 --- a/src/lib/notifications/assignmentNotifications.ts +++ b/src/lib/notifications/assignmentNotifications.ts @@ -11,6 +11,7 @@ const NOTIFY_COMP_ORIGIN = const NOTIFY_COMP_TOKEN_URL = '/.netlify/functions/notify-comp-token'; const ENABLED_STORAGE_KEY = 'assignmentNotifications.enabled'; const SERVICE_WORKER_TIMEOUT_MS = 10000; +const INVALID_PUSH_SESSION_STATUSES = new Set([401, 403, 404, 410]); interface PushSubscriptionJson { endpoint?: string; @@ -43,6 +44,14 @@ const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`; export const isAssignmentNotificationsEnabled = () => Boolean(getNotifyCompPushSessionToken()) || getLocalStorage(ENABLED_STORAGE_KEY) === 'true'; +const clearAssignmentNotificationState = () => { + clearNotifyCompPushSessionToken(); + deleteLocalStorage(ENABLED_STORAGE_KEY); +}; + +const isInvalidPushSessionResponse = (response: Response) => + INVALID_PUSH_SESSION_STATUSES.has(response.status); + const toUint8Array = (base64: string) => { const padding = '='.repeat((4 - (base64.length % 4)) % 4); const normalized = `${base64}${padding}`.replace(/-/g, '+').replace(/_/g, '/'); @@ -172,6 +181,12 @@ const getPushSubscription = async () => { }); }; +const unsubscribePushSubscription = async () => { + const registration = await getServiceWorkerRegistration(); + const subscription = await registration.pushManager.getSubscription(); + await subscription?.unsubscribe(); +}; + const pushSubscriptionPayload = (subscription: PushSubscription): PushSubscriptionPayload => { const payload = subscription.toJSON() as PushSubscriptionJson; @@ -204,7 +219,7 @@ const registerLegacySubscription = async ( }); if (!response.ok) { - deleteLocalStorage(ENABLED_STORAGE_KEY); + clearAssignmentNotificationState(); throw new Error(await readErrorMessage(response)); } }; @@ -232,16 +247,14 @@ const createPushSession = async ( } if (!response.ok) { - clearNotifyCompPushSessionToken(); - deleteLocalStorage(ENABLED_STORAGE_KEY); + clearAssignmentNotificationState(); throw new Error(await readErrorMessage(response)); } const session = (await response.json()) as PushSessionResponse; const sessionToken = session.sessionToken || session.token; if (!sessionToken) { - clearNotifyCompPushSessionToken(); - deleteLocalStorage(ENABLED_STORAGE_KEY); + clearAssignmentNotificationState(); throw new Error('NotifyComp push session response was missing a session token.'); } @@ -265,7 +278,7 @@ const updatePushSession = async ( }), }); - if (response.status === 401 || response.status === 403) { + if (isInvalidPushSessionResponse(response)) { clearNotifyCompPushSessionToken(); return false; } @@ -337,8 +350,7 @@ export const disableAssignmentNotifications = async () => { const sessionToken = getNotifyCompPushSessionToken(); if (!subscription) { - clearNotifyCompPushSessionToken(); - deleteLocalStorage(ENABLED_STORAGE_KEY); + clearAssignmentNotificationState(); return; } @@ -351,6 +363,29 @@ export const disableAssignmentNotifications = async () => { } await subscription.unsubscribe(); - clearNotifyCompPushSessionToken(); - deleteLocalStorage(ENABLED_STORAGE_KEY); + clearAssignmentNotificationState(); +}; + +export const testAssignmentNotifications = async () => { + const sessionToken = getNotifyCompPushSessionToken(); + if (!sessionToken) { + throw new Error('Enable assignment notifications before sending a test notification.'); + } + + const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current/test'), { + method: 'POST', + headers: { + Authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (isInvalidPushSessionResponse(response)) { + clearAssignmentNotificationState(); + await unsubscribePushSubscription().catch(() => undefined); + } + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } }; diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 87fef82..2b76982 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -14,6 +14,7 @@ export default function Settings() { competitions, user, }); + const notificationsBusy = notifications.isSaving || notifications.isTesting; const themeOptions: { value: Theme; label: string; description: string }[] = [ { value: 'light', label: 'Light', description: 'Always use light theme' }, @@ -95,15 +96,29 @@ export default function Settings() { )} {user && notifications.canDisable && ( - +
+ + + {notifications.canTest && ( + + )} +
)} {user && notifications.status === 'denied' && ( @@ -111,6 +126,9 @@ export default function Settings() { )} {notifications.error &&

{notifications.error}

} + {notifications.successMessage && ( +

{notifications.successMessage}

+ )}