Files
wyndham-Condon/backend/scripts/live-ui-fixture.mjs
2026-08-02 13:33:13 +08:00

446 lines
12 KiB
JavaScript

import { randomInt, randomUUID } from "node:crypto";
import process from "node:process";
import { createInterface } from "node:readline";
import pg from "pg";
const { Pool } = pg;
const TEST_YEAR = 2026;
const OWNER_NAME = "Codex Live UI Test";
const PURCHASED_ROOM_TYPE = "RM1";
const USED_ROOM_TYPE = "SU3";
const CHECK_IN = "2026-08-10";
const CHECK_OUT = "2026-08-12";
const EXPECTED_NIGHT = 2;
const EXPECTED_MULTIPLIER = 3;
const EXPECTED_USE = 6;
const EXPECTED_BALANCE = 9;
function safeError(error, fallback = "LIVE_UI_FIXTURE_FAILED") {
return typeof error?.code === "string" ? error.code : fallback;
}
function writeEvent(event, details = {}) {
process.stdout.write(`${JSON.stringify({ event, ...details })}\n`);
}
function businessCounts(row) {
return {
owners: Number(row.owners),
periods: Number(row.periods),
usage: Number(row.usage),
ledger: Number(row.ledger)
};
}
function allZero(counts) {
return Object.values(counts).every(value => value === 0);
}
async function getBusinessCounts(client) {
const result = await client.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
`);
return businessCounts(result.rows[0]);
}
async function setupFixture(pool, fixture) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const initialCounts = await getBusinessCounts(client);
if (!allZero(initialCounts)) {
throw Object.assign(new Error("CONDO business tables must be empty"), {
code: "TEST_REQUIRES_EMPTY_CONDON"
});
}
const multiplier = await client.query(
`
SELECT condon.calculate_multiplier(
$1::varchar,
$2::varchar,
NULL::smallint
)::integer AS value
`,
[PURCHASED_ROOM_TYPE, USED_ROOM_TYPE]
);
if (multiplier.rows[0].value !== EXPECTED_MULTIPLIER) {
throw Object.assign(new Error("Unexpected multiplier"), {
code: "UNEXPECTED_MULTIPLIER"
});
}
await client.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, $2, $3, $4, $5, $6)
`,
[
fixture.ownerId,
OWNER_NAME,
fixture.roomNo,
PURCHASED_ROOM_TYPE,
fixture.unitNo,
fixture.memberNo
]
);
const periodResult = await client.query(
`
SELECT period.*
FROM condon.open_entitlement_period(
$1::uuid,
$2::uuid,
$3::smallint,
$4::uuid,
NULL::uuid
) AS period
`,
[
fixture.periodId,
fixture.ownerId,
TEST_YEAR,
fixture.annualGrantLedgerId
]
);
const period = periodResult.rows[0];
if (
Number(period.annual_grant) !== 15
|| Number(period.carry_forward) !== 0
|| Number(period.current_balance) !== 15
) {
throw Object.assign(new Error("Unexpected entitlement period"), {
code: "UNEXPECTED_ENTITLEMENT_PERIOD"
});
}
const setupCounts = await getBusinessCounts(client);
if (
setupCounts.owners !== 1
|| setupCounts.periods !== 1
|| setupCounts.usage !== 0
|| setupCounts.ledger !== 1
) {
throw Object.assign(new Error("Unexpected setup counts"), {
code: "UNEXPECTED_SETUP_COUNTS"
});
}
await client.query("COMMIT");
return setupCounts;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
}
async function observeUsage(pool, fixture) {
const result = await pool.query(
`
SELECT
ur.id,
ur.confirmation_no,
ur.night_count,
ur.applied_multiplier,
ur.use_nights,
ur.balance_before,
ur.balance_after,
ur.used_room_type_code,
ur.remark,
ep.current_balance,
ep.row_version,
el.id AS usage_ledger_id,
el.delta_nights,
el.balance_before AS ledger_balance_before,
el.balance_after AS ledger_balance_after
FROM condon.usage_records AS ur
JOIN condon.entitlement_periods AS ep
ON ep.id = ur.entitlement_period_id
JOIN condon.entitlement_ledger AS el
ON el.usage_record_id = ur.id
WHERE ur.owner_account_id = $1
AND ur.entitlement_period_id = $2
AND ur.confirmation_no = $3
`,
[fixture.ownerId, fixture.periodId, fixture.confirmationNo]
);
if (result.rowCount !== 1) return null;
const row = result.rows[0];
return {
usageRecordId: row.id,
usageLedgerId: row.usage_ledger_id,
confirmationNo: row.confirmation_no,
night: Number(row.night_count),
multiplier: Number(row.applied_multiplier),
use: Number(row.use_nights),
balanceBefore: Number(row.balance_before),
balance: Number(row.balance_after),
usedRoomType: row.used_room_type_code,
remark: row.remark,
periodBalance: Number(row.current_balance),
periodRowVersion: Number(row.row_version),
ledgerDelta: Number(row.delta_nights),
ledgerBalanceBefore: Number(row.ledger_balance_before),
ledgerBalance: Number(row.ledger_balance_after)
};
}
function usageIsExpected(usage, fixture) {
return usage !== null
&& usage.confirmationNo === fixture.confirmationNo
&& usage.night === EXPECTED_NIGHT
&& usage.multiplier === EXPECTED_MULTIPLIER
&& usage.use === EXPECTED_USE
&& usage.balanceBefore === 15
&& usage.balance === EXPECTED_BALANCE
&& usage.usedRoomType === USED_ROOM_TYPE
&& usage.remark === fixture.remark
&& usage.periodBalance === EXPECTED_BALANCE
&& usage.periodRowVersion === 2
&& usage.ledgerDelta === -EXPECTED_USE
&& usage.ledgerBalanceBefore === 15
&& usage.ledgerBalance === EXPECTED_BALANCE;
}
async function cleanupFixture(pool, fixture) {
const client = await pool.connect();
let deleted;
try {
await client.query("BEGIN");
const ownerGuard = await client.query(
`
SELECT id
FROM condon.owner_accounts
WHERE id = $1
AND owner_name = $2
AND room_no = $3
AND member_no = $4
FOR UPDATE
`,
[fixture.ownerId, OWNER_NAME, fixture.roomNo, fixture.memberNo]
);
if (ownerGuard.rowCount !== 1) {
throw Object.assign(new Error("Fixture owner guard failed"), {
code: "FIXTURE_OWNER_GUARD_FAILED"
});
}
const ledgerResult = await client.query(
`
DELETE FROM condon.entitlement_ledger
WHERE entitlement_period_id = $1
RETURNING id, entry_type, usage_record_id
`,
[fixture.periodId]
);
const usageResult = await client.query(
`
DELETE FROM condon.usage_records
WHERE owner_account_id = $1
AND entitlement_period_id = $2
RETURNING id, confirmation_no
`,
[fixture.ownerId, fixture.periodId]
);
const periodResult = await client.query(
`
DELETE FROM condon.entitlement_periods
WHERE id = $1
AND owner_account_id = $2
RETURNING id
`,
[fixture.periodId, fixture.ownerId]
);
const ownerResult = await client.query(
`
DELETE FROM condon.owner_accounts
WHERE id = $1
AND owner_name = $2
AND room_no = $3
AND member_no = $4
RETURNING id
`,
[fixture.ownerId, OWNER_NAME, fixture.roomNo, fixture.memberNo]
);
if (periodResult.rowCount !== 1 || ownerResult.rowCount !== 1) {
throw Object.assign(new Error("Fixture cleanup cardinality failed"), {
code: "FIXTURE_CLEANUP_CARDINALITY_FAILED"
});
}
deleted = {
ledger: ledgerResult.rowCount,
usage: usageResult.rowCount,
periods: periodResult.rowCount,
owners: ownerResult.rowCount
};
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
const finalCounts = await getBusinessCounts(pool);
if (!allZero(finalCounts)) {
throw Object.assign(new Error("CONDO business tables are not empty"), {
code: "FINAL_CONDON_NOT_EMPTY"
});
}
return { deleted, finalCounts };
}
const lines = createInterface({ input: process.stdin, terminal: false });
const iterator = lines[Symbol.asyncIterator]();
let pool;
let fixture;
let setupComplete = false;
let cleanupComplete = false;
let usageObservation = null;
let expectUsage = true;
let failureCode = null;
let signalResolve;
const signalPromise = new Promise(resolve => {
signalResolve = resolve;
});
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.once(signal, () => signalResolve({ signal }));
}
try {
const first = await iterator.next();
if (first.done || !first.value.trim()) {
throw Object.assign(new Error("Missing connection input"), {
code: "MISSING_CONNECTION_INPUT"
});
}
const input = JSON.parse(first.value);
if (input.database !== "booking_test") {
throw Object.assign(new Error("Unexpected database"), {
code: "UNEXPECTED_DATABASE"
});
}
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: 2,
application_name: "condon_live_ui_fixture",
connectionTimeoutMillis: 10_000,
options: "-c statement_timeout=30000 -c lock_timeout=5000"
});
const token = randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase();
fixture = {
ownerId: randomUUID(),
periodId: randomUUID(),
annualGrantLedgerId: randomUUID(),
roomNo: `E2E-${token}`,
unitNo: `TEST-${token.slice(0, 6)}`,
memberNo: `E2E-${token}`,
confirmationNo: `98${Date.now()}${randomInt(100, 1000)}`,
remark: `Live UI E2E ${token}`
};
const setupCounts = await setupFixture(pool, fixture);
setupComplete = true;
writeEvent("fixtureReady", {
fixture: {
...fixture,
ownerName: OWNER_NAME,
purchasedRoomType: PURCHASED_ROOM_TYPE,
usedRoomType: USED_ROOM_TYPE,
year: TEST_YEAR,
checkIn: CHECK_IN,
checkOut: CHECK_OUT,
expectedNight: EXPECTED_NIGHT,
expectedMultiplier: EXPECTED_MULTIPLIER,
expectedUse: EXPECTED_USE,
expectedBalance: EXPECTED_BALANCE
},
setupCounts
});
const next = await Promise.race([
iterator.next(),
signalPromise
]);
if (next?.signal) {
expectUsage = false;
writeEvent("cleanupRequested", { reason: next.signal });
} else if (next.done) {
expectUsage = false;
writeEvent("cleanupRequested", { reason: "stdin_closed" });
} else {
const command = JSON.parse(next.value);
if (command.action !== "cleanup") {
throw Object.assign(new Error("Unexpected command"), {
code: "UNEXPECTED_FIXTURE_COMMAND"
});
}
expectUsage = command.expectUsage !== false;
writeEvent("cleanupRequested", { reason: "command" });
}
usageObservation = await observeUsage(pool, fixture);
if (expectUsage && !usageIsExpected(usageObservation, fixture)) {
throw Object.assign(new Error("Unexpected usage result"), {
code: "UNEXPECTED_USAGE_RESULT"
});
}
} catch (error) {
failureCode = safeError(error);
process.exitCode = 1;
writeEvent("fixtureError", { errorCode: failureCode });
} finally {
if (pool && setupComplete && fixture) {
try {
const cleanup = await cleanupFixture(pool, fixture);
cleanupComplete = true;
writeEvent("fixtureCleaned", {
usageExpected: expectUsage,
usageVerified: expectUsage ? usageIsExpected(usageObservation, fixture) : null,
usage: usageObservation,
...cleanup
});
} catch (error) {
failureCode ??= safeError(error, "LIVE_UI_CLEANUP_FAILED");
process.exitCode = 1;
writeEvent("cleanupError", { errorCode: safeError(error, "LIVE_UI_CLEANUP_FAILED") });
}
}
lines.close();
if (pool) await pool.end().catch(() => {
process.exitCode = 1;
});
writeEvent("fixtureClosed", {
ok: !failureCode && cleanupComplete,
errorCode: failureCode
});
}