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'); }); });