Files
minstrel/web/src/lib/components/NotificationBell.test.ts
T
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00

158 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
const invalidateMock = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
});
vi.mock('#lib/api/notifications.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
badgeLabel: actual.badgeLabel,
createUnreadCountQuery: vi.fn(),
createNotificationsQuery: vi.fn(),
markNotificationRead: vi.fn().mockResolvedValue(undefined),
markAllNotificationsRead: vi.fn().mockResolvedValue(undefined)
};
});
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NotificationBell from './NotificationBell.svelte';
import { goto } from '$app/navigation';
import {
badgeLabel,
createNotificationsQuery,
createUnreadCountQuery,
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '#lib/api/notifications.js';
function item(over: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
kind: 'request_completed',
title: 'Arrived: Boards of Canada – Geogaddi',
body: 'Your request is in the library.',
link: '/albums/al-1',
created_at: new Date().toISOString(),
read_at: null,
...over
};
}
function setInbox(items: NotificationItem[], unread = items.filter((i) => !i.read_at).length) {
(createUnreadCountQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: unread }));
(createNotificationsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: { items, unread_count: unread } })
);
}
async function openPanel() {
await fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('badgeLabel', () => {
test('is empty at zero, the count to nine, then 9+', () => {
expect(badgeLabel(0)).toBe('');
expect(badgeLabel(1)).toBe('1');
expect(badgeLabel(9)).toBe('9');
expect(badgeLabel(10)).toBe('9+');
expect(badgeLabel(250)).toBe('9+');
});
});
describe('NotificationBell', () => {
test('shows the unread count on the badge and in the label', () => {
setInbox([item(), item({ id: 'n2' }), item({ id: 'n3' })]);
render(NotificationBell);
expect(screen.getByTestId('notification-badge')).toHaveTextContent('3');
expect(screen.getByRole('button', { name: 'Notifications, 3 unread' })).toBeInTheDocument();
});
test('caps the badge at 9+', () => {
setInbox([item()], 42);
render(NotificationBell);
expect(screen.getByTestId('notification-badge')).toHaveTextContent('9+');
});
test('no badge when everything is read', () => {
setInbox([item({ read_at: new Date().toISOString() })]);
render(NotificationBell);
expect(screen.queryByTestId('notification-badge')).toBeNull();
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
});
test('lists each kind with the server-rendered title and body, unread marked', async () => {
setInbox([
item({ id: 'a', kind: 'request_approved', title: 'Request approved', body: 'Geogaddi is on its way.' }),
item({
id: 'b',
kind: 'tracks_missing',
title: '4 tracks went missing',
body: 'Their files are no longer where the library expects them.',
link: '/admin/missing-files',
read_at: new Date().toISOString()
})
]);
render(NotificationBell);
await openPanel();
const rows = screen.getAllByTestId('notification-item');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('Request approved');
expect(rows[0]).toHaveTextContent('Geogaddi is on its way.');
expect(rows[0]).toHaveAttribute('data-unread', 'true');
expect(rows[1]).toHaveTextContent('4 tracks went missing');
expect(rows[1]).not.toHaveAttribute('data-unread');
});
test('clicking an unread item marks it read and opens its subject', async () => {
setInbox([item({ id: 'n7', link: '/albums/al-7' })]);
render(NotificationBell);
await openPanel();
await fireEvent.click(screen.getByTestId('notification-item'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/albums/al-7'));
expect(markNotificationRead).toHaveBeenCalledWith('n7');
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
expect(screen.queryByRole('dialog', { name: 'Notifications' })).toBeNull();
});
test('clicking a read item only opens it', async () => {
setInbox([item({ read_at: new Date().toISOString(), link: '/requests' })]);
render(NotificationBell);
await openPanel();
await fireEvent.click(screen.getByTestId('notification-item'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/requests'));
expect(markNotificationRead).not.toHaveBeenCalled();
});
test('mark all read clears everything and refreshes', async () => {
setInbox([item(), item({ id: 'n2' })]);
render(NotificationBell);
await openPanel();
await fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalled());
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
});
test('an empty inbox says so and offers no mark all', async () => {
setInbox([]);
render(NotificationBell);
await openPanel();
expect(screen.getByText('Nothing waiting for you.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Mark all read' })).toBeNull();
});
});