feat(notifications): implement /notifications page with filter pills and load-more
New route with server load function (reads URL params, derives unreadCount from the page, single API call per Sara's architecture requirement), mark-all form action, and the full page UI: filter pills with ARIA radiogroup, notification rows with border+dot unread indicators (WCAG 1.4.1), "Ältere laden" client-side append, and empty state. Includes all de/en/es translation keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
136
frontend/src/routes/notifications/+page.server.spec.ts
Normal file
136
frontend/src/routes/notifications/+page.server.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeUrl(params: Record<string, string> = {}) {
|
||||
const url = new URL('http://localhost/notifications');
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── load ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('notifications page load', () => {
|
||||
it('returns notifications and unreadCount from API response', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: {
|
||||
content: [
|
||||
{ id: 'n1', read: false },
|
||||
{ id: 'n2', read: true },
|
||||
{ id: 'n3', read: false }
|
||||
],
|
||||
totalElements: 3,
|
||||
totalPages: 1,
|
||||
number: 0
|
||||
}
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.notifications).toHaveLength(3);
|
||||
expect(result.unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('passes type param to API when ?type=MENTION is in URL', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl({ type: 'MENTION' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||
expect(queryParams.type).toBe('MENTION');
|
||||
});
|
||||
|
||||
it('passes read=false to API when ?read=false is in URL', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl({ read: 'false' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||
expect(queryParams.read).toBe(false);
|
||||
});
|
||||
|
||||
it('passes no filter params when no search params present', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||
expect(queryParams.type).toBeUndefined();
|
||||
expect(queryParams.read).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls the API exactly once — no separate round-trip for unreadCount', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws 401 error when API returns 401', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: false, status: 401 },
|
||||
data: null
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await expect(
|
||||
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mark-all action ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('notifications mark-all action', () => {
|
||||
it('calls POST /api/notifications/read-all and redirects', async () => {
|
||||
const mockPost = vi.fn().mockResolvedValueOnce({ response: { ok: true } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: mockPost } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const markAll = actions['mark-all'] as (ctx: { fetch: typeof fetch }) => Promise<never>;
|
||||
await expect(markAll({ fetch: vi.fn() as unknown as typeof fetch })).rejects.toMatchObject({
|
||||
location: '/notifications'
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user