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
This commit is contained in:
tiennm99 authored and Tien Nguyen Minh committed 2026-04-17 10:14:45 +07:00
1 parent 8e1f8c4049
commit a823f8527d
14 files changed
+418 -154

No files matched your search

+19 -14
View File
@@ -9,27 +9,32 @@ function mockRequest(headers = {}) {
}
describe('getUserId', () => {
it('returns anon: prefix', () => {
const id = getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
it('returns anon: prefix', async () => {
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
expect(id).toMatch(/^anon:/);
});
it('returns deterministic ID for same IP', () => {
const req1 = mockRequest({ 'cf-connecting-ip': '192.168.1.1' });
const req2 = mockRequest({ 'cf-connecting-ip': '192.168.1.1' });
expect(getUserId(req1)).toBe(getUserId(req2));
it('returns deterministic ID for same IP', async () => {
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
expect(id1).toBe(id2);
});
it('returns different IDs for different IPs', () => {
const id1 = getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
const id2 = getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
it('returns different IDs for different IPs', async () => {
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
expect(id1).not.toBe(id2);
});
it('falls back to 127.0.0.1 when header is missing', () => {
const id = getUserId(mockRequest({}));
expect(id).toMatch(/^anon:/);
// Should be deterministic for missing header too
expect(getUserId(mockRequest({}))).toBe(id);
it('falls back to a shared dev bucket when header is missing', async () => {
const id = await getUserId(mockRequest({}));
expect(id).toBe('anon:dev');
// Deterministic for missing header
expect(await getUserId(mockRequest({}))).toBe(id);
});
it('uses 16-hex-char (8-byte) suffix from SHA-256', async () => {
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '203.0.113.45' }));
expect(id).toMatch(/^anon:[0-9a-f]{16}$/);
});
});