Files
rplace/test/worker-validation.test.js
T
tiennm99andGitHub f59e55a852 refactor(rate-limit): 1 req/sec cooldown, batch up to 2048 (#3)
* refactor(rate-limit): switch to 1 req/sec cooldown, batch size up to 2048

Replace per-pixel credit/token-bucket model with a simple per-user cooldown
(SET NX EX 1). Batch size is now independent of the rate limit and capped
at MAX_BATCH_SIZE = 2048.

- rate-limiter: SET NX EX replaces Lua credit script
- worker: response shape { ok: true } (no credits field)
- client: drop credit state/timer/UserInfo; uploader paces by cooldown
- tests: mock checkRateLimit; integration test exercises SET NX EX
- docs: README, system-architecture, code-standards, deployment-guide

* chore(plans): remove implemented plan directories

rplace-implementation (base build), review-fixes, and
image-importer-enhancements are all shipped. Keep plans/reports/ as
historical code-review and research references.
2026-04-18 10:19:00 +07:00

161 lines
5.7 KiB
JavaScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from '../src/lib/constants.js';
// Mock all external dependencies before importing worker
vi.mock('../src/lib/canvas-storage.js', () => ({
getFullCanvas: vi.fn(() => Promise.resolve(new Uint8Array(10))),
setPixels: vi.fn(() => Promise.resolve()),
}));
vi.mock('../src/lib/rate-limiter.js', () => ({
checkRateLimit: vi.fn(() => Promise.resolve({ allowed: true, retryAfter: 0 })),
}));
vi.mock('../src/durable-objects/canvas-room.js', () => ({
CanvasRoom: class {},
}));
import app from '../src/worker.js';
import { checkRateLimit } from '../src/lib/rate-limiter.js';
/** Helper to create POST request */
function postPlace(body) {
return new Request('http://localhost/api/place', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
});
}
/** Minimal env mock with Durable Object stub */
const env = {
CANVAS_ROOM: {
idFromName: () => 'room-id',
get: () => ({
fetch: () => Promise.resolve(new Response('ok')),
}),
},
};
describe('POST /api/place validation', () => {
beforeEach(() => vi.clearAllMocks());
it('rejects invalid JSON', async () => {
const req = new Request('http://localhost/api/place', {
method: 'POST',
body: 'not json',
});
const res = await app.fetch(req, env);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe('invalid_json');
});
it('rejects missing pixels array', async () => {
const res = await app.fetch(postPlace({}), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('pixels_required');
});
it('rejects empty pixels array', async () => {
const res = await app.fetch(postPlace({ pixels: [] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('pixels_required');
});
it('rejects non-array pixels', async () => {
const res = await app.fetch(postPlace({ pixels: 'not an array' }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('pixels_required');
});
it('rejects batch exceeding MAX_BATCH_SIZE', async () => {
const pixels = Array.from({ length: MAX_BATCH_SIZE + 1 }, (_, i) => ({
x: i % CANVAS_WIDTH, y: 0, color: 0,
}));
const res = await app.fetch(postPlace({ pixels }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('batch_too_large');
});
it('rejects pixel with x out of bounds', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: CANVAS_WIDTH, y: 0, color: 0 }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('rejects pixel with negative x', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: -1, y: 0, color: 0 }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('rejects pixel with y out of bounds', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: CANVAS_HEIGHT, color: 0 }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('rejects pixel with color out of range', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: MAX_COLORS }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('rejects pixel with negative color', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: -1 }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('rejects non-integer coordinates', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: 1.5, y: 0, color: 0 }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('rejects non-integer color', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 1.5 }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('rejects string values for coordinates', async () => {
const res = await app.fetch(postPlace({ pixels: [{ x: '0', y: 0, color: 0 }] }), env);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('invalid_pixel');
});
it('returns 429 when rate limited', async () => {
checkRateLimit.mockResolvedValue({ allowed: false, retryAfter: 1 });
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
expect(res.status).toBe(429);
const data = await res.json();
expect(data.error).toBe('rate_limited');
expect(data.retryAfter).toBe(1);
});
it('accepts valid pixel placement', async () => {
checkRateLimit.mockResolvedValue({ allowed: true, retryAfter: 0 });
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.ok).toBe(true);
});
it('accepts boundary pixel values', async () => {
checkRateLimit.mockResolvedValue({ allowed: true, retryAfter: 0 });
const res = await app.fetch(postPlace({
pixels: [{ x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: MAX_COLORS - 1 }],
}), env);
expect(res.status).toBe(200);
expect((await res.json()).ok).toBe(true);
});
});
describe('GET /api/ws', () => {
it('returns 426 without WebSocket upgrade header', async () => {
const req = new Request('http://localhost/api/ws');
const res = await app.fetch(req, env);
expect(res.status).toBe(426);
});
});