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.
148 lines
4.8 KiB
JavaScript
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;
|
|
}
|
|
}
|