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
444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
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');
|
||
});
|
||
});
|