Files
syncdb/tests/database-scope-ui.test.cjs
曾志威 5e5f2aa7fe feat: enhance sync workflows and add WES configuration exports
Add the React console, strengthen synchronization and retry handling, and export WES MySQL and MongoDB configuration packages.
2026-10-14 11:39:04 +08:00

212 lines
12 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const app = fs.readFileSync(path.join(__dirname, "../cmd/syncdb/web/app.js"), "utf8");
function section(start, end) {
const from = app.indexOf(start), to = app.indexOf(end, from);
assert.ok(from >= 0 && to > from, start);
return app.slice(from, to);
}
const script = [
section("const translations =", "const pages ="),
section("function input(", "function binlogPickerField("),
section("const jsonFields =", "function submitLabel("),
section("function objectNames(", "function setSubmitting("),
section("async function pickObjects(", "const detailLabels ="),
section("async function browseObjects(", "const pendingActions ="),
section("function validAccountHost(", "function validAccountPassword("),
section("function openAccountWizard(", "function accountReviewSQL("),
section("function accountFieldChanged(", 'byId("account-form").addEventListener'),
section("function editorScopeChanged(", 'byId("editor").addEventListener("input"')
].join("\n");
function harness(kind = "newSource") {
const elements = new Map();
const element = id => {
if (!elements.has(id)) elements.set(id, {
innerHTML: "", textContent: "", open: true, disabled: false,
replaceChildren() { this.innerHTML = ""; this.textContent = ""; },
showModal() { this.open = true; },
querySelector() { return element("schema-apply"); }
});
return elements.get(id);
};
element("editor").elements = {};
element("account-form").elements = { database: { value: "", readOnly: false } };
const calls = [];
let reply = async () => [];
const state = { sources: [
{ id: "bound", name: "Bound", type: "mysql", environment: "testing", database: "default_db" },
{ id: "server", name: "Server", type: "mysql", environment: "testing", database: "" },
{ id: "mongo", name: "Mongo", type: "mongodb", environment: "testing", database: "" }
] };
const context = vm.createContext({
document: { getElementById: element }, window: {}, localStorage: { getItem: () => "en" },
location: { protocol: "https:" }, state, editorKind: kind, dialogVersion: 1,
objectRequestVersion: 0, selectedID: "server", submitting: false, schemaPlan: null,
notice: key => key, button: () => "", table: (_name, _columns, rows) => JSON.stringify(rows),
isProduction: () => false, sourceName: id => id, exportKind: () => "Local database backup",
userError: error => error.message,
listResult: value => { assert.ok(Array.isArray(value)); return value; },
FormData: class { constructor(form) {
return Object.entries(form.elements).filter(([, value]) => !value.disabled && value.value !== undefined)
.map(([name, control]) => [name, control.value]);
} },
request: url => { calls.push(url); return reply(url); },
openDetail: () => ++context.dialogVersion,
accountInputNames: ["source_id", "action", "role", "username", "host", "database"],
resetAccountWizard() {}, updateAccountControls() {}, clearAccountPassword() {}, invalidateAccountPlan() {}
});
vm.runInContext(script, context);
return {
context, element, calls, state,
reply: callback => { reply = callback; },
fields(values) { element("editor").elements = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value, checked: false }])); },
values() { return JSON.parse(JSON.stringify(context.editorValues())); }
};
}
function inputTag(html, name) {
const tag = html.match(new RegExp(`<input name="${name}"[^>]*>`));
assert.ok(tag, `${name} input exists`);
return tag[0];
}
test("a data source can be saved without a database while preserving an unchanged password", () => {
const ui = harness("editSource");
const html = ui.context.sourceFields({ name: "Server", type: "mysql" });
assert.doesNotMatch(inputTag(html, "database"), /required/);
assert.match(html, /Default database \(optional\)/);
ui.fields({ name: "Server", type: "mysql", endpoint: "localhost:3306", username: "reader", database: "", password: "" });
const values = ui.values();
assert.equal(values.database, "");
assert.equal(Object.hasOwn(values, "password"), false);
});
test("task database fields prefer the saved operation scope and otherwise use source defaults", () => {
const ui = harness("newTask");
const html = ui.context.taskFields({ source_id: "bound", target_id: "server", source_database: "orders", target_database: "archive" });
assert.match(inputTag(html, "source_database"), /value="orders".*required/);
assert.match(inputTag(html, "target_database"), /value="archive".*required/);
assert.match(inputTag(ui.context.taskFields({ source_id: "bound", target_id: "server" }), "source_database"), /value="default_db"/);
assert.match(inputTag(ui.context.exportFields(), "database"), /value="default_db".*required/);
});
test("task, export and schema payloads preserve explicit database scopes and require them", () => {
for (const kind of ["newTask", "editTask", "newExport", "compare"]) {
const ui = harness(kind);
const values = kind === "newExport"
? { source_id: "server", database: "orders", objects: "items", confirm_production: "" }
: { name: "Copy", source_id: "bound", target_id: "server", source_database: "orders", target_database: "archive",
...(kind === "compare" ? { source_object: "items", target_object: "items_copy" } : { objects: "items", dry_run: "", enabled: "" }) };
ui.fields(values);
const payload = ui.values();
assert.equal(payload[kind === "newExport" ? "database" : "source_database"], "orders");
if (kind !== "newExport") assert.equal(payload.target_database, "archive");
assert.equal(ui.state.sources[0].database, "default_db", "operation scope never changes the connection default");
ui.element("editor").elements[kind === "newExport" ? "database" : "source_database"].value = "";
assert.throws(() => ui.values(), { message: "databaseRequired" });
}
});
test("local backup uses an explicit database and bounded parallel settings", () => {
const ui = harness("quickBackup");
const html = ui.context.backupFields("server");
assert.match(html, /backupHint/);
assert.match(inputTag(html, "database"), /required/);
ui.fields({ source_id: "server", database: "orders", workers: "4", shard_rows: "20000" });
assert.deepEqual(ui.values(), { source_id: "server", database: "orders", workers: 4, shard_rows: 20000, no_lock: true });
ui.element("editor").elements.no_lock = { checked: false };
assert.equal(ui.values().no_lock, false);
ui.element("editor").elements.workers.value = "17";
assert.throws(() => ui.values(), { message: "invalidConfig" });
});
test("production local backup requires an explicit raw-data confirmation", () => {
const ui = harness("quickBackup");
ui.context.isProduction = id => id === "bound";
ui.fields({ source_id: "bound", database: "orders", workers: "4", shard_rows: "20000", confirm_production: "" });
assert.throws(() => ui.values(), { message: "required" });
ui.element("editor").elements.confirm_production.checked = true;
assert.equal(ui.values().confirm_production, true);
});
test("backup restore requires a new non-production MySQL target and confirmation", () => {
const ui = harness("restoreBackup");
const html = ui.context.restoreFields({ source_id: "bound", database: "orders", mode: "backup" });
assert.match(html, /Local database backup/);
assert.match(inputTag(html, "target_database"), /value="orders".*required/);
ui.fields({ target_id: "server", target_database: "orders_restored", prepare_replication: "on", confirm: "on" });
ui.element("editor").elements.prepare_replication.checked = true;
ui.element("editor").elements.confirm.checked = true;
assert.deepEqual(ui.values(), { target_id: "server", target_database: "orders_restored", prepare_replication: true, confirm: true });
ui.element("editor").elements.confirm.checked = false;
assert.throws(() => ui.values(), { message: "required" });
});
test("changing source or target resets its database default and clears object selections", () => {
const ui = harness("editTask");
ui.fields({ source_id: "bound", target_id: "server", source_database: "orders", target_database: "archive", objects: "old_table" });
ui.context.editorScopeChanged({ target: { name: "source_id", value: "server" } });
assert.equal(ui.element("editor").elements.source_database.value, "");
assert.equal(ui.element("editor").elements.objects.value, "");
ui.context.editorScopeChanged({ target: { name: "target_id", value: "bound" } });
assert.equal(ui.element("editor").elements.target_database.value, "default_db");
});
test("object picking uses the selected database and ignores replies from an older scope", async () => {
const ui = harness("newExport");
ui.fields({ source_id: "server", database: "sales db", objects: "" });
let resolve;
ui.reply(() => new Promise(done => { resolve = done; }));
const pending = ui.context.pickObjects(true);
assert.equal(ui.calls[0], "/datasources/server/objects?database=sales%20db");
ui.element("editor").elements.database.value = "new_db";
ui.context.editorScopeChanged({ target: { name: "database", value: "new_db" } });
resolve([{ name: "stale_table", type: "table" }]);
await pending;
assert.equal(ui.element("editor").elements.objects.value, "");
assert.equal(ui.element("editor").elements.objects.readOnly, false);
assert.equal(ui.element("save").disabled, false);
});
test("browsing an unbound source first offers database selection", async () => {
const ui = harness("detail");
ui.reply(async () => ({ databases: ["orders", "archive"] }));
await ui.context.browseObjects("server");
assert.deepEqual(ui.calls, ["/datasources/server/databases"]);
assert.match(ui.element("fields").innerHTML, /name="browse_database"/);
assert.match(ui.element("fields").innerHTML, /value="orders"/);
ui.fields({ browse_database: "archive" });
ui.reply(async () => [{ name: "items", type: "table" }]);
await ui.context.loadBrowseObjects();
assert.equal(ui.calls.at(-1), "/datasources/server/objects?database=archive");
ui.reply(async () => ({ name: "items", columns: [] }));
await ui.context.objectDetail("items");
assert.equal(ui.calls.at(-1), "/datasources/server/objects/items?database=archive");
});
test("account grants require an editable scope for unbound sources and keep bound scopes readonly", () => {
const ui = harness();
ui.context.openAccountWizard();
assert.match(inputTag(ui.element("account-fields").innerHTML, "database"), /readonly/);
ui.state.sources.reverse();
ui.context.openAccountWizard();
assert.doesNotMatch(inputTag(ui.element("account-fields").innerHTML, "database"), /readonly/);
assert.match(inputTag(ui.element("account-fields").innerHTML, "database"), /required/);
ui.context.accountFieldChanged({ target: { name: "source_id", value: "bound" } });
assert.equal(ui.element("account-form").elements.database.value, "default_db");
assert.equal(ui.element("account-form").elements.database.readOnly, true);
ui.context.accountFieldChanged({ target: { name: "source_id", value: "server" } });
assert.equal(ui.element("account-form").elements.database.readOnly, false);
const grant = { source_id: "server", username: "syncdb_reader", host: "127.0.0.1", database: "orders", action: "create", role: "reader" };
assert.doesNotThrow(() => ui.context.validateAccountInput(grant));
assert.throws(() => ui.context.validateAccountInput({ ...grant, database: "" }), { message: "accountInvalidInput" });
assert.throws(() => ui.context.validateAccountInput({ ...grant, source_id: "bound" }), { message: "accountInvalidInput" });
});