@@ -61,6 +61,10 @@ interface MountOptions {
6161 rules ?: Row [ ] | null ;
6262 /** Tenant-scoped `localization.locale` `sys_setting` row value (rung 3). */
6363 settingLocale ?: string ;
64+ /** Tenant-scoped `localization.timezone` `sys_setting` row value. */
65+ settingTimezone ?: string ;
66+ /** Tenant-scoped `localization.currency` `sys_setting` row value. */
67+ settingCurrency ?: string ;
6468 /** Whether a session resolves at all. */
6569 authenticated ?: boolean ;
6670 /**
@@ -73,7 +77,7 @@ interface MountOptions {
7377 failUserRead ?: boolean ;
7478}
7579
76- function mount ( { storedLocale, rules = [ LOCALE_SHAPE_RULE ] , settingLocale, authenticated = true , failUserRead = false } : MountOptions = { } ) {
80+ function mount ( { storedLocale, rules = [ LOCALE_SHAPE_RULE ] , settingLocale, settingTimezone , settingCurrency , authenticated = true , failUserRead = false } : MountOptions = { } ) {
7781 const reads : Array < { object : string ; opts : any } > = [ ] ;
7882 let sysUserReads = 0 ;
7983 const ql = {
@@ -83,8 +87,18 @@ function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, authe
8387 if ( failUserRead && ++ sysUserReads > 1 ) throw new Error ( 'sys_user unavailable' ) ;
8488 return opts ?. where ?. id === USER ? [ { id : USER , email : 'lang@example.com' , locale : storedLocale } ] : [ ] ;
8589 }
86- if ( object === 'sys_setting' && settingLocale !== undefined ) {
87- return [ { namespace : 'localization' , key : 'locale' , value : settingLocale , scope : 'tenant' } ] ;
90+ if ( object === 'sys_setting' ) {
91+ // The endpoint reads all three `localization` keys in ONE `$in`
92+ // query, so the double answers whichever of them the fixture
93+ // configured — and nothing for the rest.
94+ const configured : Array < [ string , string | undefined ] > = [
95+ [ 'locale' , settingLocale ] ,
96+ [ 'timezone' , settingTimezone ] ,
97+ [ 'currency' , settingCurrency ] ,
98+ ] ;
99+ return configured
100+ . filter ( ( [ , value ] ) => value !== undefined )
101+ . map ( ( [ key , value ] ) => ( { namespace : 'localization' , key, value, scope : 'tenant' } ) ) ;
88102 }
89103 return [ ] ;
90104 } ,
@@ -215,3 +229,53 @@ describe('/auth/me/localization — the signed-in user\'s language, three rungs
215229 expect ( reads . filter ( ( r ) => r . object === 'sys_user' ) ) . toEqual ( [ ] ) ;
216230 } ) ;
217231} ) ;
232+
233+ describe ( '/auth/me/localization — the regional defaults are RESOLVED, not always null (#15387)' , ( ) => {
234+ it ( 'answers the tenant\'s configured currency and time zone' , async ( ) => {
235+ const { get } = mount ( { settingTimezone : 'Asia/Shanghai' , settingCurrency : 'CNY' , settingLocale : 'zh-CN' } ) ;
236+ const { status, body } = await get ( ) ;
237+ expect ( status ) . toBe ( 200 ) ;
238+ // The WHOLE published shape, so a fourth key cannot appear unnoticed.
239+ expect ( body ) . toEqual ( { authenticated : true , currency : 'CNY' , locale : 'zh-CN' , timezone : 'Asia/Shanghai' } ) ;
240+ } ) ;
241+
242+ it ( 'resolves them independently of which locale rung won — they are not a by-product of the language cascade' , async ( ) => {
243+ // Rung 1 answers the language, so the deployment cascade does NOT decide
244+ // `locale` here. Before this card that short-circuit was the only reason
245+ // the cascade was consulted at all, and `currency` / `timezone` came off
246+ // an ExecutionContext that never carried them.
247+ const { get } = mount ( { storedLocale : 'ja-JP' , settingLocale : 'zh-CN' , settingTimezone : 'Europe/Paris' , settingCurrency : 'eur' } ) ;
248+ const { body } = await get ( 'de-DE' ) ;
249+ // `eur` lower-case: the cascade's own coercion upper-cases a 3-letter code.
250+ expect ( body ) . toEqual ( { authenticated : true , currency : 'EUR' , locale : 'ja-JP' , timezone : 'Europe/Paris' } ) ;
251+ } ) ;
252+
253+ it ( 'a value the cascade refuses falls to the cascade\'s own answer — this surface adds no second parser' , async ( ) => {
254+ // `coerceCurrency` takes exactly three letters; `coerceTimeZone` takes an
255+ // `iana_time_zone` domain member. Neither refusal is re-implemented here:
256+ // the endpoint answers whatever the shared resolver answers.
257+ const { get } = mount ( { settingTimezone : 'Middle/Earth' , settingCurrency : 'euro' } ) ;
258+ const { body } = await get ( ) ;
259+ expect ( body . timezone ) . toBe ( 'UTC' ) ;
260+ expect ( body . currency ) . toBeNull ( ) ;
261+ } ) ;
262+
263+ it ( 'with nothing configured: the time-zone floor answers, and currency is the one value that stays null' , async ( ) => {
264+ // The asymmetry is the cascade's, and it is deliberate to pin: `timezone`
265+ // has a floor (`UTC`) so an authenticated caller can never see null for
266+ // it again, while `currency` has none — a deployment that configures no
267+ // currency has no reference currency, and inventing one would be a wrong
268+ // answer rather than a missing one.
269+ const { get } = mount ( { } ) ;
270+ const { body } = await get ( ) ;
271+ expect ( body ) . toEqual ( { authenticated : true , currency : null , locale : 'en-US' , timezone : 'UTC' } ) ;
272+ } ) ;
273+
274+ it ( 'the unauthenticated answer stays localization-free' , async ( ) => {
275+ const { get, reads } = mount ( { authenticated : false , settingTimezone : 'Asia/Shanghai' , settingCurrency : 'CNY' } ) ;
276+ const { body } = await get ( ) ;
277+ expect ( body ) . toEqual ( { authenticated : false } ) ;
278+ // Anti-vacuity: no settings read is issued for a caller with no session.
279+ expect ( reads . filter ( ( r ) => r . object === 'sys_setting' ) ) . toEqual ( [ ] ) ;
280+ } ) ;
281+ } ) ;
0 commit comments