From 9d283c4500a463a9f009ed09114e13285c2eed3a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 22:51:08 +0200 Subject: [PATCH] feat(notification): add dismiss-notification and mark-all-read form actions to aktivitaeten Adds two SvelteKit form actions to /aktivitaeten/+page.server.ts so the notification bell can POST there instead of calling the backend directly from the browser. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/aktivitaeten/+page.server.ts | 28 +++++++ .../routes/aktivitaeten/page.server.spec.ts | 80 ++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/aktivitaeten/+page.server.ts b/frontend/src/routes/aktivitaeten/+page.server.ts index 21df3e85..940cedb6 100644 --- a/frontend/src/routes/aktivitaeten/+page.server.ts +++ b/frontend/src/routes/aktivitaeten/+page.server.ts @@ -1,4 +1,6 @@ +import { fail } from '@sveltejs/kit'; import { createApiClient } from '$lib/shared/api.server'; +import { getErrorMessage } from '$lib/shared/errors'; import type { components, operations } from '$lib/generated/api'; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; @@ -65,3 +67,29 @@ export async function load({ fetch, url }) { loadError }; } + +export const actions = { + 'dismiss-notification': async ({ request, fetch }) => { + const data = await request.formData(); + const notificationId = data.get('notificationId') as string; + const api = createApiClient(fetch); + const result = await api.PATCH('/api/notifications/{id}/read', { + params: { path: { id: notificationId } } + }); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + return { success: true }; + }, + + 'mark-all-read': async ({ fetch }) => { + const api = createApiClient(fetch); + const result = await api.POST('/api/notifications/read-all'); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + return { success: true }; + } +}; diff --git a/frontend/src/routes/aktivitaeten/page.server.spec.ts b/frontend/src/routes/aktivitaeten/page.server.spec.ts index 31a1619f..fbff2670 100644 --- a/frontend/src/routes/aktivitaeten/page.server.spec.ts +++ b/frontend/src/routes/aktivitaeten/page.server.spec.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { load } from './+page.server'; +import { load, actions } from './+page.server'; const mockApi = { - GET: vi.fn() + GET: vi.fn(), + PATCH: vi.fn(), + POST: vi.fn() }; vi.mock('$lib/shared/api.server', () => ({ @@ -173,3 +175,77 @@ describe('aktivitaeten/load — kinds param per filter', () => { expect(call[1].params.query.kinds).toHaveLength(2); }); }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeActionEvent(formData: FormData): any { + return { + request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }), + fetch + }; +} + +describe('aktivitaeten/actions — dismiss-notification', () => { + it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => { + mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', { + params: { path: { id: 'n-abc' } } + }); + }); + + it('returns { success: true } when the API responds ok', async () => { + mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + const result = await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(result).toEqual({ success: true }); + }); + + it('returns fail(status, { error }) when the API responds non-ok', async () => { + mockApi.PATCH.mockResolvedValue({ + response: { ok: false, status: 403 }, + error: { code: 'NOTIFICATION_NOT_FOUND' } + }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + const result = await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(result).toMatchObject({ status: 403 }); + }); +}); + +describe('aktivitaeten/actions — mark-all-read', () => { + it('calls POST /api/notifications/read-all', async () => { + mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null }); + + await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all'); + }); + + it('returns { success: true } when the API responds ok', async () => { + mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null }); + + const result = await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(result).toEqual({ success: true }); + }); + + it('returns fail(status, { error }) when the API responds non-ok', async () => { + mockApi.POST.mockResolvedValue({ + response: { ok: false, status: 500 }, + error: { code: 'INTERNAL_ERROR' } + }); + + const result = await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(result).toMatchObject({ status: 500 }); + }); +});