feat: add Condo owner desk frontend and backend
This commit is contained in:
162
tests/api-client.test.mjs
Normal file
162
tests/api-client.test.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const source = await readFile(new URL("../api-client.js", import.meta.url), "utf8");
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
async json() {
|
||||
return payload;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadClient({ search = "", config = {}, fetchImpl = async () => jsonResponse({}) } = {}) {
|
||||
const windowObject = {
|
||||
location: { search, origin: "http://127.0.0.1:4173" },
|
||||
CONDO_RUNTIME_CONFIG: {
|
||||
defaultMode: "demo",
|
||||
apiBaseUrl: "http://127.0.0.1:3000",
|
||||
periodYear: 2026,
|
||||
...config
|
||||
},
|
||||
fetch: fetchImpl,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
vm.runInNewContext(source, {
|
||||
window: windowObject,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
AbortController,
|
||||
Error,
|
||||
Object,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
JSON
|
||||
});
|
||||
return windowObject.CONDO_API;
|
||||
}
|
||||
|
||||
test("runtime requires an explicit api query mode", () => {
|
||||
assert.equal(loadClient().runtime.mode, "demo");
|
||||
assert.equal(loadClient({ search: "?mode=api" }).runtime.mode, "api");
|
||||
assert.equal(loadClient({ search: "?mode=unexpected" }).runtime.mode, "demo");
|
||||
});
|
||||
|
||||
test("owner pagination collects every API page", async () => {
|
||||
const requestedPages = [];
|
||||
const api = loadClient({
|
||||
search: "?mode=api",
|
||||
fetchImpl: async url => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
requestedPages.push(page);
|
||||
const itemCount = page === 1 ? 100 : 90;
|
||||
return jsonResponse({
|
||||
items: Array.from({ length: itemCount }, (_, index) => ({ id: `${page}-${index}` })),
|
||||
total: 190,
|
||||
page,
|
||||
pageSize: 100,
|
||||
periodYear: 2026
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const result = await api.client.listAllOwnerAccounts();
|
||||
assert.deepEqual(requestedPages, [1, 2]);
|
||||
assert.equal(result.total, 190);
|
||||
assert.equal(result.items.length, 190);
|
||||
});
|
||||
|
||||
test("API failures expose stable status and code", async () => {
|
||||
const api = loadClient({
|
||||
search: "?mode=api",
|
||||
fetchImpl: async () => jsonResponse({
|
||||
error: { code: "INSUFFICIENT_BALANCE", message: "Insufficient balance" }
|
||||
}, 422)
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.client.getDashboard(),
|
||||
error => error.name === "CondoApiError"
|
||||
&& error.status === 422
|
||||
&& error.code === "INSUFFICIENT_BALANCE"
|
||||
);
|
||||
});
|
||||
|
||||
test("authentication uses credentialed requests and the exact login contract", async () => {
|
||||
const requests = [];
|
||||
const api = loadClient({
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
if (url.pathname === "/auth/session") {
|
||||
return jsonResponse({ authenticated: false });
|
||||
}
|
||||
if (url.pathname === "/auth/login") {
|
||||
return jsonResponse({
|
||||
authenticated: true,
|
||||
user: { username: "wyndhamcondon" },
|
||||
expiresAt: "2026-08-02T12:00:00.000Z"
|
||||
});
|
||||
}
|
||||
return jsonResponse({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
await api.client.getSession();
|
||||
await api.client.login({
|
||||
username: "wyndhamcondon",
|
||||
password: "wyndhamcondon"
|
||||
});
|
||||
await api.client.logout();
|
||||
|
||||
assert.deepEqual(requests.map(request => request.url.pathname), [
|
||||
"/auth/session",
|
||||
"/auth/login",
|
||||
"/auth/logout"
|
||||
]);
|
||||
requests.forEach(request => assert.equal(request.options.credentials, "include"));
|
||||
assert.equal(requests[0].options.method, "GET");
|
||||
assert.equal(requests[1].options.method, "POST");
|
||||
assert.deepEqual(JSON.parse(requests[1].options.body), {
|
||||
username: "wyndhamcondon",
|
||||
password: "wyndhamcondon"
|
||||
});
|
||||
assert.equal(requests[2].options.method, "POST");
|
||||
assert.equal(requests[2].options.body, undefined);
|
||||
});
|
||||
|
||||
test("usage creation sends only the supplied API contract", async () => {
|
||||
let captured;
|
||||
const api = loadClient({
|
||||
search: "?mode=api",
|
||||
fetchImpl: async (url, options) => {
|
||||
captured = { url, options };
|
||||
return jsonResponse({ id: "record-id" }, 201);
|
||||
}
|
||||
});
|
||||
const input = {
|
||||
ownerAccountId: "00000000-0000-4000-8000-000000000001",
|
||||
confirmationNo: "26090001",
|
||||
checkIn: "2026-09-01",
|
||||
checkOut: "2026-09-04",
|
||||
usedRoomType: "SU1",
|
||||
manualMultiplier: null,
|
||||
remark: "",
|
||||
idempotencyKey: "00000000-0000-4000-8000-000000000002"
|
||||
};
|
||||
|
||||
await api.client.createUsageRecord(input);
|
||||
assert.equal(captured.url.pathname, "/usage-records");
|
||||
assert.equal(captured.options.method, "POST");
|
||||
assert.equal(captured.options.credentials, "include");
|
||||
assert.deepEqual(JSON.parse(captured.options.body), input);
|
||||
assert.equal("night" in JSON.parse(captured.options.body), false);
|
||||
assert.equal("use" in JSON.parse(captured.options.body), false);
|
||||
assert.equal("balance" in JSON.parse(captured.options.body), false);
|
||||
});
|
||||
81
tests/i18n.test.mjs
Normal file
81
tests/i18n.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const appSource = await readFile(new URL("../app.js", import.meta.url), "utf8");
|
||||
const htmlSource = await readFile(new URL("../index.html", import.meta.url), "utf8");
|
||||
const cssSource = await readFile(new URL("../styles.css", import.meta.url), "utf8");
|
||||
|
||||
function loadDictionaries() {
|
||||
const start = appSource.indexOf("const I18N = ");
|
||||
const end = appSource.indexOf("\n\nconst $ =", start);
|
||||
assert.notEqual(start, -1, "I18N block should exist");
|
||||
assert.notEqual(end, -1, "I18N block should have a stable end marker");
|
||||
const expression = appSource.slice(start + "const I18N = ".length, end).trim().replace(/;$/, "");
|
||||
return vm.runInNewContext(`(${expression})`, {
|
||||
formatNumber: value => String(value)
|
||||
});
|
||||
}
|
||||
|
||||
const dictionaries = loadDictionaries();
|
||||
const locales = Object.keys(dictionaries);
|
||||
|
||||
function placeholderNames(value) {
|
||||
if (typeof value === "function") {
|
||||
const match = value.toString().match(/\(\s*\{([^}]*)\}\s*\)/);
|
||||
return match
|
||||
? match[1].split(",").map(name => name.trim().split(/\s*=/)[0]).filter(Boolean).sort()
|
||||
: [];
|
||||
}
|
||||
return [...String(value).matchAll(/\{(\w+)\}/g)].map(match => match[1]).sort();
|
||||
}
|
||||
|
||||
test("all three locale dictionaries have identical keys", () => {
|
||||
assert.deepEqual(locales, ["en", "zh", "th"]);
|
||||
const expected = Object.keys(dictionaries.en).sort();
|
||||
assert.equal(expected.length, 195);
|
||||
for (const locale of locales) {
|
||||
assert.deepEqual(Object.keys(dictionaries[locale]).sort(), expected, `${locale} key parity`);
|
||||
}
|
||||
});
|
||||
|
||||
test("parameter placeholders stay aligned across locales", () => {
|
||||
for (const key of Object.keys(dictionaries.en)) {
|
||||
const expected = placeholderNames(dictionaries.en[key]);
|
||||
for (const locale of locales.slice(1)) {
|
||||
assert.deepEqual(placeholderNames(dictionaries[locale][key]), expected, `${locale}.${key} placeholders`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("all literal translation references resolve in every locale", () => {
|
||||
const referenced = new Set();
|
||||
for (const match of appSource.matchAll(/\bt\(\s*["']([^"']+)["']/g)) referenced.add(match[1]);
|
||||
for (const match of htmlSource.matchAll(/data-i18n(?:-placeholder|-aria-label)?="([^"]+)"/g)) referenced.add(match[1]);
|
||||
for (const key of referenced) {
|
||||
for (const locale of locales) assert.ok(key in dictionaries[locale], `${locale}.${key} is defined`);
|
||||
}
|
||||
assert.ok(referenced.size >= 100, `expected broad translation coverage, got ${referenced.size}`);
|
||||
});
|
||||
|
||||
test("Thai locale metadata and three-way controls are present", () => {
|
||||
assert.match(appSource, /th:\s*Object\.freeze\(\{ html: "th", intl: "th-TH-u-ca-gregory-nu-latn"/);
|
||||
assert.equal((htmlSource.match(/data-login-language=/g) || []).length, 3);
|
||||
assert.equal((htmlSource.match(/data-language=/g) || []).length, 3);
|
||||
assert.match(cssSource, /grid-template-rows: repeat\(3, 1fr\)/);
|
||||
assert.match(cssSource, /grid-template-columns: repeat\(3, 1fr\)/);
|
||||
assert.match(cssSource, /\[data-locale="th"\][^{]*\{ transform: translateY\(200%\); \}/);
|
||||
assert.match(cssSource, /\[data-locale="th"\][^{]*\{ transform: translateX\(200%\); \}/);
|
||||
});
|
||||
|
||||
test("Thai messages do not fall back for representative runtime states", () => {
|
||||
const keys = [
|
||||
"signIn", "invalidCredentials", "sessionExpired", "runtimeApiLoadingMessage", "accountsShown",
|
||||
"recordsShown", "checkoutLater", "insufficientPrivileges", "usageSaveNetwork", "usageSavedMessage"
|
||||
];
|
||||
for (const key of keys) {
|
||||
assert.notEqual(dictionaries.th[key], undefined, `Thai key ${key}`);
|
||||
if (typeof dictionaries.th[key] === "string") assert.notEqual(dictionaries.th[key], dictionaries.en[key], `Thai copy for ${key}`);
|
||||
}
|
||||
});
|
||||
298
tests/mock-api-server.mjs
Normal file
298
tests/mock-api-server.mjs
Normal file
@@ -0,0 +1,298 @@
|
||||
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)));
|
||||
}
|
||||
Reference in New Issue
Block a user