feat: add facet filters, filter values endpoint, ingredient search, extended sorting
- Extend GET /api/v1/supplements with multi-value filters: category, brand, form,
ingredient (composition search), country, price_range, min_rating
- Add sort parameter (name, rating, price, created_at, relevance) with direction (asc/desc)
- Add GET /api/v1/supplements/filters returning available filter values with counts
- Add GET /api/v1/ingredients/{name}/supplements for ingredient-based lookup
- Implement JPA Specifications for dynamic query composition
- Add SpringDoc OpenAPI annotations for all new parameters and endpoints
- Add unit tests: 36 tests, 0 failures
This commit is contained in:
parent
506d1ba761
commit
461eacfdbd
|
|
@ -0,0 +1,30 @@
|
|||
package ru.oa2.mvp.nutriapi.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import ru.oa2.mvp.nutriapi.dto.PageResponse;
|
||||
import ru.oa2.mvp.nutriapi.dto.SupplementResponse;
|
||||
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ingredients")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Ingredients", description = "Ingredient-based supplement lookup")
|
||||
public class IngredientController {
|
||||
|
||||
private final SupplementService supplementService;
|
||||
|
||||
@GetMapping("/{name}/supplements")
|
||||
@Operation(summary = "Find supplements containing a specific ingredient")
|
||||
public PageResponse<SupplementResponse> findSupplementsByIngredient(
|
||||
@Parameter(description = "Ingredient name") @PathVariable String name,
|
||||
@Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
|
||||
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size) {
|
||||
return supplementService.findByIngredientName(name, PageRequest.of(page, size, Sort.by("name").ascending()));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ import org.springframework.web.bind.annotation.*;
|
|||
import ru.oa2.mvp.nutriapi.dto.*;
|
||||
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
|
|
@ -24,29 +27,47 @@ public class SupplementController {
|
|||
private final SupplementService supplementService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List supplements with pagination and optional filtering")
|
||||
@Operation(summary = "List supplements with pagination, filtering, and sorting")
|
||||
public PageResponse<SupplementResponse> list(
|
||||
@Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
|
||||
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size,
|
||||
@Parameter(description = "Filter by category") @RequestParam(required = false) String category,
|
||||
@Parameter(description = "Filter by brand") @RequestParam(required = false) String brand,
|
||||
@Parameter(description = "Sort field") @RequestParam(defaultValue = "name") String sortBy,
|
||||
@Parameter(description = "Sort direction") @RequestParam(defaultValue = "asc") String sortDir) {
|
||||
@Parameter(description = "Filter by category (comma-separated for multiple)") @RequestParam(required = false) String category,
|
||||
@Parameter(description = "Filter by brand (comma-separated for multiple)") @RequestParam(required = false) String brand,
|
||||
@Parameter(description = "Filter by form (comma-separated for multiple)") @RequestParam(required = false) String form,
|
||||
@Parameter(description = "Filter by ingredient (comma-separated, all must match)") @RequestParam(required = false) String ingredient,
|
||||
@Parameter(description = "Filter by country") @RequestParam(required = false) String country,
|
||||
@Parameter(description = "Filter by price range") @RequestParam(name = "price_range", required = false) String priceRange,
|
||||
@Parameter(description = "Minimum rating") @RequestParam(name = "min_rating", required = false) BigDecimal minRating,
|
||||
@Parameter(description = "Sort field: relevance, name, rating, price, created_at") @RequestParam(defaultValue = "name") String sort,
|
||||
@Parameter(description = "Sort direction: asc, desc") @RequestParam(defaultValue = "asc") String direction) {
|
||||
|
||||
Sort sort = sortDir.equalsIgnoreCase("desc")
|
||||
? Sort.by(sortBy).descending()
|
||||
: Sort.by(sortBy).ascending();
|
||||
PageRequest pageable = PageRequest.of(page, size, sort);
|
||||
String sortField = mapSortField(sort);
|
||||
Sort sorting = direction.equalsIgnoreCase("desc")
|
||||
? Sort.by(sortField).descending()
|
||||
: Sort.by(sortField).ascending();
|
||||
PageRequest pageable = PageRequest.of(page, size, sorting);
|
||||
|
||||
if (category != null) {
|
||||
return supplementService.findByCategory(category, pageable);
|
||||
}
|
||||
if (brand != null) {
|
||||
return supplementService.findByBrand(brand, pageable);
|
||||
List<String> categories = parseCommaSeparated(category);
|
||||
List<String> brands = parseCommaSeparated(brand);
|
||||
List<String> forms = parseCommaSeparated(form);
|
||||
List<String> ingredients = parseCommaSeparated(ingredient);
|
||||
|
||||
boolean hasFilters = categories != null || brands != null || forms != null
|
||||
|| ingredients != null || country != null || priceRange != null || minRating != null;
|
||||
|
||||
if (hasFilters) {
|
||||
return supplementService.findFiltered(categories, brands, forms, ingredients,
|
||||
country, priceRange, minRating, pageable);
|
||||
}
|
||||
return supplementService.findAll(pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/filters")
|
||||
@Operation(summary = "Get available filter values with counts")
|
||||
public FilterValuesResponse getFilterValues() {
|
||||
return supplementService.getFilterValues();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get supplement by ID")
|
||||
public SupplementResponse getById(@PathVariable UUID id) {
|
||||
|
|
@ -84,4 +105,24 @@ public class SupplementController {
|
|||
supplementService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private List<String> parseCommaSeparated(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Arrays.stream(value.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String mapSortField(String sort) {
|
||||
return switch (sort.toLowerCase()) {
|
||||
case "rating" -> "rating";
|
||||
case "price" -> "priceRange";
|
||||
case "created_at" -> "createdAt";
|
||||
case "relevance" -> "name";
|
||||
default -> "name";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package ru.oa2.mvp.nutriapi.dto;
|
||||
|
||||
public record FilterOption(
|
||||
String value,
|
||||
long count
|
||||
) {}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package ru.oa2.mvp.nutriapi.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record FilterValuesResponse(
|
||||
List<FilterOption> categories,
|
||||
List<FilterOption> brands,
|
||||
List<FilterOption> forms,
|
||||
List<FilterOption> countries,
|
||||
List<FilterOption> priceRanges
|
||||
) {}
|
||||
|
|
@ -3,15 +3,17 @@ package ru.oa2.mvp.nutriapi.repository;
|
|||
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.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface SupplementRepository extends JpaRepository<Supplement, UUID> {
|
||||
public interface SupplementRepository extends JpaRepository<Supplement, UUID>, JpaSpecificationExecutor<Supplement> {
|
||||
|
||||
Page<Supplement> findByCategory(String category, Pageable pageable);
|
||||
|
||||
|
|
@ -28,4 +30,42 @@ public interface SupplementRepository extends JpaRepository<Supplement, UUID> {
|
|||
""",
|
||||
nativeQuery = true)
|
||||
Page<Supplement> fullTextSearch(@Param("query") String query, Pageable pageable);
|
||||
|
||||
@Query(value = """
|
||||
SELECT s.* FROM supplements s
|
||||
JOIN ingredients i ON i.supplement_id = s.id
|
||||
WHERE LOWER(i.name) = LOWER(:ingredientName)
|
||||
""",
|
||||
countQuery = """
|
||||
SELECT count(DISTINCT s.id) FROM supplements s
|
||||
JOIN ingredients i ON i.supplement_id = s.id
|
||||
WHERE LOWER(i.name) = LOWER(:ingredientName)
|
||||
""",
|
||||
nativeQuery = true)
|
||||
Page<Supplement> findByIngredientName(@Param("ingredientName") String ingredientName, Pageable pageable);
|
||||
|
||||
@Query("SELECT DISTINCT s.category FROM Supplement s WHERE s.category IS NOT NULL ORDER BY s.category")
|
||||
List<String> findDistinctCategories();
|
||||
|
||||
@Query("SELECT DISTINCT s.brand FROM Supplement s WHERE s.brand IS NOT NULL ORDER BY s.brand")
|
||||
List<String> findDistinctBrands();
|
||||
|
||||
@Query("SELECT DISTINCT s.form FROM Supplement s WHERE s.form IS NOT NULL ORDER BY s.form")
|
||||
List<String> findDistinctForms();
|
||||
|
||||
@Query("SELECT DISTINCT s.country FROM Supplement s WHERE s.country IS NOT NULL ORDER BY s.country")
|
||||
List<String> findDistinctCountries();
|
||||
|
||||
@Query("SELECT DISTINCT s.priceRange FROM Supplement s WHERE s.priceRange IS NOT NULL ORDER BY s.priceRange")
|
||||
List<String> findDistinctPriceRanges();
|
||||
|
||||
long countByCategory(String category);
|
||||
|
||||
long countByBrand(String brand);
|
||||
|
||||
long countByForm(String form);
|
||||
|
||||
long countByCountry(String country);
|
||||
|
||||
long countByPriceRange(String priceRange);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package ru.oa2.mvp.nutriapi.repository;
|
||||
|
||||
import jakarta.persistence.criteria.*;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import ru.oa2.mvp.nutriapi.entity.Ingredient;
|
||||
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public final class SupplementSpecification {
|
||||
|
||||
private SupplementSpecification() {
|
||||
}
|
||||
|
||||
public static Specification<Supplement> categoryIn(List<String> categories) {
|
||||
return (root, query, cb) -> root.get("category").in(categories);
|
||||
}
|
||||
|
||||
public static Specification<Supplement> brandIn(List<String> brands) {
|
||||
return (root, query, cb) -> root.get("brand").in(brands);
|
||||
}
|
||||
|
||||
public static Specification<Supplement> formIn(List<String> forms) {
|
||||
return (root, query, cb) -> root.get("form").in(forms);
|
||||
}
|
||||
|
||||
public static Specification<Supplement> countryEquals(String country) {
|
||||
return (root, query, cb) -> cb.equal(cb.lower(root.get("country")), country.toLowerCase());
|
||||
}
|
||||
|
||||
public static Specification<Supplement> priceRangeEquals(String priceRange) {
|
||||
return (root, query, cb) -> cb.equal(root.get("priceRange"), priceRange);
|
||||
}
|
||||
|
||||
public static Specification<Supplement> ratingGreaterThanOrEqual(BigDecimal minRating) {
|
||||
return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("rating"), minRating);
|
||||
}
|
||||
|
||||
public static Specification<Supplement> containsIngredients(List<String> ingredientNames) {
|
||||
return (root, query, cb) -> {
|
||||
query.distinct(true);
|
||||
List<Predicate> predicates = ingredientNames.stream()
|
||||
.map(name -> {
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<Ingredient> ingredientRoot = subquery.from(Ingredient.class);
|
||||
subquery.select(cb.literal(1L));
|
||||
subquery.where(
|
||||
cb.equal(ingredientRoot.get("supplement").get("id"), root.get("id")),
|
||||
cb.like(cb.lower(ingredientRoot.get("name")), "%" + name.toLowerCase().trim() + "%")
|
||||
);
|
||||
return cb.exists(subquery);
|
||||
})
|
||||
.toList();
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +4,17 @@ import lombok.RequiredArgsConstructor;
|
|||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import ru.oa2.mvp.nutriapi.dto.*;
|
||||
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
||||
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
|
||||
import ru.oa2.mvp.nutriapi.repository.SupplementRepository;
|
||||
import ru.oa2.mvp.nutriapi.repository.SupplementSpecification;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
|
|
@ -27,6 +31,45 @@ public class SupplementService {
|
|||
return toPageResponse(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SupplementResponse> findFiltered(
|
||||
List<String> categories,
|
||||
List<String> brands,
|
||||
List<String> forms,
|
||||
List<String> ingredientNames,
|
||||
String country,
|
||||
String priceRange,
|
||||
BigDecimal minRating,
|
||||
Pageable pageable) {
|
||||
|
||||
Specification<Supplement> spec = Specification.where(null);
|
||||
|
||||
if (categories != null && !categories.isEmpty()) {
|
||||
spec = spec.and(SupplementSpecification.categoryIn(categories));
|
||||
}
|
||||
if (brands != null && !brands.isEmpty()) {
|
||||
spec = spec.and(SupplementSpecification.brandIn(brands));
|
||||
}
|
||||
if (forms != null && !forms.isEmpty()) {
|
||||
spec = spec.and(SupplementSpecification.formIn(forms));
|
||||
}
|
||||
if (country != null && !country.isBlank()) {
|
||||
spec = spec.and(SupplementSpecification.countryEquals(country));
|
||||
}
|
||||
if (priceRange != null && !priceRange.isBlank()) {
|
||||
spec = spec.and(SupplementSpecification.priceRangeEquals(priceRange));
|
||||
}
|
||||
if (minRating != null) {
|
||||
spec = spec.and(SupplementSpecification.ratingGreaterThanOrEqual(minRating));
|
||||
}
|
||||
if (ingredientNames != null && !ingredientNames.isEmpty()) {
|
||||
spec = spec.and(SupplementSpecification.containsIngredients(ingredientNames));
|
||||
}
|
||||
|
||||
Page<Supplement> page = supplementRepository.findAll(spec, pageable);
|
||||
return toPageResponse(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SupplementResponse findById(UUID id) {
|
||||
Supplement supplement = supplementRepository.findById(id)
|
||||
|
|
@ -52,6 +95,37 @@ public class SupplementService {
|
|||
return toPageResponse(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SupplementResponse> findByIngredientName(String ingredientName, Pageable pageable) {
|
||||
Page<Supplement> page = supplementRepository.findByIngredientName(ingredientName, pageable);
|
||||
return toPageResponse(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public FilterValuesResponse getFilterValues() {
|
||||
List<FilterOption> categories = supplementRepository.findDistinctCategories().stream()
|
||||
.map(c -> new FilterOption(c, supplementRepository.countByCategory(c)))
|
||||
.toList();
|
||||
|
||||
List<FilterOption> brands = supplementRepository.findDistinctBrands().stream()
|
||||
.map(b -> new FilterOption(b, supplementRepository.countByBrand(b)))
|
||||
.toList();
|
||||
|
||||
List<FilterOption> forms = supplementRepository.findDistinctForms().stream()
|
||||
.map(f -> new FilterOption(f, supplementRepository.countByForm(f)))
|
||||
.toList();
|
||||
|
||||
List<FilterOption> countries = supplementRepository.findDistinctCountries().stream()
|
||||
.map(c -> new FilterOption(c, supplementRepository.countByCountry(c)))
|
||||
.toList();
|
||||
|
||||
List<FilterOption> priceRanges = supplementRepository.findDistinctPriceRanges().stream()
|
||||
.map(p -> new FilterOption(p, supplementRepository.countByPriceRange(p)))
|
||||
.toList();
|
||||
|
||||
return new FilterValuesResponse(categories, brands, forms, countries, priceRanges);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SupplementResponse create(SupplementCreateRequest request) {
|
||||
Supplement supplement = supplementMapper.toEntity(request);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
package ru.oa2.mvp.nutriapi.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import ru.oa2.mvp.nutriapi.dto.PageResponse;
|
||||
import ru.oa2.mvp.nutriapi.dto.SupplementResponse;
|
||||
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
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.*;
|
||||
|
||||
@WebMvcTest(IngredientController.class)
|
||||
class IngredientControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private SupplementService supplementService;
|
||||
|
||||
@Test
|
||||
void findSupplementsByIngredient_returnsResults() throws Exception {
|
||||
SupplementResponse response = new SupplementResponse(
|
||||
UUID.randomUUID(), "Multivitamin", "Brand", "Vitamins",
|
||||
"Tablet", "1 tablet", 60, null, null,
|
||||
"USA", null, null, null, "$",
|
||||
Collections.emptyList(), Instant.now(), Instant.now()
|
||||
);
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
List.of(response), 0, 20, 1, 1, true
|
||||
);
|
||||
when(supplementService.findByIngredientName(eq("Vitamin D"), any())).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/ingredients/{name}/supplements", "Vitamin D"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].name").value("Multivitamin"))
|
||||
.andExpect(jsonPath("$.totalElements").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSupplementsByIngredient_emptyResult() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
Collections.emptyList(), 0, 20, 0, 0, true
|
||||
);
|
||||
when(supplementService.findByIngredientName(eq("Unknown"), any())).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/ingredients/{name}/supplements", "Unknown"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isEmpty())
|
||||
.andExpect(jsonPath("$.totalElements").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSupplementsByIngredient_withPagination() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
Collections.emptyList(), 2, 10, 25, 3, false
|
||||
);
|
||||
when(supplementService.findByIngredientName(eq("Vitamin C"), any())).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/ingredients/{name}/supplements", "Vitamin C")
|
||||
.param("page", "2")
|
||||
.param("size", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.page").value(2))
|
||||
.andExpect(jsonPath("$.size").value(10));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import ru.oa2.mvp.nutriapi.dto.*;
|
|||
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
|
||||
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
|
@ -59,6 +60,101 @@ class SupplementControllerTest {
|
|||
.andExpect(jsonPath("$.totalElements").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_withMultipleCategoryFilter_returnsFiltered() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
List.of(sampleResponse()), 0, 20, 1, 1, true
|
||||
);
|
||||
when(supplementService.findFiltered(
|
||||
eq(List.of("Vitamins", "Minerals")), any(), any(), any(), any(), any(), any(), any()
|
||||
)).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/supplements")
|
||||
.param("category", "Vitamins,Minerals"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_withBrandFilter_returnsFiltered() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
List.of(sampleResponse()), 0, 20, 1, 1, true
|
||||
);
|
||||
when(supplementService.findFiltered(
|
||||
any(), eq(List.of("TestBrand")), any(), any(), any(), any(), any(), any()
|
||||
)).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/supplements")
|
||||
.param("brand", "TestBrand"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].brand").value("TestBrand"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_withIngredientFilter_returnsFiltered() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
List.of(sampleResponse()), 0, 20, 1, 1, true
|
||||
);
|
||||
when(supplementService.findFiltered(
|
||||
any(), any(), any(), eq(List.of("Vitamin D", "Magnesium")), any(), any(), any(), any()
|
||||
)).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/supplements")
|
||||
.param("ingredient", "Vitamin D,Magnesium"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_withMinRatingFilter_returnsFiltered() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
List.of(sampleResponse()), 0, 20, 1, 1, true
|
||||
);
|
||||
when(supplementService.findFiltered(
|
||||
any(), any(), any(), any(), any(), any(), eq(new BigDecimal("4.0")), any()
|
||||
)).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/supplements")
|
||||
.param("min_rating", "4.0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_withSortByRatingDesc_returnsOrdered() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
List.of(sampleResponse()), 0, 20, 1, 1, true
|
||||
);
|
||||
when(supplementService.findAll(any())).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/supplements")
|
||||
.param("sort", "rating")
|
||||
.param("direction", "desc"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterValues_returnsFilterOptions() throws Exception {
|
||||
FilterValuesResponse filters = new FilterValuesResponse(
|
||||
List.of(new FilterOption("Vitamins", 120), new FilterOption("Minerals", 45)),
|
||||
List.of(new FilterOption("TestBrand", 30)),
|
||||
List.of(new FilterOption("Tablet", 50)),
|
||||
List.of(new FilterOption("USA", 80)),
|
||||
List.of(new FilterOption("$", 60), new FilterOption("$$", 40))
|
||||
);
|
||||
when(supplementService.getFilterValues()).thenReturn(filters);
|
||||
|
||||
mockMvc.perform(get("/api/v1/supplements/filters"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.categories[0].value").value("Vitamins"))
|
||||
.andExpect(jsonPath("$.categories[0].count").value(120))
|
||||
.andExpect(jsonPath("$.brands[0].value").value("TestBrand"))
|
||||
.andExpect(jsonPath("$.forms[0].value").value("Tablet"))
|
||||
.andExpect(jsonPath("$.countries[0].value").value("USA"))
|
||||
.andExpect(jsonPath("$.priceRanges").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_existingSupplement_returnsOk() throws Exception {
|
||||
when(supplementService.findById(testId)).thenReturn(sampleResponse());
|
||||
|
|
@ -146,17 +242,4 @@ class SupplementControllerTest {
|
|||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].name").value("Vitamin C"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_withCategoryFilter_returnsFiltered() throws Exception {
|
||||
PageResponse<SupplementResponse> page = new PageResponse<>(
|
||||
List.of(sampleResponse()), 0, 20, 1, 1, true
|
||||
);
|
||||
when(supplementService.findByCategory(eq("Vitamins"), any())).thenReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/v1/supplements")
|
||||
.param("category", "Vitamins"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import ru.oa2.mvp.nutriapi.dto.*;
|
||||
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
||||
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
|
||||
|
|
@ -93,6 +94,93 @@ class SupplementServiceTest {
|
|||
assertThat(result.page()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void findFiltered_withCategoryAndBrand_returnsFilteredResults() {
|
||||
PageRequest pageable = PageRequest.of(0, 20);
|
||||
Page<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
|
||||
when(supplementRepository.findAll(any(Specification.class), eq(pageable))).thenReturn(page);
|
||||
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
|
||||
|
||||
PageResponse<SupplementResponse> result = supplementService.findFiltered(
|
||||
List.of("Vitamins"), List.of("TestBrand"), null, null,
|
||||
null, null, null, pageable);
|
||||
|
||||
assertThat(result.content()).hasSize(1);
|
||||
verify(supplementRepository).findAll(any(Specification.class), eq(pageable));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void findFiltered_withAllFilters_returnsResults() {
|
||||
PageRequest pageable = PageRequest.of(0, 20);
|
||||
Page<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
|
||||
when(supplementRepository.findAll(any(Specification.class), eq(pageable))).thenReturn(page);
|
||||
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
|
||||
|
||||
PageResponse<SupplementResponse> result = supplementService.findFiltered(
|
||||
List.of("Vitamins"), List.of("TestBrand"), List.of("Tablet"),
|
||||
List.of("Vitamin C"), "USA", "$", new BigDecimal("4.0"), pageable);
|
||||
|
||||
assertThat(result.content()).hasSize(1);
|
||||
verify(supplementRepository).findAll(any(Specification.class), eq(pageable));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void findFiltered_noFilters_returnsAll() {
|
||||
PageRequest pageable = PageRequest.of(0, 20);
|
||||
Page<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
|
||||
when(supplementRepository.findAll(any(Specification.class), eq(pageable))).thenReturn(page);
|
||||
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
|
||||
|
||||
PageResponse<SupplementResponse> result = supplementService.findFiltered(
|
||||
null, null, null, null, null, null, null, pageable);
|
||||
|
||||
assertThat(result.content()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByIngredientName_returnsMatchingSupplements() {
|
||||
PageRequest pageable = PageRequest.of(0, 20);
|
||||
Page<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
|
||||
when(supplementRepository.findByIngredientName("Vitamin C", pageable)).thenReturn(page);
|
||||
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
|
||||
|
||||
PageResponse<SupplementResponse> result = supplementService.findByIngredientName("Vitamin C", pageable);
|
||||
|
||||
assertThat(result.content()).hasSize(1);
|
||||
assertThat(result.content().getFirst().name()).isEqualTo("Vitamin C");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterValues_returnsAllFilterOptions() {
|
||||
when(supplementRepository.findDistinctCategories()).thenReturn(List.of("Vitamins", "Minerals"));
|
||||
when(supplementRepository.findDistinctBrands()).thenReturn(List.of("TestBrand"));
|
||||
when(supplementRepository.findDistinctForms()).thenReturn(List.of("Tablet", "Capsule"));
|
||||
when(supplementRepository.findDistinctCountries()).thenReturn(List.of("USA"));
|
||||
when(supplementRepository.findDistinctPriceRanges()).thenReturn(List.of("$", "$$"));
|
||||
|
||||
when(supplementRepository.countByCategory("Vitamins")).thenReturn(120L);
|
||||
when(supplementRepository.countByCategory("Minerals")).thenReturn(45L);
|
||||
when(supplementRepository.countByBrand("TestBrand")).thenReturn(30L);
|
||||
when(supplementRepository.countByForm("Tablet")).thenReturn(50L);
|
||||
when(supplementRepository.countByForm("Capsule")).thenReturn(40L);
|
||||
when(supplementRepository.countByCountry("USA")).thenReturn(80L);
|
||||
when(supplementRepository.countByPriceRange("$")).thenReturn(60L);
|
||||
when(supplementRepository.countByPriceRange("$$")).thenReturn(40L);
|
||||
|
||||
FilterValuesResponse result = supplementService.getFilterValues();
|
||||
|
||||
assertThat(result.categories()).hasSize(2);
|
||||
assertThat(result.categories().getFirst().value()).isEqualTo("Vitamins");
|
||||
assertThat(result.categories().getFirst().count()).isEqualTo(120);
|
||||
assertThat(result.brands()).hasSize(1);
|
||||
assertThat(result.forms()).hasSize(2);
|
||||
assertThat(result.countries()).hasSize(1);
|
||||
assertThat(result.priceRanges()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_validRequest_returnsSavedSupplement() {
|
||||
SupplementCreateRequest request = new SupplementCreateRequest(
|
||||
|
|
|
|||
Loading…
Reference in New Issue