Files
familienarchiv/frontend/src/routes/persons/[id]/edit/page.server.spec.ts
Marcel 491d1a015a feat(relationship): date+precision edit UI, notes, and read-view display
Regenerate api.ts for the LocalDate+DatePrecision RelationshipDTO /
RelationshipUpsertRequest and the new PUT, then migrate every caller:

- RelationshipDateField (mirrors PersonLifeDateField: DAY/MONTH/YEAR, 44px
  targets, labelled, semantic dark-mode tokens, relation_* i18n keys).
- AddRelationshipForm is now upsert-capable: an optional `relationship` prop
  pre-fills type, person, both dates+precision and notes; posts to
  ?/updateRelationship (else ?/addRelationship); the submit control disables and
  shows a progress spinner while a request is in flight (REQ-019); notes textarea
  (<=2000).
- RelationshipChip gains an accessible Edit affordance (canWrite + onEdit);
  StammbaumCard wires it, formats the date range via formatRelationshipDateRange,
  and sorts by fromDate. PersonRelationshipsCard (read view) shows the date range
  and notes; no dates -> no date line.
- persons/[id]/edit/+page.server.ts: updateRelationship action (PUT) + the
  addRelationship action reshaped to date+precision+notes (empty date omits
  precision for coherence).
- Genealogy callers fixed for the dropped year fields: familyForest spouse-order
  and StammbaumConnectors ended-edge dashing now key off fromDate/toDate.
- i18n relation_* form keys in de/en/es.

REQ-004, REQ-014, REQ-015, REQ-016, REQ-019

Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:24:24 +02:00

195 lines
6.6 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 { actions } from './+page.server';
const mockFetch = vi.fn() as unknown as typeof fetch;
beforeEach(() => vi.clearAllMocks());
function makeFormData(overrides: Partial<Record<string, string>> = {}): {
request: Request;
redirectThrown: () => unknown;
} {
const fd = new FormData();
fd.set('personType', 'PERSON');
fd.set('firstName', 'Hans');
fd.set('lastName', 'Müller');
for (const [k, v] of Object.entries(overrides)) {
if (v == null) fd.delete(k);
else fd.set(k, v);
}
return {
request: new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd }),
redirectThrown: () => {}
};
}
describe('persons/[id]/edit update action — generation (#689)', () => {
it('always includes generation in the PUT body — even when value is 0', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const { request } = makeFormData({ generation: '0' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(put).toHaveBeenCalledTimes(1);
const body = put.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', 0);
});
it('sends generation: null when the dropdown is cleared (empty option)', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const { request } = makeFormData({ generation: '' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(put).toHaveBeenCalledTimes(1);
const body = put.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', null);
});
it('sends generation: 3 when the dropdown carries G 3', async () => {
const put = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const { request } = makeFormData({ generation: '3' });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
} catch {
// redirect throws on success — ignore
}
expect(put).toHaveBeenCalledTimes(1);
const body = put.mock.calls[0][1].body;
expect(body).toHaveProperty('generation', 3);
});
});
describe('persons/[id]/edit relationship actions (#837)', () => {
function relForm(overrides: Record<string, string | null> = {}): Request {
const fd = new FormData();
fd.set('relatedPersonId', 'p2');
fd.set('relationType', 'SPOUSE_OF');
for (const [k, v] of Object.entries(overrides)) {
if (v == null) fd.delete(k);
else fd.set(k, v);
}
return new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd });
}
it('addRelationship posts date + precision + notes', async () => {
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
notes: 'Hochzeit'
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const [path, opts] = post.mock.calls[0];
expect(path).toBe('/api/persons/{id}/relationships');
expect(opts.body).toMatchObject({
relatedPersonId: 'p2',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY',
notes: 'Hochzeit'
});
});
it('addRelationship omits precision when the date is empty (coherence)', async () => {
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ fromDatePrecision: 'DAY' }); // precision but no date
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const body = post.mock.calls[0][1].body;
expect(body).not.toHaveProperty('fromDate');
expect(body).not.toHaveProperty('fromDatePrecision');
});
it('updateRelationship PUTs to the relId path with the new body', async () => {
const put = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ relId: 'rel-9', fromDate: '1923-05-12', fromDatePrecision: 'DAY' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await actions.updateRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
const [path, opts] = put.mock.calls[0];
expect(path).toBe('/api/persons/{id}/relationships/{relId}');
expect(opts.params.path).toMatchObject({ id: 'p1', relId: 'rel-9' });
expect(opts.body).toMatchObject({
relatedPersonId: 'p2',
relationType: 'SPOUSE_OF',
fromDate: '1923-05-12',
fromDatePrecision: 'DAY'
});
});
it('updateRelationship surfaces a backend error as a fail', async () => {
const put = vi.fn().mockResolvedValue({
response: { ok: false, status: 400 },
error: { code: 'INVALID_RELATIONSHIP_DATES' }
});
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
typeof createApiClient
>);
const request = relForm({ relId: 'rel-9' });
const result = (await actions.updateRelationship({
request,
params: { id: 'p1' },
fetch: mockFetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)) as { status: number; data: { relationshipError: string } };
expect(result.status).toBe(400);
expect(result.data.relationshipError).toBeTruthy();
});
});