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 @@ -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;

Expand All @@ -39,6 +42,15 @@ public ResponseEntity<Page<AccommodationResponse>> getAll(
return ResponseEntity.ok(accommodationService.getAll(page, size));
}

@GetMapping("/search")
public ResponseEntity<List<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));
}

@GetMapping("/{id}")
public ResponseEntity<AccommodationResponse> getById(@PathVariable UUID id) {
return ResponseEntity.ok(accommodationService.getById(id));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AmenityType> amenities,
LocalDateTime createdAt,
LocalDateTime updatedAt,
BigDecimal totalPrice,
BigDecimal unitPrice,
int numberOfNights
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@

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;

public interface AccommodationRepository extends JpaRepository<Accommodation, UUID> {

List<Accommodation> 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<Accommodation> searchByLocationAndGuests(
@Param("location") String location,
@Param("guests") int guests);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -105,6 +114,53 @@ public void delete(UUID id, UserContext userContext) {
accommodationRepository.save(accommodation);
}

@Transactional(readOnly = true)
public List<AccommodationSearchResponse> 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<Accommodation> candidates = accommodationRepository.searchByLocationAndGuests(location, guests);
List<AccommodationSearchResponse> results = new ArrayList<>();

for (Accommodation accommodation : candidates) {
Optional<AvailabilityPeriod> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Loading