Skip to content

Commit 1f40201

Browse files
fix(platform-objects): title a business unit membership by the person's and the unit's names (#26300)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X82u6K5ttmZbHo3BGfKiUPk)_ Fixes #26195 Clause-②: no (no exported type, schema field or spec key changes; `sys_business_unit_member.display_title` stays a text formula and `titleFormat` is unchanged) Metadata-protocol: none Part of #26059 (ruling `6416233738`, Q3 child 9). #26059 remains open; only its child card is resolved here. ## What changes `sys_business_unit_member.display_title`, the record title Setup → Business Unit Members shows in its Title column and on the record heading, composed the two stored ids (`record.user_id + ' in ' + record.business_unit_id`). It now reads the user's name (their email where the name is blank) and the unit's name, ONE hop through each lookup: `(isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name) + ' in ' + record.business_unit_id.name` ## Mechanism (named on the card first: comment 6424698289) A formula over related display names, using the one-hop formula read that is already on `main` (#24197, ADR-0079 addendum 2026-10-18). Nothing in `@objectstack/formula` or `packages/spec/**` is touched. It reuses the shape `sys_user_permission_set.display_title` already ships (`plugin-security/src/objects/sys-user-permission-set.object.ts:77`). The object comment that said a formula "cannot reach a related record's own title" predated #24197 and is rewritten. - **Write cost:** none. No stored column, no hook. - **Read cost:** one batched `sys_user` read and one batched `sys_business_unit` read per page, never one per row, under the reader's own permissions; a `findOne` makes two point reads. Reads that do not project `display_title` pay nothing. - **Rename reach:** total and immediate. Nothing is stamped, so a renamed user or unit, or a membership moved to another user or unit, shows the new names on the next read. - **Unchanged limits:** the title is still not sortable or searchable (ADR-0079 addendum item 9; it already was not). A user or unit the reader may not read, or one that no longer exists, leaves the title null and the console falls to `titleFormat`. - **`titleFormat` agreement:** `titleFormat` stays `{user_id} in {business_unit_id}`. The console draws a lookup placeholder by the related record's `name`, else its `email` (`ui/packages/core/src/utils/record-title.ts` `displayNameOfEmbeddedObject`), so over an expanded row it renders the formula's text. `identity-display-title.test.ts` now asserts this over the row the hop serves. ## Evidence **Red first, through the public door** (live `pnpm dev -- --fresh` showcase, signed in as the seeded admin, `POST` a membership then the list and record reads the console makes): - `main` (`platform-objects` rebuilt from base `c4968860`): `GET /api/v1/data/sys_business_unit_member` and `GET .../94XQV8HLtyUCrIKZ` both serve `display_title: "7eLi5ISndhNgrM0zdFvHtlv24DnJBmQK in bu_acme"`. - this branch (`81074800`): both serve `display_title: "Dev Admin in Acme Corporation"`. The base tree was then restored by `git checkout HEAD --` (HEAD blob hash matched, `git diff HEAD` empty) and rebuilt. **Tests** (at `81074800`): - New `plugin-security/src/business-unit-member-title.test.ts`, real `ObjectQL` + `SqlDriver` + the real `sys_user`, `sys_business_unit` and membership definitions: on base 7 of 8 red (`Expected "Alice Liddell in EMEA Sales"`, `Received "simLa1JzYHiOZOF5 in VJG4H1tG-WmtXsHS"`); on the branch 8/8 green. It pins the write response, `findOne`, the projected list `find`, rename and move, the email fallback, both hops read as the reader (no leak of an unreadable user or unit), a missing user or unit leaving the title null, and no stored column. - `pnpm --filter @objectstack/platform-objects test`: 96 files, 1361 tests passed. - The suites that register `sys_business_unit_member` (each exit 0 on its own): `plugin-security` 228 files, 4934 passed, 45 skipped; `plugin-sharing` 55 files, 1233 passed; `plugin-approvals` 98 files, 1276 passed. - `pnpm --filter @objectstack/platform-objects typecheck` and `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. - `eslint --no-inline-config` on the three touched source files: exit 0. **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 67 families on this diff; all 67 ran with exit 0 (`check:i18n` and `check:dual-build-cjs-loads` after a full build, `check-plugin-teardown-shape --self-test` after fetching its positive-control commit). `--ran` reconciliation: 67 derived, 67 run, 0 NOT-MEASURED. ## Acceptance notes - File surface: the claim named the object, `identity-display-title.test.ts` "and the identity tests". The engine-level test lives in `plugin-security` because `platform-objects` has no `objectql` or `driver-sql` dependency; it sits beside the sibling's `sys-user-permission-set-person-title.test.ts`. - Two lookups hopped at once in one formula had no prior pin (the sibling hops one); the new test covers it. --- _Generated by [Claude Code](https://claude.ai/code/session_01KCZSeNY7z5jAXn3EyHdZww)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KCZSeNY7z5jAXn3EyHdZww Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6294157 commit 1f40201

4 files changed

Lines changed: 282 additions & 14 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): a business unit membership is titled by the person's and the unit's names, not their ids
6+
7+
Clause-②: no (no exported type, schema field or spec key changes; `sys_business_unit_member.display_title` stays a text formula and `titleFormat` is unchanged)
8+
Metadata-protocol: none
9+
10+
`sys_business_unit_member.display_title`, the record title Setup → Business Unit Members shows in its Title column and on the record heading, read `record.user_id + ' in ' + record.business_unit_id`, so it showed two raw ids. It now reads the user's name (their email where the name is blank) and the business unit's name, one hop through each lookup, the traversal the formula read path already serves (ADR-0079 addendum, 2026-10-18), as `sys_user_permission_set`'s title does. The title reads "Ada Lovelace in EMEA Sales".
11+
12+
Nothing is stored and no hook is added: a write costs nothing more, and a read that serves the title makes one batched read of `sys_user` and one of `sys_business_unit` per page. A renamed user or unit, or a membership moved to another user or unit, shows the new names on the next read. Both hops are read with the reader's permissions: where the reader may not read the user or the unit, or it no longer exists, the title is empty and the console falls back to `titleFormat`. The title is still not sortable or searchable, as before.

‎packages/platform-objects/src/identity/identity-display-title.test.ts‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@
3232
* `ExpressionEngine.evaluate(expression, { now, …, record })` on the row the
3333
* driver returned, and writes `null` when the result is not `ok`. That is the
3434
* call below. A formula runs on the STORED row, before `$expand`, so a
35-
* `titleFormat` placeholder naming a lookup renders the stored id in both.
35+
* `titleFormat` placeholder naming a lookup renders the stored id in both —
36+
* except where the formula reads ONE hop through the lookup
37+
* (`record.user_id.name`, ADR-0079 addendum 2026-10-18). There the read path
38+
* evaluates it over a copy of the row whose lookup carries the related row,
39+
* and the console renders the `titleFormat` placeholder by that related row's
40+
* display name; the case's `row` is that copy (`sys_business_unit_member`).
41+
* The engine-level proof of the hop is
42+
* `plugin-security/src/business-unit-member-title.test.ts`.
3643
*/
3744

3845
import { describe, it, expect } from 'vitest';
@@ -57,12 +64,20 @@ type Schema = { name: string; nameField?: string; displayNameField?: string; tit
5764
* each `{field}` placeholder substituted with the row's value. It is the
5865
* reference the formula has to reproduce, not a second title resolver. The
5966
* parsed schema carries `titleFormat` as a `{ dialect: 'template', source }`
60-
* envelope.
67+
* envelope. A placeholder whose value is a related row (an expanded lookup) is
68+
* drawn by that row's `name`, else its `email`, as the console draws one.
6169
*/
6270
function renderTitleFormat(titleFormat: unknown, row: Row): string {
6371
const source = typeof titleFormat === 'string' ? titleFormat : (titleFormat as { source?: unknown })?.source;
6472
if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(titleFormat)}`);
65-
return source.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? ''));
73+
const shown = (value: unknown): string => {
74+
if (value && typeof value === 'object') {
75+
const related = value as { name?: unknown; email?: unknown };
76+
return String(typeof related.name === 'string' && related.name.trim() ? related.name : related.email ?? '');
77+
}
78+
return String(value ?? '');
79+
};
80+
return source.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => shown(row[key]));
6681
}
6782

6883
/** `display_title` evaluated as the read path evaluates a formula field. */
@@ -81,7 +96,11 @@ function servedPointers(schema: Schema): { nameField?: string; displayNameField?
8196

8297
interface Case {
8398
schema: Schema;
84-
/** A stored row as the driver returns it: every column present, ids as stored. */
99+
/**
100+
* A stored row as the driver returns it: every column present, ids as
101+
* stored; where the formula reads a hop, the copy the read path evaluates it
102+
* over, the lookup carrying the related row.
103+
*/
85104
row: Row;
86105
/** The title the `titleFormat` renders for `row`. */
87106
title: string;
@@ -95,8 +114,14 @@ const FORMULA_CASES: Case[] = [
95114
},
96115
{
97116
schema: SysBusinessUnitMember as unknown as Schema,
98-
row: { id: 'bum_9Kz1', user_id: 'usr_Ab12', business_unit_id: 'bu_emea' },
99-
title: 'usr_Ab12 in bu_emea',
117+
// The formula reads one hop through both lookups, so the row it is
118+
// evaluated against carries the related rows the read path preloads.
119+
row: {
120+
id: 'bum_9Kz1',
121+
user_id: { id: 'usr_Ab12', name: 'Ada Lovelace', email: 'ada@example.com' },
122+
business_unit_id: { id: 'bu_emea', name: 'EMEA Sales' },
123+
},
124+
title: 'Ada Lovelace in EMEA Sales',
100125
},
101126
{
102127
schema: SysInvitation as unknown as Schema,

‎packages/platform-objects/src/identity/sys-business-unit-member.object.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ export const SysBusinessUnitMember = ObjectSchema.create({
2828
// designate-only pass stamped `nameField: 'id'` (the first title-eligible
2929
// field), so a renderer honouring ADR-0079's order (an explicit `nameField`
3030
// wins over `titleFormat`) drew the raw id as the record page's H1.
31-
// `titleFormat` stays for renderers that still read it first;
32-
// `identity-display-title.test.ts` holds the two to the same text.
33-
// `user_id` and `business_unit_id` are lookups, so the formula reads their stored
34-
// ids: a formula is evaluated on the stored row, before `$expand`, and
35-
// cannot reach a related record's own title.
31+
// `titleFormat` stays for renderers that still read it first, and is the
32+
// rung below the pointer where the title has no value: the console draws a
33+
// lookup placeholder by the related record's name (else its email), so over
34+
// an expanded row it renders the same text the formula does.
35+
// `identity-display-title.test.ts` holds the two to the same text;
36+
// `plugin-security/src/business-unit-member-title.test.ts` pins the title a
37+
// read serves.
3638
displayNameField: 'display_title',
3739
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
3840
titleFormat: '{user_id} in {business_unit_id}',
@@ -47,12 +49,21 @@ export const SysBusinessUnitMember = ObjectSchema.create({
4749
}),
4850

4951
// [ADR-0079] The record title (`nameField` above). A formula is computed on
50-
// read and has no stored column. Every source column is required, so the
51-
// expression needs no null guard.
52+
// read and has no stored column, so a renamed user or unit is named anew on
53+
// the next read. It names the PERSON and the UNIT, not the stored ids: the
54+
// user's `name`, or their `email` where the name is blank (the platform's
55+
// `name || email` rule for a person's handle), and the unit's `name`, each
56+
// read ONE hop through its lookup (ADR-0079 addendum, 2026-10-18), as
57+
// `sys_user_permission_set`'s title does. Both hops are read under the
58+
// READER's own authority: a user or unit the reader may not read, or one
59+
// that no longer exists, leaves the title null, never a value computed in
60+
// its place. Neither lookup is read bare beside its hop (refused,
61+
// `traversal-bare-and-traversed`). Both lookups are required, and both
62+
// related `name` columns are required stored text (no second hop).
5263
display_title: Field.formula({
5364
label: 'Title',
5465
returnType: 'text',
55-
expression: F`record.user_id + ' in ' + record.business_unit_id`,
66+
expression: F`(isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name) + ' in ' + record.business_unit_id.name`,
5667
description: 'Record title: the user and the business unit they are assigned to (computed on read)',
5768
group: 'Assignment',
5869
}),
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)