448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { buildApp } from "../src/app.js";
|
|
import { ApiError } from "../src/errors.js";
|
|
import type { CondoRepository } from "../src/repository.js";
|
|
import type {
|
|
CreateUsageRecordInput,
|
|
OwnerAccountQuery,
|
|
UsageRecordQuery
|
|
} from "../src/types.js";
|
|
|
|
const ownerId = "00000000-0000-4000-8000-000000000001";
|
|
const usageId = "00000000-0000-4000-8000-000000000002";
|
|
const idempotencyKey = "00000000-0000-4000-8000-000000000003";
|
|
const authentication = {
|
|
username: "wyndhamcondon",
|
|
password: "wyndhamcondon",
|
|
sessionTtlMs: 12 * 60 * 60 * 1_000,
|
|
cookieSecure: false
|
|
};
|
|
|
|
function createRepository(
|
|
overrides: Partial<CondoRepository> = {}
|
|
): CondoRepository {
|
|
return {
|
|
async health() {
|
|
return {
|
|
status: "ok",
|
|
database: "booking_test",
|
|
schema: "condon",
|
|
migrationVersion: "001_create_condon_schema"
|
|
};
|
|
},
|
|
async listRoomTypes() {
|
|
return [
|
|
{
|
|
code: "RM1",
|
|
entitlementTier: 1,
|
|
requiresManualMultiplier: false
|
|
},
|
|
{
|
|
code: "AC2",
|
|
entitlementTier: null,
|
|
requiresManualMultiplier: true
|
|
}
|
|
];
|
|
},
|
|
async listOwnerAccounts(query: OwnerAccountQuery) {
|
|
return {
|
|
items: [],
|
|
total: 0,
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
periodYear: query.periodYear
|
|
};
|
|
},
|
|
async getOwnerAccount() {
|
|
return null;
|
|
},
|
|
async listUsageRecords(query: UsageRecordQuery) {
|
|
return {
|
|
items: [],
|
|
total: 0,
|
|
page: query.page,
|
|
pageSize: query.pageSize
|
|
};
|
|
},
|
|
async createUsageRecord(input: CreateUsageRecordInput) {
|
|
return {
|
|
id: usageId,
|
|
confirmationNo: input.confirmationNo,
|
|
ownerAccountId: input.ownerAccountId,
|
|
ownerName: "Test Owner",
|
|
ownerRoomNo: "3201",
|
|
checkIn: input.checkIn,
|
|
checkOut: input.checkOut,
|
|
night: 2,
|
|
use: 2,
|
|
balance: 13,
|
|
usedRoomType: input.usedRoomType,
|
|
remark: input.remark,
|
|
appliedMultiplier: 1,
|
|
createdAt: "2026-07-29T00:00:00.000Z"
|
|
};
|
|
},
|
|
async deleteUsageRecord() {},
|
|
async getDashboard(periodYear: number) {
|
|
return {
|
|
periodYear,
|
|
ownerRooms: 0,
|
|
remainingPrivileges: 0,
|
|
used: 0,
|
|
purchasedRoomTypes: [],
|
|
usedRoomTypes: [],
|
|
monthlyUse: []
|
|
};
|
|
},
|
|
async close() {},
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
async function buildAuthenticatedApp(repository: CondoRepository): Promise<{
|
|
app: FastifyInstance;
|
|
cookie: string;
|
|
}> {
|
|
const app = await buildApp({ repository, authentication });
|
|
const login = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: authentication.username,
|
|
password: authentication.password
|
|
}
|
|
});
|
|
assert.equal(login.statusCode, 200);
|
|
const setCookie = login.headers["set-cookie"];
|
|
if (typeof setCookie !== "string") throw new Error("Missing session cookie");
|
|
const cookie = setCookie.split(";", 1)[0];
|
|
if (!cookie) throw new Error("Invalid session cookie");
|
|
return { app, cookie };
|
|
}
|
|
|
|
test("authentication protects the API and logout invalidates the session", async t => {
|
|
const app = await buildApp({
|
|
repository: createRepository(),
|
|
authentication
|
|
});
|
|
t.after(() => app.close());
|
|
|
|
const anonymous = await app.inject({ method: "GET", url: "/health" });
|
|
assert.equal(anonymous.statusCode, 401);
|
|
assert.equal(anonymous.json().error.code, "UNAUTHORIZED");
|
|
|
|
const initialSession = await app.inject({ method: "GET", url: "/auth/session" });
|
|
assert.deepEqual(initialSession.json(), { authenticated: false });
|
|
|
|
const rejected = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: { username: "wyndhamcondon", password: "incorrect" }
|
|
});
|
|
assert.equal(rejected.statusCode, 401);
|
|
assert.equal(rejected.json().error.code, "INVALID_CREDENTIALS");
|
|
assert.equal(rejected.headers["set-cookie"], undefined);
|
|
|
|
const accepted = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/login",
|
|
payload: {
|
|
username: authentication.username,
|
|
password: authentication.password
|
|
}
|
|
});
|
|
assert.equal(accepted.statusCode, 200);
|
|
const setCookie = accepted.headers["set-cookie"];
|
|
if (typeof setCookie !== "string") throw new Error("Missing session cookie");
|
|
assert.match(setCookie, /^condon_session=[A-Za-z0-9_-]+;/);
|
|
assert.match(setCookie, /HttpOnly/);
|
|
assert.match(setCookie, /SameSite=Lax/);
|
|
assert.match(setCookie, /Max-Age=43200/);
|
|
assert.equal(setCookie.includes("Secure"), false);
|
|
const cookie = setCookie.split(";", 1)[0];
|
|
|
|
const activeSession = await app.inject({
|
|
method: "GET",
|
|
url: "/auth/session",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(activeSession.json().authenticated, true);
|
|
assert.equal(activeSession.json().user.username, authentication.username);
|
|
assert.match(activeSession.json().expiresAt, /^\d{4}-\d{2}-\d{2}T/);
|
|
|
|
const protectedResponse = await app.inject({
|
|
method: "GET",
|
|
url: "/health",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(protectedResponse.statusCode, 200);
|
|
assert.equal(protectedResponse.headers["cache-control"], "no-store");
|
|
|
|
const logout = await app.inject({
|
|
method: "POST",
|
|
url: "/auth/logout",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(logout.statusCode, 200);
|
|
assert.equal(logout.json().authenticated, false);
|
|
assert.match(String(logout.headers["set-cookie"]), /Max-Age=0/);
|
|
|
|
const afterLogout = await app.inject({
|
|
method: "GET",
|
|
url: "/health",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(afterLogout.statusCode, 401);
|
|
|
|
const preflight = await app.inject({
|
|
method: "OPTIONS",
|
|
url: "/health",
|
|
headers: {
|
|
origin: "http://127.0.0.1:4173",
|
|
"access-control-request-method": "GET"
|
|
}
|
|
});
|
|
assert.equal(preflight.statusCode, 204);
|
|
assert.equal(preflight.headers["access-control-allow-credentials"], "true");
|
|
});
|
|
|
|
test("health and room type contracts", async t => {
|
|
const { app, cookie } = await buildAuthenticatedApp(createRepository());
|
|
t.after(() => app.close());
|
|
|
|
const health = await app.inject({ method: "GET", url: "/health", headers: { cookie } });
|
|
assert.equal(health.statusCode, 200);
|
|
assert.deepEqual(health.json(), {
|
|
status: "ok",
|
|
database: "booking_test",
|
|
schema: "condon",
|
|
migrationVersion: "001_create_condon_schema"
|
|
});
|
|
|
|
const roomTypes = await app.inject({ method: "GET", url: "/room-types", headers: { cookie } });
|
|
assert.equal(roomTypes.statusCode, 200);
|
|
assert.equal(roomTypes.json().length, 2);
|
|
assert.deepEqual(roomTypes.json()[1], {
|
|
code: "AC2",
|
|
entitlementTier: null,
|
|
requiresManualMultiplier: true
|
|
});
|
|
});
|
|
|
|
test("owner account query applies pagination and period defaults", async t => {
|
|
let captured: OwnerAccountQuery | undefined;
|
|
const repository = createRepository({
|
|
async listOwnerAccounts(query) {
|
|
captured = query;
|
|
return {
|
|
items: [],
|
|
total: 0,
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
periodYear: query.periodYear
|
|
};
|
|
}
|
|
});
|
|
const { app, cookie } = await buildAuthenticatedApp(repository);
|
|
t.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/owner-accounts?q=3201&page=2&pageSize=10",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(response.statusCode, 200);
|
|
assert.deepEqual(captured, {
|
|
query: "3201",
|
|
roomType: undefined,
|
|
periodYear: new Date().getUTCFullYear(),
|
|
page: 2,
|
|
pageSize: 10
|
|
});
|
|
});
|
|
|
|
test("missing owner and malformed owner id return safe client errors", async t => {
|
|
const { app, cookie } = await buildAuthenticatedApp(createRepository());
|
|
t.after(() => app.close());
|
|
|
|
const missing = await app.inject({
|
|
method: "GET",
|
|
url: `/owner-accounts/${ownerId}?year=2026`,
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(missing.statusCode, 404);
|
|
assert.deepEqual(missing.json(), {
|
|
error: {
|
|
code: "NOT_FOUND",
|
|
message: "Owner account not found"
|
|
}
|
|
});
|
|
|
|
const malformed = await app.inject({
|
|
method: "GET",
|
|
url: "/owner-accounts/not-a-uuid",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(malformed.statusCode, 400);
|
|
assert.equal(malformed.json().error.code, "VALIDATION_ERROR");
|
|
});
|
|
|
|
test("usage creation normalizes optional inputs and returns 201", async t => {
|
|
let captured: CreateUsageRecordInput | undefined;
|
|
const base = createRepository();
|
|
const repository = createRepository({
|
|
async createUsageRecord(input) {
|
|
captured = input;
|
|
return base.createUsageRecord(input);
|
|
}
|
|
});
|
|
const { app, cookie } = await buildAuthenticatedApp(repository);
|
|
t.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/usage-records",
|
|
headers: { cookie },
|
|
payload: {
|
|
ownerAccountId: ownerId,
|
|
confirmationNo: "26090001",
|
|
checkIn: "2026-09-01",
|
|
checkOut: "2026-09-03",
|
|
usedRoomType: "RM1",
|
|
idempotencyKey
|
|
}
|
|
});
|
|
|
|
assert.equal(response.statusCode, 201);
|
|
assert.deepEqual(captured, {
|
|
ownerAccountId: ownerId,
|
|
confirmationNo: "26090001",
|
|
checkIn: "2026-09-01",
|
|
checkOut: "2026-09-03",
|
|
usedRoomType: "RM1",
|
|
manualMultiplier: null,
|
|
remark: "",
|
|
idempotencyKey
|
|
});
|
|
assert.equal(response.json().use, 2);
|
|
assert.equal(response.json().balance, 13);
|
|
});
|
|
|
|
test("invalid usage payload is rejected before repository access", async t => {
|
|
let called = false;
|
|
const repository = createRepository({
|
|
async createUsageRecord(input) {
|
|
called = true;
|
|
return createRepository().createUsageRecord(input);
|
|
}
|
|
});
|
|
const { app, cookie } = await buildAuthenticatedApp(repository);
|
|
t.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/usage-records",
|
|
headers: { cookie },
|
|
payload: {
|
|
ownerAccountId: ownerId,
|
|
confirmationNo: "ABC",
|
|
checkIn: "2026-09-01",
|
|
checkOut: "2026-09-03",
|
|
usedRoomType: "RM1",
|
|
idempotencyKey
|
|
}
|
|
});
|
|
assert.equal(response.statusCode, 400);
|
|
assert.equal(response.json().error.code, "VALIDATION_ERROR");
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
test("business errors retain safe status and code", async t => {
|
|
const repository = createRepository({
|
|
async createUsageRecord() {
|
|
throw new ApiError(
|
|
422,
|
|
"INSUFFICIENT_BALANCE",
|
|
"The owner account does not have enough stay privileges"
|
|
);
|
|
}
|
|
});
|
|
const { app, cookie } = await buildAuthenticatedApp(repository);
|
|
t.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/usage-records",
|
|
headers: { cookie },
|
|
payload: {
|
|
ownerAccountId: ownerId,
|
|
confirmationNo: "26090002",
|
|
checkIn: "2026-09-01",
|
|
checkOut: "2026-09-03",
|
|
usedRoomType: "SU3",
|
|
idempotencyKey
|
|
}
|
|
});
|
|
assert.equal(response.statusCode, 422);
|
|
assert.equal(response.json().error.code, "INSUFFICIENT_BALANCE");
|
|
assert.equal(JSON.stringify(response.json()).includes("password"), false);
|
|
});
|
|
|
|
test("usage deletion validates the record id and returns no content", async t => {
|
|
let captured: string | undefined;
|
|
const repository = createRepository({
|
|
async deleteUsageRecord(id) {
|
|
captured = id;
|
|
}
|
|
});
|
|
const { app, cookie } = await buildAuthenticatedApp(repository);
|
|
t.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: "DELETE",
|
|
url: `/usage-records/${usageId}`,
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(response.statusCode, 204);
|
|
assert.equal(response.body, "");
|
|
assert.equal(captured, usageId);
|
|
|
|
const malformed = await app.inject({
|
|
method: "DELETE",
|
|
url: "/usage-records/not-a-uuid",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(malformed.statusCode, 400);
|
|
assert.equal(malformed.json().error.code, "VALIDATION_ERROR");
|
|
});
|
|
|
|
test("dashboard and OpenAPI expose the planned surface", async t => {
|
|
const { app, cookie } = await buildAuthenticatedApp(createRepository());
|
|
t.after(() => app.close());
|
|
|
|
const dashboard = await app.inject({
|
|
method: "GET",
|
|
url: "/dashboard?year=2026",
|
|
headers: { cookie }
|
|
});
|
|
assert.equal(dashboard.statusCode, 200);
|
|
assert.equal(dashboard.json().periodYear, 2026);
|
|
|
|
const openApi = app.swagger();
|
|
const paths = Object.keys(openApi.paths ?? {}).sort();
|
|
assert.deepEqual(paths, [
|
|
"/auth/login",
|
|
"/auth/logout",
|
|
"/auth/session",
|
|
"/dashboard",
|
|
"/health",
|
|
"/owner-accounts",
|
|
"/owner-accounts/{id}",
|
|
"/room-types",
|
|
"/usage-records",
|
|
"/usage-records/{id}"
|
|
]);
|
|
});
|