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
3548import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
3649import { type VerifyStack } from '@objectstack/verify' ;
3750import { bootShowcase } from './showcase-boot.js' ;
51+ import { declaredOverride , ensurePair , inPair } from './customization-pair.js' ;
3852import { EmailServicePlugin } from '@objectstack/plugin-email' ;
3953import { fileURLToPath } from 'node:url' ;
4054import { 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`. */
4963const NAME = 'showcase_task_done_email' ;
64+ const BASE = 'com.example.showcase' ;
65+ const PAIR = 'com.example.showcase.custom' ;
5066const PACKAGE_SUBJECT = '✅ Task done: {{title}}' ;
5167const ADMIN_EN = 'Done (admin, English): {{title}}' ;
5268const 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. */
5573const 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` ) ;
0 commit comments