Files
familienarchiv/frontend/scripts/capture-network-fixture.mjs
Marcel 36bd7e0414 chore(stammbaum): add /api/network capture script + canonical fixture (#361)
Local-only developer utility that authenticates against the running backend,
captures the current /api/network snapshot, and writes it to
src/lib/person/genealogy/__fixtures__/stammbaum.json. Sanity gates exit
non-zero on a vacuous capture (< 50 nodes, < 5 generations, 0 SPOUSE_OF
edges). Fixture and script land together so the fixture is reproducible from
the script that generated it.

Captured snapshot: 62 nodes, 43 edges, 28 SPOUSE_OF (0 with fromYear),
generations G0-G4. Albert de Gruyter is the canonical multi-spouse case with
4 marriages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:55:30 +02:00

122 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
// Local-only. Never invoked from CI. Re-run intentionally; commit the
// resulting JSON in one atomic commit.
//
// Captures the current /api/network response into the canonical fixture used
// by buildLayout.test.ts. Asserts a minimum shape so a silently-empty backend
// can't write a vacuous fixture.
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
const EMAIL = process.env.CAPTURE_EMAIL ?? 'admin@familyarchive.local';
const PASSWORD = process.env.CAPTURE_PASSWORD ?? 'admin123';
const HERE = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`;
// Sanity floors — calibrated against the canonical dataset (May 2026: 62
// nodes, 5 generations, 28 SPOUSE_OF edges). The point is catching a silently
// empty backend, not strict size validation; raise these only if the
// canonical graph grows substantially.
const MIN_NODES = 50;
const MIN_GENERATIONS = 5;
const MIN_SPOUSE_OF_EDGES = 1;
function parseSetCookies(headers) {
const out = new Map();
const raw = headers.getSetCookie?.() ?? [];
for (const line of raw) {
const [pair] = line.split(';');
const eq = pair.indexOf('=');
if (eq < 0) continue;
out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
return out;
}
function serialiseCookies(jar) {
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
async function login(jar) {
const xsrf = randomUUID();
jar.set('XSRF-TOKEN', xsrf);
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': xsrf,
Cookie: serialiseCookies(jar)
},
body: JSON.stringify({ email: EMAIL, password: PASSWORD })
});
if (!res.ok) {
throw new Error(`Login failed: ${res.status} ${await res.text()}`);
}
for (const [k, v] of parseSetCookies(res.headers)) jar.set(k, v);
}
async function fetchNetwork(jar) {
const res = await fetch(`${BACKEND_URL}/api/network`, {
headers: { Cookie: serialiseCookies(jar) }
});
if (!res.ok) {
throw new Error(`GET /api/network failed: ${res.status} ${await res.text()}`);
}
return res.json();
}
function validate(network) {
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
const edges = Array.isArray(network.edges) ? network.edges : [];
const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF');
const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null));
const failures = [];
if (nodes.length < MIN_NODES) {
failures.push(`expected >= ${MIN_NODES} nodes, got ${nodes.length}`);
}
if (generations.size < MIN_GENERATIONS) {
failures.push(`expected >= ${MIN_GENERATIONS} distinct generations, got ${generations.size}`);
}
if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) {
failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`);
}
if (failures.length > 0) {
throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`);
}
return {
nodes: nodes.length,
edges: edges.length,
spouseEdges: spouseEdges.length,
generations: [...generations].sort((a, b) => a - b)
};
}
function writeFixture(network) {
mkdirSync(dirname(FIXTURE_PATH), { recursive: true });
writeFileSync(FIXTURE_PATH, JSON.stringify(network, null, '\t') + '\n', 'utf8');
}
async function main() {
const jar = new Map();
console.error(`Capturing /api/network from ${BACKEND_URL} as ${EMAIL} ...`);
await login(jar);
const network = await fetchNetwork(jar);
const stats = validate(network);
writeFixture(network);
console.error(
`Wrote ${FIXTURE_PATH}\n nodes: ${stats.nodes}\n edges: ${stats.edges}\n SPOUSE_OF edges: ${stats.spouseEdges}\n generations: G${stats.generations.join(', G')}`
);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});