diff --git a/server/regions.test.ts b/server/regions.test.ts index 0859ed6..05ce09d 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -1,5 +1,8 @@ -import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'; import { fileURLToPath } from 'node:url'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { allTerritoryKeys, expandCountriesToKeys, @@ -101,3 +104,50 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { afterAll(() => resetRegions()); }); + +describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => { + const realRoot = root; + // A root that mimics the prod Docker image: no public/, the region files live + // in dist/ (where Vite copies public/* at build). This is the exact layout that + // broke v0.19.0 in prod (the loader only looked in public/). + const base = mkdtempSync(path.join(tmpdir(), 'history-regions-')); + const prodRoot = path.join(base, 'prod'); + + beforeEach(() => { + mkdirSync(path.join(prodRoot, 'dist'), { recursive: true }); + copyFileSync( + path.join(realRoot, 'public', 'region-keys.json'), + path.join(prodRoot, 'dist', 'region-keys.json'), + ); + copyFileSync( + path.join(realRoot, 'public', 'regions.geo.json'), + path.join(prodRoot, 'dist', 'regions.geo.json'), + ); + }); + + afterEach(() => resetRegions()); + + afterAll(() => { + rmSync(base, { recursive: true, force: true }); + }); + + it('loads from dist/ when public/ is absent (the prod container layout)', () => { + expect(loadRegions(prodRoot)).toBe(true); + expect(hasRegionData()).toBe(true); + expect(validKeys().size).toBe(1796); + expect(expandCountriesToKeys(['FRA'])).toHaveLength(42); + }); + + it('still loads from public/ (the dev layout, the first candidate)', () => { + expect(loadRegions(realRoot)).toBe(true); + expect(validKeys().size).toBe(1796); + }); + + it('returns false and no data when neither public/ nor dist/ holds the files', () => { + const emptyRoot = path.join(base, 'empty'); + mkdirSync(emptyRoot, { recursive: true }); + expect(loadRegions(emptyRoot)).toBe(false); + expect(hasRegionData()).toBe(false); + expect(validKeys().size).toBe(0); + }); +}); diff --git a/server/regions.ts b/server/regions.ts index e5ab90a..9774a5d 100644 --- a/server/regions.ts +++ b/server/regions.ts @@ -1,7 +1,9 @@ // Region-key data access (server). Loads the deterministic region-key manifest -// (public/region-keys.json: stable feature id -> region key) and the sub-national -// geo (public/regions.geo.json: feature id -> { name, parent }), then joins them -// into the lookups the region-level ownership model (v0.19, #16 P1) needs: +// (region-keys.json: stable feature id -> region key) and the sub-national geo +// (regions.geo.json: feature id -> { name, parent }) — from public/ in dev (Vite +// serves it) or dist/ in prod (Vite copies public/* -> dist/ at build, and the +// Docker image keeps only dist/) — and joins them into the lookups the +// region-level ownership model (v0.19, #16 P1) needs: // // - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions // - build the LLM "regions" reference table (key -> "name (parent ISO_A3)") @@ -35,18 +37,29 @@ interface RegionData { let data: RegionData | null = null; /** - * Load (or reload) the region-key manifest + geo from `root`. Idempotent. - * Returns true when both files loaded cleanly. A missing/broken file clears the - * data and returns false, so the server still boots and degrades gracefully - * (expansion becomes a no-op, ownership falls back to the raw input keys). + * Load (or reload) the region-key manifest + geo. Idempotent. The two files are + * served to the client from `public/` in dev (Vite serves it directly) and from + * `dist/` in prod (Vite copies `public/*` → `dist/` at build and the Docker image + * keeps only `dist/`), so probe `public/` then `dist/` (plus the root as a last + * resort) and use the first directory that holds both files. Returns true when + * both loaded cleanly; a missing/broken pair clears the data and returns false + * so the server still boots and degrades gracefully (expansion becomes a no-op, + * ownership falls back to the raw input keys). */ export function loadRegions(root: string): boolean { - const keyPath = path.join(root, 'public', 'region-keys.json'); - const geoPath = path.join(root, 'public', 'regions.geo.json'); - if (!existsSync(keyPath) || !existsSync(geoPath)) { + const keyFile = 'region-keys.json'; + const geoFile = 'regions.geo.json'; + const base = [ + path.join(root, 'public'), // dev: Vite serves public/ at / + path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir) + root, // last resort + ].find((d) => existsSync(path.join(d, keyFile)) && existsSync(path.join(d, geoFile))); + if (!base) { data = null; return false; } + const keyPath = path.join(base, keyFile); + const geoPath = path.join(base, geoFile); try { const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record; const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {