All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Closes #837 Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free. Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`). ## What's in it - **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped. - **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint. - New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6). - `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision. - Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated. - Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019. ## Requirements All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`. ## Test plan - **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds. - **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean. ## Notes for reviewers - **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`. - `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned). - **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Marcel <marcel@familienarchiv> Reviewed-on: #841
191 lines
5.6 KiB
TypeScript
191 lines
5.6 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
||
import { cleanup, render } from 'vitest-browser-svelte';
|
||
import { page } from 'vitest/browser';
|
||
import StammbaumCard from './StammbaumCard.svelte';
|
||
|
||
afterEach(cleanup);
|
||
|
||
const baseProps = (overrides: Record<string, unknown> = {}) => ({
|
||
personId: 'p-1',
|
||
familyMember: false,
|
||
relationships: [] as unknown[],
|
||
inferredRelationships: [] as unknown[],
|
||
canWrite: false,
|
||
relationshipError: null as string | null,
|
||
...overrides
|
||
});
|
||
|
||
describe('StammbaumCard', () => {
|
||
it('renders the heading', async () => {
|
||
render(StammbaumCard, { props: baseProps() });
|
||
|
||
await expect
|
||
.element(page.getByRole('heading', { name: /stammbaum & beziehungen/i }))
|
||
.toBeVisible();
|
||
});
|
||
|
||
it('renders the family-member toggle when canWrite is true', async () => {
|
||
render(StammbaumCard, { props: baseProps({ canWrite: true }) });
|
||
|
||
await expect.element(page.getByRole('switch')).toBeVisible();
|
||
});
|
||
|
||
it('omits the family-member toggle when canWrite is false', async () => {
|
||
render(StammbaumCard, { props: baseProps() });
|
||
|
||
await expect.element(page.getByRole('switch')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('marks the toggle as aria-checked=true when familyMember is true', async () => {
|
||
render(StammbaumCard, { props: baseProps({ canWrite: true, familyMember: true }) });
|
||
|
||
await expect.element(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
|
||
});
|
||
|
||
it('renders the in-tree banner when familyMember is true', async () => {
|
||
render(StammbaumCard, { props: baseProps({ familyMember: true }) });
|
||
|
||
await expect.element(page.getByText('Erscheint im Stammbaum')).toBeVisible();
|
||
await expect
|
||
.element(page.getByRole('link', { name: /ansehen/i }))
|
||
.toHaveAttribute('href', '/stammbaum?focus=p-1');
|
||
});
|
||
|
||
it('hides the in-tree banner when familyMember is false', async () => {
|
||
render(StammbaumCard, { props: baseProps() });
|
||
|
||
await expect.element(page.getByText('Erscheint im Stammbaum')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('shows the relationshipError alert when set', async () => {
|
||
render(StammbaumCard, {
|
||
props: baseProps({ relationshipError: 'Beziehung konnte nicht gespeichert werden.' })
|
||
});
|
||
|
||
await expect
|
||
.element(page.getByText('Beziehung konnte nicht gespeichert werden.'))
|
||
.toBeVisible();
|
||
});
|
||
|
||
it('renders the empty placeholder for direct relationships when none exist', async () => {
|
||
render(StammbaumCard, { props: baseProps() });
|
||
|
||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeVisible();
|
||
});
|
||
|
||
it('hides the inferred-relationships disclosure when there are none', async () => {
|
||
render(StammbaumCard, { props: baseProps() });
|
||
|
||
await expect.element(page.getByText('Abgeleitete Beziehungen')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('renders the AddRelationshipForm when canWrite is true', async () => {
|
||
render(StammbaumCard, { props: baseProps({ canWrite: true }) });
|
||
|
||
// AddRelationshipForm renders interactive elements
|
||
const buttons = document.querySelectorAll('button');
|
||
expect(buttons.length).toBeGreaterThan(1);
|
||
});
|
||
|
||
it('renders direct relationships sorted by relationType order', async () => {
|
||
render(StammbaumCard, {
|
||
props: baseProps({
|
||
relationships: [
|
||
{
|
||
id: 'r-friend',
|
||
relationType: 'FRIEND',
|
||
personA: { id: 'p-1', displayName: 'Anna' },
|
||
personB: { id: 'p-friend', displayName: 'Carlos' }
|
||
},
|
||
{
|
||
id: 'r-parent',
|
||
relationType: 'PARENT_OF',
|
||
personA: { id: 'p-1', displayName: 'Anna' },
|
||
personB: { id: 'p-child', displayName: 'Daniel' }
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
const items = document.querySelectorAll('ul.divide-y > li, ul.divide-y > *');
|
||
expect(items.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it('renders the date range "from – to" for a relationship with both dates', async () => {
|
||
render(StammbaumCard, {
|
||
props: baseProps({
|
||
relationships: [
|
||
{
|
||
id: 'r-1',
|
||
personId: 'p-1',
|
||
relatedPersonId: 'p-x',
|
||
personDisplayName: 'Anna',
|
||
relatedPersonDisplayName: 'Xavier',
|
||
relationType: 'COLLEAGUE',
|
||
fromDate: '1940-01-01',
|
||
fromDatePrecision: 'YEAR',
|
||
toDate: '1945-01-01',
|
||
toDatePrecision: 'YEAR'
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
expect(document.body.textContent).toContain('1940');
|
||
expect(document.body.textContent).toContain('1945');
|
||
});
|
||
|
||
it('renders only the start date for a relationship with no end date', async () => {
|
||
render(StammbaumCard, {
|
||
props: baseProps({
|
||
relationships: [
|
||
{
|
||
id: 'r-2',
|
||
personId: 'p-1',
|
||
relatedPersonId: 'p-y',
|
||
personDisplayName: 'Anna',
|
||
relatedPersonDisplayName: 'Yvonne',
|
||
relationType: 'NEIGHBOR',
|
||
fromDate: '1935-01-01',
|
||
fromDatePrecision: 'YEAR',
|
||
toDatePrecision: 'UNKNOWN'
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
expect(document.body.textContent).toContain('1935');
|
||
expect(document.body.textContent).not.toContain('1935 –');
|
||
});
|
||
|
||
it('renders the inferred-relationships disclosure when topDerived has items', async () => {
|
||
render(StammbaumCard, {
|
||
props: baseProps({
|
||
inferredRelationships: [
|
||
{
|
||
label: 'GRANDPARENT_OF',
|
||
person: { id: 'p-grand', displayName: 'Grandma' }
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
await expect.element(page.getByText('Abgeleitete Beziehungen')).toBeVisible();
|
||
expect(document.body.textContent).toContain('Grandma');
|
||
});
|
||
|
||
it('caps the inferred relationships at 5 items', async () => {
|
||
const inferred = Array.from({ length: 8 }, (_, i) => ({
|
||
label: 'COUSIN_OF',
|
||
person: { id: `p-cousin-${i}`, displayName: `Cousin ${i}` }
|
||
}));
|
||
|
||
render(StammbaumCard, {
|
||
props: baseProps({ inferredRelationships: inferred })
|
||
});
|
||
|
||
const items = document.querySelectorAll('details ul > li');
|
||
expect(items.length).toBe(5);
|
||
});
|
||
});
|