mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-14 14:17:57 +00:00
A 403, a malformed answer, a missing thumbnail or a Redis error inside the retry all counted as the panorama being deleted, so one such failure on the cached pick handed players after it a different round under the same number. UpstreamError now carries a 'gone' code for a Mapillary 400 or 404 and a missing thumbnail, and only that moves the day; everything else is thrown. The Redis calls sit outside the retry. Also: the draw budget caps the request in flight, the token travels in an Authorization header rather than the URL, and the daily route's comment no longer claims the daily credits a board.
216 lines
8.4 KiB
JavaScript
216 lines
8.4 KiB
JavaScript
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
vi.mock('@neondatabase/serverless', async () => {
|
|
const { neonModule } = await import('./mock-neon.js');
|
|
return neonModule();
|
|
});
|
|
|
|
import { fetchRegionPanorama } from '../src/lib/mapillary.js';
|
|
import { provinceOf } from '../src/lib/regions.js';
|
|
import { seedPanoFixtures, fixtureIds } from './pano-fixtures.js';
|
|
|
|
// These stub fetch wholesale, which is safe only because nothing here touches
|
|
// Redis -- against a real instance the Upstash client speaks over fetch too.
|
|
// The panorama pool comes from the PGlite fake behind the Neon mock.
|
|
//
|
|
// The district a guess is credited to is resolved here and nowhere else, so
|
|
// these tests pin the two behaviours that carry it: the leaf must come from the
|
|
// attempt that actually succeeded, and an exhausted pool must not become a 500.
|
|
|
|
const ORIGINAL_TOKEN = process.env.MAPILLARY_ACCESS_TOKEN;
|
|
|
|
/**
|
|
* A Graph API reply for one image.
|
|
* @param {string} id Image id.
|
|
* @returns {Object} Response body.
|
|
*/
|
|
function imageBody(id) {
|
|
return {
|
|
id,
|
|
thumb_2048_url: `https://example.invalid/${id}.jpg`,
|
|
is_pano: true,
|
|
geometry: { coordinates: [106.7, 10.77] },
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await seedPanoFixtures(false);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
process.env.MAPILLARY_ACCESS_TOKEN = 'test-token';
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
if (ORIGINAL_TOKEN === undefined) delete process.env.MAPILLARY_ACCESS_TOKEN;
|
|
else process.env.MAPILLARY_ACCESS_TOKEN = ORIGINAL_TOKEN;
|
|
});
|
|
|
|
describe('fetchRegionPanorama', () => {
|
|
it('reports the district of the attempt that succeeded', async () => {
|
|
// Every retry draws a fresh candidate, potentially from a different
|
|
// district. Carrying the first one forward would credit the wrong place.
|
|
const seenIds = [];
|
|
vi.stubGlobal('fetch', async (url) => {
|
|
const id = String(url).split('/').pop().split('?')[0];
|
|
seenIds.push(id);
|
|
// Fail the first lookup, as a deleted image would.
|
|
if (seenIds.length === 1) return new Response('gone', { status: 404 });
|
|
return new Response(JSON.stringify(imageBody(id)), { status: 200 });
|
|
});
|
|
|
|
const result = await fetchRegionPanorama('TPHCM');
|
|
expect(result.success).toBe(true);
|
|
expect(seenIds.length).toBe(2);
|
|
|
|
// The reported district must be the one holding the SECOND id.
|
|
const winner = seenIds[1];
|
|
expect(fixtureIds(result.data.regionCode)).toContain(winner);
|
|
expect(provinceOf(result.data.regionCode)).toBe('TPHCM');
|
|
});
|
|
|
|
it('returns a failure rather than throwing when the pool is exhausted', async () => {
|
|
// Every lookup fails, so all three attempts burn and the retry budget runs
|
|
// out. The route turns this into a message, not a 500.
|
|
vi.stubGlobal('fetch', async () => new Response('gone', { status: 404 }));
|
|
|
|
const result = await fetchRegionPanorama('LD');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/No panorama could be loaded/);
|
|
});
|
|
|
|
it('surfaces an authentication failure instead of retrying it', async () => {
|
|
// A bad token fails identically on every attempt; retrying wastes the
|
|
// budget and buries the real cause.
|
|
let calls = 0;
|
|
vi.stubGlobal('fetch', async () => {
|
|
calls++;
|
|
return new Response('unauthorized', { status: 401 });
|
|
});
|
|
|
|
await expect(fetchRegionPanorama('LD')).rejects.toThrow(/authentication failed/);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('resolves a district draw to that district', async () => {
|
|
vi.stubGlobal('fetch', async (url) => {
|
|
const id = String(url).split('/').pop().split('?')[0];
|
|
return new Response(JSON.stringify(imageBody(id)), { status: 200 });
|
|
});
|
|
|
|
const result = await fetchRegionPanorama('DL');
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.regionCode).toBe('DL');
|
|
});
|
|
|
|
it('sends the token in a header, never in the URL', async () => {
|
|
const seen = [];
|
|
vi.stubGlobal('fetch', async (url, init) => {
|
|
seen.push({ url: String(url), auth: init?.headers?.Authorization });
|
|
const id = String(url).split('/').pop().split('?')[0];
|
|
return new Response(JSON.stringify(imageBody(id)), { status: 200 });
|
|
});
|
|
|
|
expect((await fetchRegionPanorama('DL')).success).toBe(true);
|
|
expect(seen).toHaveLength(1);
|
|
expect(seen[0].url).not.toContain('test-token');
|
|
expect(seen[0].auth).toBe('OAuth test-token');
|
|
});
|
|
|
|
it('resolves a country draw to a district, never to the country', async () => {
|
|
vi.stubGlobal('fetch', async (url) => {
|
|
const id = String(url).split('/').pop().split('?')[0];
|
|
return new Response(JSON.stringify(imageBody(id)), { status: 200 });
|
|
});
|
|
|
|
const result = await fetchRegionPanorama('VN');
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.regionCode).not.toBe('VN');
|
|
expect(provinceOf(result.data.regionCode)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('fetchRegionPanorama with a recent-location history', () => {
|
|
/** Stub every Graph API lookup as a success. */
|
|
function stubEveryLookup() {
|
|
vi.stubGlobal('fetch', async (url) => {
|
|
const id = String(url).split('/').pop().split('?')[0];
|
|
return new Response(JSON.stringify(imageBody(id)), { status: 200 });
|
|
});
|
|
}
|
|
|
|
it('does not return a panorama the player has just seen', async () => {
|
|
stubEveryLookup();
|
|
const all = fixtureIds('DL');
|
|
const recent = new Set(all.slice(0, all.length - 1));
|
|
|
|
// Run it repeatedly: the draw is random, so one pass proves nothing.
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const result = await fetchRegionPanorama('DL', recent);
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.id).toBe(all.at(-1));
|
|
}
|
|
});
|
|
|
|
// A small district holds a few hundred panoramas. Refusing to repeat one
|
|
// there must never turn into "this region has no coverage".
|
|
it('allows a repeat rather than failing when the history covers the pool', async () => {
|
|
stubEveryLookup();
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const all = fixtureIds('DL');
|
|
|
|
const result = await fetchRegionPanorama('DL', new Set(all));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(all).toContain(result.data.id);
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Recent-location filter exhausted DL'));
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('does not spend lookup attempts on the history downgrade', async () => {
|
|
// The downgrade redraws inside the same attempt. Spending one would cost a
|
|
// third of the budget in exactly the small regions that need it most.
|
|
const seenIds = [];
|
|
vi.stubGlobal('fetch', async (url) => {
|
|
const id = String(url).split('/').pop().split('?')[0];
|
|
seenIds.push(id);
|
|
// Fail the first two lookups: only a full budget survives this.
|
|
if (seenIds.length <= 2) return new Response('gone', { status: 404 });
|
|
return new Response(JSON.stringify(imageBody(id)), { status: 200 });
|
|
});
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
const result = await fetchRegionPanorama('DL', new Set(fixtureIds('DL')));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(seenIds).toHaveLength(3);
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('still reports a genuinely empty region as a failure', async () => {
|
|
stubEveryLookup();
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
// TPHCM-Q3 is a real district code with no fixture rows behind it.
|
|
const result = await fetchRegionPanorama('TPHCM-Q3', new Set(['hcm-q1-1']));
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/No panoramas left/);
|
|
// And it must not blame the history for it: a region holding zero rows is
|
|
// a broken reseed, and saying "filter exhausted" sends whoever reads the
|
|
// log to tune HISTORY_LIMIT instead of to the real cause.
|
|
expect(warn).not.toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('lets an infrastructure error through instead of reading it as coverage', async () => {
|
|
// The regression guard for the soft/hard split: the history downgrade must
|
|
// not swallow a Postgres failure and report it as an exhausted pool.
|
|
stubEveryLookup();
|
|
const { getFakeDb } = await import('./fake-neon.js');
|
|
const db = getFakeDb();
|
|
const broken = vi.spyOn(db, 'query').mockRejectedValue(new Error('connection terminated'));
|
|
|
|
await expect(fetchRegionPanorama('DL', new Set(['ld-1']))).rejects.toThrow(/connection terminated/);
|
|
broken.mockRestore();
|
|
});
|
|
});
|