Files
nur-falah-prevention/e2e-family-tree.cjs
wmj e7a111c387 Add mini family genealogy tree with per-person photos
Per explicit request: "mini family genealogy mapping with ability to add
photos on each family member." Scoped via clarifying questions: people are
lightweight records independent of login accounts (most real tree nodes —
grandparents, deceased relatives, young children — never sign up),
relationships support parent/child + spouse (enough to render a real
multi-generation tree), photos go to Supabase Storage (native to the
backend already in use, no new infrastructure).

Schema: nf_people (name, gender, birth/death dates, notes, optional
linked_user_id if the person also happens to have a real account,
photo_path) and nf_relationships (person_a/person_b + type: parent_of
directional, spouse_of symmetric). Both family-member-shared like the
Asset Registry — any owner/agent/member can view and edit, unlike the
per-author Wassiyah/Waqf model. New private Storage bucket
nf-people-photos, path convention {family_id}/{person_id}/{filename} so
RLS can check family membership straight from the path without a join.

New FamilyTree.svelte: add/edit/delete a person, upload/replace/remove
their photo (signed URLs, 1hr TTL, refreshed on every load since the
bucket is private), link/unlink relationships, and a recursive generational
tree render — roots are anyone with no recorded parent, spouses shown
inline next to their partner rather than as separate branches.

e2e-family-tree.cjs: 4-person 3-generation tree built end to end against
the live backend — add people, link parent/child + spouse relationships,
upload a real PNG to Supabase Storage and confirm it renders as the
avatar, remove it and confirm it reverts to the initial-letter fallback,
edit a person's notes, delete a relationship, and confirm a second family
under the same account sees none of this tree (isolation, same pattern
already proven for Wassiyah/Waqf). Found two real test-authoring bugs
along the way (not app bugs): a case-insensitive :has-text substring match
on "Spouse" was also matching the relationship-type select's own "is
spouse of" option text, and a person-summary click meant to inspect an
already-expanded card was instead toggling it closed. Both fixed by
selecting on DOM position/structure instead of loose text matching.
9/9 passing.

Full regression sweep: e2e-uat 32/32, e2e-fastpath 16/16, e2e-trust 12/12,
e2e-business 10/10, e2e-digital-vehicle 10/10 (one transient failure on
first run, passed clean on retry — consistent with earlier-observed
flakiness under heavy parallel test load, not a regression),
e2e-property 9/9, e2e-other 4/4, e2e-info 31/31, e2e-per-member 11/11,
e2e-family-tree 9/9 — 174/174 total.
2026-08-14 09:16:42 +08:00

128 lines
7.7 KiB
JavaScript

// Verifies the mini family genealogy map end-to-end against the live backend:
// add people (with photo upload to Supabase Storage), link parent/child and
// spouse relationships, confirm the generational tree renders correctly, and
// that a second family's tree is fully isolated (no data bleed across
// families sharing the same account, matching the isolation pattern already
// proven for Wassiyah/Waqf).
const { chromium } = require('playwright');
const path = require('path');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
const consoleErrors = [];
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message));
const familyName = await signInFreshFamily(page, BASE, 'e2e-family-tree');
await page.locator('nav button[aria-label="Tree"]').click();
await page.waitForTimeout(600);
// Add three people: grandfather, father, son
async function addPerson(name, extra = {}) {
await page.locator('.form-card .field:has-text("Full name") input').fill(name);
if (extra.gender) await page.locator('.form-card .field:has-text("Gender") select').selectOption(extra.gender);
if (extra.birthDate) await page.locator('.form-card .field:has-text("Birth date") input').fill(extra.birthDate);
await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click();
await page.locator('.person-card', { hasText: name }).waitFor({ state: 'visible', timeout: 10000 });
}
await addPerson('Grandfather Ahmad', { gender: 'male', birthDate: '1940-01-01' });
await addPerson('Father Ismail', { gender: 'male', birthDate: '1965-05-15' });
await addPerson('Grandmother Fatimah', { gender: 'female', birthDate: '1945-03-10' });
await addPerson('Son Yusuf', { gender: 'male', birthDate: '1995-08-20' });
const allAdded = await page.locator('.person-card').count();
record('Family Tree: 4 people added successfully', allAdded === 4, `${allAdded} cards`);
// Link relationships: Grandfather is parent of Father; Grandfather spouse_of Grandmother; Father parent of Son
async function addRelationship(personALabel, type, personBLabel) {
// The relationship form-card has exactly 3 selects in DOM order: person A,
// type, person B. Selecting by position avoids :has-text substring
// ambiguity (the type select's own option "is spouse of" contains
// "spouse", so a text-based locator for the third field also matches it).
const relCard = page.locator('.form-card', { has: page.locator('button.btn-primary', { hasText: 'Add relationship' }) });
const selects = relCard.locator('select');
await selects.nth(0).selectOption({ label: personALabel });
await selects.nth(1).selectOption(type);
await page.waitForTimeout(200);
await selects.nth(2).selectOption({ label: personBLabel });
await page.locator('button.btn-primary', { hasText: 'Add relationship' }).click();
await page.waitForTimeout(600);
}
await addRelationship('Grandfather Ahmad', 'parent_of', 'Father Ismail');
await addRelationship('Grandfather Ahmad', 'spouse_of', 'Grandmother Fatimah');
await addRelationship('Father Ismail', 'parent_of', 'Son Yusuf');
const relCount = await page.locator('.rel-row').count();
record('Family Tree: 3 relationships recorded', relCount === 3, `${relCount} rows`);
// Verify tree structure: Grandfather is a root, Father nested under him, Son nested under Father
const treeText = await page.locator('.tree-section').innerText();
const rootShown = treeText.includes('Grandfather Ahmad');
const spouseShown = treeText.includes('Grandmother Fatimah');
const fatherNested = treeText.includes('Father Ismail');
const sonNested = treeText.includes('Son Yusuf');
record('Family Tree: generational tree renders all 4 people correctly nested', rootShown && spouseShown && fatherNested && sonNested, treeText.replace(/\s+/g, ' ').slice(0, 300));
// Photo upload — real file, real Supabase Storage
await page.locator('.person-summary', { hasText: 'Father Ismail' }).click();
await page.waitForTimeout(400);
const fileInput = page.locator('.person-card', { hasText: 'Father Ismail' }).locator('input[type=file]');
await fileInput.setInputFiles(path.join(__dirname, 'test-photo.png'));
await page.waitForTimeout(2500);
const avatarImgVisible = await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('img.avatar').isVisible().catch(() => false);
record('Family Tree: photo uploads and renders as the person\'s avatar', avatarImgVisible);
// Photo removal
if (avatarImgVisible) {
await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('button', { hasText: 'Remove photo' }).click();
await page.waitForTimeout(1000);
const avatarGoneBack = await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('.avatar-placeholder').isVisible().catch(() => false);
record('Family Tree: removing photo reverts to initial-letter placeholder', avatarGoneBack);
}
// Edit a person (expand the card first — Edit is only visible when expanded)
await page.locator('.person-summary', { hasText: 'Son Yusuf' }).click();
await page.waitForTimeout(300);
await page.locator('.person-card', { hasText: 'Son Yusuf' }).locator('button', { hasText: 'Edit' }).click();
await page.waitForTimeout(300);
await page.locator('.form-card .field:has-text("Notes") input').fill('Eldest grandchild');
await page.locator('.form-card button.btn-primary', { hasText: 'Save changes' }).click();
await page.waitForTimeout(600);
// Card should still be expanded (editing doesn't collapse it) — check directly.
const notesVisible = await page.locator('.person-card', { hasText: 'Son Yusuf' }).locator('.notes').isVisible().catch(() => false);
record('Family Tree: editing a person persists notes', notesVisible);
// Delete a relationship, confirm it's gone
const delBtn = page.locator('.rel-row', { hasText: 'Grandmother Fatimah' }).locator('button');
await delBtn.click();
await page.waitForTimeout(600);
const relCountAfterDelete = await page.locator('.rel-row').count();
record('Family Tree: deleting a relationship removes it', relCountAfterDelete === 2, `${relCountAfterDelete} rows remain`);
// ── Isolation: a fresh family (same account) should NOT see this tree ──
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
await signInFreshFamily(isolationPage, BASE, 'e2e-family-tree-isolation');
await isolationPage.locator('nav button[aria-label="Tree"]').click();
await isolationPage.waitForTimeout(600);
const leakedPerson = await isolationPage.locator('.person-card', { hasText: 'Grandfather Ahmad' }).isVisible().catch(() => false);
record('Family Tree: a different family sees none of this tree (isolation)', !leakedPerson);
await isolationPage.close();
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });