272 lines
13 KiB
JavaScript
272 lines
13 KiB
JavaScript
// Regression suite for lib/common/registeredTable.js against REAL Saltcorn
|
|
// (@saltcorn/data from the upstream checkout) on a THROWAWAY database: a temp
|
|
// SQLite file by default, or with RT_TEST_PG=1 a Postgres database rt_<pid>
|
|
// (createdb/dropdb over the local socket) -- never a shared instance's database.
|
|
//
|
|
// node test/registeredTableTest.js
|
|
// RT_TEST_PG=1 node test/registeredTableTest.js
|
|
//
|
|
// Covers: an unregistered orphan table (empty -> replaced; with rows ->
|
|
// adopted, rows and ids kept), an already-registered table (no-op, also with a
|
|
// stale cache), healing a damaged registration, merging a renamed-aside legacy
|
|
// copy by key, row transforms, rollback of a failed adoption, site-sync's real
|
|
// binding (createAllTables adopting _ss_table_modes), and WORKERS processes
|
|
// racing to register the same table. Exit code is 0 only if every check passed.
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const Module = require("node:module");
|
|
const { execFileSync, spawn } = require("node:child_process");
|
|
|
|
// The project root: the upstream Saltcorn checkout is ROOT/saltcorn.
|
|
const ROOT = path.resolve(__dirname, "..", "..");
|
|
const COMMON_DIR = path.resolve(__dirname, "..", "lib", "common");
|
|
const WORKER_FLAG = "--worker";
|
|
const IS_WORKER = process.argv.includes(WORKER_FLAG);
|
|
const USE_PG = process.env.RT_TEST_PG === "1";
|
|
const PG_HOST = "/var/run/postgresql";
|
|
const WORKERS = 3;
|
|
const OWNER = "registeredTableTest";
|
|
const RACE_TABLE = "_rt_race";
|
|
const RACE_ROWS = 3;
|
|
// Fields + the auto id primary key.
|
|
const FIELDS = [
|
|
{ name: "key", type: "String", is_unique: true, required: true },
|
|
{ name: "label", type: "String" },
|
|
{ name: "n", type: "Integer" }
|
|
];
|
|
const FIELD_COUNT_WITH_ID = FIELDS.length + 1;
|
|
|
|
// The parent creates the throwaway database and passes it to the workers.
|
|
if (!IS_WORKER) {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "rt-unit-"));
|
|
process.env.RT_STATE_DIR = stateDir;
|
|
if (USE_PG) {
|
|
delete process.env.SQLITE_FILEPATH;
|
|
delete process.env.FORCE_SQLITE;
|
|
delete process.env.SALTCORN_MULTI_TENANT;
|
|
process.env.PGHOST = PG_HOST;
|
|
process.env.PGUSER = process.env.PGUSER || os.userInfo().username;
|
|
process.env.PGPASSWORD = process.env.PGPASSWORD || "unused-peer-auth";
|
|
process.env.PGDATABASE = `rt_${process.pid}`;
|
|
execFileSync("createdb", ["-h", PG_HOST, process.env.PGDATABASE]);
|
|
} else {
|
|
process.env.FORCE_SQLITE = "true";
|
|
process.env.SQLITE_FILEPATH = path.join(stateDir, "rt.sqlite");
|
|
}
|
|
process.env.SALTCORN_FILE_STORE = path.join(stateDir, "files");
|
|
process.env.SALTCORN_SESSION_SECRET = "registered-table-test-FAKE-secret";
|
|
}
|
|
process.env.NODE_PATH = [path.join(ROOT, "saltcorn", "node_modules"), process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
|
|
Module._initPaths();
|
|
|
|
const { ensureRegisteredTable, physicalTableExists, tableRef } = require(path.join(COMMON_DIR, "registeredTable.js"));
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
|
|
const check = async (name, fn) => {
|
|
try {
|
|
await fn();
|
|
passed++;
|
|
console.log(` PASS ${name}`);
|
|
} catch (e) {
|
|
failed++;
|
|
console.log(` FAIL ${name}\n ${e && e.message ? e.message.split("\n")[0] : e}`);
|
|
}
|
|
};
|
|
|
|
|
|
const bootState = async () => {
|
|
const { getState } = require("@saltcorn/data/db/state");
|
|
getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
|
|
await getState().refresh();
|
|
};
|
|
|
|
|
|
const spec = (name, extra) => ({ owner: OWNER, name: name, fields: FIELDS, ...(extra || {}) });
|
|
|
|
|
|
// A raw (unregistered) table, the shape an orphan / legacy table has.
|
|
const createRaw = async (name, columnsSql) => {
|
|
const db = require("@saltcorn/data/db");
|
|
const idCol = db.isSQLite ? "id INTEGER PRIMARY KEY" : "id SERIAL PRIMARY KEY";
|
|
await db.query(`CREATE TABLE ${tableRef(name)} (${columnsSql.replace("$ID", idCol)})`);
|
|
};
|
|
|
|
|
|
const registeredRow = async (name) => {
|
|
return await require("@saltcorn/data/db").selectMaybeOne("_sc_tables", { name: name });
|
|
};
|
|
|
|
|
|
const fieldCount = async (name) => {
|
|
const db = require("@saltcorn/data/db");
|
|
const row = await registeredRow(name);
|
|
return row ? (await db.select("_sc_fields", { table_id: row.id })).length : 0;
|
|
};
|
|
|
|
|
|
const rowsOf = async (name) => {
|
|
const db = require("@saltcorn/data/db");
|
|
return (await db.query(`SELECT * FROM ${tableRef(name)} ORDER BY id`)).rows;
|
|
};
|
|
|
|
|
|
// Child: boot Saltcorn state and race to register RACE_TABLE; exit 0 on success.
|
|
const worker = async () => {
|
|
await bootState();
|
|
let code = 0;
|
|
try {
|
|
await ensureRegisteredTable(spec(RACE_TABLE, { keyField: "key" }));
|
|
} catch (e) {
|
|
console.log(` worker ${process.pid} ERROR ${e.message}`);
|
|
code = 1;
|
|
}
|
|
await require("@saltcorn/data/db").close();
|
|
process.exit(code);
|
|
};
|
|
|
|
|
|
const spawnWorker = () => {
|
|
return new Promise((resolve) => {
|
|
const p = spawn(process.execPath, [__filename, WORKER_FLAG], { env: process.env, stdio: ["ignore", "inherit", "inherit"] });
|
|
p.on("exit", (code) => resolve(code));
|
|
});
|
|
};
|
|
|
|
|
|
const main = async () => {
|
|
const db = require("@saltcorn/data/db");
|
|
console.log(`registeredTable suite -- ${USE_PG ? `postgres ${process.env.PGDATABASE}` : `sqlite ${process.env.SQLITE_FILEPATH}`}`);
|
|
await require("@saltcorn/data/db/reset_schema")(true);
|
|
await bootState();
|
|
const Table = require("@saltcorn/data/models/table");
|
|
const { getState } = require("@saltcorn/data/db/state");
|
|
|
|
console.log("\n[unregistered orphan tables]");
|
|
await check("control: plain Table.create throws on an orphan (the live bug)", async () => {
|
|
await createRaw("_rt_control", "$ID");
|
|
await assert.rejects(() => Table.create("_rt_control"), /exists/);
|
|
});
|
|
await check("an empty orphan is replaced by the registered table", async () => {
|
|
await createRaw("_rt_empty", "$ID, key TEXT");
|
|
assert.equal(await ensureRegisteredTable(spec("_rt_empty")), true);
|
|
assert.ok(await registeredRow("_rt_empty"), "not registered");
|
|
assert.equal(await fieldCount("_rt_empty"), FIELD_COUNT_WITH_ID);
|
|
assert.equal((await rowsOf("_rt_empty")).length, 0);
|
|
});
|
|
await check("an orphan with rows is adopted: rows (by column name) and ids preserved", async () => {
|
|
await createRaw("_rt_rows", "$ID, key TEXT, label TEXT, extra TEXT");
|
|
await db.query(`INSERT INTO ${tableRef("_rt_rows")} (id, key, label, extra) VALUES (5, 'a', 'A', 'x'), (7, 'b', NULL, 'y')`);
|
|
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), true);
|
|
const rows = await rowsOf("_rt_rows");
|
|
assert.deepEqual(rows.map((r) => [r.id, r.key, r.label]), [[5, "a", "A"], [7, "b", null]]);
|
|
const fresh = await db.insert("_rt_rows", { key: "later" });
|
|
assert.ok(fresh > 7, `new id ${fresh} collides with an adopted one`);
|
|
});
|
|
|
|
console.log("\n[registered tables]");
|
|
await check("an already-registered table is a no-op (rows and registration untouched)", async () => {
|
|
const before = await registeredRow("_rt_rows");
|
|
const rows = await rowsOf("_rt_rows");
|
|
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), false);
|
|
assert.equal((await registeredRow("_rt_rows")).id, before.id);
|
|
assert.deepEqual(await rowsOf("_rt_rows"), rows);
|
|
});
|
|
await check("... also when this process's table cache is stale", async () => {
|
|
getState().tables = getState().tables.filter((t) => t.name !== "_rt_rows");
|
|
const rows = await rowsOf("_rt_rows");
|
|
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), false);
|
|
assert.deepEqual(await rowsOf("_rt_rows"), rows);
|
|
await getState().refresh_tables(true);
|
|
});
|
|
await check("a damaged registration is healed (lost column re-added, new field registered)", async () => {
|
|
await db.query(`ALTER TABLE ${tableRef("_rt_rows")} DROP COLUMN "label"`);
|
|
const more = FIELDS.concat([{ name: "added", type: "String" }]);
|
|
assert.equal(await ensureRegisteredTable({ ...spec("_rt_rows"), fields: more }), false);
|
|
const cols = Object.keys((await rowsOf("_rt_rows"))[0]);
|
|
assert.ok(cols.includes("label") && cols.includes("added"), `columns now: ${cols}`);
|
|
assert.equal(await fieldCount("_rt_rows"), FIELD_COUNT_WITH_ID + 1);
|
|
});
|
|
|
|
console.log("\n[legacy copies, transforms, failures]");
|
|
await check("a renamed-aside legacy copy is merged by keyField, then dropped", async () => {
|
|
await db.query(`CREATE TABLE ${tableRef("_rt_rows__legacy")} (key TEXT, label TEXT)`);
|
|
await db.query(`INSERT INTO ${tableRef("_rt_rows__legacy")} (key, label) VALUES ('a', 'dup'), ('c', 'C')`);
|
|
assert.equal(await ensureRegisteredTable(spec("_rt_rows", { keyField: "key", legacyTables: ["_rt_rows__legacy"] })), false);
|
|
const rows = await rowsOf("_rt_rows");
|
|
const aRows = rows.filter((r) => r.key === "a");
|
|
assert.equal(aRows.length, 1, "a row present in both was duplicated");
|
|
assert.notEqual(aRows[0].label, "dup", "an existing row was overwritten");
|
|
assert.equal(rows.find((r) => r.key === "c").label, "C", "the legacy-only row was not adopted");
|
|
assert.ok(!(await physicalTableExists("_rt_rows__legacy")), "legacy copy left behind");
|
|
});
|
|
await check("rowTransform reshapes adopted rows (null drops one)", async () => {
|
|
await createRaw("_rt_xform", "$ID, key TEXT, legacy_n INTEGER");
|
|
await db.query(`INSERT INTO ${tableRef("_rt_xform")} (key, legacy_n) VALUES ('keep', 4), ('drop', 9)`);
|
|
const rowTransform = async (r) => (r.key === "drop" ? null : { key: r.key, n: r.legacy_n * 10 });
|
|
await ensureRegisteredTable(spec("_rt_xform", { rowTransform: rowTransform }));
|
|
assert.deepEqual((await rowsOf("_rt_xform")).map((r) => [r.key, r.n]), [["keep", 40]]);
|
|
});
|
|
await check("an adoption that cannot complete rolls back: orphan intact, nothing registered", async () => {
|
|
await createRaw("_rt_fail", "$ID, key TEXT");
|
|
await db.query(`INSERT INTO ${tableRef("_rt_fail")} (key) VALUES ('d'), ('d')`);
|
|
await assert.rejects(() => ensureRegisteredTable(spec("_rt_fail")), /could not register table _rt_fail/);
|
|
assert.ok(!(await registeredRow("_rt_fail")), "registered after a failed adoption");
|
|
assert.equal((await rowsOf("_rt_fail")).length, 2, "orphan rows lost");
|
|
});
|
|
|
|
const siteSyncSchema = path.join(__dirname, "..", "lib", "schema.js");
|
|
console.log("\n[site-sync binding]");
|
|
await check("createAllTables adopts an unregistered _ss_table_modes with its row", async () => {
|
|
await createRaw("_ss_table_modes", "$ID, table_name TEXT, data_mode TEXT, updated_at TEXT");
|
|
await db.query(`INSERT INTO ${tableRef("_ss_table_modes")} (id, table_name, data_mode, updated_at) VALUES (4, 'refdata', 'managed', 'x')`);
|
|
await require(siteSyncSchema).createAllTables();
|
|
assert.ok(await registeredRow("_ss_table_modes"), "not registered");
|
|
assert.deepEqual((await rowsOf("_ss_table_modes")).map((r) => [r.id, r.table_name, r.data_mode]), [[4, "refdata", "managed"]]);
|
|
await require(siteSyncSchema).createAllTables();
|
|
assert.equal((await rowsOf("_ss_table_modes")).length, 1, "a re-run duplicated the row");
|
|
});
|
|
|
|
console.log(`\n[${WORKERS} processes racing to register an orphan with rows]`);
|
|
await check(`${WORKERS} racing workers all succeed; registered once, rows adopted once`, async () => {
|
|
await createRaw(RACE_TABLE, "$ID, key TEXT, label TEXT");
|
|
for (let i = 1; i <= RACE_ROWS; i++) {
|
|
await db.query(`INSERT INTO ${tableRef(RACE_TABLE)} (id, key, label) VALUES ($1, $2, 'r')`, [i * 10, `k${i}`]);
|
|
}
|
|
const codes = await Promise.all(Array.from({ length: WORKERS }, () => spawnWorker()));
|
|
assert.ok(codes.every((c) => c === 0), `exit codes ${codes.join(",")}`);
|
|
const registrations = (await db.query("SELECT COUNT(*) AS c FROM _sc_tables WHERE name = $1", [RACE_TABLE])).rows[0].c;
|
|
assert.equal(Number(registrations), 1, "registered more than once");
|
|
assert.equal(await fieldCount(RACE_TABLE), FIELD_COUNT_WITH_ID);
|
|
assert.deepEqual((await rowsOf(RACE_TABLE)).map((r) => r.id), [10, 20, 30]);
|
|
});
|
|
};
|
|
|
|
|
|
const cleanup = async () => {
|
|
await require("@saltcorn/data/db").close();
|
|
fs.rmSync(process.env.RT_STATE_DIR, { recursive: true, force: true });
|
|
if (USE_PG) {
|
|
execFileSync("dropdb", ["-h", PG_HOST, "--if-exists", process.env.PGDATABASE]);
|
|
}
|
|
};
|
|
|
|
|
|
if (IS_WORKER) {
|
|
worker();
|
|
} else {
|
|
main().then(async () => {
|
|
console.log(`\n${passed} passed, ${failed} failed (${USE_PG ? "postgres" : "sqlite"})`);
|
|
await cleanup();
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
}).catch(async (e) => {
|
|
console.error("suite crashed:", e);
|
|
await cleanup();
|
|
process.exit(1);
|
|
});
|
|
}
|