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