Files
familienarchiv/frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts
Marcel 94d7d8099f
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m54s
CI / fail2ban Regex (pull_request) Successful in 47s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 22s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / Constitution Impact (pull_request) Successful in 16s
fix(types): make DocumentOption precision fields optional; narrow spec data access
DocumentOption's metaDatePrecision/metaDateEnd are now optional so a
TimelineEvent DocumentRef (id/title/documentDate only) maps cleanly into a
picker chip — formatDocumentOption already degrades gracefully when precision is
absent. The server specs read fail()'s union data via a small failData() cast
that TS cannot narrow. svelte-check shows zero new errors in the #781 files.

Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:00:18 +02:00

159 lines
5.1 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server';
import { load, actions } from './+page.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
// fail() returns a union type that TS won't narrow; read its data loosely.
const failData = (r: unknown) => (r as { data: Record<string, unknown> }).data;
beforeEach(() => vi.clearAllMocks());
function localsWith(perms: string[] | null) {
if (perms === null) return { user: null };
return { user: { groups: [{ permissions: perms }] } };
}
function loadEvent(perms: string[] | null, id = 'e1') {
const url = new URL(`http://localhost/zeitstrahl/events/${id}/edit`);
return {
locals: localsWith(perms),
url,
params: { id },
fetch: mockFetch,
request: new Request(url),
route: { id: '/zeitstrahl/events/[id]/edit' }
} as never;
}
function actionEvent(
method: 'save' | 'delete',
fields: Record<string, string | string[]>,
id = 'e1'
) {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) {
if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
else fd.set(k, v);
}
return {
request: new Request(`http://localhost/zeitstrahl/events/${id}/edit?/${method}`, {
method: 'POST',
body: fd
}),
params: { id },
fetch: mockFetch,
route: { id: '/zeitstrahl/events/[id]/edit' }
} as never;
}
const EVENT_VIEW = {
id: 'e1',
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
precision: 'DAY',
version: 2,
createdBy: 'u1',
createdAt: '2026-01-01T00:00:00Z',
updatedBy: 'u1',
updatedAt: '2026-01-01T00:00:00Z',
persons: [],
documents: []
};
describe('zeitstrahl/events/[id]/edit load — gating (REQ-002/003)', () => {
it('throws 403 for an unauthenticated (null) user', async () => {
await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
});
it('throws 403 for a user without WRITE_ALL', async () => {
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
});
});
describe('zeitstrahl/events/[id]/edit load — fail closed (REQ-012)', () => {
it('throws 404 when the GET is not ok (unknown or derived id)', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 404 }, data: undefined })
} as never);
await expect(load(loadEvent(['WRITE_ALL']))).rejects.toMatchObject({ status: 404 });
});
it('seeds the form with the event on an ok GET', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW })
} as never);
const result = await load(loadEvent(['WRITE_ALL']));
expect(result.event).toMatchObject({ id: 'e1', title: 'Umzug' });
});
});
describe('zeitstrahl/events/[id]/edit save action (REQ-005/013)', () => {
it('updates via PUT (with version) and redirects on success', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
await expect(
actions.save(
actionEvent('save', {
title: 'Umzug II',
type: 'PERSONAL',
eventDate: '1925-04-01',
version: '2'
})
)
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
expect(put).toHaveBeenCalledTimes(1);
expect(put.mock.calls[0][1].params.path.id).toBe('e1');
expect(put.mock.calls[0][1].body).toMatchObject({ title: 'Umzug II', version: 2 });
});
it('maps a 409 conflict and does not redirect', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 409 }, error: { code: 'CONFLICT' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
const result = await actions.save(
actionEvent('save', { title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
);
expect(result).toMatchObject({ status: 409 });
expect(failData(result).error).toBeTruthy();
});
});
describe('zeitstrahl/events/[id]/edit delete action (REQ-006/007)', () => {
const validUuid = '22222222-2222-2222-2222-222222222222';
it('deletes via DELETE and redirects to the resolved target on success', async () => {
const del = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 } });
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
await expect(
actions.delete(actionEvent('delete', { originPersonId: validUuid }))
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
expect(del.mock.calls[0][1].params.path.id).toBe('e1');
});
it('returns fail(status) and does not redirect when DELETE is not ok', async () => {
const del = vi
.fn()
.mockResolvedValue({ response: { ok: false, status: 500 }, error: { code: 'INTERNAL' } });
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
const result = await actions.delete(actionEvent('delete', {}));
expect(result).toMatchObject({ status: 500 });
expect(failData(result).error).toBeTruthy();
});
});