187 lines
10 KiB
JavaScript
187 lines
10 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 binlogIndexKnown(", "function binlogPickerField("),
|
|
section("async function loadBinlogPicker(", "function slowSQLLocalTime("),
|
|
section("function resetBinlogExport(", "function csvToast("),
|
|
section("async function exportBinlogCSV(", "function hasAuditFilters("),
|
|
section("async function loadPageData(", "const jsonFields =")
|
|
].join("\n");
|
|
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; };
|
|
|
|
function harness() {
|
|
const elements = new Map();
|
|
const element = id => {
|
|
if (!elements.has(id)) elements.set(id, { innerHTML: "", textContent: "", disabled: false, isConnected: true,
|
|
setAttribute() {}, removeAttribute() {}, insertAdjacentHTML(_where, html) { this.innerHTML += html; } });
|
|
return elements.get(id);
|
|
};
|
|
const values = { source_id: "ds-1", index_start_mode: "history", index_retention_days: "7", index_max_mib: "1024",
|
|
from: "2026-09-25T01:00:00", to: "2026-09-25T02:00:00", database: "orders", table: "items", keyword: "item", type: "", operations: "insert",
|
|
limit: "100" };
|
|
element("query-form").elements = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {
|
|
value, disabled: false, validity: { valid: true }, focus() {}, dispatchEvent() {}
|
|
}]));
|
|
element("query-form").querySelector = selector => element(selector);
|
|
const calls = [], exports = [], errors = [];
|
|
let reply = async () => ({ source_id: "ds-1", status: "NOT_STARTED" });
|
|
const state = { authenticated: true, binlog: { events: [], files: [], next_position: null } };
|
|
const context = vm.createContext({
|
|
document: { getElementById: element }, localStorage: { getItem: () => "en" },
|
|
state, page: () => "binlog", authVersion: 1, queryVersion: 1, AbortController, URLSearchParams, Event,
|
|
binlogIndexes: new Map(), binlogIndexPending: null, binlogIndexAfter: "", binlogIndexAction: null,
|
|
binlogQueryRequest: null, binlogPaused: false, csvExportPending: null, binlogSubmitting: () => false,
|
|
binlogPickerRequests: new WeakMap(),
|
|
resetBinlogPicker: name => { element(`binlog-${name}-picker`).innerHTML = ""; },
|
|
syncBinlogPicker() {},
|
|
FormData: class { constructor(form) { return Object.entries(form.elements).filter(([, control]) => !control.disabled).map(([key, control]) => [key, String(control.value)]); } },
|
|
request: (url, options) => { calls.push({ url, options }); return reply(url); },
|
|
send: async (url, method, body) => { calls.push({ url, method, body }); return {}; },
|
|
submitCSVExport: async (kind, query) => exports.push({ kind, query }),
|
|
listResult: value => { assert.ok(Array.isArray(value)); return value; },
|
|
sanitizeBinlogEvent: value => value, date: value => value || "—", badge: value => value,
|
|
notice: value => value, errorKey: value => value, userError: error => error.message,
|
|
showPageError: error => errors.push(error.message), render() {}, updateBinlogStop() {}, binlogWarningKeys: () => [],
|
|
cancelBinlogQuery: () => { context.queryVersion++; context.binlogQueryRequest = null; }
|
|
});
|
|
vm.runInContext(script, context);
|
|
return { context, state, calls, exports, errors, element, fields: element("query-form").elements, reply: fn => { reply = fn; } };
|
|
}
|
|
|
|
test("a stopped existing index remains queryable and locks its original starting mode", async () => {
|
|
const ui = harness();
|
|
ui.reply(async () => ({ source_id: "ds-1", status: "STOPPED", enabled: false, start_mode: "current", retention_days: 14, max_bytes: 20971520 }));
|
|
await ui.context.refreshBinlogIndex(true);
|
|
assert.equal(ui.context.binlogIndexKnown("ds-1"), true);
|
|
assert.equal(ui.fields.index_start_mode.value, "current");
|
|
assert.equal(ui.fields.index_start_mode.disabled, true);
|
|
assert.equal(ui.fields.index_retention_days.value, 14);
|
|
assert.equal(ui.fields.index_max_mib.value, 20);
|
|
assert.equal(ui.calls.length, 1, "loading status never starts collection");
|
|
});
|
|
|
|
test("an unindexed source requires index creation and cannot export until caught up", async () => {
|
|
const ui = harness();
|
|
await ui.context.refreshBinlogIndex(true);
|
|
assert.equal(ui.context.binlogIndexKnown("ds-1"), false);
|
|
assert.equal(ui.element("binlog-export-csv").disabled, true);
|
|
assert.equal(ui.fields.index_start_mode.disabled, false);
|
|
ui.reply(async () => ({ source_id: "ds-1", status: "WATCHING", enabled: true, caught_up: true }));
|
|
await ui.context.refreshBinlogIndex(true);
|
|
assert.equal(ui.element("binlog-export-csv").disabled, false);
|
|
assert.equal(ui.element("binlog-index-start").disabled, true);
|
|
assert.equal(ui.element("binlog-index-stop").disabled, false);
|
|
});
|
|
|
|
test("late index status from another source or session cannot overwrite the selected source", async () => {
|
|
const ui = harness();
|
|
const pending = deferred();
|
|
ui.reply(() => pending.promise);
|
|
const task = ui.context.refreshBinlogIndex(true);
|
|
ui.fields.source_id.value = "ds-2";
|
|
pending.resolve({ source_id: "ds-1", status: "WATCHING" });
|
|
await task;
|
|
assert.equal(ui.context.binlogIndexes.has("ds-1"), false);
|
|
assert.equal(ui.context.binlogIndexKnown("ds-2"), false);
|
|
});
|
|
|
|
test("start submits validated retention and byte capacity while catch-up blocks complete exports", async () => {
|
|
const ui = harness();
|
|
ui.fields.index_max_mib.value = "20";
|
|
ui.reply(async () => ({ source_id: "ds-1", status: "CATCHING_UP", enabled: true }));
|
|
await ui.context.changeBinlogIndex("start");
|
|
assert.equal(ui.calls[0].url, "/binlog/index/start");
|
|
assert.deepEqual(JSON.parse(JSON.stringify(ui.calls[0].body)), { source_id: "ds-1", start_mode: "history", retention_days: 7, max_bytes: 20971520 });
|
|
assert.equal(ui.element("binlog-export-csv").disabled, true);
|
|
assert.equal(ui.fields.index_start_mode.disabled, true);
|
|
const invalid = harness();
|
|
invalid.fields.index_max_mib.value = "9";
|
|
await invalid.context.changeBinlogIndex("start");
|
|
assert.equal(invalid.calls.length, 0);
|
|
assert.equal(invalid.element("binlog-index-status").textContent, "binlogIndexInvalidConfig");
|
|
});
|
|
|
|
test("indexed queries send filters and after_id without replay position or panel configuration", async () => {
|
|
const ui = harness();
|
|
ui.context.refreshBinlogIndex = async () => {};
|
|
ui.context.binlogIndexes.set("ds-1", { status: "CATCHING_UP", caught_up: false });
|
|
ui.context.binlogIndexAfter = "1234";
|
|
ui.reply(async () => ({ files: [], events: [{ index_id: 1235 }], next_cursor: 1235, complete: false }));
|
|
await ui.context.loadPageData();
|
|
const url = new URL(ui.calls[0].url, "http://localhost");
|
|
assert.equal(url.pathname, "/binlog/index/events");
|
|
assert.equal(url.searchParams.get("after_id"), "1234");
|
|
assert.equal(url.searchParams.get("database"), "orders");
|
|
assert.equal(url.searchParams.get("operations"), "insert");
|
|
for (const key of ["file", "position", "mode", "storage", "index_retention_days", "index_start_mode", "index_max_mib"]) assert.equal(url.searchParams.has(key), false, key);
|
|
assert.equal(ui.state.binlog.complete, false);
|
|
assert.equal(ui.context.binlogHasNext(), true);
|
|
});
|
|
|
|
test("CSV background submission always uses the local index", async () => {
|
|
const ui = harness();
|
|
ui.context.binlogIndexes.set("ds-1", { status: "WATCHING", caught_up: true });
|
|
await ui.context.exportBinlogCSV();
|
|
assert.deepEqual(ui.errors, []);
|
|
assert.equal(ui.exports.length, 1);
|
|
assert.equal(ui.exports[0].kind, "binlog");
|
|
assert.equal(ui.exports[0].query.storage, "index");
|
|
assert.equal(ui.exports[0].query.database, "orders");
|
|
assert.equal(Object.hasOwn(ui.exports[0].query, "file"), false);
|
|
assert.equal(ui.calls.length, 0, "CSV submission never runs a foreground replay");
|
|
});
|
|
|
|
test("handlers reject missing or incomplete indexes without any direct-preview fallback", async () => {
|
|
for (const item of [null, { status: "CATCHING_UP", caught_up: false }]) {
|
|
const ui = harness();
|
|
if (item) ui.context.binlogIndexes.set("ds-1", item);
|
|
await ui.context.exportBinlogCSV();
|
|
assert.deepEqual(ui.errors, [item ? "binlogIndexNotReady" : "binlogIndexRequired"]);
|
|
assert.equal(ui.exports.length, 0);
|
|
assert.equal(ui.calls.length, 0);
|
|
}
|
|
const ui = harness();
|
|
ui.context.refreshBinlogIndex = async () => {};
|
|
await assert.rejects(ui.context.loadPageData(), { message: "binlogIndexRequired" });
|
|
assert.equal(ui.calls.length, 0);
|
|
});
|
|
|
|
test("database and table choices come only from local index objects and preserve manual filters", async () => {
|
|
const ui = harness();
|
|
ui.reply(async () => [{ name: "historical_db" }, { name: "historical_db" }, { name: "<archived>" }]);
|
|
await ui.context.loadBinlogPicker("database");
|
|
assert.equal(ui.calls[0].url, "/binlog/index/objects?source_id=ds-1");
|
|
assert.equal(ui.fields.database.value, "orders");
|
|
assert.match(ui.element("binlog-database-picker").innerHTML, /historical_db/);
|
|
assert.match(ui.element("binlog-database-picker").innerHTML, /<archived>/);
|
|
assert.equal((ui.element("binlog-database-picker").innerHTML.match(/value="historical_db"/g) || []).length, 1);
|
|
ui.reply(async () => [{ name: "old_items" }]);
|
|
await ui.context.loadBinlogPicker("table");
|
|
assert.equal(ui.calls[1].url, "/binlog/index/objects?source_id=ds-1&database=orders");
|
|
assert.equal(ui.fields.table.value, "items");
|
|
assert.match(ui.element("binlog-table-hint").textContent, /local index/);
|
|
assert.equal(ui.calls.some(call => call.url.startsWith("/datasources/")), false);
|
|
});
|
|
|
|
test("late object choices do not overwrite a newly selected database", async () => {
|
|
const ui = harness();
|
|
const pending = deferred();
|
|
ui.reply(() => pending.promise);
|
|
const request = ui.context.loadBinlogPicker("table");
|
|
ui.fields.database.value = "another_db";
|
|
pending.resolve([{ name: "stale_table" }]);
|
|
await request;
|
|
assert.doesNotMatch(ui.element("binlog-table-picker").innerHTML, /stale_table/);
|
|
});
|