Files
familienarchiv/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts
marcel 8558567688
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
feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
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
2026-06-14 21:17:36 +02:00

444 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonHoverCard from './PersonHoverCard.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthDate: '1882-01-01',
birthDatePrecision: 'YEAR',
deathDate: '1944-01-01',
deathDatePrecision: 'YEAR'
} as unknown as Person;
const POSITION = { top: 100, left: 200 };
afterEach(() => cleanup());
describe('PersonHoverCard — loading state', () => {
it('shows the skeleton when state.status is loading', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
});
it('renders three skeleton bars', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
expect(bars.length).toBe(3);
});
});
describe('PersonHoverCard — error state', () => {
it('shows a generic error message when state.status is error', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'error' }
});
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
});
it('still allows the link footer to navigate (link present in error state)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'error' }
});
// The card root must show the footer link even when the body errored —
// click navigation works regardless of fetch outcome.
const link = document.querySelector('a[href="/persons/p-aug"]');
expect(link).not.toBeNull();
});
});
describe('PersonHoverCard — loaded state', () => {
it('renders the person displayName as the header name', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
it('renders the life-date range when birth and death dates are present', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
await expect
.element(page.getByTestId('person-hover-card-dates'))
.toHaveTextContent('* 1882 † 1944');
});
it('renders a DAY-precision birth date as a full localized date', async () => {
const exact = {
...AUGUSTE,
birthDate: '1882-03-14',
birthDatePrecision: 'DAY'
} as unknown as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: exact, relationships: [] }
});
await expect
.element(page.getByTestId('person-hover-card-dates'))
.toHaveTextContent('14. März 1882');
});
it('renders APPROX-precision legacy dates with the ca. prefix', async () => {
const approx = {
...AUGUSTE,
birthDate: '1882-01-01',
birthDatePrecision: 'APPROX',
deathDate: undefined,
deathDatePrecision: 'UNKNOWN'
} as unknown as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: approx, relationships: [] }
});
await expect.element(page.getByTestId('person-hover-card-dates')).toHaveTextContent('ca. 1882');
});
it('keeps the * and † glyphs out of the accessible text via aria-hidden', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const hidden = [
...document.querySelectorAll(
'[data-testid="person-hover-card-dates"] span[aria-hidden="true"]'
)
].map((el) => el.textContent?.trim());
expect(hidden).toContain('*');
expect(hidden).toContain('†');
});
it('omits the life-date line when both dates are missing', async () => {
const noDates = {
...AUGUSTE,
birthDate: undefined,
birthDatePrecision: 'UNKNOWN',
deathDate: undefined,
deathDatePrecision: 'UNKNOWN'
} as unknown as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: noDates, relationships: [] }
});
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
expect(dates).toBeNull();
});
it('renders "geb. <alias>" when alias is set', async () => {
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withAlias, relationships: [] }
});
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
});
it('omits the maiden name line when alias is null', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
expect(maiden).toBeNull();
});
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
const relationships: RelationshipDTO[] = [
{
id: 'r1',
personId: 'p-aug',
relatedPersonId: 'p-spouse',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Otto Raddatz',
relationType: 'SPOUSE_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'r2',
personId: 'p-aug',
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
},
{
id: 'r3',
personId: 'p-aug',
relatedPersonId: 'p-sibling',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Marie Sister',
relationType: 'SIBLING_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships }
});
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
// Non-family relationship type must be filtered out
const friendChip = page.getByText('Karl Friend');
await expect.element(friendChip).not.toBeInTheDocument();
});
it('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => {
// Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card,
// the chip must show "Heinrich" (the parent), not "Auguste" (herself).
const relationships: RelationshipDTO[] = [
{
id: 'r-parent',
personId: 'p-heinrich',
relatedPersonId: 'p-aug',
personDisplayName: 'Heinrich Raddatz',
relatedPersonDisplayName: 'Auguste Raddatz',
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships }
});
await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument();
// Auguste must NOT appear as her own parent chip name
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
expect(chips?.textContent).not.toContain('Auguste Raddatz');
});
it('omits the chips section entirely when no family relationships', async () => {
const onlyFriend: RelationshipDTO[] = [
{
id: 'r1',
personId: 'p-aug',
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
});
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
expect(chips).toBeNull();
});
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withNotes, relationships: [] }
});
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
});
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
const long = 'x'.repeat(150);
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withLongNotes, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
expect(notes.textContent).toBe('x'.repeat(120) + '…');
});
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
// 150-char string with spaces — must cut at the last space, not mid-word.
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
// length is 180, last space at idx ≤120
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withLongNotes, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
const text = notes.textContent ?? '';
// Ends with ellipsis
expect(text.endsWith('…')).toBe(true);
// Last char before the ellipsis is NOT a half-word — verify by checking that
// the position right before … is the end of a word (i.e., there's no letter
// further along in the original text immediately after our cut point).
const cut = text.slice(0, -1); // strip the …
// Find this cut substring in the original sentence
const idx = sentence.indexOf(cut);
expect(idx).toBe(0);
const charAfterCut = sentence[cut.length];
// The next char should be a space — confirming we cut on a boundary
expect(charAfterCut).toBe(' ');
});
it('omits notes section when notes is null', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
expect(notes).toBeNull();
});
it('footer renders an anchor link to /persons/{personId}', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const link = document.querySelector('a[href="/persons/p-aug"]')!;
expect(link).not.toBeNull();
});
});
describe('PersonHoverCard — accessibility', () => {
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.getAttribute('aria-live')).toBe('polite');
});
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.getAttribute('aria-busy')).toBe('true');
});
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
// aria-busy is either absent or "false"
const busy = root.getAttribute('aria-busy');
expect(busy === null || busy === 'false').toBe(true);
});
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
});
it('names the region with a generic loading label while loading', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
// Region must have an accessible name in every state — axe-core flags
// role="region" without aria-label / aria-labelledby.
expect(root.getAttribute('aria-label')).toBeTruthy();
});
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-xyz',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.id).toBe('card-xyz');
});
it('positions itself fixed at the given top/left', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: { top: 333, left: 444 },
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
expect(root.style.top).toBe('333px');
expect(root.style.left).toBe('444px');
expect(root.style.position).toBe('fixed');
});
});