|
| 1 | +#!/usr/bin/env node |
| 2 | +// prove-org-relations.mjs — acceptance proof for the org-relations feature |
| 3 | +// (context-v/plans/Org-Relations-Parent-Child-Peer-Plus-Org-Tags.md, gh #51). |
| 4 | +// |
| 5 | +// Capability checks ride NATS like every prove-* script; the final |
| 6 | +// client-tagging audit + zero-residue cleanup talk to SurrealDB directly |
| 7 | +// (the surreal-backfill-* precedent), because there is deliberately no |
| 8 | +// org-delete verb on the wire. |
| 9 | +// |
| 10 | +// Everything is minted under a throwaway client slug, so even mid-run the |
| 11 | +// test rows are invisible to every real workspace read. |
| 12 | +// |
| 13 | +// Prereqs: docker compose up -d nats record-surrealdb-resolver |
| 14 | +// Usage: set -a; source ./.env; set +a |
| 15 | +// node scripts/prove-org-relations.mjs |
| 16 | + |
| 17 | +import { createRequire } from 'node:module'; |
| 18 | +import { Surreal } from 'surrealdb'; |
| 19 | +const require = createRequire(new URL('../services/social-search/package.json', import.meta.url)); |
| 20 | +const { connect } = require('@nats-io/transport-node'); |
| 21 | + |
| 22 | +const NATS_URL = process.env.NATS_URL ?? 'nats://localhost:4222'; |
| 23 | +const CLIENT = 'proof-org-relations'; |
| 24 | +const STAMP = process.pid; // uniquify slugs across runs without Date.now() |
| 25 | +const A = `proof-child-org-${STAMP}`; |
| 26 | +const B = `proof-parent-org-${STAMP}`; |
| 27 | +const C = `proof-peer-org-${STAMP}`; |
| 28 | + |
| 29 | +const nc = await connect({ servers: NATS_URL }); |
| 30 | +const req = async (subject, body, timeout = 30_000) => |
| 31 | + JSON.parse(new TextDecoder().decode((await nc.request(subject, JSON.stringify(body), { timeout })).data)); |
| 32 | +let failed = 0; |
| 33 | +const check = (label, ok, extra = '') => { console.log(`${ok ? '✅' : '❌'} ${label} ${extra}`); if (!ok) failed++; }; |
| 34 | + |
| 35 | +// --- mint three throwaway orgs (org-only person.affiliate, the documented |
| 36 | +// no-person path: creates the org row, no edge) ------------------------------ |
| 37 | +for (const slug of [A, B, C]) { |
| 38 | + const r = await req('person.affiliate.requested', { |
| 39 | + org_action: 'create', org_name: slug, client: CLIENT, source: 'prove-org-relations', |
| 40 | + }); |
| 41 | + check(`mint ${slug}`, r.ok && r.org_created && r.org_slug === slug, r.ok ? '' : r.error); |
| 42 | +} |
| 43 | + |
| 44 | +// (a) relate parent — from A's perspective, B is the parent |
| 45 | +const rel1 = await req('organization.relate.requested', { |
| 46 | + org_slug: A, other_slug: B, rel: 'parent', kind: 'initiative_of', |
| 47 | + description: 'proof: A is an initiative of B', client: CLIENT, |
| 48 | +}); |
| 49 | +check('relate A→parent B', rel1.ok && rel1.created === true && rel1.rel === 'parent', rel1.ok ? '' : rel1.error); |
| 50 | + |
| 51 | +// (b) trichotomy from BOTH perspectives + kind/description round-trip |
| 52 | +const relA = await req('organization.relations.requested', { org_slug: A, client: CLIENT }); |
| 53 | +check('A.parents = [B] w/ kind+description', |
| 54 | + relA.ok && relA.parents.length === 1 && relA.parents[0].slug === B |
| 55 | + && relA.parents[0].kind === 'initiative_of' && /initiative of B/.test(relA.parents[0].description ?? ''), |
| 56 | + relA.ok ? JSON.stringify(relA.parents) : relA.error); |
| 57 | +const relB = await req('organization.relations.requested', { org_slug: B, client: CLIENT }); |
| 58 | +check('B.children = [A]', relB.ok && relB.children.length === 1 && relB.children[0].slug === A |
| 59 | + && relB.parents.length === 0 && relB.peers.length === 0); |
| 60 | + |
| 61 | +// (c) duplicate-pair rejection → created:false, not an error, not a second edge |
| 62 | +const dup = await req('organization.relate.requested', { |
| 63 | + org_slug: B, other_slug: A, rel: 'child', client: CLIENT, |
| 64 | +}); |
| 65 | +check('duplicate pair → created:false', dup.ok && dup.created === false); |
| 66 | + |
| 67 | +// (d) peer relation, visible from both sides |
| 68 | +const rel2 = await req('organization.relate.requested', { |
| 69 | + org_slug: A, other_slug: C, rel: 'peer', kind: 'partners_with', client: CLIENT, |
| 70 | +}); |
| 71 | +check('relate A—peer—C', rel2.ok && rel2.created === true); |
| 72 | +const relA2 = await req('organization.relations.requested', { org_slug: A, client: CLIENT }); |
| 73 | +const relC = await req('organization.relations.requested', { org_slug: C, client: CLIENT }); |
| 74 | +check('peer from both perspectives', |
| 75 | + relA2.ok && relA2.peers.length === 1 && relA2.peers[0].slug === C |
| 76 | + && relC.ok && relC.peers.length === 1 && relC.peers[0].slug === A); |
| 77 | + |
| 78 | +// (e) relation.update — flip A/B (B becomes A's child) + patch description |
| 79 | +const upd = await req('organization.relation.update.requested', { |
| 80 | + org_slug: A, other_slug: B, rel: 'child', description: 'proof: flipped', client: CLIENT, |
| 81 | +}); |
| 82 | +check('relation.update flip → rel:child', upd.ok && upd.rel === 'child', upd.ok ? '' : upd.error); |
| 83 | +const relA3 = await req('organization.relations.requested', { org_slug: A, client: CLIENT }); |
| 84 | +check('post-flip: A.children = [B], A.parents = []', |
| 85 | + relA3.ok && relA3.children.length === 1 && relA3.children[0].slug === B |
| 86 | + && relA3.parents.length === 0 && /flipped/.test(relA3.children[0].description ?? '')); |
| 87 | + |
| 88 | +// (f) zero contamination — the People Reveal read sees no org→org edges |
| 89 | +const aff = await req('organization.affiliations.requested', { org_slug: A, client: CLIENT }); |
| 90 | +check('organization.affiliations sees 0 people (no org_org bleed)', |
| 91 | + aff.ok && Array.isArray(aff.people) && aff.people.length === 0); |
| 92 | + |
| 93 | +// (g) unrelate → gone |
| 94 | +const unrel = await req('organization.unrelate.requested', { org_slug: A, other_slug: B, client: CLIENT }); |
| 95 | +check('unrelate A↔B removed=1', unrel.ok && unrel.removed === 1); |
| 96 | +const relA4 = await req('organization.relations.requested', { org_slug: A, client: CLIENT }); |
| 97 | +check('post-unrelate: only the peer remains', |
| 98 | + relA4.ok && relA4.children.length === 0 && relA4.parents.length === 0 && relA4.peers.length === 1); |
| 99 | + |
| 100 | +// (h) tags — add (toDashed: dashes-not-spaces, casing preserved), dedup, detail, remove |
| 101 | +const tag1 = await req('organization.tag.add.requested', { org_slug: A, tag: 'Proof Initiative', client: CLIENT }); |
| 102 | +check("tag.add 'Proof Initiative' → 'Proof-Initiative'", tag1.ok && tag1.tag === 'Proof-Initiative' && tag1.created === true); |
| 103 | +const tag2 = await req('organization.tag.add.requested', { org_slug: A, tag: 'Proof-Initiative', client: CLIENT }); |
| 104 | +check('tag.add duplicate → created:false', tag2.ok && tag2.created === false); |
| 105 | +const det = await req('organization.detail.requested', { org_slug: A, client: CLIENT }); |
| 106 | +check('detail.org.tags = [Proof-Initiative]', det.ok && det.org.tags.length === 1 && det.org.tags[0] === 'Proof-Initiative'); |
| 107 | +const tagRm = await req('organization.tag.remove.requested', { org_slug: A, tag: 'Proof-Initiative', client: CLIENT }); |
| 108 | +const det2 = await req('organization.detail.requested', { org_slug: A, client: CLIENT }); |
| 109 | +check('tag.remove → detail.org.tags = []', tagRm.ok && tagRm.removed === true && det2.ok && det2.org.tags.length === 0); |
| 110 | + |
| 111 | +await nc.drain(); |
| 112 | + |
| 113 | +// --- client-tagging audit (per the surrealdb-canonical-layer discipline: |
| 114 | +// re-query WITHOUT the client filter, inspect the field on every row) — then |
| 115 | +// zero-residue cleanup --------------------------------------------------------- |
| 116 | +const db = new Surreal(); |
| 117 | +await db.connect(process.env.SURREAL_URL); |
| 118 | +await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS }); |
| 119 | +await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB }); |
| 120 | + |
| 121 | +const orgs = (await db.query( |
| 122 | + `SELECT id, slug, client_access FROM organizations WHERE slug IN [$a, $b, $c];`, |
| 123 | + { a: A, b: B, c: C }, |
| 124 | +))?.[0] ?? []; |
| 125 | +check('audit: 3 proof orgs, each client_access == [proof client]', |
| 126 | + orgs.length === 3 && orgs.every((o) => Array.isArray(o.client_access) && o.client_access.length === 1 && o.client_access[0] === CLIENT), |
| 127 | + JSON.stringify(orgs.map((o) => ({ slug: o.slug, client_access: o.client_access })))); |
| 128 | + |
| 129 | +const orgIds = orgs.map((o) => o.id); |
| 130 | +const edges = (await db.query( |
| 131 | + `SELECT id, rel, client_access FROM affiliations WHERE edge_type = 'org_org' AND (in IN $ids OR out IN $ids);`, |
| 132 | + { ids: orgIds }, |
| 133 | +))?.[0] ?? []; |
| 134 | +check('audit: surviving edge (the peer) carries client_access == [proof client]', |
| 135 | + edges.length === 1 && edges[0].client_access?.length === 1 && edges[0].client_access[0] === CLIENT, |
| 136 | + `edges=${edges.length}`); |
| 137 | + |
| 138 | +const obs = (await db.query( |
| 139 | + `SELECT id, predicate, client FROM observations WHERE subject IN $ids;`, |
| 140 | + { ids: orgIds }, |
| 141 | +))?.[0] ?? []; |
| 142 | +check('audit: every proof observation carries client (singular) == proof client', |
| 143 | + obs.length > 0 && obs.every((o) => o.client === CLIENT), `n=${obs.length}`); |
| 144 | + |
| 145 | +// cleanup — edges first, then observations, then the rows |
| 146 | +await db.query(`DELETE affiliations WHERE edge_type = 'org_org' AND (in IN $ids OR out IN $ids);`, { ids: orgIds }); |
| 147 | +await db.query(`DELETE observations WHERE subject IN $ids OR object IN $ids;`, { ids: orgIds }); |
| 148 | +await db.query(`DELETE organizations WHERE slug IN [$a, $b, $c];`, { a: A, b: B, c: C }); |
| 149 | +const leftover = (await db.query( |
| 150 | + `SELECT id FROM organizations WHERE slug IN [$a, $b, $c];`, { a: A, b: B, c: C }, |
| 151 | +))?.[0] ?? []; |
| 152 | +check('cleanup: zero residue', leftover.length === 0); |
| 153 | +await db.close(); |
| 154 | + |
| 155 | +console.log(failed ? `\n${failed} check(s) failed` : '\nall green'); |
| 156 | +process.exit(failed ? 1 : 0); |
0 commit comments