Files
ChuBlog/chusql-web/test/transactions.mjs
T

175 lines
8.3 KiB
JavaScript
Raw Normal View History

2026-10-05 22:22:23 +08:00
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { beforeEach, test } from 'node:test';
import * as api from '../static/api.js';
import { createEmptyChangeSet } from '../static/core.js';
// 验证页面编辑状态、原子请求、断线重试与关闭清理。
const listeners = new Map();
globalThis.window = { addEventListener: (name, callback) => listeners.set(name, callback), dispatchEvent: () => {} };
globalThis.document = { getElementById: (id) => id === 'root' ? { addEventListener() {} } : null };
let source = await readFile(new URL('../static/app.js', import.meta.url), 'utf8');
source = source.replaceAll("'./api.js'", JSON.stringify(new URL('../static/api.js', import.meta.url).href))
.replaceAll("'./core.js'", JSON.stringify(new URL('../static/core.js', import.meta.url).href))
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql };');
const app = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
let calls;
let commitResponse;
// 生成 JSON HTTP 响应。
function response(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
// 重置页面与模拟服务端。
beforeEach(() => {
calls = [];
commitResponse = () => response({ ok: true, applied: 1, state: 'committed' });
api.forgetEdit();
Object.assign(app.state, { database: 'test', databases: ['test'], tabs: [], activeId: undefined,
view: 'workbench', session: { user: 'admin' }, edit: { id: 'page', database: 'test' },
editBusy: false, editStarting: null, editAttempt: null, editNeedsReload: false, editUncertain: null,
changes: createEmptyChangeSet(), messages: [] });
globalThis.fetch = async (url, init) => {
calls.push({ url, init, body: init.body ? JSON.parse(init.body) : undefined });
if (url === '/api/edit/commit') return commitResponse();
if (url === '/api/databases') return response(['test']);
if (url === '/api/tables') return response([]);
return response({ state: 'active' });
};
});
// 检查整批修改只发送一次提交请求。
test('all table edits use one atomic request and clear only after success', async () => {
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
app.stageUpdate('second', 'test', 2, 'value', 20, 30);
await app.commitAll();
const committed = calls.filter((call) => call.url === '/api/edit/commit');
assert.equal(committed.length, 1);
assert.equal(committed[0].body.changes.length, 2);
assert.equal(app.state.changes.updates.size, 0);
assert.equal(app.state.edit, null);
assert.equal(calls.some((call) => call.init.method === 'PATCH'), false);
});
// 检查失败保留可修正的完整编辑集。
test('constraint failure retains all edits and permits local undo before replay', async () => {
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
app.stageUpdate('second', 'test', 2, 'value', 20, 30);
commitResponse = () => response({ ok: false, applied: 0, state: 'active', message: 'constraint', reloadRequired: false }, 400);
await app.commitAll();
assert.equal(app.state.changes.updates.size, 2);
assert.equal(app.state.editNeedsReload, false);
app.removeStaged([...app.state.changes.updates.keys()][0]);
assert.equal(app.state.changes.updates.size, 1);
assert.equal(calls.some((call) => call.init.method === 'PATCH'), false);
});
// 检查冲突禁止再次提交直到回滚。
test('serialization conflict freezes edits and requires rollback and reload', async () => {
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
commitResponse = () => response({ ok: false, state: 'rolled_back', reloadRequired: true, message: 'conflict' }, 409);
await app.commitAll();
await app.commitAll();
assert.equal(calls.filter((call) => call.url === '/api/edit/commit').length, 1);
assert.equal(app.state.changes.updates.size, 1);
app.stageUpdate('first', 'test', 1, 'value', 10, 40);
assert.equal([...app.state.changes.updates.values()][0].newValue, 20);
await app.rollbackAll();
assert.equal(app.state.changes.updates.size, 0);
assert.equal(app.state.editNeedsReload, false);
assert.equal(calls.some((call) => call.url === '/api/edit/rollback'), true);
});
// 检查断线后冻结载荷并使用同一提交标识。
test('an uncertain response retries the same payload without allowing edits', async () => {
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
commitResponse = () => { throw new Error('connection lost'); };
await app.commitAll();
assert.ok(app.state.editUncertain);
app.stageUpdate('first', 'test', 1, 'value', 10, 99);
commitResponse = () => response({ ok: true, applied: 1, state: 'committed' });
await app.commitAll();
const committed = calls.filter((call) => call.url === '/api/edit/commit');
assert.deepEqual(committed[0].body, committed[1].body);
assert.equal(app.state.editUncertain, null);
});
// 检查并发点击只有一个正在提交的请求。
test('duplicate clicks cannot submit concurrently', async () => {
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
await Promise.all([app.commitAll(), app.commitAll()]);
assert.equal(calls.filter((call) => call.url === '/api/edit/commit').length, 1);
});
// 检查快照读取带页面标识且普通 SQL 无标识。
test('snapshot headers are attached only to table reads', async () => {
await api.beginEdit('page', 'test');
await api.listTables('test');
globalThis.fetch = async (url, init) => {
calls.push({ url, init });
return response({ columns: [], rows: [], rowCount: 0 });
};
await api.query('SELECT 1', 'test');
assert.equal(calls.find((call) => call.url === '/api/tables').init.headers.get('X-ChuSQL-Edit'), 'page');
assert.equal(calls.find((call) => call.url === '/api/query').init.headers.has('X-ChuSQL-Edit'), false);
});
// 检查页面离开时发送回滚请求。
test('page close sends a rollback beacon for its own transaction', async () => {
let beacon;
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { sendBeacon: (url, body) => { beacon = { url, body }; } } });
listeners.get('pagehide')();
assert.equal(beacon.url, '/api/edit/rollback');
assert.deepEqual(JSON.parse(await beacon.body.text()), { id: 'page', database: 'test' });
});
// 检查 BEGIN 回包断线后仍使用同一标识。
test('a lost BEGIN response keeps its identity for an idempotent retry', async () => {
app.state.edit = null;
globalThis.fetch = async (url, init) => {
calls.push({ url, init, body: JSON.parse(init.body) });
if (calls.length === 1) throw new Error('begin response lost');
return response({ state: 'active' });
};
await assert.rejects(app.ensureEdit('test'));
assert.ok(app.state.editAttempt);
await app.ensureEdit('test');
assert.deepEqual(calls[0].body, calls[1].body);
assert.equal(app.state.edit.id, calls[0].body.id);
});
// 检查每个 SQL 标签使用独立所有者标识。
test('console transaction cleanup only applies to its owning tab', async () => {
globalThis.fetch = async (url, init) => {
calls.push({ url, init, body: JSON.parse(init.body) });
return response({ columns: [], rows: [], rowCount: 0, transaction: true });
};
await api.query('BEGIN', 'test', 'first');
const owner = calls[0].init.headers.get('X-ChuSQL-Console');
assert.ok(owner.endsWith(':first'));
await api.rollbackConsole('second');
assert.equal(calls.length, 1);
await api.rollbackConsole('first');
assert.equal(calls[1].url, '/api/query/rollback');
assert.equal(calls[1].body.console, owner);
});
// 检查 SQL 回滚等待期间不能继续编辑旧快照。
test('edits are locked while SQL releases the previous snapshot', async () => {
let release;
globalThis.fetch = async (url, init) => {
calls.push({ url, init });
if (url === '/api/edit/rollback') return new Promise((resolve) => { release = () => resolve(response({ state: 'rolled_back' })); });
return response({ columns: ['value'], rows: [[1]], rowCount: 1 });
};
const job = app.runSql({ id: 'query', database: 'test', sql: 'SELECT 1' });
assert.equal(app.state.editBusy, true);
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
assert.equal(app.state.changes.updates.size, 0);
release();
await job;
assert.equal(app.state.editBusy, false);
assert.equal(app.state.edit, null);
});