feat: add Condo owner desk frontend and backend

This commit is contained in:
Wyndham ARR
2026-08-02 13:33:13 +08:00
commit ed6229c203
54 changed files with 18640 additions and 0 deletions

417
backend/tests/app.test.ts Normal file
View File

@@ -0,0 +1,417 @@
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 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("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"
]);
});