Skip to content

Commit 5f0ffc5

Browse files
committed
test(dogfood): write shipped-item customizations into the .custom pair
ADR-0146 D11 closes the package-less write onto a packaged item, so the dogfood suites that customized a shipped view or email template with a package-less PUT or DELETE now ensure the base's pair and write `?package=<base>.custom`, declaring the override. A shared helper, test/customization-pair.ts, holds that write. The two-locales email file keeps its second locale as a row stored before the close: the showcase ships `en-US` only, the pair cannot declare an override of `zh-CN` (#28746), and the pair's own `zh-CN` member is not listed for a send today. Its package-less save and reset are pinned refused. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5ni5yjiTswP6crgAkX
1 parent 359c796 commit 5f0ffc5

12 files changed

Lines changed: 191 additions & 42 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item.
4+
// `PUT` or `DELETE /meta/<type>/<name>` with no `?package=`, for a view,
5+
// dashboard, report, translation or email template that a read-only package
6+
// ships, now answers `403 NOT_OVERRIDABLE`. An administrator's customization
7+
// of such an item goes into the base's `.custom` pair instead, declaring the
8+
// item it overrides. These helpers are that write, for the suites that
9+
// customize a shipped item on a real boot.
10+
11+
import { expect } from 'vitest';
12+
import type { VerifyStack } from '@objectstack/verify';
13+
14+
/** The `.custom` pair of a base package. */
15+
export const pairOf = (base: string): string => `${base}.custom`;
16+
17+
/** Ensure the base's pair exists (`201` the first time, `200` after). */
18+
export async function ensurePair(stack: VerifyStack, token: string, base: string): Promise<string> {
19+
const res = await stack.apiAs(token, 'POST', `/packages/${base}/ensure-customization`);
20+
expect([200, 201], await res.clone().text()).toContain(res.status);
21+
return pairOf(base);
22+
}
23+
24+
/** `path` addressed to the base's pair (`package=<base>.custom` added to its query). */
25+
export const inPair = (path: string, base: string): string =>
26+
`${path}${path.includes('?') ? '&' : '?'}package=${pairOf(base)}`;
27+
28+
/** `body` declared as the pair's override of the item `base` ships at `version`. */
29+
export const declaredOverride = <T extends Record<string, unknown>>(body: T, base: string, version: string) =>
30+
({ ...body, overrides: { package: base, basedOn: version } });

‎packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@
3535

3636
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3737
import { bootStack, type VerifyStack } from '@objectstack/verify';
38+
import { declaredOverride, ensurePair, inPair } from './customization-pair.js';
3839
import { EmailServicePlugin } from '@objectstack/plugin-email';
3940
import { emailTemplateFixtureStack, etPasswordReset } from './fixtures/email-template-materialization-fixture.js';
4041

42+
/** The fixture app that ships the authored `auth.password_reset`. */
43+
const FIXTURE = 'com.dogfood.email-template-fixture';
4144
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
4245

4346
/**
@@ -120,10 +123,13 @@ describe('objectstack verify EMAIL TEMPLATE: authored template materializes and
120123

121124
it('a Studio save over the authored template sends on the very next mail, with no restart', async () => {
122125
const token = await stack.signIn();
123-
const put = await stack.apiAs(token, 'PUT', '/meta/email_template/auth.password_reset', {
126+
// [#28576] ADR-0146 D11: the Studio save of a packaged item goes into the
127+
// app's `.custom` pair, declaring the app's template it overrides.
128+
await ensurePair(stack, token, FIXTURE);
129+
const put = await stack.apiAs(token, 'PUT', inPair('/meta/email_template/auth.password_reset', FIXTURE), declaredOverride({
124130
...etPasswordReset,
125131
subject: 'Studio reset for {{user.name}}',
126-
});
132+
}, FIXTURE, '0.0.0'));
127133
expect(put.status, await put.clone().text()).toBe(200);
128134

129135
const email = (await stack.kernel.getServiceAsync('email')) as any;

‎packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@
4343
// §6 Q1) — an edit and a create through `/data/sys_email_template` are
4444
// refused 403 `PERMISSION_DENIED`, nothing is written, and the
4545
// metadata-door wording still sends after the next cold boot.
46+
//
47+
// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item, so
48+
// the admin's edit goes into the showcase's `.custom` pair, declaring the
49+
// template it overrides. The pair's row is the one stored row, still
50+
// environment-wide, and it is what the mailer reads.
4651

4752
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4853
import { type VerifyStack } from '@objectstack/verify';
4954
import { bootShowcase } from './showcase-boot.js';
55+
import { declaredOverride, ensurePair, inPair } from './customization-pair.js';
5056
import { EmailServicePlugin } from '@objectstack/plugin-email';
5157
import { fileURLToPath } from 'node:url';
5258
import { mkdtempSync, rmSync } from 'node:fs';
@@ -59,6 +65,7 @@ const SYS = { isSystem: true, positions: [], permissions: [] };
5965

6066
/** The one template the showcase package declares (`com.example.showcase`). */
6167
const NAME = 'showcase_task_done_email';
68+
const BASE = 'com.example.showcase';
6269
const PACKAGE_SUBJECT = '✅ Task done: {{title}}';
6370
const ADMIN_SUBJECT = 'Done (reworded by the admin): {{title}}';
6471

@@ -118,6 +125,7 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho
118125
dbFile = join(dir, 'showcase.db');
119126
stack = await boot(dbFile);
120127
token = await stack.signIn();
128+
await ensurePair(stack, token, BASE);
121129
}, 180_000);
122130

123131
afterAll(async () => {
@@ -140,7 +148,7 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho
140148
if (!k.startsWith('_')) body[k] = v;
141149
}
142150
expect(body.subject).toBe(PACKAGE_SUBJECT);
143-
const put = await call('PUT', `/meta/email_template/${NAME}`, { ...body, subject: ADMIN_SUBJECT });
151+
const put = await call('PUT', inPair(`/meta/email_template/${NAME}`, BASE), declaredOverride({ ...body, subject: ADMIN_SUBJECT }, BASE, '0.1.0'));
144152
expect(put.status, JSON.stringify(put.json)).toBe(200);
145153

146154
// [ADR-0131 D6] The door names no organization, whatever the admin's.

‎packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts‎

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,34 @@
2121
//
2222
// ## What each case pins
2323
//
24-
// - two locales saved through the metadata door are two rows (the canonical
25-
// member's NULL and `zh-CN`), and each sends its own wording at once;
24+
// - two customized locales are two rows (the canonical member's NULL and
25+
// `zh-CN`), and each sends its own wording at once;
2626
// - after a cold boot each locale still sends its own wording, and the
2727
// by-name read serves the canonical member's;
2828
// - CONTROL: a locale nobody saved keeps the package wording, and a
2929
// single-locale re-save is still one row per member;
3030
// - [#22877 stage 2] the by-name member selector: `?member=zh-CN` reads
31-
// that locale's wording, `DELETE ?member=zh-CN` resets that locale alone
32-
// and, after a cold boot, each locale serves the expected wording; a
33-
// misspelled or misplaced selector is a located 400.
31+
// that locale's wording, a `DELETE` with `?member=en-US` resets that
32+
// locale alone and, after a cold boot, each locale serves the expected
33+
// wording; a misspelled or misplaced selector is a located 400.
34+
//
35+
// [#28576] ADR-0146 D11 closed the package-less write onto a packaged item.
36+
// The showcase ships this template in `en-US` only, so:
37+
//
38+
// - the `en-US` customization is saved into the showcase's `.custom` pair,
39+
// declaring the override, and its reset names the pair;
40+
// - the `zh-CN` row is laid down as a release before the close stored it,
41+
// package-less with the member `zh-CN`. Such a row stays served (each
42+
// locale still sends its own wording), and its package-less save and reset
43+
// are refused naming the ceremony. It cannot be written into the pair as
44+
// an override (the base ships no `zh-CN`, #28746), and the pair's own
45+
// `zh-CN` member is read by name but not listed for a send (#28779's
46+
// question), so a pre-close row is how this file holds a second locale.
3447

3548
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3649
import { type VerifyStack } from '@objectstack/verify';
3750
import { bootShowcase } from './showcase-boot.js';
51+
import { declaredOverride, ensurePair, inPair } from './customization-pair.js';
3852
import { EmailServicePlugin } from '@objectstack/plugin-email';
3953
import { fileURLToPath } from 'node:url';
4054
import { mkdtempSync, rmSync } from 'node:fs';
@@ -47,9 +61,13 @@ const SYS = { isSystem: true, positions: [], permissions: [] };
4761

4862
/** The template the showcase package declares (`com.example.showcase`), in `en-US`. */
4963
const NAME = 'showcase_task_done_email';
64+
const BASE = 'com.example.showcase';
65+
const PAIR = 'com.example.showcase.custom';
5066
const PACKAGE_SUBJECT = '✅ Task done: {{title}}';
5167
const ADMIN_EN = 'Done (admin, English): {{title}}';
5268
const ADMIN_ZH = '已完成(管理员中文):{{title}}';
69+
/** The refusal's code — the save door answers it beside a string `error`, an enveloped door inside `error`. */
70+
const codeOf = (json: any): unknown => json?.code ?? json?.error?.code;
5371

5472
/** What reached the wire — `sendTemplate` returns a delivery result, not the rendered message. */
5573
const sent: Array<{ subject?: string }> = [];
@@ -102,9 +120,22 @@ describe('[#22877] two locales of one email template survive a cold boot (showca
102120
context: SYS,
103121
});
104122
return rows
105-
.map((r) => ({ member: r.bundle_member ?? null, subject: JSON.parse(String(r.metadata)).subject }))
123+
.map((r) => ({ member: r.bundle_member ?? null, packageId: r.package_id ?? null, subject: JSON.parse(String(r.metadata)).subject }))
106124
.sort((a, b) => String(a.member).localeCompare(String(b.member)));
107125
};
126+
/** The `en-US` customization, saved into the pair as the declared override of the base's member. */
127+
const saveEnglish = (served: Record<string, unknown>, subject: string) => {
128+
const { overrides: _declared, ...doc }: Record<string, unknown> = { ...served, locale: 'en-US', subject };
129+
return call('PUT', inPair(`/meta/email_template/${NAME}`, BASE), declaredOverride(doc, BASE, '0.1.0'));
130+
};
131+
/** A package-less `zh-CN` row, laid down as a release before ADR-0146 D11 stored it. */
132+
const seedPreCloseChinese = async (served: Record<string, unknown>, subject: string) => {
133+
const { overrides: _declared, ...doc }: Record<string, unknown> = { ...served, locale: 'zh-CN', subject };
134+
const protocol: any = await stack!.kernel.getServiceAsync('protocol');
135+
await protocol.getOverlayRepo().put({ type: 'email_template', name: NAME, org: 'env' }, doc, {
136+
parentVersion: null, actor: null, source: 'older-release', intent: 'override-artifact', packageId: null,
137+
});
138+
};
108139
const restart = async () => {
109140
await stack!.stop();
110141
stack = await boot(dbFile);
@@ -118,6 +149,7 @@ describe('[#22877] two locales of one email template survive a cold boot (showca
118149
dbFile = join(dir, 'showcase.db');
119150
stack = await boot(dbFile);
120151
token = await stack.signIn();
152+
await ensurePair(stack, token, BASE);
121153
}, 180_000);
122154

123155
afterAll(async () => {
@@ -126,19 +158,18 @@ describe('[#22877] two locales of one email template survive a cold boot (showca
126158
if (dir) rmSync(dir, { recursive: true, force: true });
127159
});
128160

129-
it('two locales saved through the metadata door are two rows, and each sends its own wording at once', async () => {
161+
it('two locales customized are two rows, the pair\'s `en-US` and a pre-close `zh-CN`, and each sends its own wording at once', async () => {
130162
const served = await metaBody();
131163
expect(served.status).toBe(200);
132164
expect(served.body.subject).toBe(PACKAGE_SUBJECT);
133165

134-
const en = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'en-US', subject: ADMIN_EN });
166+
const en = await saveEnglish(served.body, ADMIN_EN);
135167
expect(en.status, JSON.stringify(en.json)).toBe(200);
136-
const zh = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'zh-CN', subject: ADMIN_ZH });
137-
expect(zh.status, JSON.stringify(zh.json)).toBe(200);
168+
await seedPreCloseChinese(served.body, ADMIN_ZH);
138169

139170
expect(await overlayRows()).toEqual([
140-
{ member: null, subject: ADMIN_EN },
141-
{ member: 'zh-CN', subject: ADMIN_ZH },
171+
{ member: null, packageId: PAIR, subject: ADMIN_EN },
172+
{ member: 'zh-CN', packageId: null, subject: ADMIN_ZH },
142173
]);
143174
expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it');
144175
expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文):Ship it');
@@ -154,39 +185,48 @@ describe('[#22877] two locales of one email template survive a cold boot (showca
154185
expect(await overlayRows()).toHaveLength(2);
155186
}, 180_000);
156187

157-
it('control: a locale nobody saved keeps the package ladder, and a re-save of one locale is still one row per member', async () => {
188+
it('control: a locale nobody saved keeps the package ladder, a re-save is still one row per member, and the pre-close row\'s package-less re-save is refused', async () => {
158189
// `ja-JP` is not in the bundle: the ladder falls to `en-US`, the admin's.
159190
expect(await sendSubject('ja-JP')).toBe('Done (admin, English): Ship it');
160191

161192
const served = await metaBody();
162-
const again = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, locale: 'zh-CN', subject: ADMIN_ZH.replace(':', '(二):') });
193+
const again = await saveEnglish(served.body, ADMIN_EN);
163194
expect(again.status, JSON.stringify(again.json)).toBe(200);
195+
// [#28576] The pre-close row is served, and its package-less write is closed.
196+
const zhAgain = await call('PUT', `/meta/email_template/${NAME}`, { ...served.body, overrides: undefined, locale: 'zh-CN', subject: ADMIN_ZH.replace(':', '(二):') });
197+
expect(zhAgain.status, JSON.stringify(zhAgain.json)).toBe(403);
198+
expect(codeOf(zhAgain.json)).toBe('NOT_OVERRIDABLE');
199+
expect(JSON.stringify(zhAgain.json)).toContain('os migrate packageless-overlays');
164200
expect((await overlayRows()).map((r) => r.member)).toEqual([null, 'zh-CN']);
165201

166202
await restart();
167203
expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it');
168-
expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文)(二):Ship it');
204+
expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文):Ship it');
169205
}, 180_000);
170206

171207
it('[stage 2] one locale is read and reset by name with the member selector, and each locale serves its wording after a cold boot', async () => {
172208
// State from the cases above: `en-US` and `zh-CN` both customized.
173-
expect(await metaBody('?member=zh-CN')).toMatchObject({ status: 200, body: { subject: ADMIN_ZH.replace(':', '(二):') } });
209+
expect(await metaBody('?member=zh-CN')).toMatchObject({ status: 200, body: { subject: ADMIN_ZH } });
174210
expect(await metaBody('?member=en-US')).toMatchObject({ status: 200, body: { subject: ADMIN_EN } });
175211

176-
const reset = await call('DELETE', `/meta/email_template/${NAME}?member=zh-CN`);
212+
// [#28576] The pre-close row's package-less reset is closed, naming the ceremony.
213+
const closed = await call('DELETE', `/meta/email_template/${NAME}?member=zh-CN`);
214+
expect(closed.status, JSON.stringify(closed.json)).toBe(403);
215+
expect(codeOf(closed.json)).toBe('NOT_OVERRIDABLE');
216+
expect(JSON.stringify(closed.json)).toContain('os migrate packageless-overlays');
217+
218+
const reset = await call('DELETE', inPair(`/meta/email_template/${NAME}?member=en-US`, BASE));
177219
expect(reset.status, JSON.stringify(reset.json)).toBe(200);
178220
expect(reset.json).toMatchObject({ success: true, reset: true });
179-
expect(await overlayRows()).toEqual([{ member: null, subject: ADMIN_EN }]);
180-
// The package ships `en-US` only, so `zh-CN` now falls to the floor —
181-
// the admin's English, untouched by the reset.
182-
expect(await sendSubject('zh-CN')).toBe('Done (admin, English): Ship it');
221+
expect(await overlayRows()).toEqual([{ member: 'zh-CN', packageId: null, subject: ADMIN_ZH }]);
222+
// `en-US` is the package's wording again; `zh-CN` keeps its own, untouched by the reset.
223+
expect(await sendSubject('en-US')).toBe('✅ Task done: Ship it');
224+
expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文):Ship it');
183225

184226
await restart();
185-
expect(await sendSubject('en-US')).toBe('Done (admin, English): Ship it');
186-
expect(await sendSubject('zh-CN')).toBe('Done (admin, English): Ship it');
187-
expect(await metaBody()).toMatchObject({ status: 200, body: { subject: ADMIN_EN } });
188-
// Nothing stores or ships `zh-CN` now: the read answers it as absent.
189-
expect((await metaBody('?member=zh-CN')).status).toBe(404);
227+
expect(await sendSubject('en-US')).toBe('✅ Task done: Ship it');
228+
expect(await sendSubject('zh-CN')).toBe('已完成(管理员中文):Ship it');
229+
expect(await metaBody()).toMatchObject({ status: 200, body: { subject: PACKAGE_SUBJECT } });
190230

191231
// A located 400 for a misspelled selector, and for one on a type with no members.
192232
const misspelled = await call('GET', `/meta/email_template/${NAME}?memeber=zh-CN`);

‎packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
// 403 `NOT_OVERRIDABLE`, nothing written, nothing removed.
2323
//
2424
// The controls — what the seal leaves open, measured on the same boot:
25-
// - a regime-O overlay: a packaged VIEW is overlaid and its overlay removed;
25+
// - a regime-O customization: a packaged VIEW is customized in its base's
26+
// `.custom` pair and the customization removed (#28576: the package-less
27+
// write onto it is closed);
2628
// - a NEW flow, through `PUT /meta/flow/:name` and through `POST /automation`
2729
// (the card's positive control: creating a new flow in Studio works);
2830
// - the switch: `POST /automation/:name/toggle` turns the managed flow off and
@@ -190,11 +192,21 @@ describe('[ADR-0131 D6] managed content is sealed — the hatch opens nothing a
190192
}
191193
});
192194

193-
it('control (regime O): a packaged VIEW is overlaid, and its overlay removed', async () => {
195+
it('control (regime O): a packaged VIEW is customized in its base\'s pair, and the customization removed', async () => {
196+
// [#28576, ADR-0146 D11] The write naming no package onto it is closed,
197+
// naming the pair; the customization lives in `com.example.crm.custom`.
194198
const view = authorable(await served('view', VIEW));
195-
const put = await call('PUT', `/meta/view/${VIEW}`, { ...view, label: 'My Opportunities' });
199+
const closed = await call('PUT', `/meta/view/${VIEW}`, { ...view, label: 'My Opportunities' });
200+
expect([closed.status, closed.code], closed.text).toEqual([403, 'NOT_OVERRIDABLE']);
201+
expect(closed.text).toContain("'com.example.crm.custom' (?package=com.example.crm.custom)");
202+
const pair = await call('POST', '/packages/com.example.crm/ensure-customization');
203+
expect([200, 201], pair.text).toContain(pair.status);
204+
const put = await call('PUT', `/meta/view/${VIEW}?package=com.example.crm.custom`, {
205+
...view, label: 'My Opportunities', overrides: { package: 'com.example.crm', basedOn: '4.0.0' },
206+
});
196207
expect(put.status, put.text).toBe(200);
197-
const del = await call('DELETE', `/meta/view/${VIEW}`);
208+
expect((await served('view', VIEW)).label).toBe('My Opportunities');
209+
const del = await call('DELETE', `/meta/view/${VIEW}?package=com.example.crm.custom`);
198210
expect(del.status, del.text).toBe(200);
199211
expect(del.text).toContain('reset to artifact default');
200212
});

0 commit comments

Comments
 (0)