Skip to content

Commit 32c847b

Browse files
committed
test(plugin-sharing): pin the business-unit parent-delete refusal on a real engine; translate the help text
Claude-Session: https://claude.ai/code/session_01VBFXArkqADbAp54xq6RAZE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f6bd6c commit 32c847b

5 files changed

Lines changed: 172 additions & 4 deletions

File tree

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
930930
},
931931
parent_business_unit_id: {
932932
label: "Parent Business Unit",
933-
help: "Self-reference for the org tree. Null = root of tenant."
933+
help: "Self-reference for the org tree. Null = root of tenant. A unit that still has child units cannot be deleted until they are re-parented or deleted."
934934
},
935935
timezone: {
936936
label: "Timezone",

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
930930
},
931931
parent_business_unit_id: {
932932
label: "Unidad de negocio principal",
933-
help: "Autorreferencia para el árbol organizativo. Null = raíz del tenant."
933+
help: "Autorreferencia para el árbol organizativo. Null = raíz del tenant. Una unidad que aún tiene unidades hijas no se puede eliminar hasta que se reasignen a otro padre o se eliminen."
934934
},
935935
timezone: {
936936
label: "Zona horaria",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
930930
},
931931
parent_business_unit_id: {
932932
label: "親ビジネスユニット",
933-
help: "組織ツリーの自己参照。null = テナントのルート。"
933+
help: "組織ツリーの自己参照。null = テナントのルート。子ユニットが残っているユニットは、子ユニットを別の親に付け替えるか削除するまで削除できません。"
934934
},
935935
timezone: {
936936
label: "タイムゾーン",

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
930930
},
931931
parent_business_unit_id: {
932932
label: "上级业务单元",
933-
help: "组织树的自关联字段。Null 表示租户根节点。"
933+
help: "组织树的自关联字段。Null 表示租户根节点。仍有下级单元的业务单元无法删除,须先将下级单元改挂到其他上级或删除。"
934934
},
935935
timezone: {
936936
label: "时区",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24939] Deleting a business unit that still has child units is REFUSED.
5+
*
6+
* `sys_business_unit.parent_business_unit_id` used to declare no
7+
* `deleteBehavior`, so it took the lookup default `set_null`: deleting a unit
8+
* answered 200 and silently made each child a root of the tenant. The tree is
9+
* what a `unit_and_subordinates` sharing rule walks
10+
* (`BusinessUnitGraphService.descendants`), so a grant anchored above the
11+
* deleted unit stopped reaching the former subtree, with no word to the
12+
* administrator. The field now declares `restrict`.
13+
*
14+
* Measured on a REAL engine (ObjectQL + SqlDriver over in-memory SQLite)
15+
* carrying the SHIPPED `SysBusinessUnit` from `@objectstack/platform-objects`,
16+
* because the refusal is the engine's referential pass reading the built
17+
* declaration — a fake engine would pin only itself. It lives here rather than
18+
* in `platform-objects`, which has no engine or driver among its dependencies;
19+
* plugin-sharing has both, and owns the subtree walk the refusal protects.
20+
*
21+
* Every platform deleter of this object is pinned to still complete on a tree:
22+
* - by id (`DELETE /api/v1/data/sys_business_unit/:id`): a leaf deletes;
23+
* - in bulk (`deleteManyData` and `batchData`'s delete case, a per-id loop in
24+
* the caller's order, its `atomic` arm one transaction): leaf to root
25+
* completes, root first refuses the root alone;
26+
* - an organization's delete: `organization_id` is a plain lookup, so the
27+
* units stay and keep their tree.
28+
*/
29+
30+
import { describe, it, expect, afterEach } from 'vitest';
31+
import { ObjectQL } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { SysBusinessUnit, SysOrganization } from '@objectstack/platform-objects/identity';
34+
35+
const SYS = { isSystem: true, userId: 'usr_admin' } as any;
36+
const ORG = 'org_qa';
37+
38+
const openEngines: ObjectQL[] = [];
39+
afterEach(async () => {
40+
while (openEngines.length) {
41+
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
42+
}
43+
});
44+
45+
/** A real engine carrying the shipped business-unit (and organization) objects. */
46+
async function boot(): Promise<ObjectQL> {
47+
const driver = new SqlDriver({
48+
client: 'better-sqlite3',
49+
connection: { filename: ':memory:' },
50+
useNullAsDefault: true,
51+
});
52+
await driver.connect();
53+
const engine = new ObjectQL();
54+
openEngines.push(engine);
55+
engine.registerDriver(driver as never, true);
56+
await engine.init();
57+
for (const def of [SysOrganization, SysBusinessUnit]) {
58+
engine.registry.registerObject(def as never, 'platform-objects', 'sys');
59+
}
60+
await engine.syncSchemas();
61+
// The organization every unit belongs to: with `sys_organization` registered,
62+
// a system write on a tenant-scoped object must carry one.
63+
await engine.insert('sys_organization', { id: ORG, name: 'QA Org', slug: 'qa-org' }, { context: SYS });
64+
return engine;
65+
}
66+
67+
/** The card's tree, one level deeper: QA EMEA → QA EMEA Office → QA EMEA Desk. */
68+
async function seedTree(engine: ObjectQL): Promise<void> {
69+
for (const [id, name, parent] of [
70+
['bu_emea', 'QA EMEA', null],
71+
['bu_office', 'QA EMEA Office', 'bu_emea'],
72+
['bu_desk', 'QA EMEA Desk', 'bu_office'],
73+
] as const) {
74+
await engine.insert(
75+
'sys_business_unit',
76+
{ id, name, kind: 'department', parent_business_unit_id: parent, organization_id: ORG },
77+
{ context: SYS },
78+
);
79+
}
80+
}
81+
82+
/** `id → parent id` for every unit still present. */
83+
async function tree(engine: ObjectQL): Promise<Record<string, string | null>> {
84+
const rows = (await engine.find('sys_business_unit', { where: {}, context: SYS })) as Array<Record<string, any>>;
85+
return Object.fromEntries(rows.map((r) => [r.id, r.parent_business_unit_id ?? null]));
86+
}
87+
88+
const del = (engine: ObjectQL, id: string, context: any = SYS) =>
89+
engine.delete('sys_business_unit', { where: { id }, context });
90+
91+
describe('#24939 a business unit with child units refuses its delete (real engine, shipped object)', () => {
92+
it('declares restrict on the parent link', () => {
93+
expect((SysBusinessUnit.fields as any).parent_business_unit_id.deleteBehavior).toBe('restrict');
94+
});
95+
96+
it('refuses a unit with children with 409 DELETE_RESTRICTED, and its children keep their parent', async () => {
97+
const engine = await boot();
98+
await seedTree(engine);
99+
100+
const err: any = await del(engine, 'bu_emea').catch((e) => e);
101+
expect(err).toBeInstanceOf(Error);
102+
expect(err.code).toBe('DELETE_RESTRICTED');
103+
expect(err.status).toBe(409);
104+
// What tells the administrator to re-parent first: the blocking object
105+
// and how many of its rows hold the link.
106+
expect(err.dependentObject).toBe('sys_business_unit');
107+
expect(err.dependentCount).toBe(1);
108+
109+
expect(await tree(engine)).toEqual({ bu_emea: null, bu_office: 'bu_emea', bu_desk: 'bu_office' });
110+
});
111+
112+
it('deletes a leaf unit', async () => {
113+
const engine = await boot();
114+
await seedTree(engine);
115+
116+
await expect(del(engine, 'bu_desk')).resolves.toBe(true);
117+
expect(await tree(engine)).toEqual({ bu_emea: null, bu_office: 'bu_emea' });
118+
});
119+
120+
it('a unit whose children were re-parented deletes, and the children keep their new parent', async () => {
121+
const engine = await boot();
122+
await seedTree(engine);
123+
await engine.update(
124+
'sys_business_unit',
125+
{ id: 'bu_desk', parent_business_unit_id: 'bu_emea' },
126+
{ where: { id: 'bu_desk' }, context: SYS } as any,
127+
);
128+
129+
await expect(del(engine, 'bu_office')).resolves.toBe(true);
130+
expect(await tree(engine)).toEqual({ bu_emea: null, bu_desk: 'bu_emea' });
131+
});
132+
133+
it('bulk, leaf to root (the per-id loop of deleteManyData / batchData) completes on the tree', async () => {
134+
const engine = await boot();
135+
await seedTree(engine);
136+
137+
for (const id of ['bu_desk', 'bu_office', 'bu_emea']) await expect(del(engine, id)).resolves.toBe(true);
138+
expect(await tree(engine)).toEqual({});
139+
});
140+
141+
it('bulk, leaf to root, in one transaction (the atomic arm) completes on the tree', async () => {
142+
const engine = await boot();
143+
await seedTree(engine);
144+
145+
await engine.transaction(async (trxCtx: any) => {
146+
for (const id of ['bu_desk', 'bu_office', 'bu_emea']) await del(engine, id, trxCtx);
147+
}, SYS);
148+
expect(await tree(engine)).toEqual({});
149+
});
150+
151+
it('bulk, root first: the root alone is refused, and its subtree is left whole', async () => {
152+
const engine = await boot();
153+
await seedTree(engine);
154+
155+
const first: any = await del(engine, 'bu_emea').catch((e) => e);
156+
expect(first.code).toBe('DELETE_RESTRICTED');
157+
expect(first.status).toBe(409);
158+
expect(await tree(engine)).toEqual({ bu_emea: null, bu_office: 'bu_emea', bu_desk: 'bu_office' });
159+
});
160+
161+
it("an organization's delete completes on a tree: its units stay, and keep their parents", async () => {
162+
const engine = await boot();
163+
await seedTree(engine);
164+
165+
await expect(engine.delete('sys_organization', { where: { id: ORG }, context: SYS })).resolves.toBe(true);
166+
expect(await tree(engine)).toEqual({ bu_emea: null, bu_office: 'bu_emea', bu_desk: 'bu_office' });
167+
});
168+
});

0 commit comments

Comments
 (0)