mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
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:
1 parent
f214f4bd49
commit
c2c1ced387
15 files changed
+2018
-623
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+1044
-608
File diff suppressed because it is too large.
Load diff
+9
-11
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`.'
|
||||
);
|
||||
}
|
||||
@@ -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:',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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:',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user