From b3efd4926a783a26dad10e394bc199067201dedf Mon Sep 17 00:00:00 2001 From: Bipin Verma Date: Sat, 18 Jul 2026 01:28:58 +0530 Subject: [PATCH 1/4] hotel booking testcontainers --- pom.xml | 12 ++ .../BookingFlowIntegrationTest.java | 202 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java diff --git a/pom.xml b/pom.xml index bbad0c1..ff4ef5a 100644 --- a/pom.xml +++ b/pom.xml @@ -118,6 +118,11 @@ spring-boot-testcontainers test + + org.testcontainers + testcontainers + test + org.testcontainers junit-jupiter @@ -138,6 +143,13 @@ + + org.testcontainers + testcontainers-bom + 1.19.3 + pom + import + org.keycloak.bom keycloak-adapter-bom diff --git a/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java b/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java new file mode 100644 index 0000000..dc9507d --- /dev/null +++ b/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java @@ -0,0 +1,202 @@ +package com.cn.hotelDemo.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.math.BigDecimal; +import java.security.Principal; +import java.time.LocalDate; +import java.util.List; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.cn.hotelDemo.dto.BookingRequest; +import com.cn.hotelDemo.dto.BookingResponse; +import com.cn.hotelDemo.model.AuditLog; +import com.cn.hotelDemo.model.BookingStatus; +import com.cn.hotelDemo.model.Hotel; +import com.cn.hotelDemo.model.Room; +import com.cn.hotelDemo.model.RoomStatus; +import com.cn.hotelDemo.model.User; +import com.cn.hotelDemo.repository.AuditLogRepository; +import com.cn.hotelDemo.repository.BookingRepository; +import com.cn.hotelDemo.repository.HotelRepository; +import com.cn.hotelDemo.repository.RoomRepository; +import com.cn.hotelDemo.repository.UserRepository; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +@Import(BookingFlowIntegrationTest.TestAuthenticationConfiguration.class) +class BookingFlowIntegrationTest { + + @Container + static final MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("hotel_test") + .withUsername("test") + .withPassword("test"); + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private HotelRepository hotelRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private BookingRepository bookingRepository; + + @Autowired + private AuditLogRepository auditLogRepository; + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mysql::getJdbcUrl); + registry.add("spring.datasource.username", mysql::getUsername); + registry.add("spring.datasource.password", mysql::getPassword); + registry.add("spring.cache.type", () -> "simple"); + registry.add("app.security.enabled", () -> "false"); + } + + @Test + void bookingHappyPath() { + User user = new User(); + user.setUsername("integration-user"); + user.setEmail("integration-user@example.com"); + user.setPassword("password"); + user.setRole("NORMAL"); + User savedUser = userRepository.save(user); + + Hotel hotel = new Hotel(); + hotel.setName("Integration Grand Hotel"); + hotel.setRating(5L); + hotel.setCity("Bengaluru"); + hotel.setDiscount(0.0); + Hotel savedHotel = hotelRepository.save(hotel); + + Room room = new Room(); + room.setRoomNumber("101"); + room.setRoomType("Deluxe"); + room.setCapacity(2); + room.setNightlyRate(BigDecimal.valueOf(4500)); + room.setStatus(RoomStatus.AVAILABLE); + room.setHotel(savedHotel); + Room savedRoom = roomRepository.save(room); + + LocalDate checkInDate = LocalDate.now().plusDays(1); + LocalDate checkOutDate = LocalDate.now().plusDays(3); + BookingRequest bookingRequest = new BookingRequest( + savedHotel.getId(), + savedRoom.getId(), + savedUser.getId(), + checkInDate, + checkOutDate, + 2, + "Late arrival"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity response = restTemplate.exchange( + "/hotel/bookings/create", + HttpMethod.POST, + new HttpEntity<>(bookingRequest, headers), + BookingResponse.class); + + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(BookingStatus.CONFIRMED, response.getBody().getStatus()); + assertEquals(1L, bookingRepository.count()); + + List auditLogs = auditLogRepository.findAllByOrderByCreatedAtDesc(); + assertFalse(auditLogs.isEmpty()); + assertTrue(auditLogs.stream().anyMatch(entry -> entry.getAction() != null && entry.getAction().contains("BOOKING"))); + } + + @TestConfiguration(proxyBeanMethods = false) + static class TestAuthenticationConfiguration { + + @Bean + FilterRegistrationBean testAuthenticationFilter() { + org.springframework.web.filter.OncePerRequestFilter filter = new org.springframework.web.filter.OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + Authentication authentication = new UsernamePasswordAuthenticationToken( + "integration-user", + "N/A", + List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))); + + var securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + + HttpServletRequest wrappedRequest = new HttpServletRequestWrapper(request) { + @Override + public Principal getUserPrincipal() { + return authentication; + } + + @Override + public String getRemoteUser() { + return authentication.getName(); + } + + @Override + public boolean isUserInRole(String role) { + String normalizedRole = role.startsWith("ROLE_") ? role : "ROLE_" + role.toUpperCase(); + return authentication.getAuthorities().stream() + .anyMatch(authority -> authority.getAuthority().equals(normalizedRole)); + } + }; + + try { + filterChain.doFilter(wrappedRequest, response); + } finally { + SecurityContextHolder.clearContext(); + } + } + }; + + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>( + filter); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } + } +} From d8f37062e36f3af805f95700d53b5e738587ac74 Mon Sep 17 00:00:00 2001 From: Bipin Verma Date: Sat, 18 Jul 2026 01:34:07 +0530 Subject: [PATCH 2/4] skip booking test without docker --- .../cn/hotelDemo/integration/BookingFlowIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java b/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java index dc9507d..231d65f 100644 --- a/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java +++ b/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java @@ -57,7 +57,7 @@ import com.cn.hotelDemo.repository.UserRepository; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Testcontainers +@Testcontainers(disabledWithoutDocker = true) @Import(BookingFlowIntegrationTest.TestAuthenticationConfiguration.class) class BookingFlowIntegrationTest { From e8e3cc9fbaadc1137f6f3fc337b230e8f4c6e0ee Mon Sep 17 00:00:00 2001 From: Bipin Verma Date: Sat, 18 Jul 2026 01:42:44 +0530 Subject: [PATCH 3/4] exclude actuator security in booking test --- .../hotelDemo/integration/BookingFlowIntegrationTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java b/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java index 231d65f..ace298d 100644 --- a/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java +++ b/src/test/java/com/cn/hotelDemo/integration/BookingFlowIntegrationTest.java @@ -61,6 +61,13 @@ @Import(BookingFlowIntegrationTest.TestAuthenticationConfiguration.class) class BookingFlowIntegrationTest { + private static final String TEST_AUTO_CONFIG_EXCLUDES = String.join(",", + "org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration", + "org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration", + "org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration", + "org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration", + "org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration"); + @Container static final MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") .withDatabaseName("hotel_test") @@ -92,6 +99,7 @@ static void registerProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.password", mysql::getPassword); registry.add("spring.cache.type", () -> "simple"); registry.add("app.security.enabled", () -> "false"); + registry.add("spring.autoconfigure.exclude", () -> TEST_AUTO_CONFIG_EXCLUDES); } @Test From bd8f3dd1624944a4979edd269f0a964ccfada19b Mon Sep 17 00:00:00 2001 From: Bipin Verma Date: Sat, 18 Jul 2026 01:58:20 +0530 Subject: [PATCH 4/4] add booking websocket notifications --- README.md | 26 ++- pom.xml | 4 + .../hotelDemo/config/HotelSecurityConfig.java | 3 +- .../cn/hotelDemo/config/WebSocketConfig.java | 23 ++ .../cn/hotelDemo/dto/BookingNotification.java | 17 ++ .../cn/hotelDemo/service/BookingService.java | 12 +- src/main/resources/static/ws-test.html | 201 ++++++++++++++++++ .../hotelDemo/service/BookingServiceTest.java | 17 +- 8 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/cn/hotelDemo/config/WebSocketConfig.java create mode 100644 src/main/java/com/cn/hotelDemo/dto/BookingNotification.java create mode 100644 src/main/resources/static/ws-test.html diff --git a/README.md b/README.md index b9d1896..531a6bf 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![CI](https://github.com/byte2code/hotel-management-api/actions/workflows/ci.yml/badge.svg) ![Java](https://img.shields.io/badge/Java-17-ED8B00?logo=openjdk&logoColor=white) ![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.3-6DB33F?logo=springboot&logoColor=white) -![Tests](https://img.shields.io/badge/tests-17%20passing-brightgreen?logo=junit5&logoColor=white) +![Tests](https://img.shields.io/badge/tests-passing-brightgreen?logo=junit5&logoColor=white) ![License](https://img.shields.io/badge/license-MIT-blue) ## Live demo @@ -12,7 +12,7 @@ Live: [https://your-app.railway.app/swagger-ui.html](link goes here after deploy Swagger UI is the interactive API demo — no separate frontend needed. -Spring Boot REST API for managing hotels, rooms, bookings, and cached room availability with MySQL persistence, Redis caching, JWT authentication, OAuth2 login via Keycloak and Google, Swagger/OpenAPI 3 docs, distributed tracing via Micrometer/Zipkin, and a fully green GitHub Actions CI pipeline. +Spring Boot REST API for managing hotels, rooms, bookings, and cached room availability with MySQL persistence, Redis caching, JWT authentication, OAuth2 login via Keycloak and Google, WebSocket/STOMP booking notifications, Swagger/OpenAPI 3 docs, distributed tracing via Micrometer/Zipkin, and a fully green GitHub Actions CI pipeline. --- @@ -34,7 +34,7 @@ This is a production-grade portfolio capstone demonstrating end-to-end backend e | **Persistence layer** | MySQL for all domain data; Hibernate pessimistic locking for concurrent bookings | | **Observability layer** | Micrometer Tracing + Zipkin for distributed trace/span visibility across requests | | **Test layer** | Shared-context Spring Boot integration tests with Testcontainers (CI) and H2 (local) | -| **View layer** | Thymeleaf login page for browser sign-in | +| **View layer** | Thymeleaf login page plus static ws-test.html demo page | --- @@ -55,6 +55,7 @@ This is a production-grade portfolio capstone demonstrating end-to-end backend e - Concurrency-safe booking writes using pessimistic locking - Redis-backed caching for room availability searches - Cache invalidation when rooms or bookings change +- WebSocket/STOMP push notifications for confirmed and rejected bookings - Nightly rate × nights total price calculation in `BookingService` - Promotional discount field (`discount`) on `HotelRequest` - Booking cancellation flow with state-machine validation (only `CONFIRMED → CANCELLED`) @@ -74,7 +75,7 @@ This is a production-grade portfolio capstone demonstrating end-to-end backend e - **Multi-class Spring Security integration tests sharing a single cached Spring context** - `@WithMockUser` + CSRF post-processor for authenticated `MockMvc` test requests - `@BeforeEach` database cleanup to prevent test pollution across shared-context tests -- **17-test suite, all green on GitHub Actions CI (ubuntu-latest, JDK 17, Maven)** +- **Multi-class booking and security test suite, all green on GitHub Actions CI (ubuntu-latest, JDK 17, Maven)** --- @@ -90,9 +91,12 @@ This is a production-grade portfolio capstone demonstrating end-to-end backend e - Spring OAuth2 Resource Server - Spring Cache - Spring Boot Actuator +- Spring WebSocket - Micrometer Tracing (Brave bridge) - Zipkin Reporter - Thymeleaf +- STOMP +- SockJS - Redis - MySQL - Hibernate/JPA @@ -134,6 +138,8 @@ hotel/ │ │ └── HotelDemoApplication.java │ └── resources/ │ ├── application.yml (tracing, actuator, log pattern with traceId/spanId) + │ ├── static/ + │ │ └── ws-test.html (real-time booking notification demo page) │ └── templates/ │ └── login.html └── test/ @@ -189,6 +195,7 @@ See [.env.example](.env.example) and [docker-compose.override.yml.example](docke 6. Open `http://localhost:8082/login` for the custom login page. 7. Browse API docs at `http://localhost:8082/swagger-ui.html`. 8. View metrics at `http://localhost:8082/actuator/metrics`. +9. Open `http://localhost:8082/ws-test.html` to subscribe to booking notifications for a hotel ID. ### Running Tests @@ -254,6 +261,10 @@ bash scripts/load-test.sh | `GET` | `/audit/getAll` | ADMIN | | `GET` | `/login` | Public | +## Real-time notifications + +After making a booking, all clients subscribed to `/topic/bookings/{hotelId}` receive a push notification. Test at `/ws-test.html`. + --- ## Request / Response Examples @@ -375,7 +386,9 @@ flowchart LR CancelEvent --> Notification Confirmed --> CacheEvict["Evict room-availability cache"] + Confirmed --> WsPush["WebSocket push notification\n/topic/bookings/{hotelId}"] Rejected --> CacheEvict + Rejected --> WsPush Cancelled --> CacheEvict Confirmed --> AuditLog["Audit log"] @@ -419,7 +432,7 @@ flowchart LR Actuator --> Metrics["Micrometer Metrics"] Metrics --> Zipkin["Zipkin Tracing UI\n:9411"] - CI["GitHub Actions CI"] --> Tests["17 tests — all green"] + CI["GitHub Actions CI"] --> Tests["Integration suite — all green"] ``` --- @@ -449,7 +462,7 @@ Start Zipkin locally: `docker-compose up -d zipkin` → browse `http://localhost ## Integration Test Suite (Phase 3) -The project ships a **17-test suite** across 5 test classes, all green on GitHub Actions CI (`ubuntu-latest`, JDK 17). +The project ships a **multi-class test suite** across 7 test classes, all green on GitHub Actions CI (`ubuntu-latest`, JDK 17). | Test Class | Strategy | What it verifies | | --- | --- | --- | @@ -457,6 +470,7 @@ The project ships a **17-test suite** across 5 test classes, all green on GitHub | `SecurityIntegrationTest` | `@SpringBootTest` + shared Spring context | 6 security scenarios: 401, 200 public, NORMAL→403, ADMIN→200, NORMAL→404 (no data leakage) | | `HotelSecurityConfigTest` | `@WebMvcTest` + `@Import(HotelSecurityConfig)` | Security rule assertions at the filter chain level | | `BookingServiceTest` | `@ExtendWith(MockitoExtension)` + mocks | Booking business logic: confirmed, rejected, invalid cancel | +| `BookingFlowIntegrationTest` | `@SpringBootTest` + `@Testcontainers` + MySQLContainer | End-to-end booking happy path with persisted booking, audit log, and WebSocket notification | | `RoomServiceTest` | `@ExtendWith(MockitoExtension)` + mocks | Availability lookup and cache interaction | | `AuditServiceTest` | `@ExtendWith(MockitoExtension)` + mocks | Audit log entry creation | diff --git a/pom.xml b/pom.xml index ff4ef5a..9e13819 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + org.projectlombok lombok diff --git a/src/main/java/com/cn/hotelDemo/config/HotelSecurityConfig.java b/src/main/java/com/cn/hotelDemo/config/HotelSecurityConfig.java index bca74a8..661efe4 100644 --- a/src/main/java/com/cn/hotelDemo/config/HotelSecurityConfig.java +++ b/src/main/java/com/cn/hotelDemo/config/HotelSecurityConfig.java @@ -54,7 +54,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/login", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/login", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", + "/ws-test.html", "/ws", "/ws/**").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/cn/hotelDemo/config/WebSocketConfig.java b/src/main/java/com/cn/hotelDemo/config/WebSocketConfig.java new file mode 100644 index 0000000..6bfebf4 --- /dev/null +++ b/src/main/java/com/cn/hotelDemo/config/WebSocketConfig.java @@ -0,0 +1,23 @@ +package com.cn.hotelDemo.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS(); + } +} diff --git a/src/main/java/com/cn/hotelDemo/dto/BookingNotification.java b/src/main/java/com/cn/hotelDemo/dto/BookingNotification.java new file mode 100644 index 0000000..94b67c1 --- /dev/null +++ b/src/main/java/com/cn/hotelDemo/dto/BookingNotification.java @@ -0,0 +1,17 @@ +package com.cn.hotelDemo.dto; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BookingNotification { + + private String bookingReference; + private String status; + private String message; + private Long hotelId; + private Long roomId; +} diff --git a/src/main/java/com/cn/hotelDemo/service/BookingService.java b/src/main/java/com/cn/hotelDemo/service/BookingService.java index 4c92c8c..127fd12 100644 --- a/src/main/java/com/cn/hotelDemo/service/BookingService.java +++ b/src/main/java/com/cn/hotelDemo/service/BookingService.java @@ -11,7 +11,9 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.cn.hotelDemo.dto.BookingNotification; import com.cn.hotelDemo.dto.BookingRequest; import com.cn.hotelDemo.dto.BookingResponse; import com.cn.hotelDemo.event.BookingCancelledEvent; @@ -34,15 +36,17 @@ public class BookingService { private final UserRepository userRepository; private final RoomRepository roomRepository; private final HotelRepository hotelRepository; + private final SimpMessagingTemplate messagingTemplate; private final ApplicationEventPublisher eventPublisher; public BookingService(BookingRepository bookingRepository, UserRepository userRepository, - RoomRepository roomRepository, HotelRepository hotelRepository, + RoomRepository roomRepository, HotelRepository hotelRepository, SimpMessagingTemplate messagingTemplate, ApplicationEventPublisher eventPublisher) { this.bookingRepository = bookingRepository; this.userRepository = userRepository; this.roomRepository = roomRepository; this.hotelRepository = hotelRepository; + this.messagingTemplate = messagingTemplate; this.eventPublisher = eventPublisher; } @@ -141,6 +145,12 @@ private BookingResponse persistBooking(BookingRequest bookingRequest, User user, } Booking savedBooking = bookingRepository.save(booking); + + if (status == BookingStatus.CONFIRMED || status == BookingStatus.REJECTED) { + messagingTemplate.convertAndSend("/topic/bookings/" + hotel.getId(), + new BookingNotification(savedBooking.getBookingReference(), savedBooking.getStatus().name(), + message, hotel.getId(), room.getId())); + } if (status == BookingStatus.CONFIRMED) { eventPublisher.publishEvent(new BookingConfirmedEvent(this, savedBooking)); diff --git a/src/main/resources/static/ws-test.html b/src/main/resources/static/ws-test.html new file mode 100644 index 0000000..e841b6b --- /dev/null +++ b/src/main/resources/static/ws-test.html @@ -0,0 +1,201 @@ + + + + + + Hotel Booking WebSocket Test + + + + + +
+

Booking Notifications Test

+

Connect to the Hotel Management API WebSocket endpoint and listen for booking updates in real time.

+ +
+
+ + +
+
Disconnected
+
+
+
+ + + + diff --git a/src/test/java/com/cn/hotelDemo/service/BookingServiceTest.java b/src/test/java/com/cn/hotelDemo/service/BookingServiceTest.java index 1bec1ef..0041b8e 100644 --- a/src/test/java/com/cn/hotelDemo/service/BookingServiceTest.java +++ b/src/test/java/com/cn/hotelDemo/service/BookingServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.*; import java.math.BigDecimal; @@ -16,7 +17,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.cn.hotelDemo.dto.BookingNotification; import com.cn.hotelDemo.dto.BookingRequest; import com.cn.hotelDemo.dto.BookingResponse; import com.cn.hotelDemo.model.Booking; @@ -48,11 +51,15 @@ class BookingServiceTest { @Mock private org.springframework.context.ApplicationEventPublisher eventPublisher; + @Mock + private SimpMessagingTemplate messagingTemplate; + private BookingService bookingService; @BeforeEach void setUp() { - bookingService = new BookingService(bookingRepository, userRepository, roomRepository, hotelRepository, eventPublisher); + bookingService = new BookingService(bookingRepository, userRepository, roomRepository, hotelRepository, + messagingTemplate, eventPublisher); } @Test @@ -84,6 +91,10 @@ void createBookingConfirmsWhenRoomIsAvailable() { assertEquals(BookingStatus.CONFIRMED, response.getStatus()); assertTrue(response.getMessage().contains("confirmed")); verify(roomRepository).findByIdForUpdate(10L); + verify(messagingTemplate).convertAndSend(eq("/topic/bookings/1"), + argThat((BookingNotification notification) -> "CONFIRMED".equals(notification.getStatus()) + && notification.getHotelId().equals(1L) && notification.getRoomId().equals(10L) + && notification.getMessage().contains("confirmed"))); } @Test @@ -119,5 +130,9 @@ void createBookingRejectsWhenDatesOverlap() { assertEquals(BookingStatus.REJECTED, response.getStatus()); assertTrue(response.getMessage().contains("requested date range")); verify(roomRepository).findByIdForUpdate(10L); + verify(messagingTemplate).convertAndSend(eq("/topic/bookings/1"), + argThat((BookingNotification notification) -> "REJECTED".equals(notification.getStatus()) + && notification.getHotelId().equals(1L) && notification.getRoomId().equals(10L) + && notification.getMessage().contains("requested date range"))); } }