← back to Norma
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
M package.jsonA tests/api-crud-lifecycle.mjsA tests/e2e/admin.spec.ts
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
·
test(cycle6 Cody-gate): CRUD tests real org id-space; non-ho 2a8a6c7 →