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
146 lines
5.2 KiB
TypeScript
146 lines
5.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { pickStructuralOwner, buildFamilyForest, type Unit } from './familyForest';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
|
|
|
type Person = { id: string; birthYear?: number };
|
|
|
|
function person(id: string, opts: { birthYear?: number; generation?: number } = {}): PersonNodeDTO {
|
|
const n: PersonNodeDTO = { id, displayName: id, familyMember: true };
|
|
if (opts.birthYear != null) n.birthYear = opts.birthYear;
|
|
if (opts.generation != null) n.generation = opts.generation;
|
|
return n;
|
|
}
|
|
|
|
function parent(p: string, c: string): RelationshipDTO {
|
|
return {
|
|
id: `${p}>${c}`,
|
|
personId: p,
|
|
relatedPersonId: c,
|
|
personDisplayName: '',
|
|
relatedPersonDisplayName: '',
|
|
relationType: 'PARENT_OF',
|
|
fromDatePrecision: 'UNKNOWN',
|
|
toDatePrecision: 'UNKNOWN'
|
|
};
|
|
}
|
|
|
|
function spouse(a: string, b: string, fromYear?: number): RelationshipDTO {
|
|
return {
|
|
id: `${a}~${b}`,
|
|
personId: a,
|
|
relatedPersonId: b,
|
|
personDisplayName: '',
|
|
relatedPersonDisplayName: '',
|
|
relationType: 'SPOUSE_OF',
|
|
fromDatePrecision: 'UNKNOWN',
|
|
toDatePrecision: 'UNKNOWN',
|
|
...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
|
|
};
|
|
}
|
|
|
|
// Find the unit (anywhere in the forest) whose primary id matches.
|
|
function findUnit(roots: Unit[], primaryId: string): Unit | undefined {
|
|
for (const r of roots) {
|
|
if (r.id === primaryId) return r;
|
|
const found = findUnit(r.children, primaryId);
|
|
if (found) return found;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
describe('pickStructuralOwner', () => {
|
|
const p = (id: string, birthYear?: number): Person => ({ id, birthYear });
|
|
|
|
it('picks the earlier-born spouse as structural owner', () => {
|
|
expect(pickStructuralOwner(p('a', 1900), p('b', 1920))).toBe('a');
|
|
expect(pickStructuralOwner(p('a', 1920), p('b', 1900))).toBe('b');
|
|
});
|
|
|
|
it('sorts a missing birthYear last (the dated spouse owns)', () => {
|
|
expect(pickStructuralOwner(p('a'), p('b', 1900))).toBe('b');
|
|
expect(pickStructuralOwner(p('a', 1900), p('b'))).toBe('a');
|
|
});
|
|
|
|
it('breaks ties on stable id when birth years match or are both missing', () => {
|
|
expect(pickStructuralOwner(p('zzz', 1900), p('aaa', 1900))).toBe('aaa');
|
|
expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa');
|
|
});
|
|
});
|
|
|
|
describe('buildFamilyForest — loose-spouse absorption', () => {
|
|
it('absorbs a parentless spouse into the partner run; their child anchors to the couple', () => {
|
|
// A (founder) ⚭ S (married-in, no parents). Their child C. S has no
|
|
// ancestor subtree of its own, but C still hangs off the couple.
|
|
const forest = buildFamilyForest(
|
|
[
|
|
person('A', { birthYear: 1900 }),
|
|
person('S', { birthYear: 1905 }),
|
|
person('C', { birthYear: 1930 })
|
|
],
|
|
[spouse('A', 'S'), parent('A', 'C'), parent('S', 'C')]
|
|
);
|
|
|
|
// One root unit (A), with S absorbed — S is not its own root.
|
|
expect(forest.roots.map((r) => r.id)).toEqual(['A']);
|
|
const a = findUnit(forest.roots, 'A')!;
|
|
expect(a.members).toEqual(['A', 'S']);
|
|
// The child anchors through the couple unit.
|
|
expect(a.children.map((u) => u.id)).toEqual(['C']);
|
|
// S is parentless, so no displaced cross-link.
|
|
expect(forest.crossLinks).toEqual([]);
|
|
});
|
|
|
|
it('keeps all marriages of a multi-spouse founder in one run (marriage-year order)', () => {
|
|
// Albert-like: one founder, three parentless wives. All absorbed into the
|
|
// founder run, ordered by marriage year NULLS LAST then displayName/id.
|
|
const forest = buildFamilyForest(
|
|
[
|
|
person('alb', { birthYear: 1829 }),
|
|
person('w1925', { birthYear: 1900 }),
|
|
person('wNull', { birthYear: 1901 }),
|
|
person('w1910', { birthYear: 1902 })
|
|
],
|
|
[spouse('alb', 'w1925', 1925), spouse('alb', 'wNull'), spouse('alb', 'w1910', 1910)]
|
|
);
|
|
|
|
expect(forest.roots.map((r) => r.id)).toEqual(['alb']);
|
|
const alb = findUnit(forest.roots, 'alb')!;
|
|
// Founder first, then spouses by marriage year (1910, 1925, null last).
|
|
expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']);
|
|
});
|
|
});
|
|
|
|
describe('buildFamilyForest — sibling/branch ordering', () => {
|
|
it('orders children by birthYear ASC, NULLS LAST, then displayName, then id', () => {
|
|
// Provided out of order; the comparator must reorder to 1910, 1920, then
|
|
// the two undated by displayName/id (NULLS LAST).
|
|
const undatedB: PersonNodeDTO = { id: 'u-b', displayName: 'Zoe', familyMember: true };
|
|
const undatedA: PersonNodeDTO = { id: 'u-a', displayName: 'Anna', familyMember: true };
|
|
const forest = buildFamilyForest(
|
|
[
|
|
person('P', { birthYear: 1880 }),
|
|
person('c1920', { birthYear: 1920 }),
|
|
undatedB,
|
|
person('c1910', { birthYear: 1910 }),
|
|
undatedA
|
|
],
|
|
[parent('P', 'c1920'), parent('P', 'u-b'), parent('P', 'c1910'), parent('P', 'u-a')]
|
|
);
|
|
|
|
const p = findUnit(forest.roots, 'P')!;
|
|
// 1910, 1920 (dated ASC), then undated by displayName (Anna < Zoe).
|
|
expect(p.children.map((u) => u.id)).toEqual(['c1910', 'c1920', 'u-a', 'u-b']);
|
|
});
|
|
|
|
it('orders roots by the same rule', () => {
|
|
const forest = buildFamilyForest(
|
|
[person('late', { birthYear: 1900 }), person('early', { birthYear: 1850 })],
|
|
[]
|
|
);
|
|
expect(forest.roots.map((r) => r.id)).toEqual(['early', 'late']);
|
|
});
|
|
});
|