446 lines
12 KiB
JavaScript
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
|
|
});
|
|
}
|