[object Object]

← back to Norma Platform

test(cycle6): CRUD lifecycle suite (create->read->update->delete) + admin E2E journey

e958e0779801b52f8bf649159a1327cda5cc7d84 · 2026-08-06 12:38:28 -0700 · Steve

test:crud (tests/api-crud-lifecycle.mjs) — the 'does the write actually WORK' coverage
write-smoke doesn't give. Full round-trip per entity: CREATE (real payload) -> READ-BACK
(field persisted) -> UPDATE -> DELETE -> CONFIRM-GONE (404). Self-cleaning. 5 core entities
(clients/donations/grants/library/journalists), per-entity org scope. 35 assertions green.

It surfaced a FLEET-WIDE data-model bug (VERIFIED): org_id FKs are split across two org
tables — contacts/donations/drafts/grants/petitions/sessions -> nonprofit_accounts, but
library_items -> organizations, and the app scopes by organizations. So org-scoped CREATE
on the nonprofit_accounts-FK tables 500s (donations/grants reproduced). Expanded the gated
memo (pending-approval/norma-sessions-org-fk-datamodel.md) from sessions-only to all 6
tables — schema decision, NOT auto-fixed. Suite runs those entities org-less (valid global
path) so it proves CRUD logic; org-scoped assertions get added once the FK is reconciled.

test:e2e — added admin.spec.ts: login -> app shell -> navigate Grants/Donations/Petitions/
News tabs, assert no console errors. E2E now 9 specs.

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

Files touched

Diff

commit e958e0779801b52f8bf649159a1327cda5cc7d84
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:38:28 2026 -0700

    test(cycle6): CRUD lifecycle suite (create->read->update->delete) + admin E2E journey
    
    test:crud (tests/api-crud-lifecycle.mjs) — the 'does the write actually WORK' coverage
    write-smoke doesn't give. Full round-trip per entity: CREATE (real payload) -> READ-BACK
    (field persisted) -> UPDATE -> DELETE -> CONFIRM-GONE (404). Self-cleaning. 5 core entities
    (clients/donations/grants/library/journalists), per-entity org scope. 35 assertions green.
    
    It surfaced a FLEET-WIDE data-model bug (VERIFIED): org_id FKs are split across two org
    tables — contacts/donations/drafts/grants/petitions/sessions -> nonprofit_accounts, but
    library_items -> organizations, and the app scopes by organizations. So org-scoped CREATE
    on the nonprofit_accounts-FK tables 500s (donations/grants reproduced). Expanded the gated
    memo (pending-approval/norma-sessions-org-fk-datamodel.md) from sessions-only to all 6
    tables — schema decision, NOT auto-fixed. Suite runs those entities org-less (valid global
    path) so it proves CRUD logic; org-scoped assertions get added once the FK is reconciled.
    
    test:e2e — added admin.spec.ts: login -> app shell -> navigate Grants/Donations/Petitions/
    News tabs, assert no console errors. E2E now 9 specs.
    
    Full battery: api 195 / write 216 / crud 35 / regression 20 / e2e 9.
---
 package.json                 |   3 +-
 tests/api-crud-lifecycle.mjs | 141 +++++++++++++++++++++++++++++++++++++++++++
 tests/e2e/admin.spec.ts      |  42 +++++++++++++
 3 files changed, 185 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 9660eb9..8d39c9e 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
     "test:api": "node tests/api-smoke.mjs",
     "test:smoke": "node tests/api-smoke.mjs --smoke",
     "test:write": "node tests/api-write-smoke.mjs",
-    "test:e2e": "playwright test"
+    "test:e2e": "playwright test",
+    "test:crud": "node tests/api-crud-lifecycle.mjs"
   },
   "dependencies": {
     "@codemirror/lang-html": "^6.4.11",
diff --git a/tests/api-crud-lifecycle.mjs b/tests/api-crud-lifecycle.mjs
new file mode 100644
index 0000000..f5adf0a
--- /dev/null
+++ b/tests/api-crud-lifecycle.mjs
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+/**
+ * Norma CRUD lifecycle suite — the "does the write actually WORK" coverage that
+ * api-write-smoke.mjs (gate + no-crash) intentionally doesn't provide. For each
+ * core entity it runs a full round-trip against the :7411 test instance:
+ *
+ *   CREATE (POST, real payload) → READ-BACK (GET /[id], field persisted) →
+ *   UPDATE (PATCH /[id]) → DELETE (/[id]) → CONFIRM-GONE (GET /[id] = 404)
+ *
+ * It is self-cleaning (deletes what it creates; a finally-block sweeps any id left
+ * behind by a mid-lifecycle failure). Writes land in the scratch DB sdcc_test only —
+ * NEVER live :7400. Requires the seeded admin (pw TestPass123!).
+ *
+ *   bash scripts/test-instance.sh
+ *   npm run test:crud
+ */
+import pg from 'pg';
+
+const BASE = process.env.NORMA_TEST_URL || 'http://127.0.0.1:7411';
+const PW = process.env.NORMA_TEST_PW || 'TestPass123!';
+const TEST_DB = process.env.NORMA_TEST_DB || 'postgresql://127.0.0.1:5432/sdcc_test';
+
+// 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.
+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'] },
+];
+
+let pass = 0, fail = 0;
+const fails = [];
+function ok(cond, name, detail = '') {
+  if (cond) { pass++; console.log(`  \x1b[32mPASS\x1b[0m ${name}`); }
+  else { fail++; fails.push(name); console.log(`  \x1b[31mFAIL\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); }
+}
+
+async function login() {
+  const res = await fetch(`${BASE}/api/auth/login`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username: 'admin', password: PW }), redirect: 'manual',
+  });
+  return (res.headers.get('set-cookie') || '').match(/norma-auth=([^;]+)/)?.[1];
+}
+async function resolveOrgId() {
+  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; }
+}
+// Find a uuid-shaped id at the top level or nested one level into the response.
+function extractId(body) {
+  if (!body || typeof body !== 'object') return null;
+  if (typeof body.id === 'string') return body.id;
+  for (const v of Object.values(body)) {
+    if (v && typeof v === 'object' && typeof v.id === 'string') return v.id;
+  }
+  return null;
+}
+// Pull a field value from a response that may wrap the record ({client:{...}} etc).
+function fieldOf(body, field) {
+  if (!body || typeof body !== 'object') return undefined;
+  if (field in body) return body[field];
+  for (const v of Object.values(body)) {
+    if (v && typeof v === 'object' && field in v) return v[field];
+  }
+  return undefined;
+}
+
+async function main() {
+  console.log(`\n=== Norma CRUD lifecycle — ${BASE} ===\n`);
+  const cookie = await login();
+  const orgId = await resolveOrgId();
+  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`);
+  const base = { 'Content-Type': 'application/json', Cookie: `norma-auth=${cookie}` };
+  const req = (method, path, body, org) => fetch(`${BASE}${path}`, {
+    method,
+    headers: org && orgId ? { ...base, 'x-org-id': orgId } : base,
+    body: body ? JSON.stringify(body) : undefined,
+    redirect: 'manual',
+  });
+
+  for (const e of ENTITIES) {
+    console.log(`\x1b[1m${e.name}\x1b[0m`);
+    let id = null;
+    try {
+      // CREATE
+      const cRes = await req('POST', e.path, e.create, e.org);
+      const cBody = await cRes.json().catch(() => ({}));
+      id = extractId(cBody);
+      ok(cRes.status >= 200 && cRes.status < 300 && !!id, `${e.name}: create → 2xx + id`, `status ${cRes.status}, id ${id}`);
+      if (!id) { continue; }
+
+      // READ-BACK (create persisted)
+      const rRes = await req('GET', `${e.path}/${id}`, null, e.org);
+      const rBody = await rRes.json().catch(() => ({}));
+      const [vf, vv] = e.verify;
+      ok(rRes.status === 200, `${e.name}: read-back → 200`, `status ${rRes.status}`);
+      ok(String(fieldOf(rBody, vf)) === String(vv), `${e.name}: created ${vf} persisted`, `got ${JSON.stringify(fieldOf(rBody, vf))}`);
+
+      // UPDATE
+      const uRes = await req('PATCH', `${e.path}/${id}`, e.update, e.org);
+      ok(uRes.status >= 200 && uRes.status < 300, `${e.name}: update → 2xx`, `status ${uRes.status}`);
+      const vRes = await req('GET', `${e.path}/${id}`, null, e.org);
+      const vBody = await vRes.json().catch(() => ({}));
+      const [uf, uv] = e.updated;
+      ok(String(fieldOf(vBody, uf)) === String(uv), `${e.name}: update ${uf} persisted`, `got ${JSON.stringify(fieldOf(vBody, uf))}`);
+
+      // DELETE
+      const dRes = await req('DELETE', `${e.path}/${id}`, null, e.org);
+      ok(dRes.status >= 200 && dRes.status < 300, `${e.name}: delete → 2xx`, `status ${dRes.status}`);
+
+      // CONFIRM GONE
+      const gRes = await req('GET', `${e.path}/${id}`, null, e.org);
+      ok(gRes.status === 404, `${e.name}: gone → 404`, `status ${gRes.status}`);
+      id = null; // cleaned up
+    } catch (err) {
+      ok(false, `${e.name}: lifecycle threw`, String(err));
+    } finally {
+      if (id) { await req('DELETE', `${e.path}/${id}`, null, e.org).catch(() => {}); } // sweep leftover
+    }
+    console.log('');
+  }
+
+  console.log(`=== ${pass} passed, ${fail} failed ===\n`);
+  if (fail) { console.log('FAILURES:'); fails.forEach((f) => console.log(`  ✗ ${f}`)); }
+  process.exit(fail ? 1 : 0);
+}
+
+main().catch((e) => { console.error('crud-lifecycle harness error:', e); process.exit(2); });
diff --git a/tests/e2e/admin.spec.ts b/tests/e2e/admin.spec.ts
new file mode 100644
index 0000000..6b327f0
--- /dev/null
+++ b/tests/e2e/admin.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * Deeper admin journey — logs in as admin, confirms the app shell renders, then
+ * navigates several core tabs, asserting no real console/page errors accumulate
+ * across the SPA navigation (a tab that throws on mount would surface here).
+ */
+const PW = process.env.NORMA_TEST_PW || 'TestPass123!';
+const BENIGN = /favicon|analytics|gtag|net::ERR|Failed to load resource|manifest/i;
+
+function trackErrors(page: Page): string[] {
+  const errors: string[] = [];
+  page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', (e) => errors.push(String(e)));
+  return errors;
+}
+
+test('admin loads the shell and navigates core tabs with no console errors', async ({ page }) => {
+  const errors = trackErrors(page);
+
+  await page.goto('/login');
+  await page.getByPlaceholder('Username or email').fill('admin');
+  await page.getByPlaceholder('Password').fill(PW);
+  await page.getByPlaceholder('Password').press('Enter');
+  await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
+
+  // App shell nav present.
+  await expect(page.getByText('Dashboard', { exact: true }).first()).toBeVisible();
+
+  // Navigate a handful of core tabs; each should render without throwing.
+  for (const tab of ['Grants', 'Donations', 'Petitions', 'News']) {
+    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();
+    }
+  }
+
+  const real = errors.filter((e) => !BENIGN.test(e));
+  expect(real, `console errors during admin navigation: ${real.join(' | ')}`).toHaveLength(0);
+});

← d26f6c6 fix(cycle5 Cody-gate): truly-empty-body probe + readJson acr  ·  back to Norma Platform  ·  test(cycle6 Cody-gate): CRUD tests real org id-space; non-ho 2a8a6c7 →