|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `sys_business_unit_member`'s record title names the PERSON and the UNIT, |
| 5 | + * not the two stored ids. |
| 6 | + * |
| 7 | + * Setup → Business Unit Members, and a membership's record page, read the |
| 8 | + * title the object declares (`nameField: 'display_title'`). That formula used |
| 9 | + * to compose the stored foreign keys, `record.user_id + ' in ' + |
| 10 | + * record.business_unit_id`, so every row and every H1 read like |
| 11 | + * `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap in 7f3c…`. It now reads the user's `name` |
| 12 | + * (their `email` where the name is blank) and the unit's `name`, ONE hop |
| 13 | + * through each lookup, the traversal the formula read path serves (ADR-0079 |
| 14 | + * addendum, 2026-10-18). No column is stored for it, so nothing has to be kept |
| 15 | + * up to date: a renamed user or unit is named anew on the next read. |
| 16 | + * |
| 17 | + * Through the real engine, the real `sys_user`, `sys_business_unit` and |
| 18 | + * membership definitions, this file asserts: |
| 19 | + * |
| 20 | + * 1. the registered body points at `display_title`, a text formula that reads |
| 21 | + * both lookups only through the hop, and only columns their objects declare; |
| 22 | + * 2. the title a read serves names the person and the unit: on the write's |
| 23 | + * response, on `findOne`, and on a `find` whose projection names only the |
| 24 | + * title (the list's Title column); |
| 25 | + * 3. the title follows a renamed user or unit, and a membership moved to |
| 26 | + * another user or unit, with no write to the membership itself; |
| 27 | + * 4. a user whose stored name is blank is titled by their email; |
| 28 | + * 5. both hops are read as the READER: a user or unit the reader may not |
| 29 | + * read leaves the title null and none of its name reaches the row, and so |
| 30 | + * does one that no longer exists; |
| 31 | + * 6. the formula adds no stored column. |
| 32 | + */ |
| 33 | + |
| 34 | +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; |
| 35 | +import { ObjectQL, servedRecordTitle } from '@objectstack/objectql'; |
| 36 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 37 | +import { SysBusinessUnit, SysBusinessUnitMember, SysUser } from '@objectstack/platform-objects'; |
| 38 | +import { resolveDisplayField, resolveRecordDisplayName } from '@objectstack/spec/data'; |
| 39 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 40 | +import { installDefaultOrganization } from './default-organization.testkit.js'; |
| 41 | + |
| 42 | +const SYS = { context: { isSystem: true } } as any; |
| 43 | +const OBJECT = 'sys_business_unit_member'; |
| 44 | +const OWNER = 'com.objectstack.test.business-unit-member-title'; |
| 45 | + |
| 46 | +/** A reader who is not SYSTEM: the shape every request's context has. */ |
| 47 | +const READER: ExecutionContext = { userId: 'usr_reader', tenantId: 'org_1' }; |
| 48 | + |
| 49 | +/** Every `record.<path>` the `display_title` expression reads, as written. */ |
| 50 | +function formulaReads(): string[] { |
| 51 | + const source = (SysBusinessUnitMember as any).fields.display_title?.expression?.source; |
| 52 | + if (typeof source !== 'string') throw new Error('display_title carries no expression source'); |
| 53 | + return [...new Set([...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]))].sort(); |
| 54 | +} |
| 55 | + |
| 56 | +describe('sys_business_unit_member is titled by the person and the unit, not their ids', () => { |
| 57 | + let engine: ObjectQL; |
| 58 | + let driver: SqlDriver; |
| 59 | + /** Ids a reader that is not SYSTEM may read, per related object. */ |
| 60 | + const readable = { sys_user: new Set<string>(), sys_business_unit: new Set<string>() }; |
| 61 | + |
| 62 | + const user = async (name: string, email: string): Promise<string> => |
| 63 | + String((await engine.insert('sys_user', { name, email }, SYS)).id); |
| 64 | + const unit = async (name: string): Promise<string> => |
| 65 | + String((await engine.insert('sys_business_unit', { name }, SYS)).id); |
| 66 | + const member = async (userId: string, unitId: string) => |
| 67 | + engine.insert(OBJECT, { user_id: userId, business_unit_id: unitId }, SYS); |
| 68 | + const titleOf = async (id: unknown, ctx: any = SYS) => |
| 69 | + (await engine.findOne(OBJECT, { where: { id } }, ctx))!.display_title; |
| 70 | + |
| 71 | + beforeAll(async () => { |
| 72 | + engine = new ObjectQL(); |
| 73 | + driver = new SqlDriver({ |
| 74 | + client: 'better-sqlite3', |
| 75 | + connection: { filename: ':memory:' }, |
| 76 | + useNullAsDefault: true, |
| 77 | + }); |
| 78 | + engine.registerDriver(driver, true); |
| 79 | + await engine.init(); |
| 80 | + engine.registry.registerObject(SysUser as any, OWNER); |
| 81 | + engine.registry.registerObject(SysBusinessUnit as any, OWNER); |
| 82 | + engine.registry.registerObject(SysBusinessUnitMember as any, OWNER); |
| 83 | + await engine.syncSchemas(); |
| 84 | + // A real `single` install's Default Organization: the system writes below |
| 85 | + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). |
| 86 | + await installDefaultOrganization(engine); |
| 87 | + // Row-level security on the two related objects for a reader that is not |
| 88 | + // SYSTEM: here, the ids in `readable`. |
| 89 | + engine.registerMiddleware(async (ctx: any, next: () => Promise<void>) => { |
| 90 | + const scope = readable[ctx.object as keyof typeof readable]; |
| 91 | + if (scope && ['find', 'findOne'].includes(ctx.operation) && !ctx.context?.isSystem) { |
| 92 | + const scoped = { id: { $in: [...scope] } }; |
| 93 | + ctx.ast = { ...(ctx.ast ?? {}), where: ctx.ast?.where ? { $and: [ctx.ast.where, scoped] } : scoped }; |
| 94 | + } |
| 95 | + await next(); |
| 96 | + }); |
| 97 | + }); |
| 98 | + |
| 99 | + beforeEach(() => { |
| 100 | + readable.sys_user.clear(); |
| 101 | + readable.sys_business_unit.clear(); |
| 102 | + }); |
| 103 | + |
| 104 | + afterAll(async () => { |
| 105 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 106 | + }); |
| 107 | + |
| 108 | + it('the registered body points at display_title, a text formula reading both lookups only through the hop', () => { |
| 109 | + const registered = engine.registry.getObject(OBJECT) as any; |
| 110 | + expect(registered.nameField).toBe('display_title'); |
| 111 | + expect(registered.displayNameField).toBe('display_title'); |
| 112 | + expect(resolveDisplayField(registered)).toBe('display_title'); |
| 113 | + expect(registered.fields.display_title?.type).toBe('formula'); |
| 114 | + expect(registered.fields.display_title?.returnType).toBe('text'); |
| 115 | + // Neither id is read bare: a bare read beside a traversal is the |
| 116 | + // `traversal-bare-and-traversed` refusal. |
| 117 | + expect(formulaReads()).toEqual(['business_unit_id.name', 'user_id.email', 'user_id.name']); |
| 118 | + for (const [object, columns] of [['sys_user', ['name', 'email']], ['sys_business_unit', ['name']]] as const) { |
| 119 | + const fields = (engine.registry.getObject(object) as any).fields; |
| 120 | + for (const column of columns) expect(fields[column], `${object}.${column}`).toBeDefined(); |
| 121 | + } |
| 122 | + }); |
| 123 | + |
| 124 | + it('a read serves the person\'s and the unit\'s names: on the write response, findOne and a projected find', async () => { |
| 125 | + const alice = await user('Alice Liddell', 'alice@example.com'); |
| 126 | + const emea = await unit('EMEA Sales'); |
| 127 | + const created = await member(alice, emea); |
| 128 | + expect(created.display_title).toBe('Alice Liddell in EMEA Sales'); |
| 129 | + |
| 130 | + const registered = engine.registry.getObject(OBJECT) as any; |
| 131 | + const row = await engine.findOne(OBJECT, { where: { id: created.id } }, SYS); |
| 132 | + expect(row!.display_title).toBe('Alice Liddell in EMEA Sales'); |
| 133 | + expect(row!.display_title).not.toContain(alice); |
| 134 | + expect(row!.display_title).not.toContain(emea); |
| 135 | + // The row still holds the foreign KEYS; only the title reads through them. |
| 136 | + expect(row!.user_id).toBe(alice); |
| 137 | + expect(row!.business_unit_id).toBe(emea); |
| 138 | + expect(resolveRecordDisplayName(registered, row)).toBe('Alice Liddell in EMEA Sales'); |
| 139 | + expect(servedRecordTitle(registered, row)).toBe('Alice Liddell in EMEA Sales'); |
| 140 | + |
| 141 | + // The list's Title column: a projection naming only the title. |
| 142 | + const listed = await engine.find(OBJECT, { where: { id: created.id }, fields: ['display_title'] }, SYS); |
| 143 | + expect(listed).toEqual([{ display_title: 'Alice Liddell in EMEA Sales' }]); |
| 144 | + }); |
| 145 | + |
| 146 | + it('the title follows a rename of the user or the unit, and a move to another user or unit', async () => { |
| 147 | + const bob = await user('Bob Kinney', 'bob@example.com'); |
| 148 | + const ops = await unit('Operations'); |
| 149 | + const m = await member(bob, ops); |
| 150 | + expect(await titleOf(m.id)).toBe('Bob Kinney in Operations'); |
| 151 | + |
| 152 | + // Nothing is stored on the membership, so a rename is read on the next read. |
| 153 | + await engine.update('sys_user', { id: bob, name: 'Robert Kinney' }, SYS); |
| 154 | + await engine.update('sys_business_unit', { id: ops, name: 'Global Operations' }, SYS); |
| 155 | + expect(await titleOf(m.id)).toBe('Robert Kinney in Global Operations'); |
| 156 | + |
| 157 | + const dana = await user('Dana Ortiz', 'dana@example.com'); |
| 158 | + const apac = await unit('APAC'); |
| 159 | + await engine.update(OBJECT, { id: m.id, user_id: dana, business_unit_id: apac }, SYS); |
| 160 | + expect(await titleOf(m.id)).toBe('Dana Ortiz in APAC'); |
| 161 | + }); |
| 162 | + |
| 163 | + it('a user whose stored name is blank is titled by their email', async () => { |
| 164 | + // The write door refuses a blank `name` (it is required), so a nameless |
| 165 | + // user is a row written past it: an import, or a row older than the rule. |
| 166 | + const nameless = await user('Placeholder', 'no-name@example.com'); |
| 167 | + await driver.getKnex()('sys_user').where({ id: nameless }).update({ name: ' ' }); |
| 168 | + const m = await member(nameless, await unit('Finance')); |
| 169 | + expect(await titleOf(m.id)).toBe('no-name@example.com in Finance'); |
| 170 | + }); |
| 171 | + |
| 172 | + describe('both hops are read as the reader', () => { |
| 173 | + it('a user and a unit the reader may read are named for that reader', async () => { |
| 174 | + const erin = await user('Erin Shaw', 'erin@example.com'); |
| 175 | + const legal = await unit('Legal'); |
| 176 | + const m = await member(erin, legal); |
| 177 | + readable.sys_user.add(erin); |
| 178 | + readable.sys_business_unit.add(legal); |
| 179 | + expect(await titleOf(m.id, { context: READER })).toBe('Erin Shaw in Legal'); |
| 180 | + }); |
| 181 | + |
| 182 | + it('a user or a unit the reader may not read leaves the title null, and none of its name reaches the row', async () => { |
| 183 | + const carol = await user('Carol Hidden', 'carol.hidden@example.com'); |
| 184 | + const secret = await unit('Secret Projects'); |
| 185 | + const m = await member(carol, secret); |
| 186 | + |
| 187 | + // Unit readable, user not. |
| 188 | + readable.sys_business_unit.add(secret); |
| 189 | + let row = await engine.findOne(OBJECT, { where: { id: m.id } }, { context: READER }); |
| 190 | + expect(row!.display_title).toBeNull(); |
| 191 | + expect(JSON.stringify(row)).not.toContain('Carol Hidden'); |
| 192 | + expect(JSON.stringify(row)).not.toContain('carol.hidden@example.com'); |
| 193 | + |
| 194 | + // User readable, unit not. |
| 195 | + readable.sys_business_unit.clear(); |
| 196 | + readable.sys_user.add(carol); |
| 197 | + row = await engine.findOne(OBJECT, { where: { id: m.id } }, { context: READER }); |
| 198 | + expect(row!.display_title).toBeNull(); |
| 199 | + expect(JSON.stringify(row)).not.toContain('Secret Projects'); |
| 200 | + |
| 201 | + // SYSTEM reads both directly, so it reads both names through the hops. |
| 202 | + expect(await titleOf(m.id)).toBe('Carol Hidden in Secret Projects'); |
| 203 | + }); |
| 204 | + |
| 205 | + it('a membership whose user or unit no longer exists has no title, never one computed in its place', async () => { |
| 206 | + const frank = await user('Frank Gone', 'frank@example.com'); |
| 207 | + const hr = await unit('HR'); |
| 208 | + const noUser = await member('usr_gone', hr); |
| 209 | + const noUnit = await member(frank, 'bu_gone'); |
| 210 | + expect(await titleOf(noUser.id)).toBeNull(); |
| 211 | + expect(await titleOf(noUnit.id)).toBeNull(); |
| 212 | + }); |
| 213 | + }); |
| 214 | + |
| 215 | + it('adds no stored column', async () => { |
| 216 | + const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo()); |
| 217 | + expect(columns).toEqual(expect.arrayContaining(['user_id', 'business_unit_id'])); |
| 218 | + expect(columns).not.toContain('display_title'); |
| 219 | + }); |
| 220 | +}); |
0 commit comments