From 5765055588419737cc0266bbd8df0d8ec80d87f0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 11 May 2026 16:17:35 +0700 Subject: [PATCH] test: add DO storage + integration coverage via wrangler unstable_dev Pure-function unit tests for chunk-storage (BLOB-grow, orphan-row read, multi-chunk grouping) and cooldown-store (TTL math, INSERT cursor drain, GC sampling, release/refund) cover the bugs Phase 1 fixed. Integration suite boots a local Worker + real DO via wrangler unstable_dev and asserts cookie issuance, cooldown isolation across identities, Content-Length 411/413 guards, WS upgrade semantics, WS broadcast frame shape with monotonic seq, ping/pong, per-identity WS cap, and Origin allowlist. WS uses the ws package directly since unstable_dev's fetch strips CF's webSocket Response field. 156 tests pass in ~3.3s; verified stable across 3 consecutive runs. --- .../canvas-room.integration.test.js | 227 ++++++++++++++++++ test/durable-objects/chunk-storage.test.js | 181 ++++++++++++++ test/durable-objects/cooldown-store.test.js | 144 +++++++++++ test/helpers/do-harness.js | 135 +++++++++++ test/helpers/fake-sql.js | 97 ++++++++ vitest.config.js | 4 + 6 files changed, 788 insertions(+) create mode 100644 test/durable-objects/canvas-room.integration.test.js create mode 100644 test/durable-objects/chunk-storage.test.js create mode 100644 test/durable-objects/cooldown-store.test.js create mode 100644 test/helpers/do-harness.js create mode 100644 test/helpers/fake-sql.js diff --git a/test/durable-objects/canvas-room.integration.test.js b/test/durable-objects/canvas-room.integration.test.js new file mode 100644 index 0000000..08399f5 --- /dev/null +++ b/test/durable-objects/canvas-room.integration.test.js @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + setupDO, + placePixel, + fetchCanvas, + openWs, + nextMessage, + randomCookie, +} from '../helpers/do-harness.js'; +import { + CANVAS_WIDTH, + TOTAL_PIXELS, + MAX_BATCH_SIZE, + MAX_WS_PER_IDENTITY, +} from '../../src/lib/constants.js'; + +let harness; + +beforeAll(async () => { + harness = await setupDO(); +}, 30_000); + +afterAll(async () => { + await harness?.close(); +}); + +describe('GET /api/canvas', () => { + it('returns a TOTAL_PIXELS-sized body on first call', async () => { + const { status, bytes } = await fetchCanvas(harness); + expect(status).toBe(200); + expect(bytes.length).toBe(TOTAL_PIXELS); + }); + + it('issues Set-Cookie when no cookie is present', async () => { + const { setCookie } = await fetchCanvas(harness); + expect(setCookie).toMatch(/^rplace_id=[0-9a-f-]{36}/); + expect(setCookie).toMatch(/HttpOnly/); + expect(setCookie).toMatch(/Secure/); + expect(setCookie).toMatch(/SameSite=Lax/); + }); + + it('does not re-issue Set-Cookie when a valid cookie is present', async () => { + const cookie = randomCookie(); + const { setCookie } = await fetchCanvas(harness, { cookie }); + expect(setCookie).toBeNull(); + }); +}); + +describe('POST /api/place — happy path', () => { + it('places a single pixel and is visible on /api/canvas', async () => { + const cookie = randomCookie(); + const x = 100, y = 200, color = 42; + const res = await placePixel(harness, { x, y, color, cookie }); + expect(res.status).toBe(200); + expect(res.json?.ok).toBe(true); + + const canvas = await fetchCanvas(harness, { cookie }); + const offset = y * CANVAS_WIDTH + x; + expect(canvas.bytes[offset]).toBe(color); + }); +}); + +describe('POST /api/place — cooldown', () => { + it('429s the same cookie within 1 second', async () => { + const cookie = randomCookie(); + const a = await placePixel(harness, { x: 0, y: 0, color: 1, cookie }); + expect(a.status).toBe(200); + const b = await placePixel(harness, { x: 1, y: 0, color: 2, cookie }); + expect(b.status).toBe(429); + expect(b.json?.error).toBe('rate_limited'); + }); + + it('does not interfere across distinct cookies', async () => { + const c1 = randomCookie(); + const c2 = randomCookie(); + const r1 = await placePixel(harness, { x: 10, y: 0, color: 5, cookie: c1 }); + const r2 = await placePixel(harness, { x: 11, y: 0, color: 6, cookie: c2 }); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + }); +}); + +describe('POST /api/place — Content-Length guard', () => { + it('rejects POST with missing Content-Length (411)', async () => { + const res = await harness.fetch('/api/place', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"pixels":[]}', + }); + expect(res.status).toBe(411); + }); + + it('rejects POST with zero Content-Length (411)', async () => { + const res = await harness.fetch('/api/place', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': '0' }, + body: '', + }); + expect(res.status).toBe(411); + }); + + it('rejects POST above the body cap (413)', async () => { + // Send a body that actually matches the declared Content-Length so the + // HTTP transport accepts it; the edge validation should still reject on + // size alone, before parsing. + const oversized = '{"pad":"' + 'x'.repeat(MAX_BATCH_SIZE * 64 + 10) + '"}'; + const res = await harness.fetch('/api/place', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(new TextEncoder().encode(oversized).byteLength), + }, + body: oversized, + }); + expect(res.status).toBe(413); + }); +}); + +describe('WS upgrade', () => { + it('rejects requests missing the Upgrade header', async () => { + const res = await harness.fetch('/api/ws'); + expect(res.status).toBe(426); + }); + + it('accepts an upgrade with a valid cookie', async () => { + const { status, ws } = await openWs(harness, { cookie: randomCookie() }); + expect(status).toBe(101); + ws.close(); + }); +}); + +describe('WS broadcast', () => { + it('delivers a pixels frame with monotonic seq to every connected socket', async () => { + const subCookie = randomCookie(); + const placerCookie = randomCookie(); + const { ws } = await openWs(harness, { cookie: subCookie }); + + // Pre-arm the listener BEFORE triggering the broadcast: the WS message + // can arrive faster than the HTTP response, so registering after the + // placePixel await would miss it. + const msgPromise = nextMessage(ws); + const res = await placePixel(harness, { x: 50, y: 50, color: 9, cookie: placerCookie }); + expect(res.status).toBe(200); + + const data = JSON.parse(await msgPromise); + expect(data.type).toBe('pixels'); + expect(typeof data.seq).toBe('number'); + expect(data.seq).toBeGreaterThan(0); + expect(Array.isArray(data.pixels)).toBe(true); + expect(data.pixels[0]).toMatchObject({ x: 50, y: 50, color: 9 }); + + ws.close(); + }); + + it('seq increments across placements', async () => { + const subCookie = randomCookie(); + const p1 = randomCookie(); + const p2 = randomCookie(); + const { ws } = await openWs(harness, { cookie: subCookie }); + + const m1Promise = nextMessage(ws); + await placePixel(harness, { x: 60, y: 0, color: 1, cookie: p1 }); + const m1 = JSON.parse(await m1Promise); + + const m2Promise = nextMessage(ws); + await placePixel(harness, { x: 61, y: 0, color: 2, cookie: p2 }); + const m2 = JSON.parse(await m2Promise); + + expect(m2.seq).toBeGreaterThan(m1.seq); + ws.close(); + }); +}); + +describe('WS ping/pong', () => { + it('responds to "ping" with {type:"pong"}', async () => { + const { ws } = await openWs(harness, { cookie: randomCookie() }); + ws.send('ping'); + const msg = await nextMessage(ws); + const data = JSON.parse(msg); + expect(data.type).toBe('pong'); + ws.close(); + }); +}); + +describe('WS per-identity cap', () => { + it('accepts up to MAX_WS_PER_IDENTITY sockets, rejects the next', async () => { + const cookie = randomCookie(); + const sockets = []; + for (let i = 0; i < MAX_WS_PER_IDENTITY; i++) { + const r = await openWs(harness, { cookie }); + expect(r.status).toBe(101); + sockets.push(r.ws); + } + const over = await openWs(harness, { cookie }); + expect(over.status).toBe(429); + for (const ws of sockets) ws.close(); + }); +}); + +describe('WS Origin allowlist (env override)', () => { + let restricted; + beforeAll(async () => { + restricted = await setupDO({ + vars: { ALLOWED_ORIGINS: 'https://allowed.example', ENVIRONMENT: 'development' }, + }); + }, 30_000); + afterAll(async () => { + await restricted?.close(); + }); + + it('rejects WS upgrade from a disallowed Origin (403)', async () => { + const r = await openWs(restricted, { + cookie: randomCookie(), + origin: 'https://evil.example', + }); + expect(r.status).toBe(403); + }); + + it('accepts WS upgrade from an allowed Origin', async () => { + const r = await openWs(restricted, { + cookie: randomCookie(), + origin: 'https://allowed.example', + }); + expect(r.status).toBe(101); + r.ws?.close(); + }); +}); diff --git a/test/durable-objects/chunk-storage.test.js b/test/durable-objects/chunk-storage.test.js new file mode 100644 index 0000000..e3f1733 --- /dev/null +++ b/test/durable-objects/chunk-storage.test.js @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { readChunk, readAllChunks, writePixels } from '../../src/durable-objects/lib/chunk-storage.js'; +import { + CANVAS_WIDTH, + CHUNK_BYTES, + CHUNK_COUNT, + TOTAL_PIXELS, +} from '../../src/lib/constants.js'; +import { createFakeSql } from '../helpers/fake-sql.js'; + +describe('readChunk', () => { + it('returns a zero-filled buffer for missing rows (lazy init)', () => { + const sql = createFakeSql(); + const buf = readChunk(sql, 0); + expect(buf).toBeInstanceOf(Uint8Array); + expect(buf.length).toBe(CHUNK_BYTES); + expect(buf.every((b) => b === 0)).toBe(true); + }); + + it('returns the persisted blob when row exists', () => { + const sql = createFakeSql(); + const seed = new Uint8Array(CHUNK_BYTES); + seed[42] = 99; + sql._seedChunk(0, seed); + const buf = readChunk(sql, 0); + expect(buf[42]).toBe(99); + }); +}); + +describe('writePixels — single chunk', () => { + it('persists pixels and reads them back identically', () => { + const sql = createFakeSql(); + writePixels(sql, [ + { x: 0, y: 0, color: 5 }, + { x: 1, y: 0, color: 17 }, + { x: 5, y: 0, color: 250 }, + ]); + const chunk0 = readChunk(sql, 0); + expect(chunk0[0]).toBe(5); + expect(chunk0[1]).toBe(17); + expect(chunk0[5]).toBe(250); + }); + + it('overwrites previous color at same coordinate', () => { + const sql = createFakeSql(); + writePixels(sql, [{ x: 10, y: 0, color: 1 }]); + writePixels(sql, [{ x: 10, y: 0, color: 42 }]); + const chunk0 = readChunk(sql, 0); + expect(chunk0[10]).toBe(42); + }); + + it('is a no-op on empty input', () => { + const sql = createFakeSql(); + writePixels(sql, []); + expect(sql._chunks.size).toBe(0); + }); +}); + +describe('writePixels — multi-chunk', () => { + it('groups writes across chunks correctly', () => { + const sql = createFakeSql(); + // (x=0,y=0) → offset 0 → chunk 0 + // (x=CHUNK_BYTES,y=0) → offset CHUNK_BYTES → chunk 1, byteOffset 0 + writePixels(sql, [ + { x: 0, y: 0, color: 11 }, + { x: CHUNK_BYTES, y: 0, color: 22 }, + ]); + expect(sql._chunks.size).toBe(2); + expect(readChunk(sql, 0)[0]).toBe(11); + expect(readChunk(sql, 1)[0]).toBe(22); + }); + + it('issues exactly one INSERT per touched chunk regardless of pixel count', () => { + const sql = createFakeSql(); + let writes = 0; + const origExec = sql.exec; + sql.exec = (q, ...p) => { + if (q.startsWith('INSERT INTO canvas_chunks')) writes++; + return origExec(q, ...p); + }; + writePixels(sql, [ + { x: 0, y: 0, color: 1 }, + { x: 1, y: 0, color: 2 }, + { x: 2, y: 0, color: 3 }, + { x: 3, y: 0, color: 4 }, + ]); + expect(writes).toBe(1); + }); +}); + +describe('writePixels — BLOB-grow safety', () => { + it('writes against chunkSize, not the persisted blob length', () => { + const sql = createFakeSql(); + // Pre-seed chunk 0 with a short blob (e.g. legacy 8 KB). + const shortBlob = new Uint8Array(8192); + shortBlob[100] = 7; + sql._seedChunk(0, shortBlob); + + // Write a pixel at byteOffset 30000 — well beyond the seeded 8 KB. + // y=0, x=30000 → offset=30000 → chunk 0, byteOffset 30000. + writePixels(sql, [{ x: 30000, y: 0, color: 123 }]); + + const buf = readChunk(sql, 0); + expect(buf.length).toBe(CHUNK_BYTES); + expect(buf[100]).toBe(7); // preserved + expect(buf[30000]).toBe(123); // newly written, would have been dropped + }); +}); + +describe('readAllChunks', () => { + it('returns a TOTAL_PIXELS Uint8Array even when chunks are empty', () => { + const sql = createFakeSql(); + const out = readAllChunks(sql); + expect(out).toBeInstanceOf(Uint8Array); + expect(out.length).toBe(TOTAL_PIXELS); + expect(out[0]).toBe(0); + expect(out[TOTAL_PIXELS - 1]).toBe(0); + }); + + it('concatenates seeded chunks at correct offsets', () => { + const sql = createFakeSql(); + const c0 = new Uint8Array(CHUNK_BYTES); + c0[0] = 1; + c0[CHUNK_BYTES - 1] = 2; + const c1 = new Uint8Array(CHUNK_BYTES); + c1[0] = 3; + sql._seedChunk(0, c0); + sql._seedChunk(1, c1); + + const out = readAllChunks(sql); + expect(out[0]).toBe(1); + expect(out[CHUNK_BYTES - 1]).toBe(2); + expect(out[CHUNK_BYTES]).toBe(3); + }); + + it('skips orphan rows with chunk_id >= CHUNK_COUNT (canvas-shrink residue)', () => { + const sql = createFakeSql(); + // Seed a valid chunk and an orphan past the boundary. + const c0 = new Uint8Array(CHUNK_BYTES); + c0[0] = 9; + sql._seedChunk(0, c0); + const orphan = new Uint8Array(CHUNK_BYTES); + orphan[0] = 99; + sql._seedChunk(CHUNK_COUNT + 5, orphan); + + const out = readAllChunks(sql); + expect(out.length).toBe(TOTAL_PIXELS); + expect(out[0]).toBe(9); + // Orphan would have caused a RangeError on out.set() if not bounded. + }); + + it('trims oversized blobs at the last chunk to expected size', () => { + const sql = createFakeSql(); + // Last chunk's "expected" size equals CHUNK_BYTES when TOTAL_PIXELS is a + // multiple of CHUNK_BYTES (our current case). Seed an oversized blob and + // verify no overflow into adjacent memory. + const lastId = CHUNK_COUNT - 1; + const oversized = new Uint8Array(CHUNK_BYTES + 100); + oversized[0] = 4; + oversized[CHUNK_BYTES + 99] = 7; // beyond expected — must be trimmed + sql._seedChunk(lastId, oversized); + + // Should not throw. + const out = readAllChunks(sql); + expect(out.length).toBe(TOTAL_PIXELS); + const lastChunkStart = lastId * CHUNK_BYTES; + expect(out[lastChunkStart]).toBe(4); + }); +}); + +describe('pixel-to-chunk math (coverage via writePixels)', () => { + it('maps (x,y) coordinates to the expected chunk index', () => { + const sql = createFakeSql(); + // Pick a y that crosses chunk boundaries: y=16 with CANVAS_WIDTH=4096 + // gives offset = 65536 → chunk 1, byteOffset 0. + writePixels(sql, [{ x: 0, y: CHUNK_BYTES / CANVAS_WIDTH, color: 200 }]); + expect(sql._chunks.size).toBe(1); + expect(sql._chunks.has(1)).toBe(true); + expect(readChunk(sql, 1)[0]).toBe(200); + }); +}); diff --git a/test/durable-objects/cooldown-store.test.js b/test/durable-objects/cooldown-store.test.js new file mode 100644 index 0000000..28cf5fb --- /dev/null +++ b/test/durable-objects/cooldown-store.test.js @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { tryAcquire, release, gc } from '../../src/durable-objects/lib/cooldown-store.js'; +import { REQUEST_COOLDOWN_SEC } from '../../src/lib/constants.js'; +import { createFakeSql } from '../helpers/fake-sql.js'; + +const TTL_MS = REQUEST_COOLDOWN_SEC * 1000; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('tryAcquire — first call', () => { + it('inserts a fresh row and returns allowed', () => { + const sql = createFakeSql(); + const now = 1_000_000; + const r = tryAcquire(sql, 'cookie:abc', now); + expect(r).toEqual({ allowed: true, retryAfter: 0 }); + expect(sql._cooldowns.get('cookie:abc')).toBe(now + TTL_MS); + }); +}); + +describe('tryAcquire — within window', () => { + it('rejects a second call inside TTL', () => { + const sql = createFakeSql(); + const now = 1_000_000; + tryAcquire(sql, 'cookie:abc', now); + const r = tryAcquire(sql, 'cookie:abc', now + 500); + expect(r.allowed).toBe(false); + expect(r.retryAfter).toBe(REQUEST_COOLDOWN_SEC); + // The active row should remain untouched. + expect(sql._cooldowns.get('cookie:abc')).toBe(now + TTL_MS); + }); +}); + +describe('tryAcquire — after window expires', () => { + it('accepts a second call after expires_at', () => { + const sql = createFakeSql(); + const now = 1_000_000; + tryAcquire(sql, 'cookie:abc', now); + const later = now + TTL_MS + 1; + const r = tryAcquire(sql, 'cookie:abc', later); + expect(r.allowed).toBe(true); + expect(sql._cooldowns.get('cookie:abc')).toBe(later + TTL_MS); + }); + + it('accepts exactly at expires_at boundary (<=)', () => { + const sql = createFakeSql(); + const now = 1_000_000; + tryAcquire(sql, 'cookie:abc', now); + const r = tryAcquire(sql, 'cookie:abc', now + TTL_MS); + expect(r.allowed).toBe(true); + }); +}); + +describe('tryAcquire — independent users', () => { + it('does not interfere across user IDs', () => { + const sql = createFakeSql(); + const now = 1_000_000; + expect(tryAcquire(sql, 'cookie:a', now).allowed).toBe(true); + expect(tryAcquire(sql, 'cookie:b', now).allowed).toBe(true); + expect(tryAcquire(sql, 'cookie:a', now).allowed).toBe(false); + expect(tryAcquire(sql, 'cookie:b', now).allowed).toBe(false); + }); +}); + +describe('tryAcquire — INSERT branch executes (cursor drain)', () => { + it('persists the INSERT even when no UPDATE preceded it', () => { + const sql = createFakeSql(); + let inserts = 0; + const origExec = sql.exec; + sql.exec = (q, ...p) => { + if (q.startsWith('INSERT INTO cooldowns')) inserts++; + return origExec(q, ...p); + }; + tryAcquire(sql, 'cookie:fresh', 1_000_000); + expect(inserts).toBe(1); + expect(sql._cooldowns.has('cookie:fresh')).toBe(true); + }); +}); + +describe('tryAcquire — GC sweep sampling', () => { + it('deletes expired rows when the sample fires', () => { + const sql = createFakeSql(); + // Seed expired rows. + sql._seedCooldown('cookie:dead1', 100); + sql._seedCooldown('cookie:dead2', 200); + // Force the sample to fire. + vi.spyOn(Math, 'random').mockReturnValue(0); + tryAcquire(sql, 'cookie:fresh', 1_000_000); + expect(sql._cooldowns.has('cookie:dead1')).toBe(false); + expect(sql._cooldowns.has('cookie:dead2')).toBe(false); + expect(sql._cooldowns.has('cookie:fresh')).toBe(true); + }); + + it('does not run GC when sample misses', () => { + const sql = createFakeSql(); + sql._seedCooldown('cookie:dead', 100); + vi.spyOn(Math, 'random').mockReturnValue(0.99); + tryAcquire(sql, 'cookie:fresh', 1_000_000); + expect(sql._cooldowns.has('cookie:dead')).toBe(true); + }); + + it('continues if GC throws (best-effort)', () => { + const sql = createFakeSql(); + vi.spyOn(Math, 'random').mockReturnValue(0); + const origExec = sql.exec; + sql.exec = (q, ...p) => { + if (q.startsWith('DELETE FROM cooldowns WHERE expires_at')) { + throw new Error('boom'); + } + return origExec(q, ...p); + }; + const r = tryAcquire(sql, 'cookie:fresh', 1_000_000); + expect(r.allowed).toBe(true); + }); +}); + +describe('release', () => { + it('removes the cooldown row so the next acquire succeeds immediately', () => { + const sql = createFakeSql(); + tryAcquire(sql, 'cookie:abc', 1_000_000); + expect(sql._cooldowns.has('cookie:abc')).toBe(true); + release(sql, 'cookie:abc'); + expect(sql._cooldowns.has('cookie:abc')).toBe(false); + // Second acquire at the same instant should succeed now. + expect(tryAcquire(sql, 'cookie:abc', 1_000_000).allowed).toBe(true); + }); + + it('is a no-op when the row does not exist', () => { + const sql = createFakeSql(); + expect(() => release(sql, 'cookie:missing')).not.toThrow(); + }); +}); + +describe('gc', () => { + it('deletes only expired rows', () => { + const sql = createFakeSql(); + sql._seedCooldown('cookie:dead', 100); + sql._seedCooldown('cookie:alive', 1_000_000_000); + gc(sql, 5_000_000); + expect(sql._cooldowns.has('cookie:dead')).toBe(false); + expect(sql._cooldowns.has('cookie:alive')).toBe(true); + }); +}); diff --git a/test/helpers/do-harness.js b/test/helpers/do-harness.js new file mode 100644 index 0000000..c02e481 --- /dev/null +++ b/test/helpers/do-harness.js @@ -0,0 +1,135 @@ +import { unstable_dev } from 'wrangler'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { WebSocket } from 'ws'; + +/** + * Boot a local Worker + Durable Object via miniflare (`wrangler unstable_dev`). + * + * Returns: + * - worker: the UnstableDevWorker instance + * - fetch: a bound fetch(url, init) that hits the local worker + * - close: async () => worker.stop() + * + * The Worker uses real DO SQLite storage, so reads/writes/broadcasts behave + * as in production. State persists across tests within one suite (one worker + * per setupDO call). Tests should use unique identities (random cookies) to + * avoid cooldown cross-talk. + */ + +function ensureDistPlaceholder() { + // The wrangler.json `assets.directory` must exist or miniflare warns. + // Don't fail tests just because the user hasn't run `npm run build`. + if (!existsSync('dist')) { + mkdirSync('dist', { recursive: true }); + writeFileSync('dist/index.html', ''); + } +} + +export async function setupDO(overrides = {}) { + ensureDistPlaceholder(); + const worker = await unstable_dev('src/worker.js', { + config: 'wrangler.json', + experimental: { + disableExperimentalWarning: true, + disableDevRegistry: true, + }, + persist: false, + ip: '127.0.0.1', + ...overrides, + }); + return { + worker, + fetch: worker.fetch.bind(worker), + address: `${worker.address}:${worker.port}`, + close: () => worker.stop(), + }; +} + +/** + * POST /api/place helper. Computes Content-Length explicitly because the + * Worker's edge validation requires it. + */ +export async function placePixel(harness, { x, y, color, cookie }) { + const body = JSON.stringify({ pixels: [{ x, y, color }] }); + const headers = { + 'Content-Type': 'application/json', + 'Content-Length': String(new TextEncoder().encode(body).byteLength), + }; + if (cookie) headers.Cookie = `rplace_id=${cookie}`; + const res = await harness.fetch('/api/place', { method: 'POST', headers, body }); + const setCookie = res.headers.get('set-cookie'); + return { + status: res.status, + setCookie, + json: res.headers.get('content-type')?.includes('json') ? await res.json() : null, + }; +} + +/** GET /api/canvas — returns { status, bytes, setCookie }. */ +export async function fetchCanvas(harness, { cookie } = {}) { + const headers = {}; + if (cookie) headers.Cookie = `rplace_id=${cookie}`; + const res = await harness.fetch('/api/canvas', { headers }); + const bytes = res.status === 200 ? new Uint8Array(await res.arrayBuffer()) : null; + return { + status: res.status, + bytes, + setCookie: res.headers.get('set-cookie'), + }; +} + +/** + * Open a WS connection against the running worker. `unstable_dev`'s fetch is + * undici-based and strips the CF-specific `.webSocket` field, so we connect + * with the `ws` package against the worker's HTTP port instead. + * + * Returns either `{ status: 101, ws }` once the open event fires, or + * `{ status, ws: null }` if the upgrade is rejected with a non-101 response. + */ +export function openWs(harness, { cookie, origin } = {}) { + const url = `ws://${harness.address}/api/ws`; + const headers = {}; + if (cookie) headers.Cookie = `rplace_id=${cookie}`; + if (origin) headers.Origin = origin; + const ws = new WebSocket(url, { headers }); + return new Promise((resolve) => { + let settled = false; + function done(value) { + if (settled) return; + settled = true; + ws.removeAllListeners('open'); + ws.removeAllListeners('unexpected-response'); + ws.removeAllListeners('error'); + resolve(value); + } + ws.once('open', () => done({ status: 101, ws })); + ws.once('unexpected-response', (_req, res) => done({ status: res.statusCode, ws: null })); + ws.once('error', (err) => { + // Pull HTTP status out of common ws-library errors (e.g. "Unexpected + // server response: 403"). Fall back to -1 to surface unknown errors. + const match = String(err?.message || '').match(/(\d{3})/); + done({ status: match ? Number(match[1]) : -1, ws: null }); + }); + }); +} + +/** Generate a UUID-shaped cookie value (matches the validator in get-user-id.js). */ +export function randomCookie() { + return crypto.randomUUID(); +} + +/** Promise wrapper for the next WS message (works with `ws` package). */ +export function nextMessage(ws, { timeoutMs = 5000 } = {}) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => { + ws.off('message', onMsg); + reject(new Error(`WS message timeout (${timeoutMs}ms)`)); + }, timeoutMs); + function onMsg(data) { + clearTimeout(t); + ws.off('message', onMsg); + resolve(typeof data === 'string' ? data : data.toString('utf8')); + } + ws.on('message', onMsg); + }); +} diff --git a/test/helpers/fake-sql.js b/test/helpers/fake-sql.js new file mode 100644 index 0000000..12f7997 --- /dev/null +++ b/test/helpers/fake-sql.js @@ -0,0 +1,97 @@ +/** + * Minimal stand-in for CF DO SqlStorage used in unit tests. + * + * Supports just the statements actually issued by chunk-storage.js and + * cooldown-store.js. Anything else throws so tests fail loudly when the + * source surface grows. + * + * Each table is an in-memory Map. Each exec() returns a cursor object that + * mimics the CF shape: + * - cursor.toArray() returns the rows array + * - cursor.rowsWritten is the row count touched by the last write + * - cursor[Symbol.iterator] iterates rows (for of) + */ + +function makeCursor(rows, rowsWritten = 0) { + return { + toArray: () => rows, + rowsWritten, + [Symbol.iterator]: () => rows[Symbol.iterator](), + }; +} + +export function createFakeSql() { + /** @type {Map} */ + const chunks = new Map(); + /** @type {Map} */ // user_id -> expires_at + const cooldowns = new Map(); + + function exec(query, ...params) { + const q = query.trim().replace(/\s+/g, ' '); + + // ── canvas_chunks ──────────────────────────────────────────────── + if (q.startsWith('SELECT bytes FROM canvas_chunks WHERE chunk_id = ?')) { + const chunkId = params[0]; + const blob = chunks.get(chunkId); + return makeCursor(blob ? [{ bytes: blob }] : []); + } + if (q.startsWith('SELECT chunk_id, bytes FROM canvas_chunks WHERE chunk_id < ?')) { + const upper = params[0]; + const rows = []; + for (const [chunkId, bytes] of chunks) { + if (chunkId < upper) rows.push({ chunk_id: chunkId, bytes }); + } + return makeCursor(rows); + } + if (q.startsWith('INSERT INTO canvas_chunks (chunk_id, bytes) VALUES (?, ?) ON CONFLICT')) { + const [chunkId, bytes] = params; + chunks.set(chunkId, bytes); + return makeCursor([], 1); + } + + // ── cooldowns ──────────────────────────────────────────────────── + if (q.startsWith('UPDATE cooldowns SET expires_at = ? WHERE user_id = ? AND expires_at <= ?')) { + const [newExpires, userId, now] = params; + const current = cooldowns.get(userId); + if (current !== undefined && current <= now) { + cooldowns.set(userId, newExpires); + return makeCursor([], 1); + } + return makeCursor([], 0); + } + if (q.startsWith('INSERT INTO cooldowns (user_id, expires_at) VALUES (?, ?)')) { + const [userId, expiresAt] = params; + if (cooldowns.has(userId)) { + const err = new Error('UNIQUE constraint failed: cooldowns.user_id'); + err.code = 'SQLITE_CONSTRAINT'; + throw err; + } + cooldowns.set(userId, expiresAt); + return makeCursor([], 1); + } + if (q.startsWith('DELETE FROM cooldowns WHERE user_id = ?')) { + const [userId] = params; + const had = cooldowns.delete(userId); + return makeCursor([], had ? 1 : 0); + } + if (q.startsWith('DELETE FROM cooldowns WHERE expires_at <= ?')) { + const [now] = params; + let n = 0; + for (const [uid, exp] of cooldowns) { + if (exp <= now) { cooldowns.delete(uid); n++; } + } + return makeCursor([], n); + } + + throw new Error(`fake-sql: unhandled query: ${q}`); + } + + return { + exec, + // Test-only inspection / seeding hooks. + _chunks: chunks, + _cooldowns: cooldowns, + _seedChunk(chunkId, bytes) { chunks.set(chunkId, bytes); }, + _seedCooldown(userId, expiresAt) { cooldowns.set(userId, expiresAt); }, + }; +} diff --git a/vitest.config.js b/vitest.config.js index 2f6aa92..29540b3 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -5,5 +5,9 @@ export default defineConfig({ globals: true, include: ['test/**/*.test.js'], exclude: ['test/integration/**'], + // Integration tests boot a local Worker via `wrangler unstable_dev`; allow + // headroom over the default 5s for startup + multi-WS test paths. + testTimeout: 30_000, + hookTimeout: 30_000, }, });