mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
Scoring, the Upstash key adapter, sessions and the leaderboards had no coverage, and they carry the anti-cheat model: server-held coordinates, session TTLs, rank arithmetic. One suite runs against two backing stores. By default @upstash/redis is mocked with an in-memory fake, so `npm test` needs no service and finishes well under a second. With TEST_REDIS=real the mock steps aside and the same files run against a real Redis behind SRH, the proxy Upstash recommends for local work, since the SDK speaks HTTP REST rather than RESP and a plain Redis container cannot serve it alone. Running both is what keeps the fake honest: a semantic it gets wrong shows up as a green unit run and a red integration run. Two tests skip against real Redis, one asserting a response shape only an older SDK produces and one fast-forwarding half an hour to watch a session expire. The suite was checked by mutation rather than by passing alone. Breaking a score band, the key prefix and the session TTL each failed a test; reversing the global trim window did not, because only the city leaderboard was asserted, so that test now covers both scopes. The compose stack doubles as a local Redis for `npm run dev`, which had no credentials to reach one before. package.json also loses the eight unused runtime dependencies removed in the cleanup that follows; they cannot be split from the same file.
90 lines
2.9 KiB
JavaScript
90 lines
2.9 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',
|
|
cityCode: 'TPHCM',
|
|
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', cityCode: 'HN' });
|
|
expect((await getGameSession('sess-1')).cityCode).toBe('TPHCM');
|
|
expect((await getGameSession('sess-2')).cityCode).toBe('HN');
|
|
});
|
|
|
|
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('tolerates deleting a session that is already gone', async () => {
|
|
await expect(deleteGameSession('never-existed')).resolves.toBe(true);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|