feat(chronik): add /chronik route (page.server.ts + +page.svelte + spec)
page.server.ts loads /api/dashboard/activity (limit=40) and unread
/api/notifications in parallel via Promise.allSettled so a dashboard-activity
failure still renders the Für-dich box. Form actions ?/dismiss and ?/mark-all
back the Dismiss and "Alle gelesen" controls with CSRF-safe SvelteKit
endpoints.
+page.svelte composes all six chronik components:
- ChronikFuerDichBox at the top, seeded from the SSR unread set on first
render and switching to the live SSE singleton once notifications arrive;
- ChronikFilterPills below, wired to URL via goto(?filter=…) with
replaceState so the browser history stays clean across filter changes;
- ChronikTimeline for the day-bucketed feed, filtered client-side per pill
(alle / fuer-dich / hochgeladen / transkription / kommentare);
- ChronikEmptyState for first-run vs filter-empty states;
- ChronikErrorCard on activity load failure.
"Mehr laden" pagination keeps focus on the button after load (via tick() +
$state-bound ref), renders 3 static skeleton rows with aria-busy, and
announces "{count} weitere Einträge geladen" through a polite aria-live
region. Inbox-zero in the Für-dich box links to /chronik?filter=fuer-dich.
Co-located page.server.spec.ts covers load(): limit=40, unread=read:false,
filter parsing with "alle" fallback, activity-fulfilled-but-not-ok surfaces
loadError, plus the dismiss and mark-all actions (success + missing-id
branch). 8 tests green.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
frontend/src/routes/chronik/page.server.spec.ts
Normal file
129
frontend/src/routes/chronik/page.server.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
const mockApi = {
|
||||
GET: vi.fn(),
|
||||
POST: vi.fn(),
|
||||
PATCH: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('$lib/api.server', () => ({
|
||||
createApiClient: () => mockApi
|
||||
}));
|
||||
|
||||
function buildUrl(search = ''): URL {
|
||||
return new URL(`http://localhost/chronik${search}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('chronik/load', () => {
|
||||
it('requests the activity feed with a 40-item limit', async () => {
|
||||
mockApi.GET.mockImplementation((path: string) => {
|
||||
if (path === '/api/dashboard/activity') {
|
||||
return Promise.resolve({ response: { ok: true }, data: [] });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||
});
|
||||
|
||||
await load({ fetch, url: buildUrl() } as never);
|
||||
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||
params: { query: { limit: 40 } }
|
||||
});
|
||||
});
|
||||
|
||||
it('requests only unread notifications for Für-dich', async () => {
|
||||
mockApi.GET.mockImplementation((path: string) => {
|
||||
if (path === '/api/dashboard/activity') {
|
||||
return Promise.resolve({ response: { ok: true }, data: [] });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||
});
|
||||
|
||||
await load({ fetch, url: buildUrl() } as never);
|
||||
|
||||
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
|
||||
params: { query: { read: false, page: 0, size: 20 } }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the activity feed and unread notifications on success', async () => {
|
||||
const feed = [{ kind: 'FILE_UPLOADED', documentId: 'd1' }];
|
||||
const unread = [{ id: 'n1', type: 'MENTION' }];
|
||||
mockApi.GET.mockImplementation((path: string) => {
|
||||
if (path === '/api/dashboard/activity') {
|
||||
return Promise.resolve({ response: { ok: true }, data: feed });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: unread } });
|
||||
});
|
||||
|
||||
const result = await load({ fetch, url: buildUrl() } as never);
|
||||
|
||||
expect(result.activityFeed).toEqual(feed);
|
||||
expect(result.unreadNotifications).toEqual(unread);
|
||||
expect(result.filter).toBe('alle');
|
||||
expect(result.loadError).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces "activity" loadError when the dashboard endpoint returns non-ok', async () => {
|
||||
mockApi.GET.mockImplementation((path: string) => {
|
||||
if (path === '/api/dashboard/activity') {
|
||||
return Promise.resolve({ response: { ok: false, status: 500 }, error: {} });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||
});
|
||||
|
||||
const result = await load({ fetch, url: buildUrl() } as never);
|
||||
|
||||
expect(result.loadError).toBe('activity');
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
|
||||
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||
|
||||
const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||
expect(validResult.filter).toBe('fuer-dich');
|
||||
|
||||
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||
const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never);
|
||||
expect(invalidResult.filter).toBe('alle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chronik/actions', () => {
|
||||
it('dismiss: PATCHes /api/notifications/{id}/read with the form id', async () => {
|
||||
mockApi.PATCH.mockResolvedValue({ response: { ok: true } });
|
||||
const formData = new FormData();
|
||||
formData.set('id', 'n-42');
|
||||
|
||||
const result = await actions.dismiss({
|
||||
request: { formData: async () => formData },
|
||||
fetch
|
||||
} as never);
|
||||
|
||||
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
|
||||
params: { path: { id: 'n-42' } }
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('dismiss: fails with 400 when id is missing', async () => {
|
||||
const formData = new FormData();
|
||||
const result = await actions.dismiss({
|
||||
request: { formData: async () => formData },
|
||||
fetch
|
||||
} as never);
|
||||
expect((result as { status: number }).status).toBe(400);
|
||||
});
|
||||
|
||||
it('mark-all: POSTs /api/notifications/read-all', async () => {
|
||||
mockApi.POST.mockResolvedValue({ response: { ok: true } });
|
||||
const result = await actions['mark-all']({ fetch } as never);
|
||||
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user