|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #14238 — the organization hierarchy carries the IANA zone a date boundary is |
| 5 | + * computed in. Maintainer ruling 2026-09-02 (option A, verbatim 「同意」): |
| 6 | + * `sys_business_unit` gains a nullable `timezone` that inherits down the |
| 7 | + * `parent_business_unit_id` chain, and `sys_organization` gains `timezone` as |
| 8 | + * that chain's root default. No resolver API (option B waits for a second |
| 9 | + * consumer); `sys_user` is not the home (option C refused). |
| 10 | + * |
| 11 | + * This file pins the DECLARED shape, in three parts: |
| 12 | + * |
| 13 | + * 1. both columns exist, on `text`, optional, and declare |
| 14 | + * `valueDomain: 'iana_time_zone'` — the ruling's own precondition: 「if not, |
| 15 | + * the engine seat sequences this card behind it rather than shipping an |
| 16 | + * unvalidated text column」. A column that lost the declaration would be |
| 17 | + * exactly the shape the ruling refused, and every other assertion here |
| 18 | + * would still pass over it; |
| 19 | + * 2. the two columns are ONE shape — same type, optionality, bound and domain, |
| 20 | + * no default on either. The card's thesis is that every author invents this |
| 21 | + * column differently, and the platform's own two precedents |
| 22 | + * (`sys_job.timezone`: 100, no default; `sys_report_schedule.timezone`: 64, |
| 23 | + * default `UTC`; neither validated) already disagree in three dimensions. |
| 24 | + * The ruled pair must not become a third and a fourth spelling; |
| 25 | + * 3. the declared domain admits `UTC`, the fallback the contract names for a |
| 26 | + * wholly unset chain, and the declared bound admits every zone the runtime |
| 27 | + * enumerates. Why the first is not automatic — `Intl.supportedValuesOf` |
| 28 | + * omits `UTC`, so a column judged against the enumeration would refuse the |
| 29 | + * platform's own default — is measured and pinned beside the predicate in |
| 30 | + * `packages/spec` (`value-domain.test.ts`), once; this file asks the |
| 31 | + * predicate the column actually inherits and does not re-implement it. |
| 32 | + * |
| 33 | + * The write-path half — a non-member WRITTEN to either column is refused with |
| 34 | + * the ADR-0114 code `value_domain` — lives in plugin-auth's |
| 35 | + * `org-hierarchy-timezone-write-contract.test.ts`, the one package that depends |
| 36 | + * on both the columns and the evaluator. |
| 37 | + */ |
| 38 | + |
| 39 | +import { describe, it, expect } from 'vitest'; |
| 40 | +import { isValueDomainMember } from '@objectstack/spec/shared'; |
| 41 | +import { SysBusinessUnit } from './sys-business-unit.object'; |
| 42 | +import { SysOrganization } from './sys-organization.object'; |
| 43 | + |
| 44 | +type ColumnShape = { |
| 45 | + type?: unknown; |
| 46 | + required?: unknown; |
| 47 | + maxLength?: unknown; |
| 48 | + valueDomain?: unknown; |
| 49 | + defaultValue?: unknown; |
| 50 | + readonly?: unknown; |
| 51 | + group?: unknown; |
| 52 | +}; |
| 53 | + |
| 54 | +const unitColumn = () => (SysBusinessUnit.fields as Record<string, ColumnShape>).timezone; |
| 55 | +const orgColumn = () => (SysOrganization.fields as Record<string, ColumnShape>).timezone; |
| 56 | + |
| 57 | +/** The keys on which the two ruled columns must agree — "one spelling". */ |
| 58 | +const SHAPE_KEYS = ['type', 'required', 'maxLength', 'valueDomain', 'defaultValue', 'readonly'] as const; |
| 59 | + |
| 60 | +describe('#14238 — sys_business_unit.timezone and sys_organization.timezone', () => { |
| 61 | + it('reads the real declarations, not an empty probe', () => { |
| 62 | + // Vacuity control: a renamed column or a changed export would otherwise let |
| 63 | + // every assertion below pass over `undefined`. |
| 64 | + expect(SysBusinessUnit.name).toBe('sys_business_unit'); |
| 65 | + expect(SysOrganization.name).toBe('sys_organization'); |
| 66 | + expect(unitColumn()).toBeTypeOf('object'); |
| 67 | + expect(orgColumn()).toBeTypeOf('object'); |
| 68 | + }); |
| 69 | + |
| 70 | + it.each([ |
| 71 | + ['sys_business_unit', unitColumn], |
| 72 | + ['sys_organization', orgColumn], |
| 73 | + ])('%s.timezone is an optional, domain-validated, bounded text column', (_object, column) => { |
| 74 | + const c = column(); |
| 75 | + expect(c.type).toBe('text'); |
| 76 | + // Nullable, by the ruling's word: on the unit "null" means INHERIT, on the |
| 77 | + // organization it means UTC. `required: true` would make both meanings |
| 78 | + // unreachable. |
| 79 | + expect(c.required).toBe(false); |
| 80 | + // The ruling's precondition — the one line that turns an unvalidated text |
| 81 | + // column into a validated one. `VALUE_DOMAIN_FIELD_TYPES` is `{text}`, so |
| 82 | + // the declaration is also the reason the type above must stay `text`. |
| 83 | + expect(c.valueDomain).toBe('iana_time_zone'); |
| 84 | + expect(c.maxLength).toBe(64); |
| 85 | + // No schema default on either column, deliberately: on the unit a default |
| 86 | + // would mean "stop inheriting"; on the organization it would give UTC two |
| 87 | + // spellings (unset on rows that predate the column, 'UTC' on rows minted |
| 88 | + // after it). The contract has one: unset resolves to UTC. |
| 89 | + expect('defaultValue' in c).toBe(false); |
| 90 | + // `stripReadonlyFields` runs on the update path BEFORE the validator, so a |
| 91 | + // readonly column is one an administrator could never set — and the root |
| 92 | + // default is, by the ruling's word, a value an administrator sets. |
| 93 | + expect(c.readonly ?? false).toBe(false); |
| 94 | + }); |
| 95 | + |
| 96 | + it('the two columns are ONE shape — the platform does not invent it twice', () => { |
| 97 | + const pick = (c: ColumnShape) => Object.fromEntries(SHAPE_KEYS.map((k) => [k, c[k]])); |
| 98 | + expect(pick(unitColumn())).toEqual(pick(orgColumn())); |
| 99 | + }); |
| 100 | + |
| 101 | + it('the unit column lives in the Hierarchy group — it is resolved along the hierarchy', () => { |
| 102 | + expect(unitColumn().group).toBe('Hierarchy'); |
| 103 | + }); |
| 104 | + |
| 105 | + it('the declared domain admits the fallback the contract names (`UTC`) and refuses a zone that does not exist', () => { |
| 106 | + // Asked of the predicate the write path calls (`isValueDomainMember`), under |
| 107 | + // the domain the column actually declares — not of a re-implementation. |
| 108 | + const domain = unitColumn().valueDomain as 'iana_time_zone'; |
| 109 | + expect(isValueDomainMember(domain, 'UTC')).toBe(true); |
| 110 | + expect(isValueDomainMember(domain, 'Asia/Shanghai')).toBe(true); |
| 111 | + // Shape-valid and nonexistent — the case a `pattern` cannot refuse. |
| 112 | + expect(isValueDomainMember(domain, 'Mars/Olympus')).toBe(false); |
| 113 | + }); |
| 114 | + |
| 115 | + it('the declared bound admits every zone the runtime enumerates, with room for the tzdb links it omits', () => { |
| 116 | + // A sourced bound, not an alignment convenience: the enumeration's longest |
| 117 | + // name on the repo's Node baseline is 30 characters and the tzdb caps each |
| 118 | + // path component at 14, so 64 is twice the domain's real ceiling. If a |
| 119 | + // future ICU ever enumerates a name the bound refuses, this goes red |
| 120 | + // instead of the column silently refusing a legal zone. |
| 121 | + const longest = Math.max(...Intl.supportedValuesOf('timeZone').map((z) => z.length)); |
| 122 | + expect(longest).toBeLessThanOrEqual(unitColumn().maxLength as number); |
| 123 | + // The longest identifier in the tzdb itself is a backward link the |
| 124 | + // enumeration omits and the probe admits — 32 characters, still under half |
| 125 | + // the bound. |
| 126 | + const longestLink = 'America/Argentina/ComodRivadavia'; |
| 127 | + expect(longestLink.length).toBe(32); |
| 128 | + expect(isValueDomainMember('iana_time_zone', longestLink)).toBe(true); |
| 129 | + expect(longestLink.length).toBeLessThanOrEqual(unitColumn().maxLength as number); |
| 130 | + }); |
| 131 | +}); |
0 commit comments