mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
Backend: - rate-limiter: retryAfter now in seconds; ms-precision lu preserves fractional regen residue across calls (C1, C2) - redis-client: throw on Upstash 200-with-error envelope; redisRaw returns body.result (NH1) - constants: MAX_BATCH_SIZE = MAX_CREDITS = 256 (was 512 vs 256) - worker: content-length cap, gzip + s-maxage=10 on /api/canvas, broadcast via executionCtx.waitUntil with r.ok check (NC2, H4, H5) - canvas-storage: warn on truncated Upstash read instead of silent zero-pad (NH2) - get-user-id: SHA-256 (16 hex chars) replaces 32-bit string hash; missing cf-connecting-ip routes to anon:dev with warn (H1, H2); function is now async - canvas-room: log unclean WS closes and errors; defensive close on unexpected client message (NH4, N5) Frontend: - pixel buffer capped at MAX_BATCH_SIZE with toast (NC2) - Submit error UX: toast for 429/413/400/5xx/network; honor retryAfter (NC1) - committedColors allocated upfront so WS updates during initial fetch no longer null-deref (NC3) - handleWheel always renders even when zoom is clamped (C1, C2) - canvas-decoder throws on truncated input instead of reading past end with || 0 (C3) - WS reconnect refetches canvas to recover missed pixels (C4) - pixel-buffer Map cache for O(1) getColorAt/pixelCount (NH1) - cancel in-progress stroke on mode switch (NH3) - canvas load error overlay with Retry button (NH5) - DPR-aware canvas sizing (H1) - onMount cleanup is now sync (no leaked resize listener) (H2) Tests: - update for async getUserId, decoder bounds check, redis error format; add Upstash error-envelope coverage
118 lines
3.5 KiB
JavaScript
118 lines
3.5 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock global fetch
|
|
const mockFetch = vi.fn();
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
import { redisRaw, redisRawBinary } from '../../src/lib/redis-client.js';
|
|
|
|
const env = {
|
|
UPSTASH_REDIS_REST_URL: 'https://redis.example.com',
|
|
UPSTASH_REDIS_REST_TOKEN: 'test-token',
|
|
};
|
|
|
|
describe('redisRaw', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it('sends POST with JSON body and auth header', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ result: 'OK' }),
|
|
});
|
|
|
|
await redisRaw(env, ['SET', 'key', 'value']);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(env.UPSTASH_REDIS_REST_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: '["SET","key","value"]',
|
|
});
|
|
});
|
|
|
|
it('returns the response result field', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ result: 'PONG' }),
|
|
});
|
|
const result = await redisRaw(env, ['PING']);
|
|
expect(result).toBe('PONG');
|
|
});
|
|
|
|
it('throws on non-ok HTTP response', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
text: () => Promise.resolve('Unauthorized'),
|
|
});
|
|
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis HTTP 401');
|
|
});
|
|
|
|
it('throws on Upstash 200 with error envelope', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ error: 'ERR wrong number of arguments' }),
|
|
});
|
|
await expect(redisRaw(env, ['BITFIELD'])).rejects.toThrow(/Redis error.*wrong number of arguments/);
|
|
});
|
|
});
|
|
|
|
describe('redisRawBinary', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it('uses path-based URL with Upstash-Encoding header', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ result: 'dGVzdA==' }),
|
|
});
|
|
|
|
await redisRawBinary(env, ['GETRANGE', 'mykey', '0', '100']);
|
|
|
|
const [url, opts] = mockFetch.mock.calls[0];
|
|
expect(url).toBe('https://redis.example.com/GETRANGE/mykey/0/100');
|
|
expect(opts.headers['Upstash-Encoding']).toBe('base64');
|
|
expect(opts.headers.Authorization).toBe(`Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`);
|
|
});
|
|
|
|
it('URL-encodes special characters in path segments', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ result: null }),
|
|
});
|
|
|
|
await redisRawBinary(env, ['GETRANGE', 'rplace:canvas', '0', '10']);
|
|
|
|
const [url] = mockFetch.mock.calls[0];
|
|
expect(url).toContain('rplace%3Acanvas');
|
|
});
|
|
|
|
it('returns base64-encoded result string', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ result: 'AQID' }),
|
|
});
|
|
|
|
const result = await redisRawBinary(env, ['GET', 'key']);
|
|
expect(result).toBe('AQID');
|
|
});
|
|
|
|
it('throws on non-ok HTTP response', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: () => Promise.resolve('Internal Error'),
|
|
});
|
|
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis HTTP 500');
|
|
});
|
|
|
|
it('throws on Upstash 200 with error envelope', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ error: 'ERR no such key' }),
|
|
});
|
|
await expect(redisRawBinary(env, ['GET', 'missing'])).rejects.toThrow(/Redis error.*no such key/);
|
|
});
|
|
});
|