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