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>
158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
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();
|
||
});
|
||
});
|