mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-14 18:19:30 +00:00
A round now carries two regions. The one the player picked is public and comes back in every response; the one the panorama actually sits in is a secret, and it is what the leaderboard fans out from. Revealing the second before the guess would collapse a country-wide round to a single district, so it joins the exact coordinates on the never-serialized list -- including in the session lookup handler, which echoes fields back to whoever holds the session id, and that is the player. Session consumption is now a claim rather than a courtesy. Reading a session and then deleting it is not a guard: ten concurrent submits all read it alive, all delete it, and all score. DEL is atomic and returns how many keys it removed, so exactly one caller sees a 1 -- the route scores only if it won that. Consuming before the writes also closes the sequential case, where a failure partway through the fan-out would otherwise leave the session alive for half an hour and let a retry re-credit every level that already succeeded. A guess lost to a mid-write failure is the accepted cost. Region parsing lives in one place. Four routes accept a region, and four slightly different ideas about casing and defaulting is how a typo becomes a leaderboard key nobody reads. Unknown and uncovered codes are rejected with a 400 that names the region, rather than served as an empty board that looks exactly like a region nobody has played yet. Sessions created before this change still score, at province level, since they carry no district. They expire within half an hour, so the fallback can go a release from now. The debug coverage route serves any region with an outline rather than only the five provinces. Its directory keeps the city-coverage name for now: the page that calls it cannot be read in this environment, and renaming the route without updating its caller would break it.
100 lines
3.5 KiB
JavaScript
100 lines
3.5 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
vi.mock('@upstash/redis', async (importOriginal) => {
|
|
const { upstashModule } = await import('./mock-upstash.js');
|
|
return upstashModule(importOriginal);
|
|
});
|
|
|
|
import {
|
|
storeGameSession,
|
|
getGameSession,
|
|
deleteGameSession,
|
|
} from '../src/lib/session.js';
|
|
import { fakeOnly, resetStore, storedKeys, ttlOf } from './redis-harness.js';
|
|
|
|
const SESSION = {
|
|
sessionId: 'sess-1',
|
|
pickedRegion: 'TPHCM',
|
|
regionCode: 'TPHCM-Q7',
|
|
exactLocation: { lat: 10.7769, lng: 106.7009 },
|
|
imageId: '123456789',
|
|
createdAt: 1_700_000_000_000,
|
|
};
|
|
|
|
describe('game sessions', () => {
|
|
beforeEach(async () => {
|
|
await resetStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('round-trips a session', async () => {
|
|
await storeGameSession('sess-1', SESSION);
|
|
expect(await getGameSession('sess-1')).toEqual(SESSION);
|
|
});
|
|
|
|
it('returns null for an unknown session', async () => {
|
|
expect(await getGameSession('never-existed')).toBeNull();
|
|
});
|
|
|
|
it('namespaces the key under session:', async () => {
|
|
await storeGameSession('sess-1', SESSION);
|
|
expect(await storedKeys()).toEqual(['vngeoguessr:session:sess-1']);
|
|
});
|
|
|
|
it('keeps sessions separate', async () => {
|
|
await storeGameSession('sess-1', SESSION);
|
|
await storeGameSession('sess-2', { ...SESSION, sessionId: 'sess-2', regionCode: 'HN-BADINH' });
|
|
expect((await getGameSession('sess-1')).regionCode).toBe('TPHCM-Q7');
|
|
expect((await getGameSession('sess-2')).regionCode).toBe('HN-BADINH');
|
|
});
|
|
|
|
it('overwrites on re-store', async () => {
|
|
await storeGameSession('sess-1', SESSION);
|
|
await storeGameSession('sess-1', { ...SESSION, imageId: 'replaced' });
|
|
expect((await getGameSession('sess-1')).imageId).toBe('replaced');
|
|
});
|
|
|
|
it('deletes a session', async () => {
|
|
await storeGameSession('sess-1', SESSION);
|
|
await deleteGameSession('sess-1');
|
|
expect(await getGameSession('sess-1')).toBeNull();
|
|
});
|
|
|
|
it('reports that it did not remove a session that was already gone', async () => {
|
|
// The return value is a claim, not a status: scoring is gated on it, so a
|
|
// second submit of the same session has to come back false rather than
|
|
// report success for a delete that removed nothing.
|
|
await expect(deleteGameSession('never-existed')).resolves.toBe(false);
|
|
});
|
|
|
|
it('reports the claim exactly once for a session that exists', async () => {
|
|
await storeGameSession('claim-me', SESSION);
|
|
await expect(deleteGameSession('claim-me')).resolves.toBe(true);
|
|
await expect(deleteGameSession('claim-me')).resolves.toBe(false);
|
|
});
|
|
|
|
it('sets a thirty minute expiry', async () => {
|
|
// The TTL is what stops an abandoned round's coordinates from lingering in
|
|
// Redis, so it is worth pinning rather than trusting the constant.
|
|
await storeGameSession('sess-1', SESSION);
|
|
const ttl = await ttlOf('vngeoguessr:session:sess-1');
|
|
expect(ttl).toBeGreaterThan(29 * 60);
|
|
expect(ttl).toBeLessThanOrEqual(30 * 60);
|
|
});
|
|
|
|
it('stops returning a session once the expiry passes', fakeOnly, async () => {
|
|
// Only the fake can be fast-forwarded; against real Redis this would mean
|
|
// sleeping half an hour.
|
|
vi.useFakeTimers();
|
|
await storeGameSession('sess-1', SESSION);
|
|
|
|
vi.advanceTimersByTime(29 * 60 * 1000);
|
|
expect(await getGameSession('sess-1')).toEqual(SESSION);
|
|
|
|
vi.advanceTimersByTime(2 * 60 * 1000);
|
|
expect(await getGameSession('sess-1')).toBeNull();
|
|
});
|
|
});
|