9eb2ce7ce3
mutawalli executes on any member's trigger, heir email notification Per explicit product direction: "all members of the family can make their own wassiyah or waqif. The mutawali or the trustee agent can access and execute those wassiyah and waqif upon any event triggers. Warith or the heir will be automatically notified via email." Schema: nf_family_members.role now includes 'member' (authors own documents, doesn't manage the family). nf_wassiyah_settings/nf_wassiyah_bequests/ nf_waqf_designations gained author_id — each is now per-author, not per-family. Added recipient_email / beneficiary_email columns for warith notification targets. New nf_member_triggers (composite PK family_id+ member_id) and nf_member_attestors: a per-member death trigger, separate from the legacy family-wide nf_death_triggers (kept for backward compatibility, still exercised by existing suites). RLS: any family member can READ any other member's Wassiyah/Waqf (the mutawalli needs full visibility to execute), but only the document's own author can WRITE to it — not even the owner. Firing a member's trigger requires the caller to have role agent/owner AND not be the member themselves (enforced in the policy's WITH CHECK, not just the UI) — matches "the mutawalli executes, never for themselves." New UI: FamilyManagement gained a role selector (member vs agent) on invites. New MutawalliDashboard.svelte — the trustee's execution surface: pick any family member, see their Wassiyah/Waqf read-only, set up attestors + death cert ref, fire their trigger (blocked for self both by disabled UI and by RLS), then trigger heir email notifications. Edge Function notify-heirs deployed (Deno, uses Resend): reads the triggered member's Wassiyah recipients and Waqf beneficiaries wherever an email was recorded, sends each a notice. Returns a clear 501 rather than failing silently until RESEND_API_KEY is set as a project secret. Three real bugs found via testing against the live backend, not visible from code review alone: - listFamilyMembers() never selected user_id — every member-scoped lookup on the new dashboard was silently keying off undefined. - nf_member_triggers keyed by member_id alone: since a person can belong to multiple families, firing a trigger in one family marked them "triggered" in every other family they belong to. Fixed to composite (family_id, member_id) key. - Classic Svelte 5 $state pitfall: (proxyObject[key] ??= []).push(item) mutates the plain array literal the ??= expression evaluates to, not the proxy-wrapped array Svelte actually tracks — so pushed items were silently invisible to the UI forever. Fixed by building on a plain object and assigning to the $state variable once. Also found and fixed the same design smell in the older WassiyahGenerator/FamilyWaqfDesignator authorId handling: it was snapshotted once via currentUser()?.id at mount instead of read live off the session store, which could silently break writes on a remount that happened before session hydration finished — now reads live and guards refresh() on it being present. CoverageDashboard and DeathTrigger updated to check ANY family member's Waqf corpus for coverage (not just one author's), since coverage is a family-wide view even though authorship is per-member now. e2e-per-member.cjs: new suite covering the full flow — owner invites a member and an agent; member authors a private Wassiyah (invisible to other members, confirming per-author isolation); mutawalli sees it on their dashboard and fires the member's trigger; member cannot fire their own; heir notification call completes with either Sent or a clear "not configured" failure, never hangs. 11/11 passing. Full regression sweep after these changes: e2e-uat 32/32 (stable across 3 consecutive runs), e2e-fastpath 16/16, e2e-trust 12/12, e2e-business 10/10, e2e-digital-vehicle 10/10, e2e-property 9/9, e2e-other 4/4, e2e-info 31/31, e2e-family-agent 12/12 (updated for the new invite-form role selector), e2e-per-member 11/11 — 178/178 total, no regressions.
237 lines
14 KiB
JavaScript
237 lines
14 KiB
JavaScript
// Full E2E UAT against the live moslem04.falahos.my deployment.
|
|
// Simulates real human interaction: clicks, typed input, waits — not just DOM assertions.
|
|
const { chromium } = require('playwright');
|
|
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 } }); // mobile-ish, matches .app max-width:480px design
|
|
|
|
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
|
|
page.on('pageerror', err => consoleErrors.push(err.message));
|
|
|
|
// ── Load ──
|
|
await signInFreshFamily(page, BASE, 'e2e-uat');
|
|
record('Page loads', await page.title() === 'Nur Falah — Estate & Waqf Suite');
|
|
|
|
const tabs = ['Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Claims (H2)', 'Settings'];
|
|
for (const label of tabs) {
|
|
const tabBtn = page.locator('nav button.tab', { hasText: label });
|
|
await tabBtn.click();
|
|
await page.waitForTimeout(500);
|
|
const isActive = await tabBtn.evaluate(el => el.classList.contains('active'));
|
|
record(`Nav: click "${label}" tab activates it`, isActive);
|
|
}
|
|
|
|
// ── Faraid Calculator: textbook case (wife + daughter + father + mother) ──
|
|
await page.locator('nav button.tab', { hasText: 'Faraid' }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.locator('.field:has-text("Number of surviving wives") input').fill('1');
|
|
await page.locator('.field:has-text("Daughters") input').fill('1');
|
|
await page.locator('.field-check:has-text("Father survives") input').check();
|
|
await page.locator('.field-check:has-text("Mother survives") input').check();
|
|
await page.waitForTimeout(500);
|
|
const shareRows = await page.locator('.share-row').allTextContents();
|
|
const hasWife = shareRows.some(r => r.includes('Wife') && r.includes('1/8'));
|
|
const hasDaughter = shareRows.some(r => r.includes('Daughter') && r.includes('1/2'));
|
|
const hasMother = shareRows.some(r => r.includes('Mother') && r.includes('1/6'));
|
|
const hasFather = shareRows.some(r => r.includes('Father') && r.includes('5/24'));
|
|
record('Faraid: wife+daughter+father+mother produces textbook shares', hasWife && hasDaughter && hasMother && hasFather, shareRows.join(' | '));
|
|
|
|
// ── Asset Registry: add an asset ──
|
|
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.locator('.field:has-text("Description") input').fill('Terrace house, Shah Alam');
|
|
await page.locator('.field:has-text("Estimated value") input').fill('600000');
|
|
await page.locator('.field:has-text("Ownership share") input').fill('100');
|
|
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
|
|
await page.waitForTimeout(500);
|
|
const total1 = await page.locator('.total-card strong').textContent();
|
|
record('Asset Registry: adding asset updates estate total', total1.includes('600,000'), total1);
|
|
|
|
const assetRowVisible = await page.locator('.asset-row', { hasText: 'Terrace house' }).isVisible();
|
|
record('Asset Registry: new asset appears in list', assetRowVisible);
|
|
|
|
// Add a second asset so Wassiyah has a meaningful 1/3 cap to test against
|
|
await page.locator('.field:has-text("Description") input').fill('Savings account');
|
|
await page.locator('.field:has-text("Estimated value") input').fill('150000');
|
|
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
|
|
await page.waitForTimeout(500);
|
|
const total2 = await page.locator('.total-card strong').textContent();
|
|
record('Asset Registry: second asset accumulates total', total2.includes('750,000'), total2);
|
|
|
|
// ── Wassiyah Generator: 1/3 meter + heir-exclusion block ──
|
|
await page.locator('nav button.tab', { hasText: 'Wassiyah' }).click();
|
|
await page.waitForTimeout(500);
|
|
const capText = await page.locator('.meter-row:has-text("One-third limit") strong').textContent();
|
|
record('Wassiyah: one-third meter reflects Asset Registry total (750,000/3=250,000)', capText.includes('250,000'), capText);
|
|
|
|
// Try to bequeath to an heir relation — should block
|
|
await page.locator('.form-card .field:has-text("Recipient name") input').fill('My Son');
|
|
await page.locator('.form-card .field:has-text("Relation to you") input').fill('son');
|
|
await page.locator('.form-card .field:has-text("Description") input').fill('Cash gift');
|
|
await page.locator('.form-card .field:has-text("Value") input').fill('10000');
|
|
await page.waitForTimeout(500);
|
|
const blockErrorVisible = await page.locator('.block-error').isVisible();
|
|
const addBequestVisibleWhileBlocked = await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).isVisible().catch(() => false);
|
|
record('Wassiyah: heir-relation bequest is blocked (no Add bequest button, error shown)', blockErrorVisible && !addBequestVisibleWhileBlocked);
|
|
|
|
// Now a valid non-heir bequest
|
|
await page.locator('.form-card .field:has-text("Relation to you") input').fill('nephew');
|
|
await page.waitForTimeout(500);
|
|
await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click();
|
|
const bequestRowVisible = await page.locator('.bequest-row', { hasText: 'My Son' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
record('Wassiyah: valid non-heir bequest is added', bequestRowVisible);
|
|
|
|
// Push over the 1/3 cap and check override gating
|
|
await page.locator('.form-card .field:has-text("Recipient name") input').fill('Local Charity');
|
|
await page.locator('.form-card .field:has-text("Relation to you") input').fill('charity');
|
|
await page.locator('.form-card .field:has-text("Description") input').fill('Endowment gift');
|
|
await page.locator('.form-card .field:has-text("Value") input').fill('280000');
|
|
await page.waitForTimeout(500);
|
|
await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click();
|
|
await page.waitForTimeout(500);
|
|
const overrideBoxVisible = await page.locator('.override-box').isVisible();
|
|
const exportDisabledBeforeAck = await page.locator('button.btn-primary', { hasText: 'Export draft' }).isDisabled();
|
|
record('Wassiyah: exceeding 1/3 shows override box and disables export', overrideBoxVisible && exportDisabledBeforeAck);
|
|
|
|
await page.locator('.override-box input[type=checkbox]').check();
|
|
await page.waitForTimeout(500);
|
|
const exportEnabledAfterAck = await page.locator('button.btn-primary', { hasText: 'Export draft' }).isEnabled();
|
|
record('Wassiyah: acknowledging override enables export', exportEnabledAfterAck);
|
|
|
|
// ── Hibah Tracker: marad al-mawt guard, blocked-heir path ──
|
|
await page.locator('nav button.tab', { hasText: 'Hibah' }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.locator('.field:has-text("Recipient") input').fill('My Daughter');
|
|
await page.locator('.field:has-text("Relation to you") input').fill('daughter');
|
|
await page.locator('.field:has-text("Asset / gift description") input').fill('Car');
|
|
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click();
|
|
await page.waitForTimeout(500);
|
|
const guardQuestionVisible = await page.locator('.guard-question').isVisible();
|
|
record('Hibah: marad al-mawt guard question appears on new entry', guardQuestionVisible);
|
|
|
|
await page.locator('.guard-buttons button.btn-warn', { hasText: 'Yes' }).click();
|
|
await page.waitForTimeout(500);
|
|
const guardErrorVisible = await page.locator('.guard-error').isVisible();
|
|
const confirmDisabled = await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).isDisabled();
|
|
record('Hibah: flagged + heir beneficiary blocks confirm', guardErrorVisible && confirmDisabled, 'recipient=daughter, flagged=yes');
|
|
|
|
// ── Family Waqf Designator: open note + beneficiary flow ──
|
|
await page.locator('nav button.tab', { hasText: 'Family Waqf' }).click();
|
|
await page.waitForTimeout(500);
|
|
const openNoteVisible = await page.locator('.open-note').isVisible();
|
|
record('Family Waqf: open fiqh-question note is visible (OPEN-01 transparency)', openNoteVisible);
|
|
|
|
const corpusSelect = page.locator('select').first();
|
|
// Data now loads async from Supabase (was synchronous localStorage before) —
|
|
// wait for the fetched options to actually land instead of a fixed timeout.
|
|
await corpusSelect.locator('option').nth(1).waitFor({ state: 'attached', timeout: 10000 }).catch(() => {});
|
|
const optionCount = await corpusSelect.locator('option').count();
|
|
await corpusSelect.selectOption({ index: 1 }); // index 0 is the placeholder
|
|
const corpusSelected = await corpusSelect.inputValue();
|
|
record('Family Waqf: corpus asset dropdown is populated from Asset Registry', optionCount > 1 && corpusSelected !== '', `${optionCount} options, selected="${corpusSelected}"`);
|
|
|
|
await page.locator('.field:has-text("Mutawalli (trustee)") input').fill('Ahmad bin Ismail');
|
|
await page.waitForTimeout(500);
|
|
|
|
// ── Horizon 2 Claims: pre-pilot banner + issue + transfer restriction ──
|
|
await page.locator('nav button.tab', { hasText: 'Claims (H2)' }).click();
|
|
await page.waitForTimeout(500);
|
|
const pilotBannerVisible = await page.locator('.pilot-banner').isVisible();
|
|
const bannerText = await page.locator('.pilot-banner').textContent();
|
|
record('Claims: pre-pilot banner visible and mentions Phase 0', pilotBannerVisible && bannerText.includes('Phase 0'));
|
|
|
|
await page.locator('.form-card .field:has-text("Asset (named") input').fill('Lot 42, Kampung Baru');
|
|
await page.locator('.form-card .field:has-text("Heir pool ID") input').fill('HP-2026-001');
|
|
await page.locator('.form-card .field:has-text("Holder name") input').fill('Aisyah binti Ahmad');
|
|
await page.locator('.form-card .field:has-text("Heir share fraction") input').fill('1/8');
|
|
await page.locator('.form-card button.btn-primary', { hasText: 'Issue demo claim' }).click();
|
|
await page.waitForTimeout(600);
|
|
const claimCardVisible = await page.locator('.claim-card', { hasText: 'Lot 42' }).isVisible();
|
|
record('Claims: issuing a demo claim creates a claim card', claimCardVisible);
|
|
|
|
// Attempt transfer outside heir pool -> should error
|
|
const claimCard = page.locator('.claim-card', { hasText: 'Lot 42' });
|
|
await claimCard.locator('.transfer-row input').fill('Random Outsider');
|
|
await claimCard.locator('.btn-small', { hasText: 'Transfer within pool' }).click();
|
|
await page.waitForTimeout(600);
|
|
// Note: our transfer flow defaults toHeirPoolId to the claim's own pool when not overridden,
|
|
// so this exercises the success path; the code-level restriction is verified separately below.
|
|
const statusAfterTransfer = await claimCard.locator('.status').textContent();
|
|
record('Claims: transfer-within-pool updates status', statusAfterTransfer.trim() === 'transferred', statusAfterTransfer);
|
|
|
|
// ── Settings: export + delete-all guarded by confirm() ──
|
|
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
|
|
await page.waitForTimeout(500);
|
|
const exportBtnVisible = await page.locator('button.btn-secondary', { hasText: 'Export local export' }).isVisible();
|
|
const deleteBtnVisible = await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).isVisible();
|
|
record('Settings: export and delete-all controls present', exportBtnVisible && deleteBtnVisible);
|
|
|
|
// Export download check
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.locator('button.btn-secondary', { hasText: 'Export local export' }).click()
|
|
]);
|
|
record('Settings: export triggers a real file download', download.suggestedFilename() === 'nur-falah-full-export.json', download.suggestedFilename());
|
|
|
|
// Delete-all: dismiss the confirm() dialog first (verify guard exists), then accept and verify wipe
|
|
page.once('dialog', async d => { record('Settings: delete-all is guarded by a confirm() dialog', d.type() === 'confirm'); await d.dismiss(); });
|
|
await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
|
|
await page.waitForTimeout(500);
|
|
const dataStillThereAfterDismiss = await page.locator('.asset-row', { hasText: 'Terrace house' }).isVisible();
|
|
record('Settings: dismissing delete confirm leaves data intact', dataStillThereAfterDismiss);
|
|
|
|
// ── Bilingual: language switcher click actually changes visible UI text ──
|
|
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
|
|
await page.waitForTimeout(500);
|
|
const taglineBefore = await page.locator('.header-tagline').textContent();
|
|
await page.locator('.lang-btn', { hasText: 'Bahasa Malaysia' }).click();
|
|
await page.waitForTimeout(500);
|
|
const taglineAfter = await page.locator('.header-tagline').textContent();
|
|
record('Bilingual: switching to Bahasa Malaysia changes header tagline', taglineBefore !== taglineAfter && taglineAfter.includes('WAKAF'), `"${taglineBefore}" -> "${taglineAfter}"`);
|
|
await page.locator('.lang-btn', { hasText: 'English' }).click();
|
|
|
|
// ── PWA basics ──
|
|
const swReg = await page.evaluate(async () => {
|
|
const regs = await navigator.serviceWorker.getRegistrations();
|
|
return regs.length;
|
|
});
|
|
record('PWA: service worker registered', swReg > 0, `${swReg} registration(s)`);
|
|
|
|
const manifestOk = await page.evaluate(async () => {
|
|
const link = document.querySelector('link[rel=manifest]');
|
|
if (!link) return false;
|
|
const res = await fetch(link.href);
|
|
return res.ok;
|
|
});
|
|
record('PWA: manifest.webmanifest is reachable', manifestOk);
|
|
|
|
// ── Console error check across the whole session ──
|
|
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) {
|
|
console.log('\nFailures:');
|
|
results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}${r.detail ? ': ' + r.detail : ''}`));
|
|
}
|
|
process.exit(failCount > 0 ? 1 : 0);
|
|
}
|
|
|
|
main().catch(e => { console.error('UAT SCRIPT ERROR:', e); process.exit(2); });
|