Files
vngeoguessr/tests/fake-upstash-redis.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

148 lines
4.8 KiB
JavaScript

// 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;
}
}