diff --git a/api/__tests__/authRouter.test.js b/api/__tests__/authRouter.test.js new file mode 100644 index 0000000..3171a05 --- /dev/null +++ b/api/__tests__/authRouter.test.js @@ -0,0 +1,146 @@ +import bcrypt from "bcryptjs"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import db from "../db.js"; +import config from "../utils/config.js"; +config.init(); + +import { createRequest } from "./testUtils.js"; + +// Mock bcrypt functions +vi.mock("bcryptjs", () => ({ + default: { + hash: vi.fn().mockResolvedValue("$2a$10$validbcrypthashplaceholder"), + compare: vi.fn(), + }, +})); + +// Mock database module +vi.mock("../db.js", () => ({ + default: { + query: vi.fn(), + }, +})); + +describe("authRouter", () => { + const testUser = { + firstName: "Test", + lastName: "User", + email: `testuser_${Date.now()}@example.com`, + password: "password123", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should register a new user and then login (happy path)", async () => { + // 1st call: check for existing user -> none + db.query.mockResolvedValueOnce({ rows: [] }); + // 2nd call: insert new user + db.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + first_name: testUser.firstName, + last_name: testUser.lastName, + email: testUser.email, + password: "$2a$10$validbcrypthashplaceholder", + }, + ], + }); + // 3rd call: lookup for login + db.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + first_name: testUser.firstName, + last_name: testUser.lastName, + email: testUser.email, + password: "$2a$10$validbcrypthashplaceholder", + }, + ], + }); + bcrypt.compare.mockResolvedValue(true); + + const request = await createRequest(); + + // Register + const registerRes = await request + .post("/api/v1/auth/register") + .send(testUser); + expect(registerRes.status).toBe(201); + expect(registerRes.body).toHaveProperty("id"); + expect(registerRes.body.email).toBe(testUser.email); + + // Login + const loginRes = await request.post("/api/v1/auth/login").send({ + email: testUser.email, + password: testUser.password, + }); + expect(loginRes.status).toBe(200); + expect(loginRes.body).toHaveProperty("token"); + + // Ensure DB calls happened as expected + expect(db.query).toHaveBeenCalledTimes(3); + }); + + it("should return 409 when registering an already existing email", async () => { + // 1st call: check for existing user -> found one + db.query.mockResolvedValueOnce({ rows: [{ id: 2 }] }); + + const request = await createRequest(); + const res = await request.post("/api/v1/auth/register").send(testUser); + + expect(res.status).toBe(409); + // Error message is returned in 'error' field + expect(res.body).toHaveProperty("error"); + expect(res.body.error).toMatch(/already in use/i); + expect(db.query).toHaveBeenCalledTimes(1); + }); + + it("should return 401 when logging in with wrong password", async () => { + // 1st call: lookup for login + db.query.mockResolvedValueOnce({ + rows: [ + { + id: 3, + first_name: "Foo", + last_name: "Bar", + email: testUser.email, + password: "$2a$10$validbcrypthashplaceholder", + }, + ], + }); + // bcrypt.compare returns false + bcrypt.compare.mockResolvedValue(false); + + const request = await createRequest(); + const res = await request.post("/api/v1/auth/login").send({ + email: testUser.email, + password: "wrongpassword", + }); + + expect(res.status).toBe(401); + expect(res.body).toHaveProperty("error"); + expect(res.body.error).toMatch(/invalid credentials/i); + expect(db.query).toHaveBeenCalledTimes(1); + }); + + it("should return 401 when logging in with unregistered email", async () => { + // 1st call: lookup for login -> no user + db.query.mockResolvedValueOnce({ rows: [] }); + + const request = await createRequest(); + const res = await request.post("/api/v1/auth/login").send({ + email: "nonexistent@example.com", + password: "password123", + }); + + // Unregistered emails also return 401 + expect(res.status).toBe(401); + expect(res.body).toHaveProperty("error"); + expect(res.body.error).toMatch(/invalid credentials/i); + expect(db.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/__tests__/integration.test.js b/api/__tests__/integration.test.js new file mode 100644 index 0000000..9c32244 --- /dev/null +++ b/api/__tests__/integration.test.js @@ -0,0 +1,341 @@ +import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest"; + +import db from "../db.js"; +import config from "../utils/config.js"; + +import { createRequest } from "./testUtils.js"; + +config.init(); + +// Mock bcrypt functions for testing +vi.mock("bcryptjs", () => ({ + default: { + hash: vi.fn().mockResolvedValue("$2a$10$validbcrypthashplaceholder"), + compare: vi.fn().mockResolvedValue(true), + }, +})); + +// Mock database module +vi.mock("../db.js", () => ({ + default: { + query: vi.fn(), + }, +})); + +// Integration test for complete user journey +// Tests registration, login, desk viewing, booking, and logout +describe("Complete User Journey Integration Test", () => { + // Test data that we'll use throughout the test + const testUser = { + firstName: "John", + lastName: "Doe", + email: `john.doe.${Date.now()}.${Math.random().toString(36).substr(2, 9)}@example.com`, + password: "securepassword123", + }; + + const testDesk = { + id: 1, + name: "Desk A1", + location: "Floor 1", + status: "available", + }; + + const testBooking = { + deskId: 1, + date: new Date(Date.now() + 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0], // Tomorrow's date + startTime: "09:00", + endTime: "17:00", + }; + + let request; + let authToken; + let userId; + + // Setup before all tests in this describe block + beforeAll(async () => { + // Create a test request agent + request = await createRequest(); + }); + + // Reset mocks before each test + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Main test - complete user journey from registration to logout + it("should complete the full user journey: register → login → view desks → book desk → view bookings → logout", async () => { + // Step 1: User Registration + + // Mock database responses for registration + // First call: check if user exists (should return empty) + db.query.mockResolvedValueOnce({ rows: [] }); + // Second call: insert new user + db.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + first_name: testUser.firstName, + last_name: testUser.lastName, + email: testUser.email, + password: "$2a$10$validbcrypthashplaceholder", + }, + ], + }); + + // Make the registration request + const registerResponse = await request + .post("/api/v1/auth/register") + .send(testUser); + + // Verify registration was successful + expect(registerResponse.status).toBe( + 201, + "Registration should return 201 Created", + ); + expect(registerResponse.body).toHaveProperty("id"); + expect(registerResponse.body.email).toBe( + testUser.email, + "Email should match", + ); + expect(registerResponse.body.first_name).toBe( + testUser.firstName, + "First name should match", + ); + expect(registerResponse.body.last_name).toBe( + testUser.lastName, + "Last name should match", + ); + + userId = registerResponse.body.id; + + // Step 2: User Login + + // Mock database response for login (user lookup) + db.query.mockResolvedValueOnce({ + rows: [ + { + id: userId, + first_name: testUser.firstName, + last_name: testUser.lastName, + email: testUser.email, + password: "$2a$10$validbcrypthashplaceholder", + }, + ], + }); + + // Make the login request + const loginResponse = await request.post("/api/v1/auth/login").send({ + email: testUser.email, + password: testUser.password, + }); + + // Verify login was successful + expect(loginResponse.status).toBe(200, "Login should return 200 OK"); + expect(loginResponse.body).toHaveProperty("token"); + expect(loginResponse.headers["set-cookie"]).toBeDefined( + "Should set authentication cookie", + ); + + authToken = loginResponse.body.token; + + // Step 3: View Available Desks + + // Mock database response for getting desks + db.query.mockResolvedValueOnce({ + rows: [ + { ...testDesk, status: "available" }, + { id: 2, name: "Desk A2", location: "Floor 1", status: "occupied" }, + { id: 3, name: "Desk B1", location: "Floor 2", status: "available" }, + ], + }); + + // Make the request to get desks + const desksResponse = await request + .get("/api/v1/desks") + .set("Cookie", `token=${authToken}`); + + // Verify desks were retrieved successfully + expect(desksResponse.status).toBe( + 200, + "Desks request should return 200 OK", + ); + expect(Array.isArray(desksResponse.body)).toBe( + true, + "Response should be an array", + ); + expect(desksResponse.body.length).toBeGreaterThan( + 0, + "Should return at least one desk", + ); + + // Check that we have available desks + const availableDesks = desksResponse.body.filter( + (desk) => desk.status === "available", + ); + expect(availableDesks.length).toBeGreaterThan( + 0, + "Should have at least one available desk", + ); + + // Step 4: Book a Desk + + // Mock database responses for booking + // First call: check if user is already booked on this date (should return empty) + db.query.mockResolvedValueOnce({ rows: [] }); + // Second call: check if desk is already booked on this date (should return empty) + db.query.mockResolvedValueOnce({ rows: [] }); + // Third call: create the booking + db.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + desk_id: testBooking.deskId, + user_id: userId, + date: new Date(testBooking.date).toISOString(), + created_at: new Date().toISOString(), + }, + ], + }); + + // Make the booking request + const bookingResponse = await request + .post("/api/v1/bookings") + .set("Cookie", `token=${authToken}`) + .send({ + userId: userId, + deskId: testBooking.deskId, + bookingDate: testBooking.date, + }); + + // Verify booking was successful + expect(bookingResponse.status).toBe( + 201, + "Booking should return 201 Created", + ); + expect(bookingResponse.body).toHaveProperty("id"); + expect(bookingResponse.body.desk_id).toBe( + testBooking.deskId, + "Desk ID should match", + ); + + // Step 5: View User's Bookings + + // Mock database response for getting user's bookings + db.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + desk_id: testBooking.deskId, + desk_name: testDesk.name, + desk_location: testDesk.location, + date: testBooking.date, + start_time: testBooking.startTime, + end_time: testBooking.endTime, + created_at: new Date().toISOString(), + }, + ], + }); + + // Make the request to get user's bookings + const myBookingsResponse = await request + .get("/api/v1/bookings/my") + .set("Cookie", `token=${authToken}`); + + // Verify bookings were retrieved successfully + expect(myBookingsResponse.status).toBe( + 200, + "My bookings request should return 200 OK", + ); + expect(myBookingsResponse.body).toHaveProperty("upcoming"); + expect(myBookingsResponse.body).toHaveProperty("past"); + + // Step 6: User Logout + + // Make the logout request + const logoutResponse = await request + .post("/api/v1/auth/logout") + .set("Cookie", `token=${authToken}`); + + // Verify logout was successful + expect(logoutResponse.status).toBe(200, "Logout should return 200 OK"); + expect(logoutResponse.body).toHaveProperty("message"); + expect(logoutResponse.headers["set-cookie"]).toBeDefined( + "Should clear authentication cookie", + ); + + // Step 7: Verify Authentication is Required After Logout + + // Try to access protected resource without authentication + const unauthorizedResponse = await request.get("/api/v1/desks"); + + // Verify that authentication is required + expect(unauthorizedResponse.status).toBe( + 401, + "Should require authentication", + ); + + // Final verification: Database Calls + + // Verify that all expected database calls were made + expect(db.query).toHaveBeenCalledTimes( + 8, + "Should have made 8 database calls total", + ); + }); + + // Test error handling for invalid registration data + it("should handle invalid registration data gracefully", async () => { + const invalidUser = { + firstName: "", // Empty first name + lastName: "Doe", + email: "invalid-email", // Invalid email format + password: "123", // Too short password + }; + + const request = await createRequest(); + const response = await request + .post("/api/v1/auth/register") + .send(invalidUser); + + // Verify that validation errors are returned + expect(response.status).toBe( + 400, + "Should return 400 Bad Request for invalid data", + ); + expect(response.body).toHaveProperty("error"); + expect(response.body.error).toContain( + "Missing firstName", + "Should mention missing firstName", + ); + }); + + // Test that protected routes require authentication + it("should require authentication for protected routes", async () => { + const request = await createRequest(); + + // Test accessing desks without authentication + const desksResponse = await request.get("/api/v1/desks"); + expect(desksResponse.status).toBe( + 401, + "Should require authentication for desks", + ); + + // Test accessing bookings without authentication + const bookingsResponse = await request.get("/api/v1/bookings/my"); + expect(bookingsResponse.status).toBe( + 401, + "Should require authentication for bookings", + ); + + // Test creating booking without authentication + const createBookingResponse = await request + .post("/api/v1/bookings") + .send(testBooking); + expect(createBookingResponse.status).toBe( + 401, + "Should require authentication for creating bookings", + ); + }); +}); diff --git a/api/__tests__/loadTest.test.js b/api/__tests__/loadTest.test.js new file mode 100644 index 0000000..6d80e00 --- /dev/null +++ b/api/__tests__/loadTest.test.js @@ -0,0 +1,209 @@ +import request from "supertest"; +import { describe, it, expect, beforeAll } from "vitest"; + +// Utility sleep +const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); + +describe("Staggered Real-World Load Smoke Test", () => { + const baseUrl = process.env.API_URL || "http://localhost:3000"; + const TEST_USER_COUNT = 500; // Reasonable size for race condition testing + const BATCH_SIZE = 20; // Smaller batches for better race condition testing + const INTER_BATCH_DELAY = 500; // 0.5s between waves + const MAX_RETRIES = 3; + + beforeAll(async () => { + const health = await request(baseUrl).get("/healthz").timeout(10000); + if (health.status !== 200) { + throw new Error(`API not healthy (status ${health.status})`); + } + }); + + // Retry helper with optional backoff + async function retry(fn, retriesLeft = MAX_RETRIES) { + try { + return await fn(); + } catch (err) { + if (retriesLeft > 0) { + await sleep(100 * (MAX_RETRIES - retriesLeft + 1)); // linear backoff + return retry(fn, retriesLeft - 1); + } + throw err; + } + } + + it("registers users in staggered batches", async () => { + const created = []; + // collect all desk-fetch latencies here: + const allDeskTimings = []; + + for (let i = 0; i < TEST_USER_COUNT; i += BATCH_SIZE) { + const batch = Array.from( + { length: Math.min(BATCH_SIZE, TEST_USER_COUNT - i) }, + (_, j) => { + const idx = i + j; + const email = `user${idx}_${Date.now()}@example.com`; + return retry(async () => { + // add small random jitter to simulate user think-time + await sleep(Math.random() * 200); + const res = await request(baseUrl) + .post("/api/v1/auth/register") + .send({ + firstName: `User${String.fromCharCode(65 + (idx % 26))}`, + lastName: `Test${String.fromCharCode(65 + ((idx + 1) % 26))}`, + email, + password: "Password123!", + }); + if (res.status >= 200 && res.status < 300) { + const id = res.body.id || res.body.user?.id || idx; + console.log( + `Registered user ${email} with ID: ${id}, response body:`, + JSON.stringify(res.body), + ); + created.push({ id, email }); + } else { + throw new Error( + `Registration failed for ${email}: ${res.status}`, + ); + } + }); + }, + ); + + await Promise.all(batch); + // wait before next wave + await sleep(INTER_BATCH_DELAY); + } + + expect(created).toHaveLength(TEST_USER_COUNT); + + // login and poll in similar staggered fashion + const loggedIn = []; + let successfulBookings = 0; // Counter for successful bookings in race condition test + for (let i = 0; i < created.length; i += BATCH_SIZE) { + const batch = created.slice(i, i + BATCH_SIZE).map((u, batchIndex) => + retry(async () => { + const userIndex = i + batchIndex; + await sleep(Math.random() * 200); + const agent = request.agent(baseUrl); + const loginRes = await agent + .post("/api/v1/auth/login") + .send({ email: u.email, password: "Password123!" }); + if (loginRes.status !== 200) { + throw new Error(`Login failed for ${u.email}: ${loginRes.status}`); + } + + // Test desks endpoint with latency tracking + const t0 = Date.now(); + const desksRes = await agent.get("/api/v1/desks"); + const dt = Date.now() - t0; + allDeskTimings.push(dt); + + if (desksRes.status !== 200) { + throw new Error( + `Desks fetch failed for ${u.email}: ${desksRes.status}`, + ); + } + + console.log( + `Desks response for ${u.email}:`, + JSON.stringify(desksRes.body), + ); + + // Create a booking for some users (every 3rd user to test race conditions) + if ( + userIndex % 3 === 0 && + desksRes.body && + desksRes.body.length > 0 + ) { + // Use the same desk for all users to test race conditions + const deskId = desksRes.body[0].id; // Always use the first desk + + // Use the same date for all users to create competition + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setUTCHours(13, 0, 0, 0); + + console.log( + `Attempting to create booking for user ${u.email} (ID: ${u.id}) with desk ${deskId} for date ${tomorrow.toISOString().split("T")[0]} - RACE CONDITION TEST`, + ); + + const bookingRes = await agent.post("/api/v1/bookings").send({ + userId: u.id, + deskId: deskId, + bookingDate: tomorrow.toISOString().split("T")[0], + }); + + if (bookingRes.status !== 201) { + // This is expected for race conditions - only one user should succeed + console.log( + `Booking creation failed for ${u.email}: ${bookingRes.status} - ${JSON.stringify(bookingRes.body) || "Unknown error"} - EXPECTED FOR RACE CONDITION`, + ); + // Log the request details for debugging + console.log( + `Request details: userId=${u.id}, deskId=${deskId}, bookingDate=${tomorrow.toISOString().split("T")[0]}`, + ); + } else { + successfulBookings++; + console.log( + `Successfully created booking for ${u.email}: ${JSON.stringify(bookingRes.body)} - WON THE RACE!`, + ); + } + } else if (userIndex % 3 === 0) { + console.log( + `Skipping booking creation for ${u.email} - no desks available`, + ); + } + + const pollRes = await agent.get("/api/v1/bookings"); + if (pollRes.status !== 200) { + throw new Error( + `Booking poll failed for ${u.email}: ${pollRes.status}`, + ); + } + loggedIn.push(u); + }), + ); + + await Promise.all(batch); + await sleep(INTER_BATCH_DELAY); + } + + expect(loggedIn).toHaveLength(TEST_USER_COUNT); + + // Summary of race condition test + const attemptedBookings = Math.floor(TEST_USER_COUNT / 3); + console.log(`\n=== RACE CONDITION TEST SUMMARY ===`); + console.log( + `Total users that attempted to book the same desk: ${attemptedBookings}`, + ); + console.log(`Actual successful bookings: ${successfulBookings}`); + console.log( + `Expected successful bookings: 1 (only one user should win the race)`, + ); + console.log( + `This test validates that the booking system properly handles concurrent requests for the same resource.`, + ); + if (successfulBookings === 1) { + console.log( + `RACE CONDITION TEST PASSED: Only one user successfully booked the desk as expected.`, + ); + } else { + console.log( + `RACE CONDITION TEST RESULT: ${successfulBookings} users booked the same desk. This might indicate a concurrency issue.`, + ); + } + + // Output timing summary + if (allDeskTimings.length > 0) { + const sum = allDeskTimings.reduce((a, b) => a + b, 0); + const avg = Math.round(sum / allDeskTimings.length); + const min = Math.min(...allDeskTimings); + const max = Math.max(...allDeskTimings); + console.log(`\n=== DESKS FETCH TIMING SUMMARY ===`); + console.log(`Total calls: ${allDeskTimings.length}`); + console.log(`Avg latency: ${avg} ms`); + console.log(`Min latency: ${min} ms`); + console.log(`Max latency: ${max} ms`); + } + }, 180000); // extended timeout for staggered waves +}); diff --git a/api/__tests__/simultaneousLoadTest.test.js b/api/__tests__/simultaneousLoadTest.test.js new file mode 100644 index 0000000..1a42638 --- /dev/null +++ b/api/__tests__/simultaneousLoadTest.test.js @@ -0,0 +1,128 @@ +import request from "supertest"; +import { describe, it, expect, beforeAll } from "vitest"; + +describe("Simultaneous Load Test - 500 Users with Desk Polling and Timing Summary", () => { + const baseUrl = process.env.API_URL || "http://localhost:3000"; + const TEST_USER_COUNT = 500; + const POLL_INTERVAL = 5000; // 5 seconds + const POLL_COUNT = 6; // number of polls per login session + + beforeAll(async () => { + const health = await request(baseUrl).get("/healthz").timeout(10000); + if (health.status !== 200) { + throw new Error(`API not healthy (status ${health.status})`); + } + }); + + it("registers 500 users simultaneously with polling desks every 5s and outputs timing summary", async () => { + const timestamp = Date.now(); + console.log( + `\n=== STARTING SIMULTANEOUS LOAD TEST WITH POLLING & TIMING SUMMARY ===`, + ); + + // 1) Registration phase + console.log(`Registering ${TEST_USER_COUNT} users simultaneously...`); + + const registrationPromises = Array.from( + { length: TEST_USER_COUNT }, + (_, idx) => { + const email = `simultaneous_user${idx}_${timestamp}@example.com`; + return request(baseUrl) + .post("/api/v1/auth/register") + .send({ + firstName: `SimultaneousUser${String.fromCharCode(65 + (idx % 26))}`, + lastName: `Test${String.fromCharCode(65 + ((idx + 1) % 26))}`, + email, + password: "Password123!", + }) + .then((res) => ({ + id: res.body.id || res.body.user?.id || idx, + email, + success: res.status >= 200 && res.status < 300, + status: res.status, + })) + .catch((err) => ({ + id: idx, + email, + success: false, + error: err.message, + })); + }, + ); + + const registrationResults = await Promise.all(registrationPromises); + const successfulRegistrations = registrationResults.filter( + (r) => r.success, + ); + console.log(`\n=== REGISTRATION RESULTS ===`); + console.log( + `Successful: ${successfulRegistrations.length}/${TEST_USER_COUNT}`, + ); + expect(successfulRegistrations.length).toBeGreaterThan(0); + + // 2) Login and polling phase + console.log(`\n=== STARTING SIMULTANEOUS LOGIN AND POLLING ===`); + + // collect all desk-fetch latencies here: + const allDeskTimings = []; + + const loginAndPollingPromises = successfulRegistrations.map((user) => { + return request + .agent(baseUrl) + .post("/api/v1/auth/login") + .send({ email: user.email, password: "Password123!" }) + .then(async (loginRes) => { + if (loginRes.status !== 200) { + return { user, success: false }; + } + + const agent = request.agent(baseUrl); + (loginRes.headers["set-cookie"] || []).forEach((cookie) => + agent.set("Cookie", cookie), + ); + + for (let i = 1; i <= POLL_COUNT; i++) { + const t0 = Date.now(); + const res = await agent.get("/api/v1/desks"); + const dt = Date.now() - t0; + + if (res.status !== 200) { + throw new Error(`Desks fetch #${i} failed: ${res.status}`); + } + allDeskTimings.push(dt); + + if (i < POLL_COUNT) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + } + + return { user, success: true }; + }) + .catch(() => ({ user, success: false })); + }); + + const pollingResults = await Promise.all(loginAndPollingPromises); + const successfulLogins = pollingResults.filter((r) => r.success); + + console.log(`\n=== SIMULTANEOUS LOGIN AND POLLING RESULTS ===`); + console.log( + `Successful logins: ${successfulLogins.length}/${successfulRegistrations.length}`, + ); + + // 3) Output timing summary + if (allDeskTimings.length > 0) { + const sum = allDeskTimings.reduce((a, b) => a + b, 0); + const avg = Math.round(sum / allDeskTimings.length); + const min = Math.min(...allDeskTimings); + const max = Math.max(...allDeskTimings); + console.log(`\n=== DESKS FETCH TIMING SUMMARY ===`); + console.log(`Total calls: ${allDeskTimings.length}`); + console.log(`Avg latency: ${avg} ms`); + console.log(`Min latency: ${min} ms`); + console.log(`Max latency: ${max} ms`); + } + + // Final assertions + expect(successfulLogins.length).toBeGreaterThan(0); + }, 300000); +}); diff --git a/api/modules/bookings/bookingRepository.js b/api/modules/bookings/bookingRepository.js index 3bb4d55..a02d256 100644 --- a/api/modules/bookings/bookingRepository.js +++ b/api/modules/bookings/bookingRepository.js @@ -107,26 +107,42 @@ export async function createBooking({ userId, deskId, date }) { till.setUTCHours(19, 0, 0, 0); const toDate = till.toISOString(); - const { rows } = await db.query( - sql` - WITH new_booking AS ( - INSERT INTO booking (user_id, desk_id, from_date, to_date) - VALUES ($1, $2, $3, $4) - RETURNING *) - SELECT - b.id AS booking_id, - b.desk_id, b.user_id, - b.from_date, b.to_date, - u.first_name, u.last_name, - d.name AS desk_name - FROM - new_booking b - JOIN "user" u ON b.user_id = u.id - JOIN desk d ON b.desk_id = d.id - `, - [userId, deskId, date, toDate], - ); - return rows[0]; + try { + const { rows } = await db.query( + sql` + WITH new_booking AS ( + INSERT INTO booking (user_id, desk_id, from_date, to_date) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING + RETURNING *) + SELECT + b.id AS booking_id, + b.desk_id, b.user_id, + b.from_date, b.to_date, + u.first_name, u.last_name, + d.name AS desk_name + FROM + new_booking b + JOIN "user" u ON b.user_id = u.id + JOIN desk d ON b.desk_id = d.id + `, + [userId, deskId, date, toDate], + ); + + // If no rows returned, it means there was a conflict + if (rows.length === 0) { + return null; + } + + return rows[0]; + } catch (error) { + // Handle constraint violation errors + if (error.code === "23514") { + // Check constraint violation + return null; + } + throw error; + } } export async function getFilteredBookings({ from, to, userId }) { diff --git a/api/modules/bookings/bookingService.js b/api/modules/bookings/bookingService.js index 0058874..82d72a4 100644 --- a/api/modules/bookings/bookingService.js +++ b/api/modules/bookings/bookingService.js @@ -32,7 +32,18 @@ export async function handleCreateBooking({ userId, deskId, date }) { StatusCodes.CONFLICT, ); } - return await createBooking({ userId, deskId, date: date }); + + const booking = await createBooking({ userId, deskId, date: date }); + + // If createBooking returns null, it means there was a conflict + if (!booking) { + throw new ApiError( + "Desk is already booked for this date or time range.", + StatusCodes.CONFLICT, + ); + } + + return booking; } export async function getBookingById(id) { diff --git a/api/package.json b/api/package.json index 6093bc4..8fd33ba 100644 --- a/api/package.json +++ b/api/package.json @@ -8,7 +8,8 @@ "scripts": { "dev": "node --inspect --watch --watch-path . server.js", "migration": "node ./migrations/migrate.js", - "migration:create": "node-pg-migrate create --template-file-name ./migrations/template.js" + "migration:create": "node-pg-migrate create --template-file-name ./migrations/template.js", + "test": "vitest --run" }, "dependencies": { "bcryptjs": "^3.0.2", @@ -21,6 +22,7 @@ }, "devDependencies": { "@testcontainers/postgresql": "^11.1.0", - "supertest": "^7.1.1" + "supertest": "^7.1.4", + "vitest": "^3.2.2" } } diff --git a/script.sql b/script.sql index fbc97c5..671e3cd 100644 --- a/script.sql +++ b/script.sql @@ -3,6 +3,9 @@ CREATE DATABASE deskeando; \c deskeando; +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + CREATE TABLE "desk"( id uuid default uuid_generate_v4() PRIMARY KEY, "name" text not null @@ -24,6 +27,10 @@ CREATE TABLE "booking"( "to_date" timestamp with time zone not null ); +-- Prevent two users from booking the same desk at the same time +-- This ensures only one booking per desk per date +CREATE UNIQUE INDEX unique_desk_date ON booking (desk_id, from_date); + INSERT INTO "desk" (id, name) VALUES ('b142a09d-76f7-4140-a401-52a7bc5f22c5','Desk 1'),