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.
This commit is contained in:
tiennm99 committed 2026-08-30 09:26:02 +07:00
1 parent f214f4bd49
commit c2c1ced387
15 files changed
+2018 -623

No files matched your search

+3 -1
View File
@@ -20,13 +20,15 @@ For detailed information about this project, refer to the documentation files in
- `npm run build` - Build the application for production
- `npm start` - Start production server
- `npm run lint` - Run ESLint
- `npm test` - Run tests against the in-memory Redis fake (no service needed)
- `npm run test:integration` - Same tests against local Redis (`npm run redis:up` first)
**Key Guidelines:**
- **JavaScript Only**: No TypeScript files (.ts, .tsx)
- **Function Parameters**: Use individual parameters, not object destructuring
- **File Modifications**: Only modify source code, `/docs`, and `/plans`
- **Security**: Never expose secrets or keys
- **Testing**: Inform user when complete - they handle manual testing
- **Testing**: Run `npm test` for `src/lib/` changes; inform user when complete - they handle manual testing of the UI
## Critical Instructions
+36
View File
@@ -0,0 +1,36 @@
# Local stand-in for Upstash Redis.
#
# Upstash itself is cloud-only, but @upstash/redis talks HTTP REST rather than
# RESP, so any server exposing that REST surface is indistinguishable to the
# app. SRH is the proxy Upstash's own docs recommend for this: it accepts the
# Upstash REST API and forwards to a real Redis.
#
# npm run redis:up start it
# npm run redis:down stop it and discard the data
#
# Used by `npm run test:integration`, and usable for `npm run dev` by pointing
# UPSTASH_REDIS_REST_URL at http://localhost:8079 with the token below.
services:
redis:
image: redis:7-alpine
# No volume: this data is disposable by design, and tests flush it anyway.
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
srh:
image: hiett/serverless-redis-http:latest
ports:
- "8079:80"
environment:
SRH_MODE: env
# Local-only credential. SRH accepts any token it is configured with, and
# this one never reaches a real Upstash database.
SRH_TOKEN: vngeoguessr-local-token
SRH_CONNECTION_STRING: "redis://redis:6379"
depends_on:
redis:
condition: service_healthy
+49 -3
View File
@@ -6,6 +6,10 @@
- `npm run build` - Build the application for production
- `npm start` - Start production server
- `npm run lint` - Run ESLint
- `npm test` - Run the test suite against the in-memory Redis fake
- `npm run test:watch` - Re-run tests on change
- `npm run test:integration` - Run the same suite against a local Redis
- `npm run redis:up` / `npm run redis:down` - Start/stop that local Redis
## Important Development Guidelines
@@ -40,10 +44,52 @@
**Mapillary (required)**:
- `MAPILLARY_ACCESS_TOKEN` - Mapillary API token for image fetching
For local Redis without an Upstash account, see *Running Upstash locally* below.
### Testing & Completion
- After completing implementation tasks, inform the user that work is complete
- Ready for manual testing - do NOT attempt to run test commands
- Do NOT start development servers - user handles testing manually
Tests live in `tests/` and cover the logic in `src/lib/`: scoring and distance,
the Upstash key adapter, game sessions, and the leaderboards. Run `npm test`
after changing anything under `src/lib/`.
The suite runs against two backing stores, from one set of test files:
- `npm test` uses `tests/fake-upstash-redis.js`, an in-memory stand-in mocked in
at the `@upstash/redis` boundary. No service, no Docker, well under a second.
This is the default.
- `npm run test:integration` runs the same files against a real Redis. Two of
them skip: one asserts a response shape only an older SDK produces, and one
fast-forwards half an hour to watch a session expire.
Running both is what keeps the fake honest. A behaviour the fake gets wrong
shows up as a green unit run and a red integration run.
Everything above `src/lib/` (UI, panorama viewer, map interaction, Mapillary
calls) is still manual: inform the user when work is complete. Do NOT start
development servers - user handles testing manually.
### Running Upstash locally
Upstash itself is cloud-only, but `@upstash/redis` speaks HTTP REST, so any
server exposing that REST surface is indistinguishable to the app.
`docker-compose.yml` runs the proxy Upstash's own docs recommend, SRH
(`hiett/serverless-redis-http`), in front of a real Redis:
```bash
npm run redis:up # redis + SRH on http://localhost:8079
npm run redis:down # stop and discard the data
```
`npm run test:integration` points at it automatically. To use it for `npm run
dev` as well, set in `.env`:
```
UPSTASH_REDIS_REST_URL=http://localhost:8079
UPSTASH_REDIS_REST_TOKEN=vngeoguessr-local-token
```
SRH accepts commands only as a JSON array POSTed to `/`, which is what the SDK
sends; the path form Upstash also supports (`GET /set/key/value`) returns 404.
## shadcn/ui Configuration
+1044 -608
View File
File diff suppressed because it is too large. Load diff
+9 -11
View File
@@ -6,20 +6,19 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mjs",
"redis:up": "docker compose up -d --wait",
"redis:down": "docker compose down -v"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@photo-sphere-viewer/core": "^5.14.3",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@turf/turf": "^7.2.0",
"@upstash/redis": "^1.38.0",
"@vercel/analytics": "^1.5.0",
@@ -31,10 +30,8 @@
"next": "15.5.18",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "^7.81.0",
"tailwind-merge": "^3.3.1",
"uuid": "^14.0.1",
"zod": "^4.0.16"
"uuid": "^14.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.6",
@@ -42,7 +39,8 @@
"eslint": "^9.39.4",
"eslint-config-next": "15.4.6",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.3.6"
"tw-animate-css": "^1.3.6",
"vitest": "^4.1.11"
},
"engines": {
"node": ">=24"
+147
View File
@@ -0,0 +1,147 @@
// In-memory stand-in for the @upstash/redis client.
//
// The real client speaks HTTP REST, not RESP, so a plain Redis container cannot
// serve it without an extra proxy in front. The app only touches nine commands
// over strings and sorted sets, so a faithful in-memory implementation is both
// simpler to run and enough to exercise every path in lib/upstash.js.
//
// Semantics deliberately mirror Redis where the app depends on them:
// - sorted sets order by score ascending, ties broken by member lexicographically
// - zrange/zremrangebyrank take inclusive indices and accept negative offsets
// - zrange REV reverses the index space, so 0 is the highest score
// - keys with an elapsed TTL read back as missing
const instances = [];
/** Resolve a possibly-negative inclusive index pair against a list length. */
function resolveRange(length, start, stop) {
let from = start < 0 ? length + start : start;
let to = stop < 0 ? length + stop : stop;
if (from < 0) from = 0;
if (to >= length) to = length - 1;
return { from, to };
}
/** Order sorted-set entries the way Redis does: by score, then by member. */
function sortEntries(entries) {
return [...entries].sort((a, b) =>
a.score === b.score ? a.member.localeCompare(b.member) : a.score - b.score
);
}
export class FakeRedis {
constructor(config = {}) {
this.config = config;
// key -> { value, expireAt } for strings, key -> Map<member, score> for zsets.
this.strings = new Map();
this.zsets = new Map();
// Set by tests that need zrange to answer in the older array-of-objects
// shape instead of the flat [member, score, ...] the current SDK returns.
this.withScoresShape = 'flat';
instances.push(this);
}
/** Clear every store handed out so far. Call between tests. */
static resetAll() {
for (const instance of instances) {
instance.strings.clear();
instance.zsets.clear();
instance.withScoresShape = 'flat';
}
}
/** The client most recently constructed, for store-level assertions. */
static latest() {
return instances[instances.length - 1];
}
async get(key) {
const entry = this.strings.get(key);
if (!entry) return null;
if (entry.expireAt != null && Date.now() >= entry.expireAt) {
this.strings.delete(key);
return null;
}
return entry.value;
}
async set(key, value, opts) {
const expireAt = opts?.ex != null ? Date.now() + opts.ex * 1000 : null;
this.strings.set(key, { value, expireAt });
return 'OK';
}
/** Remaining TTL in seconds, mirroring Redis: -2 missing, -1 no expiry. */
async ttl(key) {
const entry = this.strings.get(key);
if (!entry) return -2;
if (entry.expireAt == null) return -1;
return Math.ceil((entry.expireAt - Date.now()) / 1000);
}
async del(key) {
const had = this.strings.delete(key);
const hadZset = this.zsets.delete(key);
return had || hadZset ? 1 : 0;
}
async zadd(key, { score, member }) {
let zset = this.zsets.get(key);
if (!zset) {
zset = new Map();
this.zsets.set(key, zset);
}
const isNew = !zset.has(member);
zset.set(member, score);
return isNew ? 1 : 0;
}
async zscore(key, member) {
const zset = this.zsets.get(key);
if (!zset || !zset.has(member)) return null;
return zset.get(member);
}
async zrange(key, start, stop, opts = {}) {
const ordered = this.#ordered(key, opts.rev);
const { from, to } = resolveRange(ordered.length, start, stop);
const slice = from > to ? [] : ordered.slice(from, to + 1);
if (!opts.withScores) return slice.map((entry) => entry.member);
if (this.withScoresShape === 'objects') {
return slice.map((entry) => ({ member: entry.member, score: entry.score }));
}
return slice.flatMap((entry) => [entry.member, entry.score]);
}
async zrank(key, member) {
const index = this.#ordered(key, false).findIndex((e) => e.member === member);
return index === -1 ? null : index;
}
async zrevrank(key, member) {
const index = this.#ordered(key, true).findIndex((e) => e.member === member);
return index === -1 ? null : index;
}
async zremrangebyrank(key, start, stop) {
const ordered = this.#ordered(key, false);
const { from, to } = resolveRange(ordered.length, start, stop);
if (from > to) return 0;
const zset = this.zsets.get(key);
const doomed = ordered.slice(from, to + 1);
for (const entry of doomed) zset.delete(entry.member);
return doomed.length;
}
/** Sorted-set contents in rank order, newest sort applied on every read. */
#ordered(key, rev) {
const zset = this.zsets.get(key);
if (!zset) return [];
const ordered = sortEntries(
[...zset.entries()].map(([member, score]) => ({ member, score }))
);
return rev ? ordered.reverse() : ordered;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import {
CITIES,
cities,
cityDeltas,
calculateDistance,
calculateScore,
formatDistance,
} from '../src/lib/game.js';
describe('calculateScore', () => {
// The bands are the whole scoring rule, so each boundary is pinned on both
// sides: an off-by-one here silently rewrites what players are rewarded for.
it.each([
[0, 5],
[50, 5],
[51, 4],
[100, 4],
[101, 3],
[200, 3],
[201, 2],
[500, 2],
[501, 1],
[1000, 1],
[1001, 0],
[50000, 0],
])('scores %dm as %d', (distance, expected) => {
expect(calculateScore(distance)).toBe(expected);
});
});
describe('calculateDistance', () => {
it('returns zero for identical coordinates', () => {
expect(calculateDistance(10.8231, 106.6297, 10.8231, 106.6297)).toBe(0);
});
it('measures one degree of latitude as roughly 111km', () => {
const metres = calculateDistance(10, 106, 11, 106);
// Turf uses a spherical earth, so the exact figure depends on its radius
// constant. Assert the magnitude, not the constant.
expect(metres).toBeGreaterThan(110_000);
expect(metres).toBeLessThan(112_000);
});
it('is symmetric', () => {
const there = calculateDistance(10.8231, 106.6297, 21.0285, 105.8542);
const back = calculateDistance(21.0285, 105.8542, 10.8231, 106.6297);
expect(there).toBe(back);
});
it('returns whole metres', () => {
const metres = calculateDistance(10.8231, 106.6297, 10.8241, 106.6307);
expect(Number.isInteger(metres)).toBe(true);
});
it('grows with separation', () => {
const near = calculateDistance(10.82, 106.62, 10.83, 106.62);
const far = calculateDistance(10.82, 106.62, 10.9, 106.62);
expect(far).toBeGreaterThan(near);
});
});
describe('formatDistance', () => {
it.each([
[0, '0m'],
[999, '999m'],
[1000, '1.00km'],
[1500, '1.50km'],
[12300, '12.30km'],
])('formats %dm as %s', (distance, expected) => {
expect(formatDistance(distance)).toBe(expected);
});
});
describe('city configuration', () => {
it('exposes only enabled cities to the UI', () => {
const exposed = cities.map((city) => city.code).sort();
const expected = Object.values(CITIES)
.filter((city) => city.enabled)
.map((city) => city.code)
.sort();
expect(exposed).toEqual(expected);
});
it('uppercases city names for the UI', () => {
for (const city of cities) {
expect(city.name).toBe(city.name.toUpperCase());
}
});
it('gives every city a Mapillary delta and a well-formed bbox', () => {
for (const city of Object.values(CITIES)) {
expect(cityDeltas[city.code]).toBeGreaterThan(0);
const [minLng, minLat, maxLng, maxLat] = city.bbox;
expect(maxLng).toBeGreaterThan(minLng);
expect(maxLat).toBeGreaterThan(minLat);
// The centre must sit inside the box it labels, or the map opens on one
// city while the panorama comes from another.
const [centreLat, centreLng] = city.center;
expect(centreLat).toBeGreaterThanOrEqual(minLat);
expect(centreLat).toBeLessThanOrEqual(maxLat);
expect(centreLng).toBeGreaterThanOrEqual(minLng);
expect(centreLng).toBeLessThanOrEqual(maxLng);
}
});
});
+191
View File
@@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('@upstash/redis', async (importOriginal) => {
const { upstashModule } = await import('./mock-upstash.js');
return upstashModule(importOriginal);
});
import {
getLeaderboard,
submitScore,
submitDistanceRecord,
} from '../src/lib/leaderboard.js';
import { resetStore, storedKeys } from './redis-harness.js';
const MAX_LEADERBOARD_SIZE = 200;
describe('score leaderboard', () => {
beforeEach(async () => {
await resetStore();
});
it('accumulates repeat submissions rather than replacing them', async () => {
await submitScore('mai', 3, 'TPHCM');
const second = await submitScore('mai', 4, 'TPHCM');
expect(second.global.score).toBe(7);
expect(second.city.score).toBe(7);
});
it('keeps city totals independent of each other', async () => {
await submitScore('mai', 5, 'TPHCM');
const inHanoi = await submitScore('mai', 2, 'HN');
expect(inHanoi.city.score).toBe(2);
// The global total spans both cities.
expect(inHanoi.global.score).toBe(7);
});
it('lowercases the city key', async () => {
await submitScore('mai', 1, 'TPHCM');
expect(await storedKeys()).toContain('vngeoguessr:leaderboard:city:tphcm');
});
it('ranks the highest total first, counting from one', async () => {
await submitScore('anh', 2, 'HN');
await submitScore('binh', 5, 'HN');
const chi = await submitScore('chi', 4, 'HN');
expect(chi.city.rank).toBe(2);
const leaderboard = await getLeaderboard('HN', 10, 'score');
expect(leaderboard).toEqual([
{ username: 'binh', score: 5, rank: 1 },
{ username: 'chi', score: 4, rank: 2 },
{ username: 'anh', score: 2, rank: 3 },
]);
});
it('moves a player up as their total grows', async () => {
await submitScore('anh', 5, 'HN');
await submitScore('binh', 3, 'HN');
expect((await submitScore('binh', 4, 'HN')).city.rank).toBe(1);
});
it('honours the requested limit', async () => {
for (const name of ['anh', 'binh', 'chi', 'dung']) {
await submitScore(name, 1, 'HN');
}
expect(await getLeaderboard('HN', 2, 'score')).toHaveLength(2);
});
it('returns an empty leaderboard before anyone plays', async () => {
expect(await getLeaderboard('HN', 10, 'score')).toEqual([]);
});
it('keeps only the top entries once the cap is passed', async () => {
// Trimming is the only thing bounding this key's growth, and it uses a
// negative rank window that is easy to get backwards.
const overflow = MAX_LEADERBOARD_SIZE + 5;
for (let i = 0; i < overflow; i++) {
await submitScore(`player${String(i).padStart(3, '0')}`, i + 1, 'HN');
}
const strongest = `player${String(overflow - 1).padStart(3, '0')}`;
// Both the city key and the global key are trimmed, by separate calls with
// separate rank windows, so both need asserting.
for (const scope of ['HN', null]) {
const all = await getLeaderboard(scope, overflow, 'score');
expect(all).toHaveLength(MAX_LEADERBOARD_SIZE);
// The five weakest players are the ones dropped, not the strongest.
expect(all[0].username).toBe(strongest);
expect(all.map((e) => e.username)).not.toContain('player000');
}
});
it.each([
['username', ['', 3, 'HN']],
['cityCode', ['mai', 3, '']],
])('rejects a submission missing %s', async (_field, args) => {
await expect(submitScore(...args)).rejects.toThrow(/Missing required fields/);
});
it('trims surrounding whitespace from the username', async () => {
const result = await submitScore(' mai ', 3, 'HN');
expect(result.global.username).toBe('mai');
expect((await getLeaderboard('HN', 10, 'score'))[0].username).toBe('mai');
});
});
describe('distance leaderboard', () => {
beforeEach(async () => {
await resetStore();
});
it('ranks the shortest distance first', async () => {
await submitDistanceRecord('anh', 900, 'DL');
await submitDistanceRecord('binh', 120, 'DL');
const chi = await submitDistanceRecord('chi', 400, 'DL');
expect(chi.cityDistance.rank).toBe(2);
const leaderboard = await getLeaderboard('DL', 10, 'distance');
expect(leaderboard.map((e) => [e.username, e.distance])).toEqual([
['binh', 120],
['chi', 400],
['anh', 900],
]);
expect(leaderboard.map((e) => e.rank)).toEqual([1, 2, 3]);
});
it('keeps every attempt instead of overwriting the previous one', async () => {
// Unlike scores, each distance attempt gets its own slot, so a player can
// hold several rows.
await submitDistanceRecord('mai', 500, 'DL');
await submitDistanceRecord('mai', 80, 'DL');
const leaderboard = await getLeaderboard('DL', 10, 'distance');
expect(leaderboard).toHaveLength(2);
expect(leaderboard.map((e) => e.distance)).toEqual([80, 500]);
});
it('decodes the username, distance and timestamp from the entry id', async () => {
const before = Date.now();
await submitDistanceRecord('mai', 250, 'DL');
const [entry] = await getLeaderboard('DL', 10, 'distance');
expect(entry.username).toBe('mai');
expect(entry.distance).toBe(250);
expect(entry.timestamp).toBeGreaterThanOrEqual(before);
});
it('writes to both the city and the global key', async () => {
await submitDistanceRecord('mai', 250, 'DL');
const keys = await storedKeys();
expect(keys).toContain('vngeoguessr:distance:city:dl');
expect(keys).toContain('vngeoguessr:distance:vietnam');
expect(await getLeaderboard(null, 10, 'distance')).toHaveLength(1);
});
it('keeps only the closest entries once the cap is passed', async () => {
const overflow = MAX_LEADERBOARD_SIZE + 5;
for (let i = 0; i < overflow; i++) {
await submitDistanceRecord('mai', (i + 1) * 10, 'DL');
}
const all = await getLeaderboard('DL', overflow, 'distance');
expect(all).toHaveLength(MAX_LEADERBOARD_SIZE);
expect(all[0].distance).toBe(10);
// The furthest five attempts are the ones dropped.
expect(all[all.length - 1].distance).toBe(MAX_LEADERBOARD_SIZE * 10);
});
it.each([
['username', ['', 100, 'DL']],
['cityCode', ['mai', 100, '']],
])('rejects a submission missing %s', async (_field, args) => {
await expect(submitDistanceRecord(...args)).rejects.toThrow(/Missing required fields/);
});
});
describe('global leaderboard', () => {
beforeEach(async () => {
await resetStore();
});
it('spans every city', async () => {
await submitScore('anh', 5, 'HN');
await submitScore('binh', 3, 'TPHCM');
const global = await getLeaderboard(null, 10, 'score');
expect(global.map((e) => e.username)).toEqual(['anh', 'binh']);
});
it('defaults to the score leaderboard', async () => {
await submitScore('anh', 5, 'HN');
expect(await getLeaderboard()).toEqual([{ username: 'anh', score: 5, rank: 1 }]);
});
});
+11
View File
@@ -0,0 +1,11 @@
// Shared vi.mock factory for @upstash/redis.
//
// Each test file hoists its own vi.mock call, but they all delegate here so the
// fake-vs-real switch lives in one place. With TEST_REDIS=real the factory hands
// back the untouched SDK, which then talks to SRH.
export async function upstashModule(importOriginal) {
if (process.env.TEST_REDIS === 'real') return importOriginal();
const { FakeRedis } = await import('./fake-upstash-redis.js');
return { Redis: FakeRedis };
}
+55
View File
@@ -0,0 +1,55 @@
// Lets one set of tests run against either backing store.
//
// By default the suite mocks @upstash/redis with the in-memory fake, so it
// needs no service at all. With TEST_REDIS=real the mock steps aside and the
// real SDK talks to SRH, which serves the Upstash REST API on top of a real
// Redis (see docker-compose.yml). Running the same assertions both ways is what
// keeps the fake honest.
//
// Test bodies must go through this module rather than reaching into the fake's
// internals, or they only work in one mode.
import { getUpstash } from '../src/lib/upstash.js';
import { FakeRedis } from './fake-upstash-redis.js';
export const isFake = process.env.TEST_REDIS !== 'real';
/** Skip helper for assertions that only make sense against the fake. */
export const fakeOnly = { skip: !isFake };
/** Empty the store so each test starts from nothing. */
export async function resetStore() {
if (isFake) {
FakeRedis.resetAll();
return;
}
const h = getUpstash();
const existing = await h.client.keys(`${h.prefix}*`);
if (existing.length > 0) await h.client.del(...existing);
}
/** Every physical key currently stored, prefix included, sorted. */
export async function storedKeys() {
if (isFake) {
const client = FakeRedis.latest();
if (!client) return [];
return [...client.strings.keys(), ...client.zsets.keys()].sort();
}
const h = getUpstash();
return (await h.client.keys(`${h.prefix}*`)).sort();
}
/** Remaining TTL in seconds for a physical key: -2 missing, -1 no expiry. */
export async function ttlOf(physicalKey) {
if (isFake) return FakeRedis.latest().ttl(physicalKey);
return await getUpstash().client.ttl(physicalKey);
}
/** Write a raw value, bypassing the adapter's JSON encoding. */
export async function writeRaw(physicalKey, value) {
if (isFake) {
FakeRedis.latest().strings.set(physicalKey, { value, expireAt: null });
return;
}
await getUpstash().client.set(physicalKey, value);
}
+89
View File
@@ -0,0 +1,89 @@
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();
});
});
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('@upstash/redis', async (importOriginal) => {
const { upstashModule } = await import('./mock-upstash.js');
return upstashModule(importOriginal);
});
import {
getUpstash,
getJson,
putJson,
del,
zAdd,
zScore,
zRangeWithScores,
zRank,
zRevRank,
zRemRangeByRank,
} from '../src/lib/upstash.js';
import { fakeOnly, resetStore, storedKeys, ttlOf, writeRaw } from './redis-harness.js';
describe('upstash adapter', () => {
beforeEach(async () => {
await resetStore();
});
describe('handle', () => {
it('is a singleton', () => {
expect(getUpstash()).toBe(getUpstash());
});
it('carries the default key prefix', () => {
expect(getUpstash().prefix).toBe('vngeoguessr:');
});
});
describe('key prefixing', () => {
// Every physical key must carry the prefix so the project can share an
// Upstash database with others without colliding. Callers pass logical keys
// and never see it, so only a store-level assertion can catch a regression.
it('prefixes string keys', async () => {
const h = getUpstash();
await putJson(h, 'session:abc', { city: 'HN' }, null);
expect(await storedKeys()).toEqual(['vngeoguessr:session:abc']);
});
it('prefixes sorted set keys', async () => {
const h = getUpstash();
await zAdd(h, 'leaderboard:vietnam', 5, 'mai');
expect(await storedKeys()).toEqual(['vngeoguessr:leaderboard:vietnam']);
});
});
describe('getJson / putJson / del', () => {
it('round-trips an object', async () => {
const h = getUpstash();
const session = { sessionId: 'abc', exactLocation: { lat: 10.8, lng: 106.6 } };
await putJson(h, 'session:abc', session, null);
expect(await getJson(h, 'session:abc')).toEqual(session);
});
it('returns null for a missing key', async () => {
expect(await getJson(getUpstash(), 'session:nope')).toBeNull();
});
it('reads a value written outside the adapter', async () => {
// Some SDK versions parse JSON before the adapter sees it, others hand
// back the raw string; getJson has to cope with either without
// double-parsing.
const h = getUpstash();
await writeRaw('vngeoguessr:session:raw', JSON.stringify({ city: 'DL' }));
expect(await getJson(h, 'session:raw')).toEqual({ city: 'DL' });
});
it('applies a TTL when one is given', async () => {
const h = getUpstash();
await putJson(h, 'session:ttl', { a: 1 }, 60);
const ttl = await ttlOf('vngeoguessr:session:ttl');
expect(ttl).toBeGreaterThan(55);
expect(ttl).toBeLessThanOrEqual(60);
});
it('leaves a key persistent when no TTL is given', async () => {
const h = getUpstash();
await putJson(h, 'session:forever', { a: 1 }, null);
expect(await ttlOf('vngeoguessr:session:forever')).toBe(-1);
});
it('deletes a key', async () => {
const h = getUpstash();
await putJson(h, 'session:gone', { a: 1 }, null);
await del(h, 'session:gone');
expect(await getJson(h, 'session:gone')).toBeNull();
});
});
describe('sorted sets', () => {
async function seed(h) {
await zAdd(h, 'lb', 10, 'anh');
await zAdd(h, 'lb', 30, 'binh');
await zAdd(h, 'lb', 20, 'chi');
}
it('reads back a score', async () => {
const h = getUpstash();
await seed(h);
expect(await zScore(h, 'lb', 'binh')).toBe(30);
});
it('returns null for an absent member', async () => {
expect(await zScore(getUpstash(), 'lb', 'ghost')).toBeNull();
});
it('overwrites rather than accumulating on re-add', async () => {
const h = getUpstash();
await zAdd(h, 'lb', 10, 'anh');
await zAdd(h, 'lb', 25, 'anh');
expect(await zScore(h, 'lb', 'anh')).toBe(25);
});
it('ranges ascending', async () => {
const h = getUpstash();
await seed(h);
expect(await zRangeWithScores(h, 'lb', 0, -1, false)).toEqual([
{ value: 'anh', score: 10 },
{ value: 'chi', score: 20 },
{ value: 'binh', score: 30 },
]);
});
it('ranges descending', async () => {
const h = getUpstash();
await seed(h);
expect(await zRangeWithScores(h, 'lb', 0, -1, true)).toEqual([
{ value: 'binh', score: 30 },
{ value: 'chi', score: 20 },
{ value: 'anh', score: 10 },
]);
});
it('honours a limit', async () => {
const h = getUpstash();
await seed(h);
const top = await zRangeWithScores(h, 'lb', 0, 1, true);
expect(top.map((e) => e.value)).toEqual(['binh', 'chi']);
});
it('reshapes the object form the older SDK returns', fakeOnly, async () => {
// lib/upstash.js normalises two different withScores shapes; the flat one
// is covered by every other case here, so pin the object one too.
const h = getUpstash();
await seed(h);
const { FakeRedis } = await import('./fake-upstash-redis.js');
FakeRedis.latest().withScoresShape = 'objects';
expect(await zRangeWithScores(h, 'lb', 0, -1, true)).toEqual([
{ value: 'binh', score: 30 },
{ value: 'chi', score: 20 },
{ value: 'anh', score: 10 },
]);
});
it('returns an empty list for a missing key', async () => {
expect(await zRangeWithScores(getUpstash(), 'lb:missing', 0, -1, true)).toEqual([]);
});
it('ranks ascending and descending', async () => {
const h = getUpstash();
await seed(h);
expect(await zRank(h, 'lb', 'anh')).toBe(0);
expect(await zRevRank(h, 'lb', 'anh')).toBe(2);
expect(await zRevRank(h, 'lb', 'binh')).toBe(0);
});
it('returns a null rank for an absent member', async () => {
const h = getUpstash();
await seed(h);
expect(await zRank(h, 'lb', 'ghost')).toBeNull();
expect(await zRevRank(h, 'lb', 'ghost')).toBeNull();
});
it('trims by rank', async () => {
const h = getUpstash();
await seed(h);
const removed = await zRemRangeByRank(h, 'lb', 0, 0);
expect(removed).toBe(1);
const remaining = await zRangeWithScores(h, 'lb', 0, -1, false);
expect(remaining.map((e) => e.value)).toEqual(['chi', 'binh']);
});
it('removes nothing when the trim window falls outside the set', async () => {
// This is the shape submitScore uses: with fewer members than the cap, the
// negative stop resolves below the start and must be a no-op.
const h = getUpstash();
await seed(h);
expect(await zRemRangeByRank(h, 'lb', 0, -201)).toBe(0);
expect(await zRangeWithScores(h, 'lb', 0, -1, false)).toHaveLength(3);
});
});
});
+36
View File
@@ -0,0 +1,36 @@
// Global setup for the integration run: fail fast and legibly when the local
// Upstash stand-in is not up, rather than letting every test time out.
// globalSetup runs before the config's `env` block reaches the process, so
// these fall back to the values docker-compose.yml serves.
const URL = process.env.UPSTASH_REDIS_REST_URL ?? 'http://localhost:8079';
const TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN ?? 'vngeoguessr-local-token';
const ATTEMPTS = 20;
const DELAY_MS = 500;
export async function setup() {
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
// A command posted as a JSON array is exactly what the SDK sends, so a
// PONG here means SRH is up and its Redis connection works. SRH serves
// only this form, not the path form Upstash also accepts.
const response = await fetch(URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(['PING']),
});
if (response.ok) return;
} catch {
// Not listening yet; fall through to the retry.
}
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
}
throw new Error(
`No Upstash-compatible server answered at ${URL} after ${ATTEMPTS} attempts. ` +
'Start it with `npm run redis:up`.'
);
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.js'],
// lib/upstash.js refuses to build a client without these. The tests mock the
// SDK itself, so the values are never dialled -- they only have to exist.
env: {
UPSTASH_REDIS_REST_URL: 'http://fake-upstash.test',
UPSTASH_REDIS_REST_TOKEN: 'fake-token',
// Pinned so the assertions on physical keys hold regardless of what the
// developer's .env sets.
KEY_PREFIX: 'vngeoguessr:',
},
},
});
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
// Runs the same suite as vitest.config.mjs, but against the local SRH container
// instead of the in-memory fake, so real Redis answers every command. Start the
// stack first: `npm run redis:up`.
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.js'],
globalSetup: ['tests/wait-for-srh.js'],
// Real network round-trips, and the overflow tests issue a few hundred of
// them, so the default timeout is too tight.
testTimeout: 30_000,
// One file at a time: they all share a single Redis and each flushes the
// keyspace between tests.
fileParallelism: false,
env: {
TEST_REDIS: 'real',
UPSTASH_REDIS_REST_URL: 'http://localhost:8079',
// Matches SRH_TOKEN in docker-compose.yml.
UPSTASH_REDIS_REST_TOKEN: 'vngeoguessr-local-token',
KEY_PREFIX: 'vngeoguessr:',
},
},
});