Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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();
});
});
37 changes: 33 additions & 4 deletions src/hooks/useAssignmentNotifications/useAssignmentNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
enableAssignmentNotifications,
getAssignmentNotificationStatus,
isAssignmentNotificationsEnabled,
testAssignmentNotifications,
} from '@/lib/notifications/assignmentNotifications';

interface UseAssignmentNotificationsParams {
Expand All @@ -20,7 +21,9 @@ export function useAssignmentNotifications({
getAssignmentNotificationStatus,
);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isEnabled, setIsEnabled] = useState(isAssignmentNotificationsEnabled);

useEffect(() => {
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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,
};
}
55 changes: 45 additions & 10 deletions src/lib/notifications/assignmentNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, '/');
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -204,7 +219,7 @@ const registerLegacySubscription = async (
});

if (!response.ok) {
deleteLocalStorage(ENABLED_STORAGE_KEY);
clearAssignmentNotificationState();
throw new Error(await readErrorMessage(response));
}
};
Expand Down Expand Up @@ -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.');
}

Expand All @@ -265,7 +278,7 @@ const updatePushSession = async (
}),
});

if (response.status === 401 || response.status === 403) {
if (isInvalidPushSessionResponse(response)) {
clearNotifyCompPushSessionToken();
return false;
}
Expand Down Expand Up @@ -337,8 +350,7 @@ export const disableAssignmentNotifications = async () => {
const sessionToken = getNotifyCompPushSessionToken();

if (!subscription) {
clearNotifyCompPushSessionToken();
deleteLocalStorage(ENABLED_STORAGE_KEY);
clearAssignmentNotificationState();
return;
}

Expand All @@ -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));
}
};
36 changes: 27 additions & 9 deletions src/pages/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -95,22 +96,39 @@ export default function Settings() {
)}

{user && notifications.canDisable && (
<Button
type="button"
variant="gray"
disabled={notifications.isSaving}
onClick={() => {
void notifications.disable();
}}>
{notifications.isSaving ? 'Disabling...' : 'Disable assignment notifications'}
</Button>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="gray"
disabled={notificationsBusy}
onClick={() => {
void notifications.disable();
}}>
{notifications.isSaving ? 'Disabling...' : 'Disable assignment notifications'}
</Button>

{notifications.canTest && (
<Button
type="button"
variant="light"
disabled={notificationsBusy}
onClick={() => {
void notifications.test();
}}>
{notifications.isTesting ? 'Sending...' : 'Test notifications'}
</Button>
)}
</div>
)}

{user && notifications.status === 'denied' && (
<p className="type-meta">Notifications are blocked in your browser settings.</p>
)}

{notifications.error && <p className="type-meta text-red-500">{notifications.error}</p>}
{notifications.successMessage && (
<p className="type-meta text-green-600">{notifications.successMessage}</p>
)}
</div>

<div className="space-y-4 p-6 bg-panel rounded-lg shadow-md shadow-tertiary-dark">
Expand Down
Loading