Files
wyndham-Condon/tests/api-client.test.mjs
2026-08-03 16:43:57 +08:00

179 lines
5.3 KiB
JavaScript

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);
});
test("usage deletion uses the record route and DELETE method", async () => {
let captured;
const api = loadClient({
search: "?mode=api",
fetchImpl: async (url, options) => {
captured = { url, options };
return jsonResponse(null, 204);
}
});
await api.client.deleteUsageRecord("00000000-0000-4000-8000-000000000002");
assert.equal(captured.url.pathname, "/usage-records/00000000-0000-4000-8000-000000000002");
assert.equal(captured.options.method, "DELETE");
assert.equal(captured.options.credentials, "include");
assert.equal(captured.options.body, undefined);
});