mirror of
https://github.com/tiennm99/rplace.git
synced 2026-09-09 02:17:35 +00:00
refactor(canvas): drop Upstash entirely after successful DO migration
Production migration ran cleanly (samples_checked: 9, mismatches: []), canvas data preserved, drawing verified by user. Phase 4 cleanup: Removed code: - src/lib/canvas-storage.js (Upstash GETRANGE reader) - src/lib/redis-client.js (Upstash REST helpers) - src/lib/rate-limiter.js (orphan since Phase 2) - src/admin/migrate-from-upstash.js (one-shot importer) - src/durable-objects/canvas-room.js #handleImport route - src/durable-objects/lib/chunk-storage.js importFullCanvas helper - src/worker.js /admin/migrate-from-upstash mount + import Removed tests: - test/lib/canvas-storage.test.js - test/lib/redis-client.test.js - test/integration/redis-canvas-roundtrip.test.js - test/durable-objects/canvas-room.test.js (was skipped pending rewrite) - vitest.integration.config.js (only Redis testcontainers used it) Removed deps: - @upstash/redis, ioredis, testcontainers (-184 packages) Removed constants: - REDIS_KEY_PREFIX, REDIS_CANVAS_KEY (only used by deleted code) Removed package.json scripts: test:integration, test:all Removed CF Worker secrets in production: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, MIGRATION_TOKEN Tests: 94/94 pass (down from 112 — 18 deleted Upstash-specific). Bundle: 71.90 KiB (down from 79.43 KiB). Production verified: canvas data intact (38% non-zero bytes), migration endpoint returns 404, secret list empty.
This commit is contained in:
Generated
-2208
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,15 @@
|
||||
"deploy": "vite build && wrangler deploy",
|
||||
"preview": "vite build && wrangler dev",
|
||||
"test": "vitest run --config vitest.config.js",
|
||||
"test:integration": "vitest run --config vitest.integration.config.js",
|
||||
"test:all": "vitest run --config vitest.config.js && vitest run --config vitest.integration.config.js",
|
||||
"test:watch": "vitest --config vitest.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@upstash/redis": "^1.34.3",
|
||||
"hono": "^4.7.6",
|
||||
"pixi.js": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"ioredis": "^5.10.1",
|
||||
"svelte": "^5.28.2",
|
||||
"testcontainers": "^11.14.0",
|
||||
"vite": "^6.3.2",
|
||||
"vitest": "^4.1.4",
|
||||
"wrangler": "^4.14.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "One-Shot Upstash Migration"
|
||||
status: code-complete
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: "2h"
|
||||
dependencies: [2]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Cleanup & Dependency Removal"
|
||||
status: pending
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: "2h"
|
||||
dependencies: [3]
|
||||
|
||||
@@ -9,6 +9,10 @@ brainstorm: ../reports/brainstorm-260509-2309-canvas-on-do-storage.md
|
||||
researchReports:
|
||||
- ../reports/researcher-260509-2255-forever-free-hosting.md
|
||||
- ../reports/researcher-260509-2246-vercel-migration-feasibility.md
|
||||
deployment:
|
||||
productionUrl: https://rplace.miti99.workers.dev
|
||||
migratedAt: 2026-05-10
|
||||
postCleanupVersionId: 34cd1900-dd16-47f1-abae-8d195b076b0a
|
||||
---
|
||||
|
||||
# Plan: Migrate canvas storage from Upstash → Durable Object SQLite
|
||||
@@ -42,12 +46,13 @@ Constants drive resize: `CHUNK_COUNT = ceil(CANVAS_WIDTH * CANVAS_HEIGHT / CHUNK
|
||||
|---|---|---|---|
|
||||
| 1 | [Storage Foundation](phase-01-storage-foundation.md) | completed | ~3h |
|
||||
| 2 | [DO Integration & Worker Proxy](phase-02-do-integration-worker-proxy.md) | completed | ~4h |
|
||||
| 3 | [One-Shot Upstash Migration](phase-03-one-shot-upstash-migration.md) | code-complete (awaits production run) | ~2h |
|
||||
| 4 | [Cleanup & Dependency Removal](phase-04-cleanup-dependency-removal.md) | blocked (waits for Phase 3 prod migration + 7d) | ~2h |
|
||||
| 5 | [Deploy & Documentation](phase-05-deploy-documentation.md) | partial (resize doc done; deploy + README await Phase 4) | ~1h |
|
||||
| 3 | [One-Shot Upstash Migration](phase-03-one-shot-upstash-migration.md) | completed | ~2h |
|
||||
| 4 | [Cleanup & Dependency Removal](phase-04-cleanup-dependency-removal.md) | completed | ~2h |
|
||||
| 5 | [Deploy & Documentation](phase-05-deploy-documentation.md) | partial (deploy done, READMEs done, 7-day observation pending) | ~1h |
|
||||
|
||||
**Total estimate:** ~12h (1.5 working days)
|
||||
**Status:** Phases 1, 2, 3 (code) and 5 (resize doc) complete. Phase 3 prod run + Phase 4 cleanup are user-gated.
|
||||
**Status:** Phases 1–4 complete. Phase 5 done except 7-day post-deploy observation window.
|
||||
**Production:** https://rplace.miti99.workers.dev — version 34cd1900, Upstash fully removed.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { getFullCanvas } from '../lib/canvas-storage.js';
|
||||
import { TOTAL_PIXELS, CANVAS_WIDTH } from '../lib/constants.js';
|
||||
|
||||
/**
|
||||
* One-shot Upstash → DO migration. Reads the full canvas via the legacy
|
||||
* Upstash REST path, ships the raw bytes to the DO `/import` endpoint,
|
||||
* then verifies a few sample coordinates round-trip correctly.
|
||||
*
|
||||
* Removed entirely in Phase 4 of the canvas-on-do storage plan, along
|
||||
* with the @upstash/redis dependency.
|
||||
*
|
||||
* @param {object} env - Worker env (Upstash creds + CANVAS_ROOM binding)
|
||||
* @param {DurableObjectStub} roomStub
|
||||
* @param {{force?: boolean}} opts
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function migrateFromUpstash(env, roomStub, { force = false } = {}) {
|
||||
let upstashBytes;
|
||||
try {
|
||||
upstashBytes = await getFullCanvas(env);
|
||||
} catch (err) {
|
||||
return Response.json({ error: 'upstash_read_failed', message: String(err) }, { status: 500 });
|
||||
}
|
||||
|
||||
if (upstashBytes.length !== TOTAL_PIXELS) {
|
||||
return Response.json(
|
||||
{
|
||||
error: 'upstash_size_mismatch',
|
||||
expected: TOTAL_PIXELS,
|
||||
got: upstashBytes.length,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const importUrl = force ? 'http://do/import?force=1' : 'http://do/import';
|
||||
const importRes = await roomStub.fetch(importUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: upstashBytes,
|
||||
});
|
||||
|
||||
if (!importRes.ok) {
|
||||
const text = await importRes.text().catch(() => '');
|
||||
return Response.json(
|
||||
{ error: 'do_import_failed', status: importRes.status, body: text },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// Round-trip verification: pull canvas back from the DO and compare a
|
||||
// handful of sampled bytes. Catches subtle byte-order or chunking bugs.
|
||||
const verifyRes = await roomStub.fetch('http://do/canvas');
|
||||
if (!verifyRes.ok) {
|
||||
return Response.json({ error: 'do_verify_read_failed' }, { status: 502 });
|
||||
}
|
||||
const doBytes = new Uint8Array(await verifyRes.arrayBuffer());
|
||||
|
||||
const samples = pickSampleOffsets(upstashBytes);
|
||||
const mismatches = [];
|
||||
for (const offset of samples) {
|
||||
if (doBytes[offset] !== upstashBytes[offset]) {
|
||||
mismatches.push({ offset, upstash: upstashBytes[offset], do: doBytes[offset] });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
ok: mismatches.length === 0,
|
||||
bytes_imported: upstashBytes.length,
|
||||
samples_checked: samples.length,
|
||||
mismatches,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick byte offsets to verify post-migration. Includes corners, midpoints,
|
||||
* and (preferentially) up to 5 offsets where the source has a non-zero
|
||||
* value — those catch byte-order bugs that all-zero samples would miss.
|
||||
*/
|
||||
function pickSampleOffsets(srcBytes) {
|
||||
const offsets = new Set([
|
||||
0, // (0, 0)
|
||||
CANVAS_WIDTH - 1, // first-row right edge
|
||||
TOTAL_PIXELS - 1, // last byte
|
||||
Math.floor(TOTAL_PIXELS / 2), // middle
|
||||
Math.floor(TOTAL_PIXELS / 2) + CANVAS_WIDTH + 1, // off-middle
|
||||
]);
|
||||
|
||||
// Add up to 5 non-zero offsets so we don't only check empty pixels.
|
||||
let found = 0;
|
||||
const stride = Math.max(1, Math.floor(TOTAL_PIXELS / 1000));
|
||||
for (let i = 0; i < TOTAL_PIXELS && found < 5; i += stride) {
|
||||
if (srcBytes[i] !== 0) {
|
||||
offsets.add(i);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
|
||||
return [...offsets];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE, TOTAL_PIXELS } from '../lib/constants.js';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from '../lib/constants.js';
|
||||
import { init as initSchema } from './lib/schema.js';
|
||||
import { readAllChunks, writePixels, importFullCanvas } from './lib/chunk-storage.js';
|
||||
import { readAllChunks, writePixels } from './lib/chunk-storage.js';
|
||||
import { tryAcquire } from './lib/cooldown-store.js';
|
||||
|
||||
/**
|
||||
@@ -23,11 +23,10 @@ export class CanvasRoom {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
switch (url.pathname) {
|
||||
case '/canvas': return this.#handleGetCanvas();
|
||||
case '/place': return this.#handlePlace(request);
|
||||
case '/import': return this.#handleImport(request);
|
||||
case '/ws': return this.#handleWsUpgrade();
|
||||
default: return new Response('not found', { status: 404 });
|
||||
case '/canvas': return this.#handleGetCanvas();
|
||||
case '/place': return this.#handlePlace(request);
|
||||
case '/ws': return this.#handleWsUpgrade();
|
||||
default: return new Response('not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,33 +86,6 @@ export class CanvasRoom {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot Upstash → DO migration target. Body is the full raw canvas
|
||||
* (TOTAL_PIXELS bytes). Token-gated by the worker; this DO endpoint is
|
||||
* not internet-reachable except via that gate.
|
||||
*/
|
||||
async #handleImport(request) {
|
||||
const url = new URL(request.url);
|
||||
const force = url.searchParams.get('force') === '1';
|
||||
const buf = new Uint8Array(await request.arrayBuffer());
|
||||
if (buf.length !== TOTAL_PIXELS) {
|
||||
return Response.json(
|
||||
{ error: 'size_mismatch', expected: TOTAL_PIXELS, got: buf.length },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = importFullCanvas(this.sql, buf, force);
|
||||
} catch (err) {
|
||||
return Response.json({ error: 'import_failed', message: String(err) }, { status: 500 });
|
||||
}
|
||||
if (result.skipped) {
|
||||
return Response.json({ error: 'already_populated', hint: 'pass ?force=1 to overwrite' }, { status: 409 });
|
||||
}
|
||||
return Response.json({ ok: true, chunks_written: result.imported });
|
||||
}
|
||||
|
||||
#handleWsUpgrade() {
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = Object.values(pair);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CANVAS_WIDTH, TOTAL_PIXELS, CHUNK_BYTES, CHUNK_COUNT } from '../../lib/constants.js';
|
||||
import { CANVAS_WIDTH, TOTAL_PIXELS, CHUNK_BYTES } from '../../lib/constants.js';
|
||||
|
||||
/**
|
||||
* Canvas pixel storage as fixed-size BLOB chunks in DO SQLite.
|
||||
@@ -102,35 +102,3 @@ export function writePixels(sql, pixels) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk replace the entire canvas. Used by the one-shot Upstash migration.
|
||||
* Refuses to run if the canvas already has data, unless `force` is true.
|
||||
*
|
||||
* @param {SqlStorage} sql
|
||||
* @param {Uint8Array} fullCanvas - exactly TOTAL_PIXELS bytes
|
||||
* @param {boolean} force - overwrite even if rows already exist
|
||||
* @returns {{imported: number, skipped: boolean}}
|
||||
*/
|
||||
export function importFullCanvas(sql, fullCanvas, force = false) {
|
||||
if (fullCanvas.length !== TOTAL_PIXELS) {
|
||||
throw new Error(`expected ${TOTAL_PIXELS} bytes, got ${fullCanvas.length}`);
|
||||
}
|
||||
if (!force) {
|
||||
const existing = sql.exec('SELECT COUNT(*) AS n FROM canvas_chunks').one().n;
|
||||
if (existing > 0) {
|
||||
return { imported: 0, skipped: true };
|
||||
}
|
||||
}
|
||||
for (let chunkId = 0; chunkId < CHUNK_COUNT; chunkId++) {
|
||||
const start = chunkId * CHUNK_BYTES;
|
||||
const end = Math.min(start + CHUNK_BYTES, TOTAL_PIXELS);
|
||||
const slice = fullCanvas.slice(start, end);
|
||||
sql.exec(
|
||||
'INSERT INTO canvas_chunks (chunk_id, bytes) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(chunk_id) DO UPDATE SET bytes = excluded.bytes',
|
||||
chunkId,
|
||||
slice,
|
||||
);
|
||||
}
|
||||
return { imported: CHUNK_COUNT, skipped: false };
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { redisRaw, redisRawBinary } from './redis-client.js';
|
||||
import { CANVAS_WIDTH, TOTAL_PIXELS, REDIS_CANVAS_KEY } from './constants.js';
|
||||
|
||||
const CANVAS_BYTES = TOTAL_PIXELS;
|
||||
|
||||
// Upstash REST returns binary as base64 (~4/3 overhead) and caps responses at
|
||||
// 10 MB on the Free plan. 4 MiB raw → ~5.33 MB base64, safely under the limit,
|
||||
// and divides a 16 MiB canvas into exactly 4 chunks.
|
||||
const CHUNK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Get the full canvas as a Uint8Array of raw bytes.
|
||||
* Fetches in parallel chunks to stay under Upstash's per-request size cap.
|
||||
* Returns a zero-filled buffer if canvas doesn't exist yet; truncated chunks
|
||||
* are zero-padded (GETRANGE clamps past-end reads to "" by default).
|
||||
* @param {object} env
|
||||
* @returns {Promise<Uint8Array>}
|
||||
*/
|
||||
export async function getFullCanvas(env) {
|
||||
const ranges = [];
|
||||
for (let start = 0; start < CANVAS_BYTES; start += CHUNK_BYTES) {
|
||||
const end = Math.min(start + CHUNK_BYTES, CANVAS_BYTES) - 1;
|
||||
ranges.push([start, end]);
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
ranges.map(([start, end]) =>
|
||||
redisRawBinary(env, ['GETRANGE', REDIS_CANVAS_KEY, String(start), String(end)])
|
||||
)
|
||||
);
|
||||
|
||||
const out = new Uint8Array(CANVAS_BYTES);
|
||||
let totalRead = 0;
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const base64 = results[i];
|
||||
if (!base64) continue;
|
||||
const raw = atob(base64);
|
||||
const offset = ranges[i][0];
|
||||
for (let j = 0; j < raw.length; j++) {
|
||||
out[offset + j] = raw.charCodeAt(j);
|
||||
}
|
||||
totalRead += raw.length;
|
||||
}
|
||||
|
||||
if (totalRead > 0 && totalRead < CANVAS_BYTES) {
|
||||
console.warn(`Canvas read short: got ${totalRead} bytes, expected ${CANVAS_BYTES}; zero-padding tail`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple pixels in a single atomic BITFIELD command.
|
||||
* Uses raw REST API — SDK bitfield builder is broken in @upstash/redis 1.x.
|
||||
* With u8, BITFIELD offsets are byte-aligned (`#N` = byte N).
|
||||
* @param {object} env
|
||||
* @param {Array<{x: number, y: number, color: number}>} pixels
|
||||
*/
|
||||
export async function setPixels(env, pixels) {
|
||||
if (!pixels.length) return;
|
||||
|
||||
const command = ['BITFIELD', REDIS_CANVAS_KEY];
|
||||
for (const { x, y, color } of pixels) {
|
||||
const offset = y * CANVAS_WIDTH + x;
|
||||
command.push('SET', 'u8', `#${offset}`, String(color));
|
||||
}
|
||||
await redisRaw(env, command);
|
||||
}
|
||||
@@ -18,11 +18,6 @@ export const MAX_BATCH_SIZE = 2048;
|
||||
export const CHUNK_BYTES = 65536;
|
||||
export const CHUNK_COUNT = Math.ceil(TOTAL_PIXELS / CHUNK_BYTES);
|
||||
|
||||
/** Legacy Upstash keys — used by the one-shot migration endpoint only.
|
||||
* Removed after migration verification (see Phase 4 of the canvas-on-do
|
||||
* storage plan). */
|
||||
export const REDIS_KEY_PREFIX = 'rplace:';
|
||||
export const REDIS_CANVAS_KEY = `${REDIS_KEY_PREFIX}canvas:v2`;
|
||||
|
||||
/**
|
||||
* Build the 256-color palette deterministically:
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getRedis } from './redis-client.js';
|
||||
import { REQUEST_COOLDOWN_SEC, REDIS_KEY_PREFIX } from './constants.js';
|
||||
|
||||
/**
|
||||
* One request per user per REQUEST_COOLDOWN_SEC.
|
||||
* Batch size is independent of the cooldown — the caller validates it separately.
|
||||
*
|
||||
* Uses SET NX EX atomically: the first request in a window wins, subsequent
|
||||
* requests return null until the key expires.
|
||||
*
|
||||
* @param {object} env
|
||||
* @param {string} userId
|
||||
* @returns {Promise<{allowed: boolean, retryAfter: number}>}
|
||||
*/
|
||||
export async function checkRateLimit(env, userId) {
|
||||
const redis = getRedis(env);
|
||||
const key = `${REDIS_KEY_PREFIX}cooldown:${userId}`;
|
||||
const result = await redis.set(key, '1', { nx: true, ex: REQUEST_COOLDOWN_SEC });
|
||||
if (result === 'OK') {
|
||||
return { allowed: true, retryAfter: 0 };
|
||||
}
|
||||
return { allowed: false, retryAfter: REQUEST_COOLDOWN_SEC };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Redis } from '@upstash/redis/cloudflare';
|
||||
|
||||
/**
|
||||
* Create an Upstash Redis client from CF Worker env bindings.
|
||||
* @param {object} env - Cloudflare Worker env (UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN)
|
||||
* @returns {Redis}
|
||||
*/
|
||||
export function getRedis(env) {
|
||||
return new Redis({
|
||||
url: env.UPSTASH_REDIS_REST_URL,
|
||||
token: env.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw Redis command via Upstash REST API.
|
||||
* Useful for commands where the SDK API is unreliable (e.g., BITFIELD).
|
||||
* @param {object} env
|
||||
* @param {string[]} command - Redis command as array, e.g. ['BITFIELD', 'key', 'SET', ...]
|
||||
* @returns {Promise<*>} the `result` field from the Upstash response
|
||||
*/
|
||||
export async function redisRaw(env, command) {
|
||||
const res = await fetch(env.UPSTASH_REDIS_REST_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Redis HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
// Upstash returns 200 with {"error":"..."} for application errors.
|
||||
const body = await res.json();
|
||||
if (body && body.error) {
|
||||
throw new Error(`Redis error: ${body.error}`);
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Redis command via Upstash path-based REST API with base64 response.
|
||||
* Uses Upstash-Encoding: base64 for binary-safe response transport.
|
||||
* @param {object} env
|
||||
* @param {string[]} command - Redis command as array, e.g. ['GETRANGE', 'key', '0', '100']
|
||||
* @returns {Promise<string|null>} base64-encoded result string
|
||||
*/
|
||||
export async function redisRawBinary(env, command) {
|
||||
const path = command.map((arg) => encodeURIComponent(String(arg))).join('/');
|
||||
const res = await fetch(`${env.UPSTASH_REDIS_REST_URL}/${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
|
||||
'Upstash-Encoding': 'base64',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Redis HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (body && body.error) {
|
||||
throw new Error(`Redis error: ${body.error}`);
|
||||
}
|
||||
return body.result; // base64-encoded string
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getUserId } from './lib/get-user-id.js';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js';
|
||||
import { migrateFromUpstash } from './admin/migrate-from-upstash.js';
|
||||
|
||||
export { CanvasRoom } from './durable-objects/canvas-room.js';
|
||||
|
||||
@@ -73,18 +72,4 @@ app.get('/api/ws', async (c) => {
|
||||
return room(c.env).fetch('http://do/ws', c.req.raw);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/migrate-from-upstash — one-shot Upstash → DO canvas import.
|
||||
* Token-gated; deleted in Phase 4 of the canvas-on-do storage plan.
|
||||
*/
|
||||
app.post('/admin/migrate-from-upstash', async (c) => {
|
||||
const auth = c.req.header('Authorization') || '';
|
||||
const expected = `Bearer ${c.env.MIGRATION_TOKEN || ''}`;
|
||||
if (!c.env.MIGRATION_TOKEN || auth !== expected) {
|
||||
return c.json({ error: 'forbidden' }, 403);
|
||||
}
|
||||
const force = c.req.query('force') === '1';
|
||||
return migrateFromUpstash(c.env, room(c.env), { force });
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CanvasRoom } from '../../src/durable-objects/canvas-room.js';
|
||||
|
||||
// TODO: Phase 4 of canvas-on-do storage plan rewrites these tests against the
|
||||
// new SQLite-backed DO via @cloudflare/vitest-pool-workers. The pre-migration
|
||||
// mocks here can't model state.storage.sql, so the suite is skipped until the
|
||||
// rewrite. See plans/260509-2309-canvas-on-do-storage/phase-04-cleanup-dependency-removal.md.
|
||||
const describeOrSkip = describe.skip;
|
||||
|
||||
/** Create a mock WebSocket */
|
||||
function mockWebSocket() {
|
||||
return { send: vi.fn(), close: vi.fn() };
|
||||
}
|
||||
|
||||
/** Create a mock Durable Object state with Hibernation API */
|
||||
function mockState() {
|
||||
const sockets = new Set();
|
||||
return {
|
||||
acceptWebSocket: vi.fn((ws) => sockets.add(ws)),
|
||||
getWebSockets: vi.fn(() => [...sockets]),
|
||||
_sockets: sockets,
|
||||
};
|
||||
}
|
||||
|
||||
describeOrSkip('CanvasRoom', () => {
|
||||
let state;
|
||||
let room;
|
||||
|
||||
beforeEach(() => {
|
||||
state = mockState();
|
||||
room = new CanvasRoom(state);
|
||||
});
|
||||
|
||||
describe('broadcast', () => {
|
||||
it('sends pixel data to all connected WebSockets', async () => {
|
||||
const ws1 = mockWebSocket();
|
||||
const ws2 = mockWebSocket();
|
||||
state._sockets.add(ws1);
|
||||
state._sockets.add(ws2);
|
||||
|
||||
const pixels = [{ x: 10, y: 20, color: 5 }];
|
||||
const req = new Request('http://internal/broadcast', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(pixels),
|
||||
});
|
||||
|
||||
const res = await room.fetch(req);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const expected = JSON.stringify({ type: 'pixels', pixels });
|
||||
expect(ws1.send).toHaveBeenCalledWith(expected);
|
||||
expect(ws2.send).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('closes WebSocket on send failure', async () => {
|
||||
const ws = mockWebSocket();
|
||||
ws.send.mockImplementation(() => { throw new Error('disconnected'); });
|
||||
state._sockets.add(ws);
|
||||
|
||||
const req = new Request('http://internal/broadcast', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{ x: 0, y: 0, color: 1 }]),
|
||||
});
|
||||
|
||||
const res = await room.fetch(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(ws.close).toHaveBeenCalledWith(1011, 'send failed');
|
||||
});
|
||||
|
||||
it('broadcasts to empty room without error', async () => {
|
||||
const req = new Request('http://internal/broadcast', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
|
||||
const res = await room.fetch(req);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webSocketClose', () => {
|
||||
it('closes the WebSocket with given code and reason', () => {
|
||||
const ws = mockWebSocket();
|
||||
room.webSocketClose(ws, 1000, 'normal', true);
|
||||
expect(ws.close).toHaveBeenCalledWith(1000, 'normal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('webSocketError', () => {
|
||||
it('closes the WebSocket with error code', () => {
|
||||
const ws = mockWebSocket();
|
||||
room.webSocketError(ws, new Error('test'));
|
||||
expect(ws.close).toHaveBeenCalledWith(1011, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('webSocketMessage', () => {
|
||||
it('ignores messages (no-op)', () => {
|
||||
const ws = mockWebSocket();
|
||||
// Should not throw
|
||||
room.webSocketMessage(ws, 'hello');
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Integration tests: BITFIELD write → GETRANGE read round-trip with real Redis.
|
||||
* Uses Testcontainers to spin up a Redis Docker container.
|
||||
* Verifies the exact command sequences our setPixels/getFullCanvas use.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { GenericContainer } from 'testcontainers';
|
||||
import Redis from 'ioredis';
|
||||
import { decodeCanvas } from '../../src/lib/canvas-decoder.js';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
|
||||
|
||||
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
|
||||
const CANVAS_BYTES = TOTAL_PIXELS; // 1 byte per pixel (u8)
|
||||
|
||||
let container;
|
||||
let redis;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = await new GenericContainer('redis:7-alpine')
|
||||
.withExposedPorts(6379)
|
||||
.start();
|
||||
redis = new Redis({
|
||||
host: container.getHost(),
|
||||
port: container.getMappedPort(6379),
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await redis?.quit();
|
||||
await container?.stop();
|
||||
});
|
||||
|
||||
/**
|
||||
* Replicate setPixels: build the same BITFIELD command our worker sends.
|
||||
*/
|
||||
async function writePixels(pixels) {
|
||||
const args = [];
|
||||
for (const { x, y, color } of pixels) {
|
||||
const offset = y * CANVAS_WIDTH + x;
|
||||
args.push('SET', 'u8', `#${offset}`, String(color));
|
||||
}
|
||||
return redis.call('BITFIELD', REDIS_CANVAS_KEY, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate getFullCanvas: GETRANGE → raw bytes → decode.
|
||||
* Uses Buffer (binary-safe, unlike Upstash REST JSON transport).
|
||||
*/
|
||||
async function readCanvasBytes() {
|
||||
const buf = await redis.getrangeBuffer(REDIS_CANVAS_KEY, 0, CANVAS_BYTES - 1);
|
||||
if (!buf || buf.length === 0) {
|
||||
return new Uint8Array(CANVAS_BYTES);
|
||||
}
|
||||
const bytes = new Uint8Array(buf.length);
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
bytes[i] = buf[i];
|
||||
}
|
||||
if (bytes.length < CANVAS_BYTES) {
|
||||
const padded = new Uint8Array(CANVAS_BYTES);
|
||||
padded.set(bytes);
|
||||
return padded;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
describe('Redis BITFIELD canvas round-trip', () => {
|
||||
it('stores and reads back a single pixel', async () => {
|
||||
await writePixels([{ x: 0, y: 0, color: 15 }]);
|
||||
const bytes = await readCanvasBytes();
|
||||
const indices = decodeCanvas(bytes.buffer);
|
||||
expect(indices[0]).toBe(15);
|
||||
});
|
||||
|
||||
it('round-trips 256 color values across the palette', async () => {
|
||||
const pixels = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
pixels.push({ x: i, y: 1, color: i });
|
||||
}
|
||||
await writePixels(pixels);
|
||||
const bytes = await readCanvasBytes();
|
||||
const indices = decodeCanvas(bytes.buffer);
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(indices[1 * CANVAS_WIDTH + i]).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles pixels at various canvas positions', async () => {
|
||||
const testCases = [
|
||||
{ x: 0, y: 0, color: 1 },
|
||||
{ x: CANVAS_WIDTH - 1, y: 0, color: 255 },
|
||||
{ x: 0, y: CANVAS_HEIGHT - 1, color: 128 },
|
||||
{ x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: 42 },
|
||||
{ x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT / 2, color: 200 },
|
||||
];
|
||||
await writePixels(testCases);
|
||||
const bytes = await readCanvasBytes();
|
||||
const indices = decodeCanvas(bytes.buffer);
|
||||
|
||||
for (const { x, y, color } of testCases) {
|
||||
expect(indices[y * CANVAS_WIDTH + x]).toBe(color);
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites existing pixels correctly', async () => {
|
||||
await writePixels([{ x: 500, y: 500, color: 10 }]);
|
||||
let bytes = await readCanvasBytes();
|
||||
let indices = decodeCanvas(bytes.buffer);
|
||||
expect(indices[500 * CANVAS_WIDTH + 500]).toBe(10);
|
||||
|
||||
await writePixels([{ x: 500, y: 500, color: 250 }]);
|
||||
bytes = await readCanvasBytes();
|
||||
indices = decodeCanvas(bytes.buffer);
|
||||
expect(indices[500 * CANVAS_WIDTH + 500]).toBe(250);
|
||||
});
|
||||
|
||||
it('batch writes are atomic (all pixels in one BITFIELD)', async () => {
|
||||
const batchSize = 500;
|
||||
const pixels = [];
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
pixels.push({ x: i, y: 2, color: i % 256 });
|
||||
}
|
||||
await writePixels(pixels);
|
||||
const bytes = await readCanvasBytes();
|
||||
const indices = decodeCanvas(bytes.buffer);
|
||||
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
expect(indices[2 * CANVAS_WIDTH + i]).toBe(i % 256);
|
||||
}
|
||||
});
|
||||
|
||||
it('adjacent pixels do not corrupt each other (byte boundary)', async () => {
|
||||
// With u8 each pixel is its own byte; verify no bleed between neighbors.
|
||||
const pixels = [];
|
||||
for (let i = 0; i < 16; i++) pixels.push({ x: i, y: 3, color: 255 }); // all bits
|
||||
for (let i = 16; i < 32; i++) pixels.push({ x: i, y: 3, color: 0 }); // zero
|
||||
await writePixels(pixels);
|
||||
const bytes = await readCanvasBytes();
|
||||
const indices = decodeCanvas(bytes.buffer);
|
||||
|
||||
for (let i = 0; i < 16; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(255);
|
||||
for (let i = 16; i < 32; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redis rate limiter cooldown (SET NX EX)', () => {
|
||||
const COOLDOWN_SEC = 1;
|
||||
const key = 'rplace:cooldown:test-user';
|
||||
|
||||
/** Mirrors checkRateLimit: SET key "1" NX EX <cooldown>. */
|
||||
async function tryAcquire() {
|
||||
const res = await redis.set(key, '1', 'EX', COOLDOWN_SEC, 'NX');
|
||||
return res === 'OK';
|
||||
}
|
||||
|
||||
it('allows first request for a fresh user', async () => {
|
||||
await redis.del(key);
|
||||
expect(await tryAcquire()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects second request within cooldown window', async () => {
|
||||
await redis.del(key);
|
||||
expect(await tryAcquire()).toBe(true);
|
||||
expect(await tryAcquire()).toBe(false);
|
||||
});
|
||||
|
||||
it('allows again after cooldown expires', async () => {
|
||||
await redis.del(key);
|
||||
expect(await tryAcquire()).toBe(true);
|
||||
// Wait slightly longer than cooldown for the EX key to expire.
|
||||
await new Promise((r) => setTimeout(r, (COOLDOWN_SEC * 1000) + 100));
|
||||
expect(await tryAcquire()).toBe(true);
|
||||
}, 5000);
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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 = CANVAS_WIDTH * CANVAS_WIDTH; // u8 = 1 byte per pixel
|
||||
|
||||
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('u8');
|
||||
const expectedOffset = 20 * CANVAS_WIDTH + 10;
|
||||
expect(call[4]).toBe(`#${expectedOffset}`);
|
||||
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: 255 },
|
||||
]);
|
||||
|
||||
const call = redisRaw.mock.calls[0][1];
|
||||
// BITFIELD key SET u8 #0 1 SET u8 #1 255
|
||||
expect(call).toEqual([
|
||||
'BITFIELD', REDIS_CANVAS_KEY,
|
||||
'SET', 'u8', '#0', '1',
|
||||
'SET', 'u8', '#1', '255',
|
||||
]);
|
||||
});
|
||||
|
||||
it('computes offset correctly for various positions', async () => {
|
||||
redisRaw.mockResolvedValue({ result: [0] });
|
||||
|
||||
// Last pixel of the canvas.
|
||||
const lastX = CANVAS_WIDTH - 1;
|
||||
const lastY = CANVAS_WIDTH - 1;
|
||||
await setPixels({}, [{ x: lastX, y: lastY, color: 0 }]);
|
||||
const offset = lastY * CANVAS_WIDTH + lastX;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
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('returns the response result field', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: 'PONG' }),
|
||||
});
|
||||
const result = await redisRaw(env, ['PING']);
|
||||
expect(result).toBe('PONG');
|
||||
});
|
||||
|
||||
it('throws on non-ok HTTP response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve('Unauthorized'),
|
||||
});
|
||||
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis HTTP 401');
|
||||
});
|
||||
|
||||
it('throws on Upstash 200 with error envelope', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ error: 'ERR wrong number of arguments' }),
|
||||
});
|
||||
await expect(redisRaw(env, ['BITFIELD'])).rejects.toThrow(/Redis error.*wrong number of arguments/);
|
||||
});
|
||||
});
|
||||
|
||||
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 HTTP response', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Internal Error'),
|
||||
});
|
||||
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis HTTP 500');
|
||||
});
|
||||
|
||||
it('throws on Upstash 200 with error envelope', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ error: 'ERR no such key' }),
|
||||
});
|
||||
await expect(redisRawBinary(env, ['GET', 'missing'])).rejects.toThrow(/Redis error.*no such key/);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['test/integration/**/*.test.js'],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 60000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user