mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585)
* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles * fix(atlas): scan admin1 features in one pass to avoid O(n^2) build createFeatureSplitter re-scanned each partial Feature from the start on every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made the one-time admin1 store build O(n^2). That pushed the streaming build (~4.2s) past the 15s test timeout under CI coverage/fork contention and added the same latency to the first live region request. Carry scan state (position, brace depth, string state) across chunks so each character is examined once. Output is byte-identical (3228 features, 197 countries) and peak build memory is unchanged (~192MB RSS under a hard 512MB cap); build time drops to ~1.7s. * fix(i18n): add packing.quantity to all locales The packing.quantity key existed only in en (Qty) and de (Menge), so i18n:parity:strict and the client parity test failed for the other 20 locales. Add the key everywhere; en/de are unchanged. --------- Co-authored-by: jubnl <jgunther021@gmail.com>
This commit is contained in:
committed by
GitHub
parent
20ca5b52bc
commit
9d72af5238
@@ -63,9 +63,21 @@ export class AtlasController {
|
||||
}
|
||||
|
||||
@Get('countries/geo')
|
||||
@Header('Cache-Control', 'public, max-age=86400')
|
||||
countryGeo(): RegionGeo {
|
||||
return this.atlas.countryGeo();
|
||||
countryGeo(@Res() res: Response): void {
|
||||
// Serve the pre-gzipped admin-0 bundle straight from disk. The browser decompresses
|
||||
// transparently, so the wire shape is identical to before, but the server never parses
|
||||
// or holds the ~145MB FeatureCollection (#1576). Content-Encoding is set explicitly, so
|
||||
// the compression middleware leaves the body untouched.
|
||||
const gz = this.atlas.countryGeoGz();
|
||||
if (!gz) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({ type: 'FeatureCollection', features: [] });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Encoding', 'gzip');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.send(gz);
|
||||
}
|
||||
|
||||
@Get('country/:code')
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
unmarkRegionVisited,
|
||||
getVisitedRegions,
|
||||
getRegionGeo,
|
||||
getCountryGeo,
|
||||
getCountryGeoGz,
|
||||
listBucketList,
|
||||
createBucketItem,
|
||||
updateBucketItem,
|
||||
@@ -38,8 +38,8 @@ export class AtlasService {
|
||||
return getRegionGeo(countries);
|
||||
}
|
||||
|
||||
countryGeo() {
|
||||
return getCountryGeo();
|
||||
countryGeoGz(): Buffer | null {
|
||||
return getCountryGeoGz();
|
||||
}
|
||||
|
||||
countryPlaces(userId: number, code: string) {
|
||||
|
||||
@@ -16,35 +16,160 @@ import { CONTINENT_MAP } from '@trek/shared';
|
||||
// __dirname is server/dist/services at runtime and server/src/services under vitest;
|
||||
// both resolve ../../assets to server/assets.
|
||||
|
||||
const geoBundleCache = new Map<string, any>();
|
||||
// Neither parsed bundle is cached. admin0 (~145MB) and admin1 (~260MB) parsed at once are
|
||||
// what exhausted a 512MB host while using Atlas (#1576). Instead we retain only compact
|
||||
// derivatives: the raw admin0 .gz bytes for direct serving, a Float64Array poly/box index
|
||||
// for server-side point-in-polygon (see buildCountryIndexes), and admin1 pre-split per
|
||||
// country into ready-to-serve GeoJSON strings, built by streaming the gz so the full
|
||||
// bundle is never materialised in one piece.
|
||||
|
||||
function loadGeoBundle(name: 'admin0' | 'admin1'): any {
|
||||
const cached = geoBundleCache.get(name);
|
||||
if (cached) return cached;
|
||||
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn(`[Atlas] ${name}.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||
const empty = { type: 'FeatureCollection', features: [] };
|
||||
geoBundleCache.set(name, empty);
|
||||
return empty;
|
||||
}
|
||||
const geo = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'));
|
||||
geoBundleCache.set(name, geo);
|
||||
console.log(`[Atlas] Loaded ${name} GeoJSON: ${geo.features?.length || 0} features`);
|
||||
return geo;
|
||||
function assetPath(name: 'admin0' | 'admin1'): string {
|
||||
return path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
|
||||
}
|
||||
|
||||
/** Full admin-0 country-border FeatureCollection (for the client map's country layer). */
|
||||
let admin0Gz: Buffer | null | undefined;
|
||||
function loadAdmin0Gz(): Buffer | null {
|
||||
if (admin0Gz !== undefined) return admin0Gz;
|
||||
const file = assetPath('admin0');
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn(`[Atlas] admin0.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||
return (admin0Gz = null);
|
||||
}
|
||||
return (admin0Gz = fs.readFileSync(file));
|
||||
}
|
||||
|
||||
/** admin-0 country borders as gzipped GeoJSON bytes, served to the client map with
|
||||
* Content-Encoding: gzip so the server never holds the parsed FeatureCollection. */
|
||||
export function getCountryGeoGz(): Buffer | null {
|
||||
return loadAdmin0Gz();
|
||||
}
|
||||
|
||||
/** Parsed admin-0 FeatureCollection, parsed on demand (not cached). Not on the client hot
|
||||
* path — the map is served the gz bytes via getCountryGeoGz — but kept for internal/test
|
||||
* callers that need the objects. */
|
||||
export function getCountryGeo(): any {
|
||||
return loadGeoBundle('admin0');
|
||||
const gz = loadAdmin0Gz();
|
||||
if (!gz) return { type: 'FeatureCollection', features: [] };
|
||||
return JSON.parse(zlib.gunzipSync(gz).toString('utf8'));
|
||||
}
|
||||
|
||||
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
||||
const geo = loadGeoBundle('admin1');
|
||||
if (!geo) return { type: 'FeatureCollection', features: [] };
|
||||
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
||||
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
||||
return { type: 'FeatureCollection', features };
|
||||
const store = await getAdmin1Store();
|
||||
const seen = new Set<string>();
|
||||
const parts: string[] = [];
|
||||
for (const code of countryCodes) {
|
||||
const c = code.toUpperCase();
|
||||
if (seen.has(c)) continue;
|
||||
seen.add(c);
|
||||
const s = store.get(c);
|
||||
if (s) parts.push(s);
|
||||
}
|
||||
if (parts.length === 0) return { type: 'FeatureCollection', features: [] };
|
||||
// Each stored value is that country's features as comma-joined GeoJSON text; wrap the
|
||||
// requested subset into one FeatureCollection and parse only that (per-viewport, small).
|
||||
return JSON.parse(`{"type":"FeatureCollection","features":[${parts.join(',')}]}`);
|
||||
}
|
||||
|
||||
// admin1 regions, pre-split per ISO_A2 into comma-joined feature text. Built once by
|
||||
// streaming the gz through a brace-depth splitter that emits one Feature at a time, so the
|
||||
// ~260MB full parse never happens (it OOMs a 512MB host). Concurrent first-callers share
|
||||
// one in-flight build via admin1Building.
|
||||
let admin1Store: Map<string, string> | null = null;
|
||||
let admin1Building: Promise<Map<string, string>> | null = null;
|
||||
|
||||
function getAdmin1Store(): Promise<Map<string, string>> {
|
||||
if (admin1Store) return Promise.resolve(admin1Store);
|
||||
if (!admin1Building) {
|
||||
admin1Building = buildAdmin1Store().then((s) => { admin1Store = s; admin1Building = null; return s; });
|
||||
}
|
||||
return admin1Building;
|
||||
}
|
||||
|
||||
// Feed arbitrary gunzip chunks; invokes onFeature(text) once per top-level Feature object
|
||||
// inside "features":[ … ]. `pending` holds only the unconsumed tail (at most one partial
|
||||
// feature + the current chunk), keeping memory flat regardless of bundle size.
|
||||
function createFeatureSplitter(onFeature: (text: string) => void): (chunk: string) => void {
|
||||
let pending = '';
|
||||
let started = false;
|
||||
// Scan progress is carried across chunks so each character is examined exactly once.
|
||||
// A large Feature (e.g. Canada, ~5.5MB) spans hundreds of gunzip chunks; re-scanning the
|
||||
// accumulated partial from the start on every chunk was O(n²) per feature and pushed the
|
||||
// one-time admin1 build past the 15s test timeout under coverage/CI (#1576-followup).
|
||||
let scanning = false; // currently inside a Feature object?
|
||||
let depth = 0, inStr = false, esc = false;
|
||||
let scanPos = 0, featStart = 0; // resume point + current feature start, in `pending`
|
||||
return (chunk: string) => {
|
||||
pending += chunk;
|
||||
if (!started) {
|
||||
const fi = pending.indexOf('"features"');
|
||||
if (fi === -1) return;
|
||||
const br = pending.indexOf('[', fi);
|
||||
if (br === -1) return;
|
||||
pending = pending.slice(br + 1);
|
||||
started = true;
|
||||
scanPos = 0;
|
||||
}
|
||||
const n = pending.length;
|
||||
while (scanPos < n) {
|
||||
if (!scanning) {
|
||||
const c = pending[scanPos];
|
||||
if (c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === ',') { scanPos++; continue; }
|
||||
if (c === ']') { scanPos = n; break; }
|
||||
if (c !== '{') { scanPos++; continue; }
|
||||
scanning = true; depth = 0; inStr = false; esc = false; featStart = scanPos;
|
||||
}
|
||||
let end = -1;
|
||||
for (let j = scanPos; j < n; j++) {
|
||||
const c = pending[j];
|
||||
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; }
|
||||
else if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}') { if (--depth === 0) { end = j + 1; scanPos = j + 1; break; } }
|
||||
}
|
||||
if (end === -1) { scanPos = n; break; } // partial feature — resume from n next chunk
|
||||
onFeature(pending.slice(featStart, end));
|
||||
scanning = false;
|
||||
}
|
||||
// Drop the fully-consumed prefix, keeping at most the current partial feature.
|
||||
const keepFrom = scanning ? featStart : scanPos;
|
||||
if (keepFrom > 0) {
|
||||
pending = pending.slice(keepFrom);
|
||||
scanPos -= keepFrom;
|
||||
if (scanning) featStart -= keepFrom;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdmin1Store(): Promise<Map<string, string>> {
|
||||
const file = assetPath('admin1');
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn(`[Atlas] admin1.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||
return Promise.resolve(new Map());
|
||||
}
|
||||
// Concatenate each country's features straight into the store as we stream, rather than
|
||||
// collecting arrays and joining at the end (that doubling, plus the source bundle's
|
||||
// whitespace, peaked high enough to OOM a 512MB host on the first build). Re-serialising
|
||||
// each feature via JSON drops the source formatting (~114MB → ~68MB retained) and the
|
||||
// parse/stringify garbage is per-feature and short-lived.
|
||||
const store = new Map<string, string>();
|
||||
const split = createFeatureSplitter((text) => {
|
||||
const f = JSON.parse(text);
|
||||
const code = f.properties?.iso_a2?.toUpperCase();
|
||||
if (!code) return; // features with a null iso_a2 are skipped, matching the old filter
|
||||
const compact = JSON.stringify(f);
|
||||
const prev = store.get(code);
|
||||
store.set(code, prev ? prev + ',' + compact : compact);
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.createReadStream(file)
|
||||
.pipe(zlib.createGunzip())
|
||||
.on('data', (chunk: Buffer) => split(chunk.toString('utf8')))
|
||||
.on('end', () => {
|
||||
console.log(`[Atlas] Indexed admin1 GeoJSON: ${store.size} countries`);
|
||||
resolve(store);
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Geocode cache ───────────────────────────────────────────────────────────
|
||||
@@ -181,11 +306,15 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise<s
|
||||
// ── Point-in-polygon over the bundled admin0 borders (#1331) ─────────────────
|
||||
|
||||
// Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring.
|
||||
function pointInRing(lng: number, lat: number, ring: number[][]): boolean {
|
||||
// Ray-cast on a flat [lng,lat,lng,lat,…] ring. Same algorithm as the classic number[][]
|
||||
// version, but the coordinates live in a Float64Array so the parsed admin0 geometry (with
|
||||
// its millions of tiny [lng,lat] arrays, ~145MB) need not be retained — only these rings.
|
||||
function pointInFlatRing(lng: number, lat: number, ring: Float64Array): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
const n = ring.length / 2;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = ring[2 * i], yi = ring[2 * i + 1];
|
||||
const xj = ring[2 * j], yj = ring[2 * j + 1];
|
||||
if (((yi > lat) !== (yj > lat)) && (lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi)) {
|
||||
inside = !inside;
|
||||
}
|
||||
@@ -193,25 +322,32 @@ function pointInRing(lng: number, lat: number, ring: number[][]): boolean {
|
||||
return inside;
|
||||
}
|
||||
|
||||
// True when (lng,lat) falls inside a Polygon/MultiPolygon, honouring holes.
|
||||
function pointInGeometry(lng: number, lat: number, geom: { type: string; coordinates: number[][][] | number[][][][] }): boolean {
|
||||
const polygons = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates) as number[][][][];
|
||||
for (const poly of polygons) {
|
||||
if (!pointInRing(lng, lat, poly[0])) continue;
|
||||
let inHole = false;
|
||||
for (let h = 1; h < poly.length; h++) {
|
||||
if (pointInRing(lng, lat, poly[h])) { inHole = true; break; }
|
||||
// True when (lng,lat) falls inside a compact Polygon/MultiPolygon, honouring holes.
|
||||
// `rings` is every ring flattened; `polyRingCounts[k]` is how many rings polygon k owns
|
||||
// (its first ring is the outer boundary, the rest are holes).
|
||||
function pointInGeometry(lng: number, lat: number, geom: CompactGeom): boolean {
|
||||
let ri = 0;
|
||||
for (const rc of geom.polyRingCounts) {
|
||||
if (pointInFlatRing(lng, lat, geom.rings[ri])) {
|
||||
let inHole = false;
|
||||
for (let h = 1; h < rc; h++) {
|
||||
if (pointInFlatRing(lng, lat, geom.rings[ri + h])) { inHole = true; break; }
|
||||
}
|
||||
if (!inHole) return true;
|
||||
}
|
||||
if (!inHole) return true;
|
||||
ri += rc;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type Geometry = { type: string; coordinates: number[][][] | number[][][][] };
|
||||
// Compact polygon geometry: rings flattened into Float64Arrays, grouped into polygons by
|
||||
// polyRingCounts. Replaces the retained parsed GeoJSON geometry.
|
||||
type CompactGeom = { rings: Float64Array[]; polyRingCounts: number[] };
|
||||
type Box = [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
||||
|
||||
// ISO_A2 → admin0 geometry + bounding boxes, both derived from the bundled admin0
|
||||
// borders on first use and cached.
|
||||
// ISO_A2 → compact admin0 geometry + bounding boxes, derived from the bundled admin0
|
||||
// borders on first use. The parsed FeatureCollection is dropped after this runs; only the
|
||||
// Float64Array rings and boxes are retained (≈1MB vs the ≈145MB parsed geometry, #1576).
|
||||
//
|
||||
// The boxes used to be a hand-maintained table, which drifted: 43 countries (NG, BY,
|
||||
// GL, KP, TD, SS, …) had no box at all, so their coordinates fell into a *neighbour's*
|
||||
@@ -222,32 +358,53 @@ type Box = [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
||||
// One box is stored PER GEOMETRY PART, not per country. A single box around a country
|
||||
// that straddles the antimeridian (RU, US, FJ, KI) would span nearly the whole globe;
|
||||
// per-part boxes keep Alaska and Chukotka separate and handle the ±180 wrap for free.
|
||||
let countryPolyIndex: Map<string, Geometry> | null = null;
|
||||
let countryPolyIndex: Map<string, CompactGeom> | null = null;
|
||||
let countryBoxIndex: Map<string, Box[]> | null = null;
|
||||
|
||||
function buildCountryIndexes(): void {
|
||||
const polys = new Map<string, Geometry>();
|
||||
const polys = new Map<string, CompactGeom>();
|
||||
const boxes = new Map<string, Box[]>();
|
||||
|
||||
for (const f of loadGeoBundle('admin0').features ?? []) {
|
||||
const raw = f.properties?.ISO_A2;
|
||||
if (!raw || raw === '-99' || !f.geometry) continue;
|
||||
const code = String(raw).toUpperCase();
|
||||
polys.set(code, f.geometry);
|
||||
const gz = loadAdmin0Gz();
|
||||
if (gz) {
|
||||
// Parse ONE feature at a time off the gunzipped string rather than JSON.parse-ing the
|
||||
// whole FeatureCollection: the full parse transiently allocates ~285MB (the 145MB
|
||||
// object graph plus intermediates) and V8 keeps those pages, which alone exhausts a
|
||||
// 512MB host before admin1 even loads (#1576).
|
||||
const json = zlib.gunzipSync(gz).toString('utf8');
|
||||
const consume = createFeatureSplitter((text) => {
|
||||
const f = JSON.parse(text);
|
||||
const raw = f.properties?.ISO_A2;
|
||||
if (!raw || raw === '-99' || !f.geometry) return;
|
||||
const code = String(raw).toUpperCase();
|
||||
|
||||
const parts = (f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates) as number[][][][];
|
||||
const codeBoxes = boxes.get(code) ?? [];
|
||||
for (const part of parts) {
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity;
|
||||
for (const [lng, lat] of part[0]) {
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
const parts = (f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates) as number[][][][];
|
||||
const rings: Float64Array[] = [];
|
||||
const polyRingCounts: number[] = [];
|
||||
const codeBoxes = boxes.get(code) ?? [];
|
||||
for (const part of parts) {
|
||||
polyRingCounts.push(part.length);
|
||||
for (const ring of part) {
|
||||
const flat = new Float64Array(ring.length * 2);
|
||||
for (let i = 0; i < ring.length; i++) { flat[2 * i] = ring[i][0]; flat[2 * i + 1] = ring[i][1]; }
|
||||
rings.push(flat);
|
||||
}
|
||||
// Bounding box from the part's outer ring (part[0]).
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity;
|
||||
for (const [lng, lat] of part[0]) {
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
codeBoxes.push([minLng, minLat, maxLng, maxLat]);
|
||||
}
|
||||
codeBoxes.push([minLng, minLat, maxLng, maxLat]);
|
||||
}
|
||||
boxes.set(code, codeBoxes);
|
||||
// Matches the previous index exactly: geometry is overwritten (last feature for a
|
||||
// code wins) while boxes accumulate across a code's features.
|
||||
polys.set(code, { rings, polyRingCounts });
|
||||
boxes.set(code, codeBoxes);
|
||||
});
|
||||
consume(json);
|
||||
}
|
||||
|
||||
// Micro-territories aren't in admin0 — give them their box, but no polygon.
|
||||
@@ -259,7 +416,7 @@ function buildCountryIndexes(): void {
|
||||
countryBoxIndex = boxes;
|
||||
}
|
||||
|
||||
function getCountryPolyIndex(): Map<string, Geometry> {
|
||||
function getCountryPolyIndex(): Map<string, CompactGeom> {
|
||||
if (!countryPolyIndex) buildCountryIndexes();
|
||||
return countryPolyIndex!;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* bespoke 400/404 bodies.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import zlib from 'zlib';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
@@ -33,7 +34,7 @@ const { mocks } = vi.hoisted(() => ({
|
||||
unmarkRegionVisited: vi.fn(),
|
||||
getVisitedRegions: vi.fn(),
|
||||
getRegionGeo: vi.fn(),
|
||||
getCountryGeo: vi.fn(),
|
||||
getCountryGeoGz: vi.fn(),
|
||||
listBucketList: vi.fn(),
|
||||
createBucketItem: vi.fn(),
|
||||
updateBucketItem: vi.fn(),
|
||||
@@ -76,10 +77,13 @@ describe('Atlas e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 countries/geo returns the admin-0 FeatureCollection', async () => {
|
||||
mocks.getCountryGeo.mockReturnValue({ type: 'FeatureCollection', features: [{ id: 'NO' }] });
|
||||
it('200 countries/geo serves gzipped admin-0 that the client decompresses to a FeatureCollection', async () => {
|
||||
const gz = zlib.gzipSync(JSON.stringify({ type: 'FeatureCollection', features: [{ id: 'NO' }] }));
|
||||
mocks.getCountryGeoGz.mockReturnValue(gz);
|
||||
const res = await request(server).get('/api/addons/atlas/countries/geo').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-encoding']).toBe('gzip');
|
||||
// superagent transparently decompresses, mirroring the browser.
|
||||
expect(res.body.type).toBe('FeatureCollection');
|
||||
expect(res.headers['cache-control']).toContain('max-age=86400');
|
||||
});
|
||||
|
||||
@@ -12,7 +12,11 @@ function makeController(svc: Partial<AtlasService>) {
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return { setHeader: vi.fn() } as unknown as Response & { setHeader: ReturnType<typeof vi.fn> };
|
||||
return { setHeader: vi.fn(), send: vi.fn(), json: vi.fn() } as unknown as Response & {
|
||||
setHeader: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
|
||||
@@ -53,11 +57,22 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /countries/geo delegates to the service', () => {
|
||||
const fc = { type: 'FeatureCollection', features: [{ id: 'NO' }] };
|
||||
const countryGeo = vi.fn().mockReturnValue(fc);
|
||||
expect(makeController({ countryGeo }).countryGeo()).toBe(fc);
|
||||
expect(countryGeo).toHaveBeenCalledWith();
|
||||
it('GET /countries/geo serves the gzipped admin-0 bundle with Content-Encoding', () => {
|
||||
const gz = Buffer.from('gzipped-bytes');
|
||||
const countryGeoGz = vi.fn().mockReturnValue(gz);
|
||||
const res = makeRes();
|
||||
makeController({ countryGeoGz }).countryGeo(res);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Encoding', 'gzip');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=86400');
|
||||
expect(res.send).toHaveBeenCalledWith(gz);
|
||||
});
|
||||
|
||||
it('GET /countries/geo falls back to an empty FeatureCollection when the bundle is missing', () => {
|
||||
const countryGeoGz = vi.fn().mockReturnValue(null);
|
||||
const res = makeRes();
|
||||
makeController({ countryGeoGz }).countryGeo(res);
|
||||
expect(res.json).toHaveBeenCalledWith({ type: 'FeatureCollection', features: [] });
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
||||
});
|
||||
|
||||
describe('country', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'أمتعة',
|
||||
'packing.noBag': 'غير معيّن',
|
||||
'packing.totalWeight': 'الوزن الإجمالي',
|
||||
'packing.quantity': 'الكمية',
|
||||
'packing.bagName': 'الاسم...',
|
||||
'packing.addBag': 'إضافة أمتعة',
|
||||
'packing.changeCategory': 'نقل إلى قائمة',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Malas',
|
||||
'packing.noBag': 'Sem mala',
|
||||
'packing.totalWeight': 'Peso total',
|
||||
'packing.quantity': 'Qtd.',
|
||||
'packing.bagName': 'Nome da mala...',
|
||||
'packing.addBag': 'Adicionar mala',
|
||||
'packing.changeCategory': 'Mover para lista',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Zavazadla',
|
||||
'packing.noBag': 'Nepřiřazeno',
|
||||
'packing.totalWeight': 'Celková váha',
|
||||
'packing.quantity': 'Počet',
|
||||
'packing.bagName': 'Název zavazadla...',
|
||||
'packing.addBag': 'Přidat zavazadlo',
|
||||
'packing.changeCategory': 'Přesunout do seznamu',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Equipaje',
|
||||
'packing.noBag': 'Sin asignar',
|
||||
'packing.totalWeight': 'Peso total',
|
||||
'packing.quantity': 'Cant.',
|
||||
'packing.bagName': 'Nombre...',
|
||||
'packing.addBag': 'Añadir equipaje',
|
||||
'packing.changeCategory': 'Mover a lista',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Bagages',
|
||||
'packing.noBag': 'Non assigné',
|
||||
'packing.totalWeight': 'Poids total',
|
||||
'packing.quantity': 'Qté',
|
||||
'packing.bagName': 'Nom...',
|
||||
'packing.addBag': 'Ajouter un bagage',
|
||||
'packing.changeCategory': 'Déplacer vers une liste',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Τσάντες',
|
||||
'packing.noBag': 'Χωρίς ανάθεση',
|
||||
'packing.totalWeight': 'Συνολικό βάρος',
|
||||
'packing.quantity': 'Ποσ.',
|
||||
'packing.bagName': 'Όνομα τσάντας...',
|
||||
'packing.addBag': 'Προσθήκη τσάντας',
|
||||
'packing.changeCategory': 'Μετακίνηση σε λίστα',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Táskák',
|
||||
'packing.noBag': 'Nincs hozzárendelve',
|
||||
'packing.totalWeight': 'Összsúly',
|
||||
'packing.quantity': 'Menny.',
|
||||
'packing.bagName': 'Táska neve...',
|
||||
'packing.addBag': 'Táska hozzáadása',
|
||||
'packing.changeCategory': 'Áthelyezés listába',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Tas',
|
||||
'packing.noBag': 'Belum ditugaskan',
|
||||
'packing.totalWeight': 'Total berat',
|
||||
'packing.quantity': 'Jml',
|
||||
'packing.bagName': 'Nama tas...',
|
||||
'packing.addBag': 'Tambah tas',
|
||||
'packing.changeCategory': 'Pindahkan ke daftar',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Valigie',
|
||||
'packing.noBag': 'Non assegnato',
|
||||
'packing.totalWeight': 'Peso totale',
|
||||
'packing.quantity': 'Qtà',
|
||||
'packing.bagName': 'Nome valigia...',
|
||||
'packing.addBag': 'Aggiungi valigia',
|
||||
'packing.changeCategory': 'Sposta nella lista',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'バッグ',
|
||||
'packing.noBag': '未割り当て',
|
||||
'packing.totalWeight': '総重量',
|
||||
'packing.quantity': '数量',
|
||||
'packing.bagName': 'バッグ名...',
|
||||
'packing.addBag': 'バッグを追加',
|
||||
'packing.changeCategory': 'リストへ移動',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': '가방',
|
||||
'packing.noBag': '미배정',
|
||||
'packing.totalWeight': '총 무게',
|
||||
'packing.quantity': '수량',
|
||||
'packing.bagName': '가방 이름...',
|
||||
'packing.addBag': '가방 추가',
|
||||
'packing.changeCategory': '목록으로 이동',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Bagage',
|
||||
'packing.noBag': 'Niet toegewezen',
|
||||
'packing.totalWeight': 'Totaalgewicht',
|
||||
'packing.quantity': 'Aantal',
|
||||
'packing.bagName': 'Naam...',
|
||||
'packing.addBag': 'Bagage toevoegen',
|
||||
'packing.changeCategory': 'Naar lijst verplaatsen',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Torby',
|
||||
'packing.noBag': 'Nieprzypisane',
|
||||
'packing.totalWeight': 'Waga całkowita',
|
||||
'packing.quantity': 'Ilość',
|
||||
'packing.bagName': 'Nazwa torby...',
|
||||
'packing.addBag': 'Dodaj torbę',
|
||||
'packing.changeCategory': 'Przenieś do listy',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Багаж',
|
||||
'packing.noBag': 'Не назначено',
|
||||
'packing.totalWeight': 'Общий вес',
|
||||
'packing.quantity': 'Кол-во',
|
||||
'packing.bagName': 'Название...',
|
||||
'packing.addBag': 'Добавить багаж',
|
||||
'packing.changeCategory': 'Переместить в список',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Väskor',
|
||||
'packing.noBag': 'Ej tilldelad',
|
||||
'packing.totalWeight': 'Totalvikt',
|
||||
'packing.quantity': 'Antal',
|
||||
'packing.bagName': 'Väskans namn...',
|
||||
'packing.addBag': 'Lägg till väska',
|
||||
'packing.changeCategory': 'Flytta till lista',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Çantalar',
|
||||
'packing.noBag': 'Atanmamış',
|
||||
'packing.totalWeight': 'Toplam ağırlık',
|
||||
'packing.quantity': 'Adet',
|
||||
'packing.bagName': 'Çanta adı...',
|
||||
'packing.addBag': 'Çanta ekle',
|
||||
'packing.changeCategory': 'Listeye taşı',
|
||||
|
||||
@@ -47,6 +47,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Багаж',
|
||||
'packing.noBag': 'Не призначено',
|
||||
'packing.totalWeight': 'Загальна вага',
|
||||
'packing.quantity': 'К-сть',
|
||||
'packing.bagName': 'Назва...',
|
||||
'packing.addBag': 'Додати багаж',
|
||||
'packing.changeCategory': 'Перемістити до списку',
|
||||
|
||||
@@ -48,6 +48,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': 'Túi xách',
|
||||
'packing.noBag': 'Chưa được chỉ định',
|
||||
'packing.totalWeight': 'Tổng trọng lượng',
|
||||
'packing.quantity': 'SL',
|
||||
'packing.bagName': 'Tên túi...',
|
||||
'packing.addBag': 'Thêm túi',
|
||||
'packing.changeCategory': 'Chuyển sang danh sách',
|
||||
|
||||
@@ -46,6 +46,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': '行李',
|
||||
'packing.noBag': '未分配',
|
||||
'packing.totalWeight': '總重量',
|
||||
'packing.quantity': '數量',
|
||||
'packing.bagName': '名稱...',
|
||||
'packing.addBag': '新增行李',
|
||||
'packing.changeCategory': '移動到清單',
|
||||
|
||||
@@ -46,6 +46,7 @@ const packing: TranslationStrings = {
|
||||
'packing.bags': '行李',
|
||||
'packing.noBag': '未分配',
|
||||
'packing.totalWeight': '总重量',
|
||||
'packing.quantity': '数量',
|
||||
'packing.bagName': '名称...',
|
||||
'packing.addBag': '添加行李',
|
||||
'packing.changeCategory': '移动到清单',
|
||||
|
||||
Reference in New Issue
Block a user