diff --git a/package.json b/package.json index 9c2ee33..da53f3e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/test/lib/canvas-decoder.test.js b/test/lib/canvas-decoder.test.js new file mode 100644 index 0000000..5f66d6e --- /dev/null +++ b/test/lib/canvas-decoder.test.js @@ -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); + } + }); +}); diff --git a/test/lib/canvas-storage.test.js b/test/lib/canvas-storage.test.js new file mode 100644 index 0000000..b204820 --- /dev/null +++ b/test/lib/canvas-storage.test.js @@ -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); + } + }); +}); diff --git a/test/lib/get-user-id.test.js b/test/lib/get-user-id.test.js new file mode 100644 index 0000000..62afe6b --- /dev/null +++ b/test/lib/get-user-id.test.js @@ -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); + }); +}); diff --git a/test/lib/pixel-buffer.test.js b/test/lib/pixel-buffer.test.js new file mode 100644 index 0000000..a1255e7 --- /dev/null +++ b/test/lib/pixel-buffer.test.js @@ -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([]); + }); + }); +}); diff --git a/test/lib/redis-client.test.js b/test/lib/redis-client.test.js new file mode 100644 index 0000000..5195a71 --- /dev/null +++ b/test/lib/redis-client.test.js @@ -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'); + }); +}); diff --git a/test/worker-validation.test.js b/test/worker-validation.test.js new file mode 100644 index 0000000..a8e32a6 --- /dev/null +++ b/test/worker-validation.test.js @@ -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); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..7382f40 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +});