Skip to content
Closed
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
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand All @@ -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 |

---

Expand All @@ -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`)
Expand All @@ -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)**

---

Expand All @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
```

---
Expand Down Expand Up @@ -449,14 +462,15 @@ 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 |
| --- | --- | --- |
| `HotelIntegrationTest` | `@SpringBootTest` + shared Spring context + `@WithMockUser(ADMIN)` + CSRF | Full DB round-trip: POST hotel → assert saved in H2 |
| `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 |

Expand Down
16 changes: 16 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
Expand Down Expand Up @@ -118,6 +122,11 @@
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
Expand All @@ -138,6 +147,13 @@

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.19.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.keycloak.bom</groupId>
<artifactId>keycloak-adapter-bom</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)

Expand Down
23 changes: 23 additions & 0 deletions src/main/java/com/cn/hotelDemo/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/cn/hotelDemo/dto/BookingNotification.java
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 11 additions & 1 deletion src/main/java/com/cn/hotelDemo/service/BookingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading