diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java index 9e3f900..d4463f3 100644 --- a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java @@ -5,14 +5,17 @@ import com.devoops.accommodation.dto.request.CreateAccommodationRequest; import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; import com.devoops.accommodation.service.AccommodationService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -39,6 +42,15 @@ public ResponseEntity> getAll( return ResponseEntity.ok(accommodationService.getAll(page, size)); } + @GetMapping("/search") + public ResponseEntity> search( + @RequestParam String location, + @RequestParam int guests, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + return ResponseEntity.ok(accommodationService.search(location, guests, startDate, endDate)); + } + @GetMapping("/{id}") public ResponseEntity getById(@PathVariable UUID id) { return ResponseEntity.ok(accommodationService.getById(id)); diff --git a/src/main/java/com/devoops/accommodation/dto/response/AccommodationSearchResponse.java b/src/main/java/com/devoops/accommodation/dto/response/AccommodationSearchResponse.java new file mode 100644 index 0000000..8ffc2b3 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/response/AccommodationSearchResponse.java @@ -0,0 +1,28 @@ +package com.devoops.accommodation.dto.response; + +import com.devoops.accommodation.entity.AmenityType; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record AccommodationSearchResponse( + UUID id, + UUID hostId, + String name, + String address, + int minGuests, + int maxGuests, + PricingMode pricingMode, + ApprovalMode approvalMode, + List amenities, + LocalDateTime createdAt, + LocalDateTime updatedAt, + BigDecimal totalPrice, + BigDecimal unitPrice, + int numberOfNights +) { +} diff --git a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java index 6b7b807..8f5f0fe 100644 --- a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java +++ b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java @@ -2,6 +2,8 @@ import com.devoops.accommodation.entity.Accommodation; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; @@ -9,4 +11,15 @@ public interface AccommodationRepository extends JpaRepository { List findByHostId(UUID hostId); + + @Query(""" + SELECT a FROM Accommodation a + WHERE LOWER(a.address) LIKE LOWER(CONCAT('%', :location, '%')) + AND a.minGuests <= :guests + AND a.maxGuests >= :guests + ORDER BY a.createdAt DESC + """) + List searchByLocationAndGuests( + @Param("location") String location, + @Param("guests") int guests); } diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationService.java b/src/main/java/com/devoops/accommodation/service/AccommodationService.java index 9342117..5f37f92 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationService.java @@ -4,11 +4,15 @@ import com.devoops.accommodation.dto.request.CreateAccommodationRequest; import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.entity.PricingMode; import com.devoops.accommodation.exception.AccommodationNotFoundException; import com.devoops.accommodation.exception.ForbiddenException; import com.devoops.accommodation.mapper.AccommodationMapper; import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -17,8 +21,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; @Service @@ -27,6 +35,7 @@ public class AccommodationService { private final AccommodationRepository accommodationRepository; private final AccommodationMapper accommodationMapper; + private final AvailabilityPeriodRepository availabilityPeriodRepository; @Transactional public AccommodationResponse create(CreateAccommodationRequest request, UserContext userContext) { @@ -105,6 +114,53 @@ public void delete(UUID id, UserContext userContext) { accommodationRepository.save(accommodation); } + @Transactional(readOnly = true) + public List search(String location, int guests, LocalDate startDate, LocalDate endDate) { + if (!endDate.isAfter(startDate)) { + throw new IllegalArgumentException("End date must be after start date"); + } + + long nights = ChronoUnit.DAYS.between(startDate, endDate); + List candidates = accommodationRepository.searchByLocationAndGuests(location, guests); + List results = new ArrayList<>(); + + for (Accommodation accommodation : candidates) { + Optional coveringPeriod = availabilityPeriodRepository + .findCoveringPeriod(accommodation.getId(), startDate, endDate); + + if (coveringPeriod.isPresent()) { + AvailabilityPeriod period = coveringPeriod.get(); + BigDecimal unitPrice = period.getPricePerDay(); + BigDecimal totalPrice; + + if (accommodation.getPricingMode() == PricingMode.PER_GUEST) { + totalPrice = unitPrice.multiply(BigDecimal.valueOf(nights)).multiply(BigDecimal.valueOf(guests)); + } else { + totalPrice = unitPrice.multiply(BigDecimal.valueOf(nights)); + } + + results.add(new AccommodationSearchResponse( + accommodation.getId(), + accommodation.getHostId(), + accommodation.getName(), + accommodation.getAddress(), + accommodation.getMinGuests(), + accommodation.getMaxGuests(), + accommodation.getPricingMode(), + accommodation.getApprovalMode(), + accommodation.getAmenities(), + accommodation.getCreatedAt(), + accommodation.getUpdatedAt(), + totalPrice, + unitPrice, + (int) nights + )); + } + } + + return results; + } + private Accommodation findAccommodationOrThrow(UUID id) { return accommodationRepository.findById(id) .orElseThrow(() -> new AccommodationNotFoundException("Accommodation not found with id: " + id)); diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java new file mode 100644 index 0000000..431ad65 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java @@ -0,0 +1,171 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RoleAuthorizationInterceptor; +import com.devoops.accommodation.config.UserContextResolver; +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.GlobalExceptionHandler; +import com.devoops.accommodation.service.AccommodationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationSearchControllerTest { + + private MockMvc mockMvc; + + @Mock + private AccommodationService accommodationService; + + @InjectMocks + private AccommodationController accommodationController; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(accommodationController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private AccommodationSearchResponse createSearchResponse() { + return new AccommodationSearchResponse( + ACCOMMODATION_ID, HOST_ID, "Test Apartment", "123 Belgrade St", + 1, 4, PricingMode.PER_GUEST, ApprovalMode.MANUAL, + List.of(), LocalDateTime.now(), LocalDateTime.now(), + new BigDecimal("500.00"), new BigDecimal("50.00"), 5 + ); + } + + @Nested + @DisplayName("GET /api/accommodation/search") + class SearchEndpoint { + + @Test + @DisplayName("With valid parameters returns 200 with results") + void search_WithValidParams_Returns200WithResults() throws Exception { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationService.search("Belgrade", 2, startDate, endDate)) + .thenReturn(List.of(createSearchResponse())); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(ACCOMMODATION_ID.toString())) + .andExpect(jsonPath("$[0].name").value("Test Apartment")) + .andExpect(jsonPath("$[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$[0].unitPrice").value(50.00)) + .andExpect(jsonPath("$[0].numberOfNights").value(5)); + } + + @Test + @DisplayName("With no results returns 200 with empty list") + void search_WithNoResults_Returns200WithEmptyList() throws Exception { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationService.search("Nowhere", 2, startDate, endDate)) + .thenReturn(List.of()); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Nowhere") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + @DisplayName("Without auth headers still returns 200 (public endpoint)") + void search_WithoutAuthHeaders_Returns200() throws Exception { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationService.search("Belgrade", 2, startDate, endDate)) + .thenReturn(List.of()); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("With missing location parameter returns 400") + void search_WithMissingLocation_Returns400() throws Exception { + mockMvc.perform(get("/api/accommodation/search") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With missing guests parameter returns 400") + void search_WithMissingGuests_Returns400() throws Exception { + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With missing date parameters returns 400") + void search_WithMissingDates_Returns400() throws Exception { + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With invalid date returns IllegalArgumentException") + void search_WithInvalidDates_Returns400() throws Exception { + var startDate = LocalDate.of(2026, 3, 10); + var endDate = LocalDate.of(2026, 3, 5); + + when(accommodationService.search("Belgrade", 2, startDate, endDate)) + .thenThrow(new IllegalArgumentException("End date must be after start date")); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-10") + .param("endDate", "2026-03-05")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java new file mode 100644 index 0000000..01491dc --- /dev/null +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java @@ -0,0 +1,299 @@ +package com.devoops.accommodation.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AccommodationSearchIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("accommodation_db_test") + .withUsername("test") + .withPassword("test"); + + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String accommodationId; + private static String accommodationIdPerUnit; + private static final UUID HOST_ID = UUID.randomUUID(); + + private static final String BASE_PATH = "/api/accommodation"; + private static final String SEARCH_PATH = BASE_PATH + "/search"; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); + } + + @Test + @Order(1) + @DisplayName("Setup: Create PER_GUEST accommodation in Belgrade") + void setup_CreatePerGuestAccommodation() throws Exception { + var request = Map.of( + "name", "Belgrade Apartment", + "address", "123 Belgrade Center, Serbia", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + MvcResult result = mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(2) + @DisplayName("Setup: Create availability period for PER_GUEST accommodation") + void setup_CreateAvailabilityPeriod() throws Exception { + var request = Map.of( + "startDate", "2026-03-01", + "endDate", "2026-03-31", + "pricePerDay", 50.00 + ); + + mockMvc.perform(post(BASE_PATH + "/" + accommodationId + "/availability") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @Order(3) + @DisplayName("Setup: Create PER_UNIT accommodation in Belgrade") + void setup_CreatePerUnitAccommodation() throws Exception { + var request = Map.of( + "name", "Belgrade Studio", + "address", "456 Belgrade New, Serbia", + "minGuests", 1, + "maxGuests", 2, + "pricingMode", "PER_UNIT", + "approvalMode", "AUTOMATIC" + ); + + MvcResult result = mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationIdPerUnit = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(4) + @DisplayName("Setup: Create availability period for PER_UNIT accommodation") + void setup_CreatePerUnitAvailabilityPeriod() throws Exception { + var request = Map.of( + "startDate", "2026-03-01", + "endDate", "2026-03-31", + "pricePerDay", 100.00 + ); + + mockMvc.perform(post(BASE_PATH + "/" + accommodationIdPerUnit + "/availability") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @Order(5) + @DisplayName("Search with matching criteria returns results with prices") + void search_WithMatchingCriteria_ReturnsResultsWithPrices() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].totalPrice").exists()) + .andExpect(jsonPath("$[*].unitPrice").exists()) + .andExpect(jsonPath("$[*].numberOfNights").exists()); + } + + @Test + @Order(6) + @DisplayName("Search PER_GUEST accommodation calculates price with guest multiplier") + void search_PerGuestAccommodation_CalculatesWithGuestMultiplier() throws Exception { + // 50/day * 5 nights * 2 guests = 500 + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade Center") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Belgrade Apartment")) + .andExpect(jsonPath("$[0].unitPrice").value(50.00)) + .andExpect(jsonPath("$[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$[0].numberOfNights").value(5)); + } + + @Test + @Order(7) + @DisplayName("Search PER_UNIT accommodation calculates price without guest multiplier") + void search_PerUnitAccommodation_CalculatesWithoutGuestMultiplier() throws Exception { + // 100/day * 5 nights = 500 (no guest multiplier) + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade New") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Belgrade Studio")) + .andExpect(jsonPath("$[0].unitPrice").value(100.00)) + .andExpect(jsonPath("$[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$[0].numberOfNights").value(5)); + } + + @Test + @Order(8) + @DisplayName("Search with non-matching location returns empty list") + void search_WithNonMatchingLocation_ReturnsEmptyList() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Paris") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @Order(9) + @DisplayName("Search with too many guests returns empty list") + void search_WithTooManyGuests_ReturnsEmptyList() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "10") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @Order(10) + @DisplayName("Search with dates outside availability returns empty list") + void search_WithDatesOutsideAvailability_ReturnsEmptyList() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-05-01") + .param("endDate", "2026-05-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @Order(11) + @DisplayName("Search without auth headers returns 200 (public endpoint)") + void search_WithoutAuth_Returns200() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()); + } + + @Test + @Order(12) + @DisplayName("Search with missing parameters returns 400") + void search_WithMissingParams_Returns400() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade")) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(13) + @DisplayName("Search with end date before start date returns 400") + void search_WithInvalidDates_Returns400() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-10") + .param("endDate", "2026-03-05")) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(14) + @DisplayName("Search returns all accommodation fields") + void search_ReturnsAllAccommodationFields() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade Center") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(accommodationId)) + .andExpect(jsonPath("$[0].hostId").value(HOST_ID.toString())) + .andExpect(jsonPath("$[0].name").value("Belgrade Apartment")) + .andExpect(jsonPath("$[0].address").value("123 Belgrade Center, Serbia")) + .andExpect(jsonPath("$[0].minGuests").value(1)) + .andExpect(jsonPath("$[0].maxGuests").value(4)) + .andExpect(jsonPath("$[0].pricingMode").value("PER_GUEST")) + .andExpect(jsonPath("$[0].approvalMode").value("MANUAL")); + } +} diff --git a/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java b/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java new file mode 100644 index 0000000..0a83a09 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java @@ -0,0 +1,240 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.mapper.AccommodationMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AccommodationSearchServiceTest { + + @Mock + private AccommodationRepository accommodationRepository; + + @Mock + private AccommodationMapper accommodationMapper; + + @Mock + private AvailabilityPeriodRepository availabilityPeriodRepository; + + @InjectMocks + private AccommodationService accommodationService; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + + private Accommodation createAccommodation(PricingMode pricingMode) { + return Accommodation.builder() + .id(ACCOMMODATION_ID) + .hostId(HOST_ID) + .name("Test Apartment") + .address("123 Belgrade St") + .minGuests(1) + .maxGuests(4) + .pricingMode(pricingMode) + .approvalMode(ApprovalMode.MANUAL) + .build(); + } + + private AvailabilityPeriod createPeriod(BigDecimal pricePerDay) { + return AvailabilityPeriod.builder() + .id(UUID.randomUUID()) + .accommodationId(ACCOMMODATION_ID) + .startDate(LocalDate.of(2026, 3, 1)) + .endDate(LocalDate.of(2026, 3, 31)) + .pricePerDay(pricePerDay) + .build(); + } + + @Nested + @DisplayName("Search") + class SearchTests { + + @Test + @DisplayName("With matching location and guests and covering period returns results") + void search_WithMatchingCriteria_ReturnsResults() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var period = createPeriod(new BigDecimal("50.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results).hasSize(1); + assertThat(results.get(0).id()).isEqualTo(ACCOMMODATION_ID); + assertThat(results.get(0).name()).isEqualTo("Test Apartment"); + } + + @Test + @DisplayName("With PER_GUEST pricing calculates total correctly") + void search_WithPerGuestPricing_CalculatesTotalCorrectly() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var period = createPeriod(new BigDecimal("50.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); // 5 nights + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results.get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("50.00")); + // 50 * 5 nights * 2 guests = 500 + assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); + assertThat(results.get(0).numberOfNights()).isEqualTo(5); + } + + @Test + @DisplayName("With PER_UNIT pricing calculates total without guest multiplier") + void search_WithPerUnitPricing_CalculatesTotalWithoutGuestMultiplier() { + var accommodation = createAccommodation(PricingMode.PER_UNIT); + var period = createPeriod(new BigDecimal("100.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); // 5 nights + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 3)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 3, startDate, endDate); + + assertThat(results.get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("100.00")); + // 100 * 5 nights = 500 (no guest multiplier) + assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); + assertThat(results.get(0).numberOfNights()).isEqualTo(5); + } + + @Test + @DisplayName("With no covering period excludes accommodation from results") + void search_WithNoCoveringPeriod_ExcludesAccommodation() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.empty()); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("With no matching location returns empty list") + void search_WithNoMatchingLocation_ReturnsEmptyList() { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Nowhere", 2)) + .thenReturn(List.of()); + + List results = accommodationService.search("Nowhere", 2, startDate, endDate); + + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("With end date before start date throws IllegalArgumentException") + void search_WithEndDateBeforeStartDate_ThrowsIllegalArgumentException() { + var startDate = LocalDate.of(2026, 3, 10); + var endDate = LocalDate.of(2026, 3, 5); + + assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, startDate, endDate)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With equal start and end date throws IllegalArgumentException") + void search_WithEqualDates_ThrowsIllegalArgumentException() { + var date = LocalDate.of(2026, 3, 10); + + assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, date, date)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With multiple candidates returns only those with availability") + void search_WithMultipleCandidates_ReturnsOnlyAvailable() { + var accommodation1 = createAccommodation(PricingMode.PER_GUEST); + var id2 = UUID.randomUUID(); + var accommodation2 = Accommodation.builder() + .id(id2) + .hostId(HOST_ID) + .name("Second Apartment") + .address("456 Belgrade Ave") + .minGuests(1) + .maxGuests(4) + .pricingMode(PricingMode.PER_UNIT) + .approvalMode(ApprovalMode.AUTOMATIC) + .build(); + var period = createPeriod(new BigDecimal("75.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation1, accommodation2)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + when(availabilityPeriodRepository.findCoveringPeriod(id2, startDate, endDate)) + .thenReturn(Optional.empty()); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results).hasSize(1); + assertThat(results.get(0).id()).isEqualTo(ACCOMMODATION_ID); + } + + @Test + @DisplayName("With single night stay calculates price correctly") + void search_WithSingleNight_CalculatesPriceCorrectly() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var period = createPeriod(new BigDecimal("80.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 6); // 1 night + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 1)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 1, startDate, endDate); + + assertThat(results.get(0).numberOfNights()).isEqualTo(1); + // 80 * 1 night * 1 guest = 80 + assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("80.00")); + } + } +}