Files
rplace/test/durable-objects/canvas-room.test.js
T
tiennm99 c3f7c02f6d feat(canvas): migrate canvas + cooldown storage to DO SQLite
Move pixel state and rate-limit cooldowns out of Upstash Redis and into
the existing CanvasRoom Durable Object's SQLite-backed storage. Worker
becomes a thin validation/proxy; the DO does atomic cooldown check +
pixel write + WS broadcast in one in-memory step.

Why: eliminate external dependency, keep $0/month free-tier forever,
exploit single-threaded actor for strong consistency without round-trips.

Architecture:
- canvas_chunks: 256 BLOB rows × 64 KB; CHUNK_COUNT derived from
  CANVAS_WIDTH × CANVAS_HEIGHT / CHUNK_BYTES so resize is config-only.
- cooldowns: user_id → expires_at, 1% sample-rate lazy GC.
- Worker forwards /api/canvas, /api/place, /api/ws to DO endpoints.
- POST /admin/migrate-from-upstash: token-gated one-shot importer.

Phases 1-3 code complete; Upstash dependency stays until prod migration
runs and 7-day rollback window passes (Phase 4).

Plan: plans/260509-2309-canvas-on-do-storage/
2026-05-09 23:53:21 +07:00

106 lines
3.2 KiB
JavaScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CanvasRoom } from '../../src/durable-objects/canvas-room.js';
// TODO: Phase 4 of canvas-on-do storage plan rewrites these tests against the
// new SQLite-backed DO via @cloudflare/vitest-pool-workers. The pre-migration
// mocks here can't model state.storage.sql, so the suite is skipped until the
// rewrite. See plans/260509-2309-canvas-on-do-storage/phase-04-cleanup-dependency-removal.md.
const describeOrSkip = describe.skip;
/** Create a mock WebSocket */
function mockWebSocket() {
return { send: vi.fn(), close: vi.fn() };
}
/** Create a mock Durable Object state with Hibernation API */
function mockState() {
const sockets = new Set();
return {
acceptWebSocket: vi.fn((ws) => sockets.add(ws)),
getWebSockets: vi.fn(() => [...sockets]),
_sockets: sockets,
};
}
describeOrSkip('CanvasRoom', () => {
let state;
let room;
beforeEach(() => {
state = mockState();
room = new CanvasRoom(state);
});
describe('broadcast', () => {
it('sends pixel data to all connected WebSockets', async () => {
const ws1 = mockWebSocket();
const ws2 = mockWebSocket();
state._sockets.add(ws1);
state._sockets.add(ws2);
const pixels = [{ x: 10, y: 20, color: 5 }];
const req = new Request('http://internal/broadcast', {
method: 'POST',
body: JSON.stringify(pixels),
});
const res = await room.fetch(req);
expect(res.status).toBe(200);
const expected = JSON.stringify({ type: 'pixels', pixels });
expect(ws1.send).toHaveBeenCalledWith(expected);
expect(ws2.send).toHaveBeenCalledWith(expected);
});
it('closes WebSocket on send failure', async () => {
const ws = mockWebSocket();
ws.send.mockImplementation(() => { throw new Error('disconnected'); });
state._sockets.add(ws);
const req = new Request('http://internal/broadcast', {
method: 'POST',
body: JSON.stringify([{ x: 0, y: 0, color: 1 }]),
});
const res = await room.fetch(req);
expect(res.status).toBe(200);
expect(ws.close).toHaveBeenCalledWith(1011, 'send failed');
});
it('broadcasts to empty room without error', async () => {
const req = new Request('http://internal/broadcast', {
method: 'POST',
body: JSON.stringify([]),
});
const res = await room.fetch(req);
expect(res.status).toBe(200);
});
});
describe('webSocketClose', () => {
it('closes the WebSocket with given code and reason', () => {
const ws = mockWebSocket();
room.webSocketClose(ws, 1000, 'normal', true);
expect(ws.close).toHaveBeenCalledWith(1000, 'normal');
});
});
describe('webSocketError', () => {
it('closes the WebSocket with error code', () => {
const ws = mockWebSocket();
room.webSocketError(ws, new Error('test'));
expect(ws.close).toHaveBeenCalledWith(1011, 'error');
});
});
describe('webSocketMessage', () => {
it('ignores messages (no-op)', () => {
const ws = mockWebSocket();
// Should not throw
room.webSocketMessage(ws, 'hello');
expect(ws.send).not.toHaveBeenCalled();
});
});
});