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; return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; }); vi.mock('#lib/api/notifications.js', async (orig) => { const actual = (await orig()) as Record; 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 { 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).mockReturnValue(mockQuery({ data: unread })); (createNotificationsQuery as ReturnType).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(); }); });