520 lines
14 KiB
JavaScript
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;
|
|
}
|
|
}
|