Files
vngeoguessr/tests/session.test.js
T
tiennm99 c2c1ced387 test: cover the lib layer against a fake and a real Redis
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.
2026-08-30 09:26:02 +07:00

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