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).
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user