feat: add Condo owner desk frontend and backend
This commit is contained in:
417
backend/tests/app.test.ts
Normal file
417
backend/tests/app.test.ts
Normal 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"
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user