mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
test: add comprehensive unit tests with Vitest
65 tests covering canvas decoder, pixel buffer, user ID hashing, canvas storage (with binary encoding regression test), Redis client API shape, and worker endpoint validation.
This commit is contained in:
1 parent
b35769cc73
commit
33cfd3d7b3
8 files changed
+692
-1
No files matched your search
+5
-1
@@ -7,16 +7,20 @@
|
||||
"dev:client": "vite dev",
|
||||
"build": "vite build",
|
||||
"deploy": "vite build && wrangler deploy",
|
||||
"preview": "vite build && wrangler dev"
|
||||
"preview": "vite build && wrangler dev",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@upstash/redis": "^1.34.3",
|
||||
"hono": "^4.7.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.14.7",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"svelte": "^5.28.2",
|
||||
"vite": "^6.3.2",
|
||||
"vitest": "^4.1.4",
|
||||
"wrangler": "^4.14.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decodeCanvas, indicesToRgba } from '../../src/lib/canvas-decoder.js';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS, COLORS_RGBA } from '../../src/lib/constants.js';
|
||||
|
||||
/** Encode color indices into 5-bit packed bytes (test helper, mirrors BITFIELD storage) */
|
||||
function encodeIndices(indices) {
|
||||
const totalBits = indices.length * 5;
|
||||
const bytes = new Uint8Array(Math.ceil(totalBits / 8));
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const bitPos = i * 5;
|
||||
const byteIndex = bitPos >> 3;
|
||||
const bitOffset = bitPos & 7;
|
||||
// Write 5-bit value across 1-2 bytes
|
||||
bytes[byteIndex] |= (indices[i] << (11 - bitOffset)) >> 8;
|
||||
if (bitOffset > 3) {
|
||||
bytes[byteIndex + 1] |= (indices[i] << (11 - bitOffset)) & 0xff;
|
||||
} else {
|
||||
bytes[byteIndex] |= (indices[i] << (3 - bitOffset));
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
describe('decodeCanvas', () => {
|
||||
it('decodes empty buffer as all zeros', () => {
|
||||
const buffer = new ArrayBuffer(0);
|
||||
const indices = decodeCanvas(buffer);
|
||||
expect(indices.length).toBe(CANVAS_WIDTH * CANVAS_HEIGHT);
|
||||
expect(indices.every((v) => v === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('decodes a single pixel', () => {
|
||||
// Color 15 at pixel 0: binary 01111 in first 5 bits
|
||||
// Byte 0: 0111_1000 = 0x78
|
||||
const bytes = new Uint8Array([0x78, 0]);
|
||||
const indices = decodeCanvas(bytes.buffer);
|
||||
expect(indices[0]).toBe(15);
|
||||
});
|
||||
|
||||
it('round-trips all 32 color values', () => {
|
||||
const input = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) input[i] = i;
|
||||
const encoded = encodeIndices(input);
|
||||
const decoded = decodeCanvas(encoded.buffer);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
expect(decoded[i]).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips repeated color patterns', () => {
|
||||
const input = new Uint8Array(100);
|
||||
for (let i = 0; i < 100; i++) input[i] = i % 32;
|
||||
const encoded = encodeIndices(input);
|
||||
const decoded = decodeCanvas(encoded.buffer);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(decoded[i]).toBe(i % 32);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles max color value (31) at various offsets', () => {
|
||||
const input = new Uint8Array(8).fill(31);
|
||||
const encoded = encodeIndices(input);
|
||||
const decoded = decodeCanvas(encoded.buffer);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
expect(decoded[i]).toBe(31);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('indicesToRgba', () => {
|
||||
it('produces correct RGBA for all 32 colors', () => {
|
||||
const indices = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) indices[i] = i;
|
||||
const rgba = indicesToRgba(indices);
|
||||
|
||||
expect(rgba.length).toBe(32 * 4);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const [r, g, b, a] = COLORS_RGBA[i];
|
||||
expect(rgba[i * 4]).toBe(r);
|
||||
expect(rgba[i * 4 + 1]).toBe(g);
|
||||
expect(rgba[i * 4 + 2]).toBe(b);
|
||||
expect(rgba[i * 4 + 3]).toBe(a);
|
||||
}
|
||||
});
|
||||
|
||||
it('always sets alpha to 255', () => {
|
||||
const indices = new Uint8Array([0, 15, 27, 31]);
|
||||
const rgba = indicesToRgba(indices);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(rgba[i * 4 + 3]).toBe(255);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns Uint8ClampedArray', () => {
|
||||
const rgba = indicesToRgba(new Uint8Array([0]));
|
||||
expect(rgba).toBeInstanceOf(Uint8ClampedArray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COLORS_RGBA consistency', () => {
|
||||
it('has 32 entries matching COLORS hex values', () => {
|
||||
expect(COLORS_RGBA.length).toBe(32);
|
||||
expect(COLORS.length).toBe(32);
|
||||
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const hex = COLORS[i];
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
expect(COLORS_RGBA[i][0]).toBe((n >> 16) & 0xff);
|
||||
expect(COLORS_RGBA[i][1]).toBe((n >> 8) & 0xff);
|
||||
expect(COLORS_RGBA[i][2]).toBe(n & 0xff);
|
||||
expect(COLORS_RGBA[i][3]).toBe(255);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CANVAS_WIDTH, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
|
||||
|
||||
// Mock redis-client module
|
||||
vi.mock('../../src/lib/redis-client.js', () => ({
|
||||
redisRaw: vi.fn(),
|
||||
redisRawBinary: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getFullCanvas, setPixels } from '../../src/lib/canvas-storage.js';
|
||||
import { redisRaw, redisRawBinary } from '../../src/lib/redis-client.js';
|
||||
|
||||
const CANVAS_BYTES = Math.ceil((CANVAS_WIDTH * CANVAS_WIDTH * 5) / 8);
|
||||
|
||||
describe('setPixels', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('does nothing for empty array', async () => {
|
||||
await setPixels({}, []);
|
||||
expect(redisRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds correct BITFIELD command for single pixel', async () => {
|
||||
redisRaw.mockResolvedValue({ result: [0] });
|
||||
await setPixels({}, [{ x: 10, y: 20, color: 5 }]);
|
||||
|
||||
const call = redisRaw.mock.calls[0][1];
|
||||
expect(call[0]).toBe('BITFIELD');
|
||||
expect(call[1]).toBe(REDIS_CANVAS_KEY);
|
||||
expect(call[2]).toBe('SET');
|
||||
expect(call[3]).toBe('u5');
|
||||
// offset = 20 * 2048 + 10 = 40970
|
||||
expect(call[4]).toBe('#40970');
|
||||
expect(call[5]).toBe('5');
|
||||
});
|
||||
|
||||
it('builds correct BITFIELD command for multiple pixels', async () => {
|
||||
redisRaw.mockResolvedValue({ result: [0, 0] });
|
||||
await setPixels({}, [
|
||||
{ x: 0, y: 0, color: 1 },
|
||||
{ x: 1, y: 0, color: 31 },
|
||||
]);
|
||||
|
||||
const call = redisRaw.mock.calls[0][1];
|
||||
// BITFIELD key SET u5 #0 1 SET u5 #1 31
|
||||
expect(call).toEqual([
|
||||
'BITFIELD', REDIS_CANVAS_KEY,
|
||||
'SET', 'u5', '#0', '1',
|
||||
'SET', 'u5', '#1', '31',
|
||||
]);
|
||||
});
|
||||
|
||||
it('computes offset correctly for various positions', async () => {
|
||||
redisRaw.mockResolvedValue({ result: [0] });
|
||||
|
||||
// Pixel at (2047, 2047) — last pixel
|
||||
await setPixels({}, [{ x: 2047, y: 2047, color: 0 }]);
|
||||
const offset = 2047 * CANVAS_WIDTH + 2047;
|
||||
expect(redisRaw.mock.calls[0][1][4]).toBe(`#${offset}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFullCanvas', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns zero-filled buffer when Redis returns null', async () => {
|
||||
redisRawBinary.mockResolvedValue(null);
|
||||
const buf = await getFullCanvas({});
|
||||
expect(buf.length).toBe(CANVAS_BYTES);
|
||||
expect(buf.every((b) => b === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns zero-filled buffer when Redis returns empty string', async () => {
|
||||
redisRawBinary.mockResolvedValue('');
|
||||
const buf = await getFullCanvas({});
|
||||
expect(buf.length).toBe(CANVAS_BYTES);
|
||||
expect(buf.every((b) => b === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('decodes base64 response correctly', async () => {
|
||||
// 3 bytes: [0x78, 0xAB, 0xFF]
|
||||
const base64 = btoa(String.fromCharCode(0x78, 0xAB, 0xFF));
|
||||
redisRawBinary.mockResolvedValue(base64);
|
||||
const buf = await getFullCanvas({});
|
||||
expect(buf[0]).toBe(0x78);
|
||||
expect(buf[1]).toBe(0xAB);
|
||||
expect(buf[2]).toBe(0xFF);
|
||||
});
|
||||
|
||||
it('pads short responses to full canvas size', async () => {
|
||||
const base64 = btoa(String.fromCharCode(0xFF));
|
||||
redisRawBinary.mockResolvedValue(base64);
|
||||
const buf = await getFullCanvas({});
|
||||
expect(buf.length).toBe(CANVAS_BYTES);
|
||||
expect(buf[0]).toBe(0xFF);
|
||||
expect(buf[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('handles all byte values (0x00-0xFF) without corruption', async () => {
|
||||
// This is the test that would have caught the binary encoding bug
|
||||
const allBytes = new Array(256);
|
||||
for (let i = 0; i < 256; i++) allBytes[i] = String.fromCharCode(i);
|
||||
const base64 = btoa(allBytes.join(''));
|
||||
redisRawBinary.mockResolvedValue(base64);
|
||||
|
||||
const buf = await getFullCanvas({});
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(buf[i]).toBe(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getUserId } from '../../src/lib/get-user-id.js';
|
||||
|
||||
/** Helper to create a mock Request with headers */
|
||||
function mockRequest(headers = {}) {
|
||||
return new Request('http://localhost', {
|
||||
headers: new Headers(headers),
|
||||
});
|
||||
}
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('returns anon: prefix', () => {
|
||||
const id = 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 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' }));
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createPixelBuffer } from '../../src/lib/pixel-buffer.js';
|
||||
|
||||
describe('createPixelBuffer', () => {
|
||||
let buffer;
|
||||
|
||||
beforeEach(() => {
|
||||
buffer = createPixelBuffer();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts empty', () => {
|
||||
expect(buffer.canUndo).toBe(false);
|
||||
expect(buffer.canRedo).toBe(false);
|
||||
expect(buffer.pixelCount).toBe(0);
|
||||
expect(buffer.getAllPixels()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addStroke', () => {
|
||||
it('adds pixels to buffer', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 5 }]);
|
||||
expect(buffer.pixelCount).toBe(1);
|
||||
expect(buffer.canUndo).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores empty strokes', () => {
|
||||
buffer.addStroke([]);
|
||||
expect(buffer.pixelCount).toBe(0);
|
||||
expect(buffer.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('clears redo stack on new stroke', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 1 }]);
|
||||
buffer.undo();
|
||||
expect(buffer.canRedo).toBe(true);
|
||||
buffer.addStroke([{ x: 1, y: 1, color: 2 }]);
|
||||
expect(buffer.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it('copies input array (does not hold reference)', () => {
|
||||
const stroke = [{ x: 0, y: 0, color: 1 }];
|
||||
buffer.addStroke(stroke);
|
||||
stroke.push({ x: 1, y: 1, color: 2 });
|
||||
expect(buffer.pixelCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo / redo', () => {
|
||||
it('undo returns removed stroke', () => {
|
||||
const stroke = [{ x: 5, y: 10, color: 3 }];
|
||||
buffer.addStroke(stroke);
|
||||
const removed = buffer.undo();
|
||||
expect(removed).toEqual(stroke);
|
||||
expect(buffer.pixelCount).toBe(0);
|
||||
});
|
||||
|
||||
it('undo on empty returns null', () => {
|
||||
expect(buffer.undo()).toBeNull();
|
||||
});
|
||||
|
||||
it('redo returns re-applied stroke', () => {
|
||||
const stroke = [{ x: 5, y: 10, color: 3 }];
|
||||
buffer.addStroke(stroke);
|
||||
buffer.undo();
|
||||
const redone = buffer.redo();
|
||||
expect(redone).toEqual(stroke);
|
||||
expect(buffer.pixelCount).toBe(1);
|
||||
});
|
||||
|
||||
it('redo on empty returns null', () => {
|
||||
expect(buffer.redo()).toBeNull();
|
||||
});
|
||||
|
||||
it('supports multiple undo/redo cycles', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 1 }]);
|
||||
buffer.addStroke([{ x: 1, y: 1, color: 2 }]);
|
||||
buffer.addStroke([{ x: 2, y: 2, color: 3 }]);
|
||||
expect(buffer.pixelCount).toBe(3);
|
||||
|
||||
buffer.undo();
|
||||
buffer.undo();
|
||||
expect(buffer.pixelCount).toBe(1);
|
||||
expect(buffer.canRedo).toBe(true);
|
||||
|
||||
buffer.redo();
|
||||
expect(buffer.pixelCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllPixels (deduplication)', () => {
|
||||
it('last stroke wins for same coordinate', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 1 }]);
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 5 }]);
|
||||
const pixels = buffer.getAllPixels();
|
||||
expect(pixels.length).toBe(1);
|
||||
expect(pixels[0].color).toBe(5);
|
||||
});
|
||||
|
||||
it('merges pixels from multiple strokes', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 1 }, { x: 1, y: 0, color: 2 }]);
|
||||
buffer.addStroke([{ x: 2, y: 0, color: 3 }]);
|
||||
expect(buffer.getAllPixels().length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColorAt', () => {
|
||||
it('returns -1 for non-pending pixel', () => {
|
||||
expect(buffer.getColorAt(0, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns latest color for pending pixel', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 10 }]);
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 20 }]);
|
||||
expect(buffer.getColorAt(0, 0)).toBe(20);
|
||||
});
|
||||
|
||||
it('returns -1 after undo removes the pixel', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 10 }]);
|
||||
buffer.undo();
|
||||
expect(buffer.getColorAt(0, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it('reveals earlier stroke after undo', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 10 }]);
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 20 }]);
|
||||
buffer.undo();
|
||||
expect(buffer.getColorAt(0, 0)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAffectedKeys', () => {
|
||||
it('returns empty set for empty buffer', () => {
|
||||
expect(buffer.getAffectedKeys().size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns unique coordinate keys', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 1 }, { x: 1, y: 0, color: 2 }]);
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 3 }]);
|
||||
const keys = buffer.getAffectedKeys();
|
||||
expect(keys.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pixelCount', () => {
|
||||
it('counts unique coordinates across strokes', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 1 }]);
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 2 }, { x: 1, y: 0, color: 3 }]);
|
||||
// (0,0) counted once + (1,0) = 2 unique pixels
|
||||
expect(buffer.pixelCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('resets all state', () => {
|
||||
buffer.addStroke([{ x: 0, y: 0, color: 1 }]);
|
||||
buffer.undo();
|
||||
buffer.clear();
|
||||
expect(buffer.canUndo).toBe(false);
|
||||
expect(buffer.canRedo).toBe(false);
|
||||
expect(buffer.pixelCount).toBe(0);
|
||||
expect(buffer.getAllPixels()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
import { redisRaw, redisRawBinary } from '../../src/lib/redis-client.js';
|
||||
|
||||
const env = {
|
||||
UPSTASH_REDIS_REST_URL: 'https://redis.example.com',
|
||||
UPSTASH_REDIS_REST_TOKEN: 'test-token',
|
||||
};
|
||||
|
||||
describe('redisRaw', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('sends POST with JSON body and auth header', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: 'OK' }),
|
||||
});
|
||||
|
||||
await redisRaw(env, ['SET', 'key', 'value']);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(env.UPSTASH_REDIS_REST_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '["SET","key","value"]',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve('Unauthorized'),
|
||||
});
|
||||
|
||||
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis command failed: 401');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redisRawBinary', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('uses path-based URL with Upstash-Encoding header', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: 'dGVzdA==' }),
|
||||
});
|
||||
|
||||
await redisRawBinary(env, ['GETRANGE', 'mykey', '0', '100']);
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('https://redis.example.com/GETRANGE/mykey/0/100');
|
||||
expect(opts.headers['Upstash-Encoding']).toBe('base64');
|
||||
expect(opts.headers.Authorization).toBe(`Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`);
|
||||
});
|
||||
|
||||
it('URL-encodes special characters in path segments', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: null }),
|
||||
});
|
||||
|
||||
await redisRawBinary(env, ['GETRANGE', 'rplace:canvas', '0', '10']);
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('rplace%3Acanvas');
|
||||
});
|
||||
|
||||
it('returns base64-encoded result string', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: 'AQID' }),
|
||||
});
|
||||
|
||||
const result = await redisRawBinary(env, ['GET', 'key']);
|
||||
expect(result).toBe('AQID');
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Internal Error'),
|
||||
});
|
||||
|
||||
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis command failed: 500');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
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', () => ({
|
||||
checkAndDeductCredits: vi.fn(() =>
|
||||
Promise.resolve({ allowed: true, remaining: 100, retryAfter: 0 }),
|
||||
),
|
||||
}));
|
||||
vi.mock('../src/durable-objects/canvas-room.js', () => ({
|
||||
CanvasRoom: class {},
|
||||
}));
|
||||
|
||||
import app from '../src/worker.js';
|
||||
import { checkAndDeductCredits } 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 () => {
|
||||
checkAndDeductCredits.mockResolvedValue({ allowed: false, remaining: 0, retryAfter: 5 });
|
||||
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
|
||||
expect(res.status).toBe(429);
|
||||
expect((await res.json()).error).toBe('rate_limited');
|
||||
});
|
||||
|
||||
it('accepts valid pixel placement', async () => {
|
||||
checkAndDeductCredits.mockResolvedValue({ allowed: true, remaining: 255, 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);
|
||||
expect(data.credits).toBe(255);
|
||||
});
|
||||
|
||||
it('accepts boundary pixel values', async () => {
|
||||
checkAndDeductCredits.mockResolvedValue({ allowed: true, remaining: 255, 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user