Files
wyndham-Condon/tests/mock-api-server.mjs
2026-08-02 13:33:13 +08:00

299 lines
11 KiB
JavaScript

import { randomBytes, randomUUID } from "node:crypto";
import http from "node:http";
const emptyScenario = process.argv.includes("--empty");
const roomTypes = [
["RM1", 1], ["RM2", 1], ["RM3", 1], ["RM4", 1], ["UG1", 1], ["UG2", 1],
["SU1", 2], ["SU2", 2], ["SU6", 2], ["SU3", 3], ["AC2", null]
].map(([code, entitlementTier]) => ({
code,
entitlementTier,
requiresManualMultiplier: entitlementTier === null
}));
const owners = emptyScenario ? [] : [
{
id: "00000000-0000-4000-8000-000000000101",
accountNo: 101,
transferDate: "2024-01-08",
name: "API Test Owner",
roomNo: "A101",
purchasedRoomType: "RM1",
unitNo: "UNIT-A101",
memberNo: "M101",
remainingStayPrivileges: 13
},
{
id: "00000000-0000-4000-8000-000000000102",
accountNo: null,
transferDate: null,
name: "Nullable API Owner",
roomNo: "A102",
purchasedRoomType: "AC2",
unitNo: "UNIT-A102",
memberNo: "M102",
remainingStayPrivileges: 15
}
];
let usageRecords = emptyScenario ? [] : [
{
id: "00000000-0000-4000-8000-000000000201",
confirmationNo: "26070001",
ownerAccountId: owners[0].id,
ownerName: owners[0].name,
ownerRoomNo: owners[0].roomNo,
checkIn: "2026-07-10",
checkOut: "2026-07-12",
night: 2,
use: 2,
balance: 13,
usedRoomType: "RM1",
remark: "Mock API record",
appliedMultiplier: 1,
createdAt: "2026-07-12T08:00:00.000Z"
}
];
const idempotentResponses = new Map();
const sessions = new Map();
const sessionTtlMs = 12 * 60 * 60 * 1_000;
const authUsername = "wyndhamcondon";
const authPassword = "wyndhamcondon";
function send(response, status, payload, headers = {}) {
response.writeHead(status, {
"Access-Control-Allow-Origin": "http://127.0.0.1:4173",
"Access-Control-Allow-Credentials": "true",
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...headers
});
response.end(JSON.stringify(payload));
}
function sessionToken(request) {
const cookie = request.headers.cookie || "";
for (const part of cookie.split(";")) {
const [name, value] = part.trim().split("=", 2);
if (name === "condon_session" && value) return value;
}
return null;
}
function activeSession(request) {
const token = sessionToken(request);
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (session.expiresAtMs <= Date.now()) {
sessions.delete(token);
return null;
}
return session;
}
function paginated(items, url) {
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
const pageSize = Math.min(100, Math.max(1, Number(url.searchParams.get("pageSize")) || 25));
const start = (page - 1) * pageSize;
return { items: items.slice(start, start + pageSize), total: items.length, page, pageSize };
}
function tier(code) {
return roomTypes.find(roomType => roomType.code === code)?.entitlementTier ?? null;
}
function dashboard() {
const purchasedCounts = new Map();
const usedCounts = new Map();
const monthlyCounts = new Map();
owners.forEach(owner => purchasedCounts.set(owner.purchasedRoomType, (purchasedCounts.get(owner.purchasedRoomType) || 0) + 1));
usageRecords.forEach(record => {
usedCounts.set(record.usedRoomType, (usedCounts.get(record.usedRoomType) || 0) + record.night);
const month = Number(record.checkIn.slice(5, 7));
monthlyCounts.set(month, (monthlyCounts.get(month) || 0) + record.use);
});
return {
periodYear: 2026,
ownerRooms: owners.length,
remainingPrivileges: owners.reduce((total, owner) => total + (owner.remainingStayPrivileges || 0), 0),
used: usageRecords.reduce((total, record) => total + record.use, 0),
purchasedRoomTypes: [...purchasedCounts].map(([roomType, count]) => ({ roomType, count })),
usedRoomTypes: [...usedCounts].map(([roomType, count]) => ({ roomType, count })),
monthlyUse: [...monthlyCounts].map(([month, use]) => ({ month, use }))
};
}
async function readJson(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
const server = http.createServer(async (request, response) => {
if (request.method === "OPTIONS") {
response.writeHead(204, {
"Access-Control-Allow-Origin": "http://127.0.0.1:4173",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS"
});
response.end();
return;
}
const url = new URL(request.url || "/", "http://127.0.0.1:3000");
if (request.method === "GET" && url.pathname === "/auth/session") {
const session = activeSession(request);
send(response, 200, session ? {
authenticated: true,
user: { username: session.username },
expiresAt: new Date(session.expiresAtMs).toISOString()
} : { authenticated: false });
return;
}
if (request.method === "POST" && url.pathname === "/auth/login") {
try {
const input = await readJson(request);
if (input.username !== authUsername || input.password !== authPassword) {
send(response, 401, { error: { code: "INVALID_CREDENTIALS", message: "Invalid username or password" } });
return;
}
const token = randomBytes(32).toString("base64url");
const expiresAtMs = Date.now() + sessionTtlMs;
sessions.set(token, { username: authUsername, expiresAtMs });
send(response, 200, {
authenticated: true,
user: { username: authUsername },
expiresAt: new Date(expiresAtMs).toISOString()
}, {
"Set-Cookie": `condon_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`
});
} catch {
send(response, 400, { error: { code: "VALIDATION_ERROR", message: "Invalid JSON" } });
}
return;
}
if (request.method === "POST" && url.pathname === "/auth/logout") {
const token = sessionToken(request);
if (token) sessions.delete(token);
send(response, 200, { authenticated: false }, {
"Set-Cookie": "condon_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
});
return;
}
if (!activeSession(request)) {
send(response, 401, { error: { code: "UNAUTHORIZED", message: "Authentication required" } });
return;
}
if (request.method === "GET" && url.pathname === "/health") {
send(response, 200, { status: "ok", database: "booking_test", schema: "condon", migrationVersion: "001" });
return;
}
if (request.method === "GET" && url.pathname === "/room-types") {
send(response, 200, roomTypes);
return;
}
if (request.method === "GET" && url.pathname === "/owner-accounts") {
const q = (url.searchParams.get("q") || "").toLowerCase();
const roomType = url.searchParams.get("roomType") || "";
const filtered = owners.filter(owner => {
const searchable = `${owner.accountNo ?? ""} ${owner.name} ${owner.roomNo} ${owner.unitNo} ${owner.memberNo}`.toLowerCase();
return (!q || searchable.includes(q)) && (!roomType || owner.purchasedRoomType === roomType);
});
send(response, 200, { ...paginated(filtered, url), periodYear: 2026 });
return;
}
if (request.method === "GET" && url.pathname.startsWith("/owner-accounts/")) {
const owner = owners.find(item => item.id === decodeURIComponent(url.pathname.slice("/owner-accounts/".length)));
send(response, owner ? 200 : 404, owner || { error: { code: "NOT_FOUND", message: "Owner account not found" } });
return;
}
if (request.method === "GET" && url.pathname === "/usage-records") {
const ownerAccountId = url.searchParams.get("ownerAccountId") || "";
const confirmationNo = url.searchParams.get("confirmationNo") || "";
const usedRoomType = url.searchParams.get("usedRoomType") || "";
const filtered = usageRecords.filter(record => (
(!ownerAccountId || record.ownerAccountId === ownerAccountId)
&& (!confirmationNo || record.confirmationNo.includes(confirmationNo))
&& (!usedRoomType || record.usedRoomType === usedRoomType)
));
send(response, 200, paginated(filtered, url));
return;
}
if (request.method === "GET" && url.pathname === "/dashboard") {
send(response, 200, dashboard());
return;
}
if (request.method === "POST" && url.pathname === "/usage-records") {
try {
const input = await readJson(request);
if (idempotentResponses.has(input.idempotencyKey)) {
send(response, 201, idempotentResponses.get(input.idempotencyKey));
return;
}
const owner = owners.find(item => item.id === input.ownerAccountId);
if (!owner) {
send(response, 404, { error: { code: "NOT_FOUND", message: "Owner account not found" } });
return;
}
if (usageRecords.some(record => record.confirmationNo === input.confirmationNo)) {
send(response, 409, { error: { code: "CONFLICT", message: "Confirmation already exists" } });
return;
}
const start = Date.parse(`${input.checkIn}T00:00:00Z`);
const end = Date.parse(`${input.checkOut}T00:00:00Z`);
const night = Math.round((end - start) / 86_400_000);
const purchasedTier = tier(owner.purchasedRoomType);
const usedTier = tier(input.usedRoomType);
const multiplier = purchasedTier === null || usedTier === null
? Number(input.manualMultiplier)
: Math.max(1, usedTier - purchasedTier + 1);
const use = night * multiplier;
if (!Number.isInteger(night) || night <= 0 || !Number.isInteger(multiplier) || multiplier < 1 || multiplier > 3) {
send(response, 400, { error: { code: "BUSINESS_RULE_VIOLATION", message: "Invalid usage rule" } });
return;
}
if (use > owner.remainingStayPrivileges) {
send(response, 422, { error: { code: "INSUFFICIENT_BALANCE", message: "Insufficient balance" } });
return;
}
owner.remainingStayPrivileges -= use;
const created = {
id: randomUUID(),
confirmationNo: input.confirmationNo,
ownerAccountId: owner.id,
ownerName: owner.name,
ownerRoomNo: owner.roomNo,
checkIn: input.checkIn,
checkOut: input.checkOut,
night,
use,
balance: owner.remainingStayPrivileges,
usedRoomType: input.usedRoomType,
remark: input.remark || "",
appliedMultiplier: multiplier,
createdAt: new Date().toISOString()
};
usageRecords = [created, ...usageRecords];
idempotentResponses.set(input.idempotencyKey, created);
send(response, 201, created);
} catch {
send(response, 400, { error: { code: "VALIDATION_ERROR", message: "Invalid JSON" } });
}
return;
}
send(response, 404, { error: { code: "NOT_FOUND", message: "Route not found" } });
});
server.listen(3000, "127.0.0.1", () => {
process.stdout.write(`MOCK_API_READY http://127.0.0.1:3000 scenario=${emptyScenario ? "empty" : "sample"}\n`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.once(signal, () => server.close(() => process.exit(0)));
}