|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#5676] `DiscoverySchema.environment` ⊂ `EnvironmentTypeSchema`. |
| 5 | + * |
| 6 | + * One concept — "which kind of environment is this" — is declared by two enums |
| 7 | + * in this package: |
| 8 | + * |
| 9 | + * | declaration | members | |
| 10 | + * |:---|:---| |
| 11 | + * | `DiscoveryEnvironmentSchema` (`api/discovery.zod.ts`) | `production` `sandbox` `development` | |
| 12 | + * | `EnvironmentTypeSchema` (`cloud/environment.zod.ts`) | those three + `test` `staging` `preview` `trial` | |
| 13 | + * |
| 14 | + * Keeping both is the ruled outcome, not a defect: discovery answers the coarse |
| 15 | + * question ("am I talking to production?") on a machine-readable surface whose |
| 16 | + * consumers `switch` over three values, so widening it would be a breaking |
| 17 | + * change to a RESPONSE enum. #4828 introduced the lossy fold that makes the two |
| 18 | + * co-exist (`resolveDiscoveryEnvironment`: `staging` → `sandbox`, `test` → |
| 19 | + * `development`), and the maintainer's 2026-08-05 ruling requires every producer |
| 20 | + * to land inside the three. |
| 21 | + * |
| 22 | + * What was missing is the thing that makes "subset" a FACT rather than a comment: |
| 23 | + * nothing referenced one enum from the other, so a rename or a removal on the |
| 24 | + * seven-member side would leave the three-member side silently claiming a |
| 25 | + * membership it no longer has. The prose cross-references now run both ways |
| 26 | + * (`discovery.zod.ts`'s `.describe()` since #4828; `environment.zod.ts`'s JSDoc |
| 27 | + * since this pin) — and prose is unassertable, which is what this file is for. |
| 28 | + * |
| 29 | + * ⛔ Scope: this pins the RELATION only. It deliberately does not pin either |
| 30 | + * enum's exact membership — `EnvironmentTypeSchema` is free to grow a new |
| 31 | + * bucket, and a change-detector here would just tax that. What must never |
| 32 | + * happen silently is the three drifting OUT of the seven. |
| 33 | + * |
| 34 | + * Every assertion carries an anti-vacuity guard, because the failure mode of a |
| 35 | + * subset test is passing on an empty left-hand side. |
| 36 | + */ |
| 37 | + |
| 38 | +import { describe, it, expect } from 'vitest'; |
| 39 | + |
| 40 | +import { EnvironmentTypeSchema } from '../cloud/environment.zod'; |
| 41 | + |
| 42 | +import { DiscoveryEnvironmentSchema } from './discovery.zod'; |
| 43 | + |
| 44 | +/** `.options` through the `lazySchema` Proxy — read once, asserted below. */ |
| 45 | +const discoveryMembers = DiscoveryEnvironmentSchema.options as readonly string[]; |
| 46 | +const environmentMembers = EnvironmentTypeSchema.options as readonly string[]; |
| 47 | + |
| 48 | +describe('[#5676] DiscoveryEnvironment ⊂ EnvironmentType', () => { |
| 49 | + it('reads a non-empty membership off both enums (anti-vacuity)', () => { |
| 50 | + // Without this, every `every()` below passes against a broken import. |
| 51 | + expect(Array.isArray(discoveryMembers)).toBe(true); |
| 52 | + expect(Array.isArray(environmentMembers)).toBe(true); |
| 53 | + expect(discoveryMembers.length).toBeGreaterThan(0); |
| 54 | + expect(environmentMembers.length).toBeGreaterThan(discoveryMembers.length); |
| 55 | + }); |
| 56 | + |
| 57 | + it('declares every discovery environment as an EnvironmentType member', () => { |
| 58 | + const missing = discoveryMembers.filter(m => !environmentMembers.includes(m)); |
| 59 | + expect( |
| 60 | + missing, |
| 61 | + `${missing.join(', ')} is advertised by DiscoverySchema.environment but is no longer an ` |
| 62 | + + 'EnvironmentTypeSchema member. The two describe one concept and discovery is the coarse ' |
| 63 | + + 'view of it (#5676) — if a bucket was renamed on the cloud side, rename it here and in ' |
| 64 | + + "`NODE_ENV_TO_DISCOVERY_ENVIRONMENT`'s values too, or the fold points at a dead value.", |
| 65 | + ).toEqual([]); |
| 66 | + }); |
| 67 | + |
| 68 | + it('PARSES every discovery environment as an EnvironmentType (not just string equality)', () => { |
| 69 | + // The arrays could agree while the schemas disagree — a refinement, a |
| 70 | + // transform, a branded type. Judge the schema, not its `.options` list. |
| 71 | + for (const member of discoveryMembers) { |
| 72 | + expect(EnvironmentTypeSchema.safeParse(member).success, member).toBe(true); |
| 73 | + } |
| 74 | + }); |
| 75 | + |
| 76 | + it('is a STRICT subset — the extra EnvironmentType buckets are rejected by discovery', () => { |
| 77 | + // The negative control. Without it the test above would still pass if the |
| 78 | + // two enums had been collapsed into one, which is the outcome #4828's |
| 79 | + // ruling declined (widening a response enum breaks 3-value consumers). |
| 80 | + const extras = environmentMembers.filter(m => !discoveryMembers.includes(m)); |
| 81 | + expect(extras.length, 'no extra buckets left — did the two enums get collapsed?') |
| 82 | + .toBeGreaterThan(0); |
| 83 | + for (const member of extras) { |
| 84 | + expect(DiscoveryEnvironmentSchema.safeParse(member).success, member).toBe(false); |
| 85 | + } |
| 86 | + }); |
| 87 | +}); |
0 commit comments