Casting null to string caused PATCH to fire against /api/notifications/null/read when the field was absent. Added an early-return fail(400) and a test that submitting an empty form returns 400 without calling the API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
8.0 KiB
TypeScript
259 lines
8.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { load, actions } from './+page.server';
|
|
|
|
const mockApi = {
|
|
GET: vi.fn(),
|
|
PATCH: vi.fn(),
|
|
POST: vi.fn()
|
|
};
|
|
|
|
vi.mock('$lib/shared/api.server', () => ({
|
|
createApiClient: () => mockApi
|
|
}));
|
|
|
|
function buildUrl(search = ''): URL {
|
|
return new URL(`http://localhost/aktivitaeten${search}`);
|
|
}
|
|
|
|
function mockSuccess() {
|
|
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: [] } });
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('aktivitaeten/load — core', () => {
|
|
it('requests only unread notifications for Für-dich', async () => {
|
|
mockSuccess();
|
|
await load({
|
|
fetch,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
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,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
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,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
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,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
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,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
url: buildUrl('?filter=bogus')
|
|
} as never);
|
|
expect(invalidResult.filter).toBe('alle');
|
|
});
|
|
});
|
|
|
|
describe('aktivitaeten/load — kinds param per filter', () => {
|
|
it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => {
|
|
mockSuccess();
|
|
await load({
|
|
fetch,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
url: buildUrl()
|
|
} as never);
|
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
|
params: { query: { limit: 40 } }
|
|
});
|
|
});
|
|
|
|
it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => {
|
|
mockSuccess();
|
|
await load({
|
|
fetch,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
url: buildUrl('?filter=fuer-dich')
|
|
} as never);
|
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
|
params: { query: { limit: 40 } }
|
|
});
|
|
});
|
|
|
|
it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => {
|
|
mockSuccess();
|
|
await load({
|
|
fetch,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
url: buildUrl('?filter=hochgeladen')
|
|
} as never);
|
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
|
params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } }
|
|
});
|
|
});
|
|
|
|
it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => {
|
|
mockSuccess();
|
|
await load({
|
|
fetch,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
url: buildUrl('?filter=transkription')
|
|
} as never);
|
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
|
params: {
|
|
query: {
|
|
limit: 40,
|
|
kinds: expect.arrayContaining(['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED'])
|
|
}
|
|
}
|
|
});
|
|
const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity');
|
|
expect(call[1].params.query.kinds).toHaveLength(3);
|
|
});
|
|
|
|
it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => {
|
|
mockSuccess();
|
|
await load({
|
|
fetch,
|
|
request: new Request('http://localhost/aktivitaeten'),
|
|
url: buildUrl('?filter=kommentare')
|
|
} as never);
|
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
|
params: {
|
|
query: {
|
|
limit: 40,
|
|
kinds: expect.arrayContaining(['COMMENT_ADDED', 'MENTION_CREATED'])
|
|
}
|
|
}
|
|
});
|
|
const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity');
|
|
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('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
|
|
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
|
|
|
|
expect(result).toMatchObject({ status: 400 });
|
|
expect(mockApi.PATCH).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|