Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ public ResponseEntity<Page<AccommodationResponse>> getAll(
}

@GetMapping("/search")
public ResponseEntity<List<AccommodationSearchResponse>> search(
public ResponseEntity<Page<AccommodationSearchResponse>> 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));
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size) {
return ResponseEntity.ok(accommodationService.search(location, guests, startDate, endDate, page, size));
}

@GetMapping("/{id}")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.devoops.accommodation.repository;

import com.devoops.accommodation.entity.Accommodation;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.devoops.accommodation.repository.AvailabilityPeriodRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
Expand Down Expand Up @@ -115,14 +116,14 @@ public void delete(UUID id, UserContext userContext) {
}

@Transactional(readOnly = true)
public List<AccommodationSearchResponse> search(String location, int guests, LocalDate startDate, LocalDate endDate) {
public Page<AccommodationSearchResponse> search(String location, int guests, LocalDate startDate, LocalDate endDate, int page, int size) {
if (!endDate.isAfter(startDate)) {
throw new IllegalArgumentException("End date must be after start date");
}

long nights = ChronoUnit.DAYS.between(startDate, endDate);
List<Accommodation> candidates = accommodationRepository.searchByLocationAndGuests(location, guests);
List<AccommodationSearchResponse> results = new ArrayList<>();
List<AccommodationSearchResponse> allResults = new ArrayList<>();

for (Accommodation accommodation : candidates) {
Optional<AvailabilityPeriod> coveringPeriod = availabilityPeriodRepository
Expand All @@ -139,7 +140,7 @@ public List<AccommodationSearchResponse> search(String location, int guests, Loc
totalPrice = unitPrice.multiply(BigDecimal.valueOf(nights));
}

results.add(new AccommodationSearchResponse(
allResults.add(new AccommodationSearchResponse(
accommodation.getId(),
accommodation.getHostId(),
accommodation.getName(),
Expand All @@ -158,7 +159,13 @@ public List<AccommodationSearchResponse> search(String location, int guests, Loc
}
}

return results;
int start = page * size;
int end = Math.min(start + size, allResults.size());
List<AccommodationSearchResponse> pageContent = start >= allResults.size()
? List.of()
: allResults.subList(start, end);

return new PageImpl<>(pageContent, PageRequest.of(page, size), allResults.size());
}

private Accommodation findAccommodationOrThrow(UUID id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

Expand Down Expand Up @@ -65,44 +67,49 @@ private AccommodationSearchResponse createSearchResponse() {
class SearchEndpoint {

@Test
@DisplayName("With valid parameters returns 200 with results")
@DisplayName("With valid parameters returns 200 with paginated results")
void search_WithValidParams_Returns200WithResults() throws Exception {
var startDate = LocalDate.of(2026, 3, 5);
var endDate = LocalDate.of(2026, 3, 10);
var searchResponse = createSearchResponse();

when(accommodationService.search("Belgrade", 2, startDate, endDate))
.thenReturn(List.of(createSearchResponse()));
when(accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12))
.thenReturn(new PageImpl<>(List.of(searchResponse), PageRequest.of(0, 12), 1));

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));
.andExpect(jsonPath("$.content[0].id").value(ACCOMMODATION_ID.toString()))
.andExpect(jsonPath("$.content[0].name").value("Test Apartment"))
.andExpect(jsonPath("$.content[0].totalPrice").value(500.00))
.andExpect(jsonPath("$.content[0].unitPrice").value(50.00))
.andExpect(jsonPath("$.content[0].numberOfNights").value(5))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.number").value(0))
.andExpect(jsonPath("$.last").value(true));
}

@Test
@DisplayName("With no results returns 200 with empty list")
void search_WithNoResults_Returns200WithEmptyList() throws Exception {
@DisplayName("With no results returns 200 with empty page")
void search_WithNoResults_Returns200WithEmptyPage() 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());
when(accommodationService.search("Nowhere", 2, startDate, endDate, 0, 12))
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 12), 0));

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());
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content").isEmpty())
.andExpect(jsonPath("$.totalElements").value(0));
}

@Test
Expand All @@ -111,8 +118,8 @@ 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());
when(accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12))
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 12), 0));

mockMvc.perform(get("/api/accommodation/search")
.param("location", "Belgrade")
Expand Down Expand Up @@ -152,12 +159,12 @@ void search_WithMissingDates_Returns400() throws Exception {
}

@Test
@DisplayName("With invalid date returns IllegalArgumentException")
@DisplayName("With invalid date returns 400")
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))
when(accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12))
.thenThrow(new IllegalArgumentException("End date must be after start date"));

mockMvc.perform(get("/api/accommodation/search")
Expand All @@ -167,5 +174,26 @@ void search_WithInvalidDates_Returns400() throws Exception {
.param("endDate", "2026-03-05"))
.andExpect(status().isBadRequest());
}

@Test
@DisplayName("With custom page and size parameters uses them")
void search_WithCustomPageAndSize_UsesThem() throws Exception {
var startDate = LocalDate.of(2026, 3, 5);
var endDate = LocalDate.of(2026, 3, 10);

when(accommodationService.search("Belgrade", 2, startDate, endDate, 1, 6))
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(1, 6), 0));

mockMvc.perform(get("/api/accommodation/search")
.param("location", "Belgrade")
.param("guests", "2")
.param("startDate", "2026-03-05")
.param("endDate", "2026-03-10")
.param("page", "1")
.param("size", "6"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.number").value(1))
.andExpect(jsonPath("$.size").value(6));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,18 +155,20 @@ void setup_CreatePerUnitAvailabilityPeriod() throws Exception {

@Test
@Order(5)
@DisplayName("Search with matching criteria returns results with prices")
void search_WithMatchingCriteria_ReturnsResultsWithPrices() throws Exception {
@DisplayName("Search with matching criteria returns paginated results with prices")
void search_WithMatchingCriteria_ReturnsPaginatedResultsWithPrices() 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());
.andExpect(jsonPath("$.content", hasSize(2)))
.andExpect(jsonPath("$.content[*].totalPrice").exists())
.andExpect(jsonPath("$.content[*].unitPrice").exists())
.andExpect(jsonPath("$.content[*].numberOfNights").exists())
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.last").value(true));
}

@Test
Expand All @@ -180,11 +182,11 @@ void search_PerGuestAccommodation_CalculatesWithGuestMultiplier() throws Excepti
.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));
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].name").value("Belgrade Apartment"))
.andExpect(jsonPath("$.content[0].unitPrice").value(50.00))
.andExpect(jsonPath("$.content[0].totalPrice").value(500.00))
.andExpect(jsonPath("$.content[0].numberOfNights").value(5));
}

@Test
Expand All @@ -198,50 +200,53 @@ void search_PerUnitAccommodation_CalculatesWithoutGuestMultiplier() throws Excep
.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));
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].name").value("Belgrade Studio"))
.andExpect(jsonPath("$.content[0].unitPrice").value(100.00))
.andExpect(jsonPath("$.content[0].totalPrice").value(500.00))
.andExpect(jsonPath("$.content[0].numberOfNights").value(5));
}

@Test
@Order(8)
@DisplayName("Search with non-matching location returns empty list")
void search_WithNonMatchingLocation_ReturnsEmptyList() throws Exception {
@DisplayName("Search with non-matching location returns empty page")
void search_WithNonMatchingLocation_ReturnsEmptyPage() 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)));
.andExpect(jsonPath("$.content", hasSize(0)))
.andExpect(jsonPath("$.totalElements").value(0));
}

@Test
@Order(9)
@DisplayName("Search with too many guests returns empty list")
void search_WithTooManyGuests_ReturnsEmptyList() throws Exception {
@DisplayName("Search with too many guests returns empty page")
void search_WithTooManyGuests_ReturnsEmptyPage() 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)));
.andExpect(jsonPath("$.content", hasSize(0)))
.andExpect(jsonPath("$.totalElements").value(0));
}

@Test
@Order(10)
@DisplayName("Search with dates outside availability returns empty list")
void search_WithDatesOutsideAvailability_ReturnsEmptyList() throws Exception {
@DisplayName("Search with dates outside availability returns empty page")
void search_WithDatesOutsideAvailability_ReturnsEmptyPage() 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)));
.andExpect(jsonPath("$.content", hasSize(0)))
.andExpect(jsonPath("$.totalElements").value(0));
}

@Test
Expand Down Expand Up @@ -279,21 +284,40 @@ void search_WithInvalidDates_Returns400() throws Exception {

@Test
@Order(14)
@DisplayName("Search returns all accommodation fields")
@DisplayName("Search returns all accommodation fields in paginated response")
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"));
.andExpect(jsonPath("$.content[0].id").value(accommodationId))
.andExpect(jsonPath("$.content[0].hostId").value(HOST_ID.toString()))
.andExpect(jsonPath("$.content[0].name").value("Belgrade Apartment"))
.andExpect(jsonPath("$.content[0].address").value("123 Belgrade Center, Serbia"))
.andExpect(jsonPath("$.content[0].minGuests").value(1))
.andExpect(jsonPath("$.content[0].maxGuests").value(4))
.andExpect(jsonPath("$.content[0].pricingMode").value("PER_GUEST"))
.andExpect(jsonPath("$.content[0].approvalMode").value("MANUAL"));
}

@Test
@Order(15)
@DisplayName("Search with pagination returns correct page")
void search_WithPagination_ReturnsCorrectPage() throws Exception {
mockMvc.perform(get(SEARCH_PATH)
.param("location", "Belgrade")
.param("guests", "2")
.param("startDate", "2026-03-05")
.param("endDate", "2026-03-10")
.param("page", "0")
.param("size", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.totalPages").value(2))
.andExpect(jsonPath("$.last").value(false))
.andExpect(jsonPath("$.number").value(0));
}
}
Loading