mirror of
https://github.com/tiennm99/rplace.git
synced 2026-09-09 02:17:35 +00:00
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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', '<!-- test placeholder -->');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<number, Uint8Array>} */
|
||||
const chunks = new Map();
|
||||
/** @type {Map<string, number>} */ // 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); },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user