75 lines
2.9 KiB
TypeScript
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');
|
||
|
|
});
|
||
|
|
});
|