[object Object]

← back to Norma

test(cycle6 Cody-gate): CRUD tests real org id-space; non-hollow admin E2E

2a8a6c76a7969f43bc07ec7713cc7513b593024e · 2026-08-06 12:49:52 -0700 · Steve

Cody VERIFIED the CRUD suite only tested the null/global path (which no real tenant takes)
and the admin E2E was hollow (silent skip + fixed sleep + body-visible-always-true).

- CRUD: scope each entity with the id-space its org_id FK actually requires — donations/
  grants now POST with a real nonprofit_accounts.id (201, genuine org-scoped write path),
  clients/library with a real organizations.id, journalists org-less. Proves the routes
  work under real tenant scoping (not org=null). 35 assertions green.
- admin E2E: removed the silent-skip; each navigated tab must settle #main-content to
  non-empty content, and >=3 tabs must be navigated (can't pass by skipping all). Waits on
  networkidle, not a fixed timeout.
- Expanded the gated memo: the split FK makes a coherent staff org_id impossible (all seeded
  users have org_id=null; null-org staff reads cross-tenant) — a second-order symptom of the
  same data-model bug, gated to Steve (not app-fixable without the schema decision).

Full battery: api 195 / write 216 / crud 35 / regression 20 / e2e 9.

Files touched

Diff

commit 2a8a6c76a7969f43bc07ec7713cc7513b593024e
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:49:52 2026 -0700

    test(cycle6 Cody-gate): CRUD tests real org id-space; non-hollow admin E2E
    
    Cody VERIFIED the CRUD suite only tested the null/global path (which no real tenant takes)
    and the admin E2E was hollow (silent skip + fixed sleep + body-visible-always-true).
    
    - CRUD: scope each entity with the id-space its org_id FK actually requires — donations/
      grants now POST with a real nonprofit_accounts.id (201, genuine org-scoped write path),
      clients/library with a real organizations.id, journalists org-less. Proves the routes
      work under real tenant scoping (not org=null). 35 assertions green.
    - admin E2E: removed the silent-skip; each navigated tab must settle #main-content to
      non-empty content, and >=3 tabs must be navigated (can't pass by skipping all). Waits on
      networkidle, not a fixed timeout.
    - Expanded the gated memo: the split FK makes a coherent staff org_id impossible (all seeded
      users have org_id=null; null-org staff reads cross-tenant) — a second-order symptom of the
      same data-model bug, gated to Steve (not app-fixable without the schema decision).
    
    Full battery: api 195 / write 216 / crud 35 / regression 20 / e2e 9.
---
 tests/api-crud-lifecycle.mjs | 46 ++++++++++++++++++++++++++------------------
 tests/e2e/admin.spec.ts      | 23 +++++++++++++++-------
 2 files changed, 43 insertions(+), 26 deletions(-)

diff --git a/tests/api-crud-lifecycle.mjs b/tests/api-crud-lifecycle.mjs
index f5adf0a..ec15c44 100644
--- a/tests/api-crud-lifecycle.mjs
+++ b/tests/api-crud-lifecycle.mjs
@@ -22,16 +22,21 @@ const TEST_DB = process.env.NORMA_TEST_DB || 'postgresql://127.0.0.1:5432/sdcc_t
 
 // Each entity: create path + minimal valid payload, a field to prove the create
 // persisted (read-back), and a PATCH field to prove update works.
-// `org: true` sends x-org-id (a real organizations id) — required for tables whose
-// org_id is NOT NULL and keyed to organizations (clients, library_items). The
-// nonprofit_accounts-FK tables (donations, grants) are created org-less (null) because
-// their org-scoped path is blocked by the gated FK inconsistency — see the memo.
+// `org` selects the id-space each entity's org_id FK actually requires, so the suite
+// exercises the REAL org-scoped write path (org filter + FK), not the null/global path:
+//   'org' → a real organizations.id   (clients: NOT NULL no-FK; library_items: FK→organizations)
+//   'npa' → a real nonprofit_accounts.id (donations, grants: FK→nonprofit_accounts)
+//   false → org-less (journalists: no org_id FK)
+// The APP supplies an organizations.id for ALL of these via getOrgId(), so the real
+// tenant UI flow for the 'npa' tables is broken — that app-level id-space conflation is
+// the gated data-model finding (pending-approval/norma-sessions-org-fk-datamodel.md). The
+// routes themselves work when given the FK-correct id, which is what this suite proves.
 const ENTITIES = [
-  { name: 'clients',     path: '/api/clients',     org: true,  create: { first_name: 'Smoke', last_name: 'Test' }, verify: ['first_name', 'Smoke'],   update: { city: 'SmokeCity' },        updated: ['city', 'SmokeCity'] },
-  { name: 'donations',   path: '/api/donations',   org: false, create: { amount: 12.34 },                           verify: ['amount', 12.34],         update: { notes: 'smoke-updated' },   updated: ['notes', 'smoke-updated'] },
-  { name: 'grants',      path: '/api/grants',      org: false, create: { title: 'Smoke Grant', funder: 'SmokeCo' }, verify: ['title', 'Smoke Grant'],   update: { notes: 'smoke-updated' },   updated: ['notes', 'smoke-updated'] },
-  { name: 'library',     path: '/api/library',     org: true,  create: { item_type: 'template', title: 'Smoke Item' }, verify: ['title', 'Smoke Item'], update: { title: 'Smoke Updated' },  updated: ['title', 'Smoke Updated'] },
-  { name: 'journalists', path: '/api/journalists', org: false, create: { name: 'Smoke J', outlet: 'SmokeOutlet' },  verify: ['name', 'Smoke J'],       update: { beat: 'smoke-updated' },    updated: ['beat', 'smoke-updated'] },
+  { name: 'clients',     path: '/api/clients',     org: 'org',   create: { first_name: 'Smoke', last_name: 'Test' }, verify: ['first_name', 'Smoke'],   update: { city: 'SmokeCity' },        updated: ['city', 'SmokeCity'] },
+  { name: 'donations',   path: '/api/donations',   org: 'npa',   create: { amount: 12.34 },                           verify: ['amount', 12.34],         update: { notes: 'smoke-updated' },   updated: ['notes', 'smoke-updated'] },
+  { name: 'grants',      path: '/api/grants',      org: 'npa',   create: { title: 'Smoke Grant', funder: 'SmokeCo' }, verify: ['title', 'Smoke Grant'],   update: { notes: 'smoke-updated' },   updated: ['notes', 'smoke-updated'] },
+  { name: 'library',     path: '/api/library',     org: 'org',   create: { item_type: 'template', title: 'Smoke Item' }, verify: ['title', 'Smoke Item'], update: { title: 'Smoke Updated' },  updated: ['title', 'Smoke Updated'] },
+  { name: 'journalists', path: '/api/journalists', org: false,   create: { name: 'Smoke J', outlet: 'SmokeOutlet' },  verify: ['name', 'Smoke J'],       update: { beat: 'smoke-updated' },    updated: ['beat', 'smoke-updated'] },
 ];
 
 let pass = 0, fail = 0;
@@ -48,12 +53,14 @@ async function login() {
   });
   return (res.headers.get('set-cookie') || '').match(/norma-auth=([^;]+)/)?.[1];
 }
-async function resolveOrgId() {
+async function resolveOrgIds() {
   try {
     const c = new pg.Client({ connectionString: TEST_DB }); await c.connect();
-    const r = await c.query('SELECT id FROM organizations LIMIT 1'); await c.end();
-    return r.rows[0]?.id || null;
-  } catch { return null; }
+    const org = (await c.query('SELECT id FROM organizations LIMIT 1')).rows[0]?.id || null;
+    const npa = (await c.query('SELECT id FROM nonprofit_accounts LIMIT 1')).rows[0]?.id || null;
+    await c.end();
+    return { org, npa };
+  } catch { return { org: null, npa: null }; }
 }
 // Find a uuid-shaped id at the top level or nested one level into the response.
 function extractId(body) {
@@ -77,16 +84,17 @@ function fieldOf(body, field) {
 async function main() {
   console.log(`\n=== Norma CRUD lifecycle — ${BASE} ===\n`);
   const cookie = await login();
-  const orgId = await resolveOrgId();
+  const { org: orgId, npa: npaId } = await resolveOrgIds();
   if (!cookie) { console.error('\x1b[31mFATAL\x1b[0m admin login failed — is :7411 up?'); process.exit(2); }
-  // Org scope is applied PER ENTITY (see ENTITIES). The org_id FK is inconsistent across
-  // tables (some -> organizations NOT NULL, some -> nonprofit_accounts nullable), a
-  // data-model issue gated to Steve (pending-approval/norma-sessions-org-fk-datamodel.md).
-  console.log(`org id (for org:true entities): ${orgId || '(none found)'}\n`);
+  // Org scope is applied PER ENTITY (see ENTITIES) using the id-space each FK requires.
+  // The split org_id FK (organizations vs nonprofit_accounts) is a data-model issue gated
+  // to Steve (pending-approval/norma-sessions-org-fk-datamodel.md).
+  console.log(`organizations id: ${orgId || '(none)'}  ·  nonprofit_accounts id: ${npaId || '(none)'}\n`);
   const base = { 'Content-Type': 'application/json', Cookie: `norma-auth=${cookie}` };
+  const orgHeader = (org) => (org === 'org' && orgId ? { 'x-org-id': orgId } : org === 'npa' && npaId ? { 'x-org-id': npaId } : {});
   const req = (method, path, body, org) => fetch(`${BASE}${path}`, {
     method,
-    headers: org && orgId ? { ...base, 'x-org-id': orgId } : base,
+    headers: { ...base, ...orgHeader(org) },
     body: body ? JSON.stringify(body) : undefined,
     redirect: 'manual',
   });
diff --git a/tests/e2e/admin.spec.ts b/tests/e2e/admin.spec.ts
index 6b327f0..2a33beb 100644
--- a/tests/e2e/admin.spec.ts
+++ b/tests/e2e/admin.spec.ts
@@ -26,16 +26,25 @@ test('admin loads the shell and navigates core tabs with no console errors', asy
 
   // App shell nav present.
   await expect(page.getByText('Dashboard', { exact: true }).first()).toBeVisible();
+  // The main content region the skip-link targets — used to assert each tab renders
+  // real content, not a blank/error-boundary shell.
+  const main = page.locator('#main-content');
+  await expect(main).toBeVisible();
 
-  // Navigate a handful of core tabs; each should render without throwing.
-  for (const tab of ['Grants', 'Donations', 'Petitions', 'News']) {
+  // Navigate core tabs. For each one present, clicking it MUST settle #main-content to
+  // non-empty content (a blank/error-boundary tab fails here). We require at least 3 real
+  // navigations so the test can't pass by silently skipping every tab (a hollow check).
+  const candidates = ['Grants', 'Petitions', 'News', 'Drafts', 'Statements', 'Pipeline', 'Donations', 'Clients / Borrowers'];
+  let navigated = 0;
+  for (const tab of candidates) {
     const item = page.getByText(tab, { exact: true }).first();
-    if (await item.count()) {
-      await item.click();
-      await page.waitForTimeout(700);
-      await expect(page.locator('body')).toBeVisible();
-    }
+    if (!(await item.count())) continue;
+    await item.click();
+    await page.waitForLoadState('networkidle');
+    await expect(main, `"${tab}" tab should render non-empty content`).not.toBeEmpty();
+    navigated++;
   }
+  expect(navigated, 'should have navigated at least 3 admin tabs with real content').toBeGreaterThanOrEqual(3);
 
   const real = errors.filter((e) => !BENIGN.test(e));
   expect(real, `console errors during admin navigation: ${real.join(' | ')}`).toHaveLength(0);

← e958e07 test(cycle6): CRUD lifecycle suite (create->read->update->de  ·  back to Norma  ·  test(cycle7): authorization-matrix + tenant-isolation suite f7e95d8 →