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"
]);
});

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { SessionManager } from "../src/auth.js";
test("session manager expires and revokes opaque sessions", () => {
let now = Date.parse("2026-08-02T00:00:00.000Z");
const sessions = new SessionManager({
username: "operator",
password: "secret",
sessionTtlMs: 60_000,
cookieSecure: true
}, () => now);
assert.equal(sessions.credentialsMatch("operator", "secret"), true);
assert.equal(sessions.credentialsMatch("operator", "wrong"), false);
assert.equal(sessions.credentialsMatch("wrong", "secret"), false);
const created = sessions.create();
const cookie = sessions.sessionCookie(created.token);
assert.match(cookie, /^condon_session=[A-Za-z0-9_-]+;/);
assert.match(cookie, /HttpOnly/);
assert.match(cookie, /SameSite=Lax/);
assert.match(cookie, /Secure/);
assert.match(cookie, /Max-Age=60/);
assert.equal(cookie.includes("secret"), false);
const requestCookie = cookie.split(";", 1)[0];
assert.equal(sessions.get(requestCookie)?.username, "operator");
now += 60_001;
assert.equal(sessions.get(requestCookie), null);
const replacement = sessions.create();
const replacementCookie = sessions.sessionCookie(replacement.token).split(";", 1)[0];
sessions.revoke(replacementCookie);
assert.equal(sessions.get(replacementCookie), null);
assert.match(sessions.clearedCookie(), /Max-Age=0/);
});

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ConfigurationError,
loadConfig
} from "../src/config.js";
test("configuration accepts only booking_test and never echoes secrets", () => {
const secret = "sensitive-test-value";
const config = loadConfig({
DB_HOST: "database.example",
DB_PORT: "5432",
DB_USER: "app_user",
DB_PASSWORD: secret,
DB_NAME: "booking_test",
DB_SSL: "true"
});
assert.equal(config.database.name, "booking_test");
assert.equal(config.database.ssl, true);
assert.equal(config.apiHost, "127.0.0.1");
assert.deepEqual(config.corsOrigins, [
"http://127.0.0.1:4173",
"http://localhost:4173"
]);
assert.deepEqual(config.authentication, {
username: "wyndhamcondon",
password: "wyndhamcondon",
sessionTtlMs: 12 * 60 * 60 * 1_000,
cookieSecure: false
});
const productionConfig = loadConfig({
NODE_ENV: "production",
AUTH_USERNAME: "operator",
AUTH_PASSWORD: "production-secret",
AUTH_SESSION_TTL_HOURS: "24",
DB_HOST: "database.example",
DB_USER: "app_user",
DB_PASSWORD: secret,
DB_NAME: "booking_test"
});
assert.equal(productionConfig.authentication.username, "operator");
assert.equal(productionConfig.authentication.sessionTtlMs, 24 * 60 * 60 * 1_000);
assert.equal(productionConfig.authentication.cookieSecure, true);
assert.throws(
() => loadConfig({
DB_HOST: "database.example",
DB_USER: "app_user",
DB_PASSWORD: secret,
DB_NAME: "another_database"
}),
error => {
assert.ok(error instanceof ConfigurationError);
assert.equal(error.message.includes("DB_NAME"), true);
assert.equal(error.message.includes(secret), false);
return true;
}
);
});

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mapDatabaseError } from "../src/errors.js";
test("database business errors map to stable API responses", () => {
const insufficient = mapDatabaseError({
code: "P0001",
message: "CONDON_INSUFFICIENT_BALANCE"
});
assert.equal(insufficient.statusCode, 422);
assert.equal(insufficient.code, "INSUFFICIENT_BALANCE");
const duplicate = mapDatabaseError({
code: "23505",
message: "CONDON_CONFIRMATION_NO_EXISTS"
});
assert.equal(duplicate.statusCode, 409);
assert.equal(duplicate.code, "CONFLICT");
const invalid = mapDatabaseError({
code: "22023",
message: "CONDON_CROSS_YEAR_STAY_NOT_SUPPORTED"
});
assert.equal(invalid.statusCode, 400);
assert.equal(invalid.code, "BUSINESS_RULE_VIOLATION");
const unknown = mapDatabaseError({
code: "XX000",
message: "internal database details"
});
assert.equal(unknown.statusCode, 500);
assert.equal(unknown.message, "An internal error occurred");
assert.equal(unknown.message.includes("database details"), false);
});

View File

@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { dateValue } from "../src/postgres-repository.js";
test("database date serialization preserves the local calendar date", () => {
const previousTimezone = process.env.TZ;
process.env.TZ = "Asia/Shanghai";
try {
const databaseDate = new Date(2026, 7, 10);
assert.equal(databaseDate.toISOString().slice(0, 10), "2026-08-09");
assert.equal(dateValue(databaseDate), "2026-08-10");
assert.equal(dateValue("2026-08-10"), "2026-08-10");
} finally {
if (previousTimezone === undefined) delete process.env.TZ;
else process.env.TZ = previousTimezone;
}
});