Files
wyndham-Condon/backend/scripts/test-database.mjs
2026-08-02 13:33:13 +08:00

520 lines
14 KiB
JavaScript

import { randomUUID } from "node:crypto";
import process from "node:process";
import { createInterface } from "node:readline";
import pg from "pg";
const { Pool } = pg;
async function readStandardInput() {
const lines = createInterface({
input: process.stdin,
terminal: false
});
for await (const line of lines) {
lines.close();
return line.trim();
}
throw Object.assign(new Error("Missing connection input"), {
code: "MISSING_CONNECTION_INPUT"
});
}
function databaseMessage(error) {
return typeof error?.message === "string" ? error.message : "";
}
function safeError(error) {
return {
ok: false,
errorCode: typeof error?.code === "string"
? error.code
: "DATABASE_TEST_FAILED",
errorMessage: databaseMessage(error).startsWith("CONDON_")
? databaseMessage(error)
: "Database integration test failed"
};
}
async function expectDatabaseError(
client,
checks,
label,
text,
values,
expectedMessage
) {
await client.query("SAVEPOINT expected_error");
let matched = false;
try {
await client.query(text, values);
} catch (error) {
matched = databaseMessage(error) === expectedMessage;
} finally {
await client.query("ROLLBACK TO SAVEPOINT expected_error");
await client.query("RELEASE SAVEPOINT expected_error");
}
checks[label] = matched;
}
function usageFunctionSql() {
return `
SELECT record.*
FROM condon.create_usage_record(
$1::uuid,
$2::uuid,
$3::uuid,
$4::varchar,
$5::date,
$6::date,
$7::varchar,
$8::smallint,
$9::text,
$10::uuid
) AS record
`;
}
const checks = {};
let pool;
let rollbackClient;
let concurrencyOwnerId;
let concurrencyPeriodId;
try {
const input = JSON.parse(await readStandardInput());
pool = new Pool({
host: input.host,
port: input.port,
user: input.user,
password: input.password,
database: input.database,
ssl: input.ssl ? { rejectUnauthorized: false } : undefined,
max: 4,
application_name: "condon_database_integration_test",
connectionTimeoutMillis: 10_000,
options: "-c statement_timeout=30000 -c lock_timeout=5000"
});
const identityResult = await pool.query(`
SELECT
current_database() AS database_name,
pg_catalog.to_regnamespace('condon') IS NOT NULL AS schema_exists
`);
checks.databaseTarget =
identityResult.rows[0].database_name === "booking_test"
&& identityResult.rows[0].schema_exists === true;
const initialCountResult = await pool.query(`
SELECT
(SELECT count(*)::integer FROM condon.owner_accounts) AS owners,
(SELECT count(*)::integer FROM condon.entitlement_periods) AS periods,
(SELECT count(*)::integer FROM condon.usage_records) AS usage,
(SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger
`);
if (Object.values(initialCountResult.rows[0]).some(value => value !== 0)) {
throw Object.assign(new Error("CONDO business tables must be empty"), {
code: "TEST_REQUIRES_EMPTY_CONDON"
});
}
checks.initiallyEmpty = true;
rollbackClient = await pool.connect();
await rollbackClient.query("BEGIN");
const matrixResult = await rollbackClient.query(`
SELECT
count(*)::integer AS case_count,
bool_and(
condon.calculate_multiplier(
purchased.code,
used.code,
NULL
) = GREATEST(
1,
used.entitlement_tier - purchased.entitlement_tier + 1
)
) AS all_match
FROM condon.room_types AS purchased
CROSS JOIN condon.room_types AS used
WHERE NOT purchased.requires_manual_multiplier
AND NOT used.requires_manual_multiplier
`);
checks.fullAutomaticMatrix =
matrixResult.rows[0].case_count === 100
&& matrixResult.rows[0].all_match === true;
const manualResult = await rollbackClient.query(`
SELECT
condon.calculate_multiplier('AC2', 'RM1', 3::smallint) AS purchased_ac2,
condon.calculate_multiplier('RM1', 'AC2', 2::smallint) AS used_ac2
`);
checks.ac2ManualValues =
manualResult.rows[0].purchased_ac2 === 3
&& manualResult.rows[0].used_ac2 === 2;
await expectDatabaseError(
rollbackClient,
checks,
"ac2RequiresManual",
"SELECT condon.calculate_multiplier('AC2', 'RM1', NULL::smallint)",
[],
"CONDON_MANUAL_MULTIPLIER_REQUIRED"
);
await expectDatabaseError(
rollbackClient,
checks,
"automaticRejectsManual",
"SELECT condon.calculate_multiplier('RM1', 'SU1', 2::smallint)",
[],
"CONDON_MANUAL_MULTIPLIER_NOT_ALLOWED"
);
const ownerId = randomUUID();
const periodId = randomUUID();
const grantLedgerId = randomUUID();
await rollbackClient.query(
`
INSERT INTO condon.owner_accounts (
id,
account_no,
transfer_date,
owner_name,
room_no,
purchased_room_type_code,
unit_no,
member_no
)
VALUES ($1, NULL, NULL, 'Rollback Test Owner', $2, 'RM1', 'TEST/1', 'TEST-1')
`,
[ownerId, `R${ownerId.replaceAll("-", "").slice(0, 12)}`]
);
const periodResult = await rollbackClient.query(
`
SELECT period.*
FROM condon.open_entitlement_period(
$1::uuid,
$2::uuid,
2026::smallint,
$3::uuid,
NULL::uuid
) AS period
`,
[periodId, ownerId, grantLedgerId]
);
checks.openingPeriod =
periodResult.rows[0].annual_grant === 15
&& periodResult.rows[0].carry_forward === 0
&& periodResult.rows[0].current_balance === 15;
const usageId = randomUUID();
const usageLedgerId = randomUUID();
const idempotencyKey = randomUUID();
const usageResult = await rollbackClient.query(
usageFunctionSql(),
[
usageId,
usageLedgerId,
ownerId,
"990000001",
"2026-09-01",
"2026-09-04",
"SU1",
null,
"matrix test",
idempotencyKey
]
);
checks.derivedUsage =
usageResult.rows[0].night_count === 3
&& usageResult.rows[0].applied_multiplier === 2
&& usageResult.rows[0].use_nights === 6
&& usageResult.rows[0].balance_before === 15
&& usageResult.rows[0].balance_after === 9;
const retryResult = await rollbackClient.query(
usageFunctionSql(),
[
randomUUID(),
randomUUID(),
ownerId,
"990000001",
"2026-09-01",
"2026-09-04",
"SU1",
null,
"matrix test",
idempotencyKey
]
);
const idempotencyCountResult = await rollbackClient.query(
`
SELECT
(SELECT count(*)::integer FROM condon.usage_records WHERE owner_account_id = $1) AS usage_count,
(SELECT current_balance FROM condon.entitlement_periods WHERE id = $2) AS balance
`,
[ownerId, periodId]
);
checks.idempotency =
retryResult.rows[0].id === usageId
&& idempotencyCountResult.rows[0].usage_count === 1
&& idempotencyCountResult.rows[0].balance === 9;
await expectDatabaseError(
rollbackClient,
checks,
"duplicateConfirmation",
usageFunctionSql(),
[
randomUUID(),
randomUUID(),
ownerId,
"990000001",
"2026-10-01",
"2026-10-02",
"RM1",
null,
"",
randomUUID()
],
"CONDON_CONFIRMATION_NO_EXISTS"
);
await expectDatabaseError(
rollbackClient,
checks,
"crossYearRejected",
usageFunctionSql(),
[
randomUUID(),
randomUUID(),
ownerId,
"990000002",
"2026-12-30",
"2027-01-02",
"RM1",
null,
"",
randomUUID()
],
"CONDON_CROSS_YEAR_STAY_NOT_SUPPORTED"
);
await expectDatabaseError(
rollbackClient,
checks,
"insufficientBalance",
usageFunctionSql(),
[
randomUUID(),
randomUUID(),
ownerId,
"990000003",
"2026-10-01",
"2026-10-05",
"SU3",
null,
"",
randomUUID()
],
"CONDON_INSUFFICIENT_BALANCE"
);
const carryPeriodResult = await rollbackClient.query(
`
SELECT period.*
FROM condon.open_entitlement_period(
$1::uuid,
$2::uuid,
2027::smallint,
$3::uuid,
$4::uuid
) AS period
`,
[randomUUID(), ownerId, randomUUID(), randomUUID()]
);
checks.annualCarryForward =
carryPeriodResult.rows[0].annual_grant === 15
&& carryPeriodResult.rows[0].carry_forward === 9
&& carryPeriodResult.rows[0].current_balance === 24;
await rollbackClient.query("ROLLBACK");
rollbackClient.release();
rollbackClient = undefined;
const postRollbackCountResult = await pool.query(`
SELECT
(SELECT count(*)::integer FROM condon.owner_accounts) AS owners,
(SELECT count(*)::integer FROM condon.entitlement_periods) AS periods,
(SELECT count(*)::integer FROM condon.usage_records) AS usage,
(SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger
`);
checks.rollbackLeavesNoData =
Object.values(postRollbackCountResult.rows[0]).every(value => value === 0);
concurrencyOwnerId = randomUUID();
concurrencyPeriodId = randomUUID();
const concurrencyRoomNo =
`C${concurrencyOwnerId.replaceAll("-", "").slice(0, 12)}`;
const setupClient = await pool.connect();
try {
await setupClient.query("BEGIN");
await setupClient.query(
`
INSERT INTO condon.owner_accounts (
id,
account_no,
transfer_date,
owner_name,
room_no,
purchased_room_type_code,
unit_no,
member_no
)
VALUES ($1, NULL, NULL, 'Concurrency Test Owner', $2, 'RM1', 'TEST/2', 'TEST-2')
`,
[concurrencyOwnerId, concurrencyRoomNo]
);
await setupClient.query(
`
SELECT condon.open_entitlement_period(
$1::uuid,
$2::uuid,
2098::smallint,
$3::uuid,
NULL::uuid
)
`,
[concurrencyPeriodId, concurrencyOwnerId, randomUUID()]
);
await setupClient.query("COMMIT");
} catch (error) {
await setupClient.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
setupClient.release();
}
const concurrentCalls = [
[
randomUUID(),
randomUUID(),
concurrencyOwnerId,
"998000001",
"2098-01-01",
"2098-01-11",
"RM1",
null,
"concurrency A",
randomUUID()
],
[
randomUUID(),
randomUUID(),
concurrencyOwnerId,
"998000002",
"2098-02-01",
"2098-02-11",
"RM1",
null,
"concurrency B",
randomUUID()
]
];
const concurrentResults = await Promise.allSettled(
concurrentCalls.map(values => pool.query(usageFunctionSql(), values))
);
const fulfilled = concurrentResults.filter(result => result.status === "fulfilled");
const rejected = concurrentResults.filter(result => result.status === "rejected");
checks.concurrentOverspendBlocked =
fulfilled.length === 1
&& rejected.length === 1
&& databaseMessage(rejected[0]?.reason) === "CONDON_INSUFFICIENT_BALANCE";
const concurrencyStateResult = await pool.query(
`
SELECT
ep.current_balance,
(SELECT count(*)::integer FROM condon.usage_records WHERE owner_account_id = $1) AS usage_count,
(
SELECT count(*)::integer
FROM condon.entitlement_ledger
WHERE entitlement_period_id = $2
AND entry_type = 'usage'
) AS usage_ledger_count
FROM condon.entitlement_periods AS ep
WHERE ep.id = $2
`,
[concurrencyOwnerId, concurrencyPeriodId]
);
checks.concurrentStateConsistent =
concurrencyStateResult.rows[0].current_balance === 5
&& concurrencyStateResult.rows[0].usage_count === 1
&& concurrencyStateResult.rows[0].usage_ledger_count === 1;
} catch (error) {
process.stdout.write(`${JSON.stringify(safeError(error), null, 2)}\n`);
process.exitCode = 1;
} finally {
if (rollbackClient) {
await rollbackClient.query("ROLLBACK").catch(() => undefined);
rollbackClient.release();
}
if (pool && concurrencyOwnerId && concurrencyPeriodId) {
const cleanupClient = await pool.connect().catch(() => undefined);
if (cleanupClient) {
try {
await cleanupClient.query("BEGIN");
await cleanupClient.query(
"DELETE FROM condon.entitlement_ledger WHERE entitlement_period_id = $1",
[concurrencyPeriodId]
);
await cleanupClient.query(
"DELETE FROM condon.usage_records WHERE owner_account_id = $1",
[concurrencyOwnerId]
);
await cleanupClient.query(
"DELETE FROM condon.entitlement_periods WHERE id = $1",
[concurrencyPeriodId]
);
await cleanupClient.query(
"DELETE FROM condon.owner_accounts WHERE id = $1",
[concurrencyOwnerId]
);
await cleanupClient.query("COMMIT");
checks.concurrencyCleanup = true;
} catch {
await cleanupClient.query("ROLLBACK").catch(() => undefined);
checks.concurrencyCleanup = false;
process.exitCode = 1;
} finally {
cleanupClient.release();
}
}
}
if (pool) {
try {
const finalCountResult = await pool.query(`
SELECT
(SELECT count(*)::integer FROM condon.owner_accounts) AS owners,
(SELECT count(*)::integer FROM condon.entitlement_periods) AS periods,
(SELECT count(*)::integer FROM condon.usage_records) AS usage,
(SELECT count(*)::integer FROM condon.entitlement_ledger) AS ledger
`);
checks.finalBusinessTablesEmpty =
Object.values(finalCountResult.rows[0]).every(value => value === 0);
if (!checks.finalBusinessTablesEmpty) process.exitCode = 1;
} catch {
checks.finalBusinessTablesEmpty = false;
process.exitCode = 1;
}
await pool.end().catch(() => {
process.exitCode = 1;
});
}
if (Object.keys(checks).length > 0) {
const ok = Object.values(checks).every(Boolean);
process.stdout.write(`${JSON.stringify({ ok, checks }, null, 2)}\n`);
if (!ok) process.exitCode = 1;
}
}