Files
rplace/test/lib/redis-client.test.js
T
tiennm99 a823f8527d fix: address ultrareview findings across backend and frontend
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
2026-04-17 10:14:45 +07:00

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