Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions HotelApp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

</dependencies>

<build>
Expand Down
12 changes: 12 additions & 0 deletions HotelApp/src/main/java/com/yen/HotelApp/entity/Booking.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.persistence.*;
import java.time.LocalDate;
import org.springframework.data.annotation.Version;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with other JPA annotations like @Entity and @Table used in this class, it's better to use jakarta.persistence.Version instead of org.springframework.data.annotation.Version. While Spring Data's @Version is designed for non-JPA data stores, using the standard JPA annotation directly makes the dependency on JPA more explicit and consistent throughout your JPA entities.

Suggested change
import org.springframework.data.annotation.Version;
import jakarta.persistence.Version;


@Entity
@Table(name = "bookings")
Expand Down Expand Up @@ -32,6 +33,9 @@ public class Booking {

@Enumerated(EnumType.STRING)
private BookingStatus status = BookingStatus.CONFIRMED;

@Version
private Long version;

public enum BookingStatus {
CONFIRMED, CANCELLED, COMPLETED
Expand Down Expand Up @@ -112,4 +116,12 @@ public BookingStatus getStatus() {
public void setStatus(BookingStatus status) {
this.status = status;
}

public Long getVersion() {
return version;
}

public void setVersion(Long version) {
this.version = version;
}
}
12 changes: 12 additions & 0 deletions HotelApp/src/main/java/com/yen/HotelApp/entity/Room.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.yen.HotelApp.entity;

import jakarta.persistence.*;
import org.springframework.data.annotation.Version;

@Entity
@Table(name = "rooms")
Expand All @@ -23,6 +24,9 @@ public class Room {
private Boolean available = true;

private String description;

@Version
private Long version;

public Room() {}

Expand Down Expand Up @@ -81,4 +85,12 @@ public String getDescription() {
public void setDescription(String description) {
this.description = description;
}

public Long getVersion() {
return version;
}

public void setVersion(Long version) {
this.version = version;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.yen.HotelApp.exception;

import com.yen.HotelApp.controller.HotelController;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import jakarta.persistence.OptimisticLockException;

@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler({OptimisticLockException.class, ObjectOptimisticLockingFailureException.class})
public ResponseEntity<HotelController.ErrorResponse> handleOptimisticLockException(Exception e) {
String message = "The room was just booked by someone else. Please refresh and try again.";
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new HotelController.ErrorResponse(message));
}
Comment on lines +16 to +20

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It's a good practice to log exceptions on the server side for debugging and monitoring purposes. The handleOptimisticLockException method currently swallows the exception details as the parameter e is unused. Consider adding logging to record the exception.

Suggested change
public ResponseEntity<HotelController.ErrorResponse> handleOptimisticLockException(Exception e) {
String message = "The room was just booked by someone else. Please refresh and try again.";
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new HotelController.ErrorResponse(message));
}
public ResponseEntity<HotelController.ErrorResponse> handleOptimisticLockException(Exception e) {
// TODO: Add logging for the exception `e` for debugging purposes, e.g., using log.warn(...)
String message = "The room was just booked by someone else. Please refresh and try again.";
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new HotelController.ErrorResponse(message));
}


@ExceptionHandler(RuntimeException.class)
public ResponseEntity<HotelController.ErrorResponse> handleRuntimeException(RuntimeException e) {
return ResponseEntity.badRequest()
.body(new HotelController.ErrorResponse(e.getMessage()));
}
Comment on lines +22 to +26

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This generic RuntimeException handler has two issues:

  1. It returns 400 Bad Request for all runtime exceptions. Many runtime exceptions (like NullPointerException) indicate a server-side bug and should result in a 500 Internal Server Error.
  2. It exposes the raw exception message (e.getMessage()) to the client. This can be a security risk as it might leak sensitive information about the application's internal workings.

It's better to return a generic error message for 500 errors and log the full exception details on the server for debugging.

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<HotelController.ErrorResponse> handleRuntimeException(RuntimeException e) {
        // Log the exception here for debugging, e.g., using log.error("Unhandled exception", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new HotelController.ErrorResponse("An unexpected internal error occurred."));
    }

}
8 changes: 8 additions & 0 deletions HotelApp/src/main/resources/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,10 @@ <h4>Room ${booking.room.roomNumber} - ${booking.room.roomType}</h4>
closeBookingModal();
loadAllRooms();
loadAllBookings();
} else if (response.status === 409) {
showMessage('❌ ' + result.message);
closeBookingModal();
loadAllRooms(); // Refresh room data to show updated availability
} else {
showMessage('❌ ' + result.message);
}
Expand Down Expand Up @@ -1290,6 +1294,10 @@ <h4>Room ${booking.room.roomNumber} - ${booking.room.roomType}</h4>
if (currentView === 'rooms') {
loadAllRooms(); // Refresh room availability
}
} else if (response.status === 409) {
showMessage('❌ ' + result.message);
loadAllBookings(); // Refresh booking data
displayBookings();
} else {
showMessage('❌ ' + result.message);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.yen.HotelApp.service;

import com.yen.HotelApp.entity.Room;
import com.yen.HotelApp.repository.RoomRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@ActiveProfiles("test")
public class OptimisticLockingTest {

@Autowired
private HotelService hotelService;

@Autowired
private RoomRepository roomRepository;

@Test
@Transactional
public void testOptimisticLockingOnRoomBooking() throws InterruptedException {
// Create a test room
Room room = new Room("999", "Single", 100.0, "Test room for concurrency");
room = roomRepository.save(room);
final Long roomId = room.getId();

// Setup for concurrent booking attempts
int numberOfThreads = 3;
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);

AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);

// Simulate concurrent booking attempts
for (int i = 0; i < numberOfThreads; i++) {
final int threadId = i;
executor.submit(() -> {
try {
hotelService.createBooking(
roomId,
"Guest " + threadId,
"guest" + threadId + "@test.com",
LocalDate.now().plusDays(1),
LocalDate.now().plusDays(3)
);
successCount.incrementAndGet();
} catch (RuntimeException | ObjectOptimisticLockingFailureException e) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The catch block is too broad. Catching RuntimeException can lead to false positives in this test, as any unexpected runtime error during booking would be counted as an optimistic locking failure. To make the test more precise and reliable, you should only catch the specific optimistic locking exceptions. If any other RuntimeException occurs, the test should fail to indicate an unexpected problem.

Suggested change
} catch (RuntimeException | ObjectOptimisticLockingFailureException e) {
} catch (ObjectOptimisticLockingFailureException e) {

failureCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}

latch.await(); // Wait for all threads to complete
executor.shutdown();

// Assertions
assertEquals(1, successCount.get(), "Only one booking should succeed");
assertEquals(numberOfThreads - 1, failureCount.get(), "Other attempts should fail");

// Verify room is no longer available
Room updatedRoom = roomRepository.findById(roomId).orElseThrow();
assertFalse(updatedRoom.getAvailable(), "Room should be unavailable after successful booking");
assertNotNull(updatedRoom.getVersion(), "Room should have a version number");
}

@Test
public void testVersionFieldsExist() {
// Test that version fields are properly added
Room room = new Room("998", "Double", 150.0, "Version test room");
room = roomRepository.save(room);

assertNotNull(room.getVersion(), "Room should have a version field after save");
assertTrue(room.getVersion() >= 0, "Version should be non-negative");
}

@Test
public void testOptimisticLockExceptionOnRoomUpdate() {
// Create and save a room
Room room = new Room("997", "Suite", 250.0, "Lock test room");
room = roomRepository.save(room);

// Get two instances of the same room
Room room1 = roomRepository.findById(room.getId()).orElseThrow();
Room room2 = roomRepository.findById(room.getId()).orElseThrow();

// Update and save first instance
room1.setPrice(300.0);
roomRepository.save(room1);

// Try to update second instance (should fail due to optimistic locking)
room2.setPrice(350.0);

assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
roomRepository.save(room2);
});
}
}
18 changes: 18 additions & 0 deletions HotelApp/src/test/resources/application-test.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
spring.application.name=HotelApp-Test

# MySQL Database Configuration for Testing
spring.datasource.url=jdbc:mysql://localhost:3306/hoteldb_test?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA Configuration for Testing
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Connection Pool Settings for Testing
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=30000
Loading