feat: add Condo owner desk frontend and backend
This commit is contained in:
519
backend/scripts/test-database.mjs
Normal file
519
backend/scripts/test-database.mjs
Normal file
@@ -0,0 +1,519 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user