Files
regress/server/src/services/btcmap.test.ts
T
ssmithx bb03bfc365 Go global: worldwide BTC Map sync, marker clustering, response compression
- Sync now pulls every BTC Map place worldwide (~29k live), not just
  Madeira — dropped BTCMAP_CENTER_LAT/LON/RADIUS_KM entirely.
- Incremental via a stored watermark (settings.btcmap_synced_since):
  only fetches what changed since the last sync, not the whole dataset
  every time. Handles deletions too (BTC Map delistings correctly evict
  the local place + cascade its claim/links, not just go unclaimed).
- Real bug caught and fixed before it shipped: tried BTC Map's own
  recommended updated_since+limit pagination first, but their timestamps
  mix millisecond and whole-second precision, and naive string comparison
  across that isn't chronologically safe — silently truncated a real
  sync to ~4,000 of ~42,000 records with no error. Measured the
  alternative (single unpaginated request) instead: 2-3s for the full
  dataset, simpler and actually correct. Full writeup in
  services/btcmap.ts, regression test for the specific bug in
  services/btcmap.test.ts.
- @fastify/compress added — the places list is now tens of thousands of
  rows, gzip/br/zstd auto-negotiated.
- Also fixed while touching dependencies: @fastify/static had a real
  high-severity path-traversal/auth-bypass advisory (GHSA-pr96-94w5-mx2h
  et al) affecting the version we were pinned to — bumped to the patched
  10.1.2.
- Frontend: leaflet.markercluster (raw per-marker rendering doesn't scale
  to tens of thousands of points), cyberpunk-themed cluster icons to
  match the existing HUD styling, batch marker insertion (addLayers, not
  a per-marker addLayer loop) since that's dramatically faster at this
  scale. Default map view is now world-scale, recentering on the
  player's location if geolocation is available.
- 60 backend tests passing throughout (7 new for the sync rewrite).
2026-08-05 17:32:54 +00:00

75 lines
2.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { fetchAllPlaces, latestUpdatedAt, type BtcMapPlace } from './btcmap.js';
const BASE = 'https://fake-btcmap.example';
function place(id: number, updatedAt: string, deleted = false): BtcMapPlace {
return {
id,
lat: id,
lon: id,
name: `place-${id}`,
updated_at: updatedAt,
...(deleted ? { deleted_at: updatedAt } : {}),
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('fetchAllPlaces', () => {
it('requests include_deleted and the given since, with no pagination params', async () => {
let capturedUrl: URL | undefined;
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL) => {
capturedUrl = input instanceof URL ? input : new URL(String(input));
return new Response(JSON.stringify([]), { status: 200 });
}),
);
await fetchAllPlaces(BASE, '2026-01-01T00:00:00Z');
expect(capturedUrl?.searchParams.get('include_deleted')).toBe('true');
expect(capturedUrl?.searchParams.get('updated_since')).toBe('2026-01-01T00:00:00Z');
expect(capturedUrl?.searchParams.has('limit')).toBe(false);
});
it('returns whatever the API responds with, deleted entries included', async () => {
const places = [place(1, '2026-01-01T00:00:00Z'), place(2, '2026-01-02T00:00:00Z', true)];
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(places), { status: 200 })));
const result = await fetchAllPlaces(BASE, '1970-01-01T00:00:00Z');
expect(result).toEqual(places);
});
it('throws on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 500, statusText: 'Internal Error' })));
await expect(fetchAllPlaces(BASE, '1970-01-01T00:00:00Z')).rejects.toThrow(/500/);
});
});
describe('latestUpdatedAt', () => {
it('returns the fallback for an empty list', () => {
expect(latestUpdatedAt([], '2026-01-01T00:00:00Z')).toBe('2026-01-01T00:00:00Z');
});
it('picks the chronologically latest timestamp, not the lexicographically largest', () => {
// The whole reason this exists: naive string comparison gets this backwards —
// "...27Z" (whole-second) sorts AFTER "...27.500Z" (mid-second) as a string,
// even though .500Z happened later within that same second.
const places = [place(1, '2026-01-01T00:00:27Z'), place(2, '2026-01-01T00:00:27.500Z')];
expect(latestUpdatedAt(places, '1970-01-01T00:00:00Z')).toBe('2026-01-01T00:00:27.500Z');
});
it('ignores entries with no updated_at', () => {
const places: BtcMapPlace[] = [{ id: 1, lat: 0, lon: 0 }, place(2, '2026-01-01T00:00:00Z')];
expect(latestUpdatedAt(places, '1970-01-01T00:00:00Z')).toBe('2026-01-01T00:00:00Z');
});
it('keeps the fallback if nothing in the batch is newer', () => {
const places = [place(1, '2020-01-01T00:00:00Z')];
expect(latestUpdatedAt(places, '2026-01-01T00:00:00Z')).toBe('2026-01-01T00:00:00Z');
});
});