Files
tiennm99 35999a809d fix(api): give every store call a deadline, harden the small things
Every Upstash command and Neon query now has a deadline (5 s and 8 s): the
Redis client retried a hung connection past the browser's fifteen-second
wait, and the Neon client had no limit. Usernames are NFC-normalised so a
tone mark typed as a combining character is accepted and does not make two
board members of one name. The debug key is compared in constant time. A
panorama whose district the tree does not know fails the draw before a
session is written, not /api/guess after it is consumed. The country branch
of countPanos and the never-triggered per-city cap are gone.
2026-09-29 20:13:07 +07:00

254 lines
9.0 KiB
JavaScript

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,
zIncrBy,
zRangeWithScores,
zRank,
zRevRank,
zRemRangeByRank,
scanKeys,
putJsonIfAbsent,
} 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('gives every command a deadline', () => {
// The client retries a hung connection with exponential backoff; the
// signal caps the whole call so a Redis outage fails the request
// inside the browser's timeout instead of after it.
const signal = getUpstash().client.config.signal();
expect(signal).toBeInstanceOf(AbortSignal);
});
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('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 zRangeWithScores(h, 'lb', 0, -1, false)).toEqual([{ value: 'anh', score: 25 }]);
});
it('increments atomically, creating an absent member at zero', async () => {
const h = getUpstash();
expect(await zIncrBy(h, 'lb', 5, 'ghost')).toBe(5);
expect(await zIncrBy(h, 'lb', 2, 'ghost')).toBe(7);
});
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);
});
});
describe('scanKeys', () => {
// A hand-rolled client, so the cursor loop and the prefix filter can be
// driven directly: the fake answers every scan in one page.
function pagedHandle(pages, prefix = 'vngeoguessr:') {
const calls = [];
const client = {
async scan(cursor, opts) {
calls.push({ cursor, match: opts.match });
const index = Number(cursor);
return [index + 1 < pages.length ? String(index + 1) : '0', pages[index]];
},
};
return { handle: { client, prefix }, calls };
}
it('follows the cursor to the end, de-duplicating repeats', async () => {
const { handle, calls } = pagedHandle([
['vngeoguessr:leaderboard:VN', 'vngeoguessr:leaderboard:HN'],
['vngeoguessr:leaderboard:HN'],
['vngeoguessr:leaderboard:TPHCM'],
]);
const keys = await scanKeys(handle, 'leaderboard:*');
expect(keys.sort()).toEqual(['leaderboard:HN', 'leaderboard:TPHCM', 'leaderboard:VN']);
expect(calls.map((call) => call.cursor)).toEqual(['0', '1', '2']);
expect(calls[0].match).toBe('vngeoguessr:leaderboard:*');
});
it("never hands back another tenant's keys", async () => {
const { handle } = pagedHandle([['other:leaderboard:VN', 'vngeoguessr:leaderboard:VN']]);
expect(await scanKeys(handle, 'leaderboard:*')).toEqual(['leaderboard:VN']);
});
it('finds keys written through the adapter', async () => {
const h = getUpstash();
await putJson(h, 'daily:2026-09-21', { id: 'a' }, 60);
await putJson(h, 'session:x', { id: 'b' }, 60);
expect(await scanKeys(h, 'daily:*')).toEqual(['daily:2026-09-21']);
});
});
describe('putJsonIfAbsent', () => {
it('writes once and refuses to overwrite', async () => {
const h = getUpstash();
expect(await putJsonIfAbsent(h, 'nx', { v: 1 }, 60)).toBe(true);
expect(await putJsonIfAbsent(h, 'nx', { v: 2 }, 60)).toBe(false);
expect(await getJson(h, 'nx')).toEqual({ v: 1 });
});
});
});