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.dto.*;
|
||||||
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
|
@ -24,29 +27,47 @@ public class SupplementController {
|
||||||
private final SupplementService supplementService;
|
private final SupplementService supplementService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List supplements with pagination and optional filtering")
|
@Operation(summary = "List supplements with pagination, filtering, and sorting")
|
||||||
public PageResponse<SupplementResponse> list(
|
public PageResponse<SupplementResponse> list(
|
||||||
@Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
|
@Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
|
||||||
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size,
|
@Parameter(description = "Page size") @RequestParam(defaultValue = "20") int size,
|
||||||
@Parameter(description = "Filter by category") @RequestParam(required = false) String category,
|
@Parameter(description = "Filter by category (comma-separated for multiple)") @RequestParam(required = false) String category,
|
||||||
@Parameter(description = "Filter by brand") @RequestParam(required = false) String brand,
|
@Parameter(description = "Filter by brand (comma-separated for multiple)") @RequestParam(required = false) String brand,
|
||||||
@Parameter(description = "Sort field") @RequestParam(defaultValue = "name") String sortBy,
|
@Parameter(description = "Filter by form (comma-separated for multiple)") @RequestParam(required = false) String form,
|
||||||
@Parameter(description = "Sort direction") @RequestParam(defaultValue = "asc") String sortDir) {
|
@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")
|
String sortField = mapSortField(sort);
|
||||||
? Sort.by(sortBy).descending()
|
Sort sorting = direction.equalsIgnoreCase("desc")
|
||||||
: Sort.by(sortBy).ascending();
|
? Sort.by(sortField).descending()
|
||||||
PageRequest pageable = PageRequest.of(page, size, sort);
|
: Sort.by(sortField).ascending();
|
||||||
|
PageRequest pageable = PageRequest.of(page, size, sorting);
|
||||||
|
|
||||||
if (category != null) {
|
List<String> categories = parseCommaSeparated(category);
|
||||||
return supplementService.findByCategory(category, pageable);
|
List<String> brands = parseCommaSeparated(brand);
|
||||||
}
|
List<String> forms = parseCommaSeparated(form);
|
||||||
if (brand != null) {
|
List<String> ingredients = parseCommaSeparated(ingredient);
|
||||||
return supplementService.findByBrand(brand, pageable);
|
|
||||||
|
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);
|
return supplementService.findAll(pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/filters")
|
||||||
|
@Operation(summary = "Get available filter values with counts")
|
||||||
|
public FilterValuesResponse getFilterValues() {
|
||||||
|
return supplementService.getFilterValues();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@Operation(summary = "Get supplement by ID")
|
@Operation(summary = "Get supplement by ID")
|
||||||
public SupplementResponse getById(@PathVariable UUID id) {
|
public SupplementResponse getById(@PathVariable UUID id) {
|
||||||
|
|
@ -84,4 +105,24 @@ public class SupplementController {
|
||||||
supplementService.delete(id);
|
supplementService.delete(id);
|
||||||
return ResponseEntity.noContent().build();
|
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.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface SupplementRepository extends JpaRepository<Supplement, UUID> {
|
public interface SupplementRepository extends JpaRepository<Supplement, UUID>, JpaSpecificationExecutor<Supplement> {
|
||||||
|
|
||||||
Page<Supplement> findByCategory(String category, Pageable pageable);
|
Page<Supplement> findByCategory(String category, Pageable pageable);
|
||||||
|
|
||||||
|
|
@ -28,4 +30,42 @@ public interface SupplementRepository extends JpaRepository<Supplement, UUID> {
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
Page<Supplement> fullTextSearch(@Param("query") String query, Pageable pageable);
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import ru.oa2.mvp.nutriapi.dto.*;
|
import ru.oa2.mvp.nutriapi.dto.*;
|
||||||
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
||||||
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
|
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
|
||||||
import ru.oa2.mvp.nutriapi.repository.SupplementRepository;
|
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;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
|
@ -27,6 +31,45 @@ public class SupplementService {
|
||||||
return toPageResponse(page);
|
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)
|
@Transactional(readOnly = true)
|
||||||
public SupplementResponse findById(UUID id) {
|
public SupplementResponse findById(UUID id) {
|
||||||
Supplement supplement = supplementRepository.findById(id)
|
Supplement supplement = supplementRepository.findById(id)
|
||||||
|
|
@ -52,6 +95,37 @@ public class SupplementService {
|
||||||
return toPageResponse(page);
|
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
|
@Transactional
|
||||||
public SupplementResponse create(SupplementCreateRequest request) {
|
public SupplementResponse create(SupplementCreateRequest request) {
|
||||||
Supplement supplement = supplementMapper.toEntity(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.exception.ResourceNotFoundException;
|
||||||
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
import ru.oa2.mvp.nutriapi.service.SupplementService;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -59,6 +60,101 @@ class SupplementControllerTest {
|
||||||
.andExpect(jsonPath("$.totalElements").value(1));
|
.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
|
@Test
|
||||||
void getById_existingSupplement_returnsOk() throws Exception {
|
void getById_existingSupplement_returnsOk() throws Exception {
|
||||||
when(supplementService.findById(testId)).thenReturn(sampleResponse());
|
when(supplementService.findById(testId)).thenReturn(sampleResponse());
|
||||||
|
|
@ -146,17 +242,4 @@ class SupplementControllerTest {
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.content[0].name").value("Vitamin C"));
|
.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.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import ru.oa2.mvp.nutriapi.dto.*;
|
import ru.oa2.mvp.nutriapi.dto.*;
|
||||||
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
import ru.oa2.mvp.nutriapi.entity.Supplement;
|
||||||
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
|
import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException;
|
||||||
|
|
@ -93,6 +94,93 @@ class SupplementServiceTest {
|
||||||
assertThat(result.page()).isZero();
|
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
|
@Test
|
||||||
void create_validRequest_returnsSavedSupplement() {
|
void create_validRequest_returnsSavedSupplement() {
|
||||||
SupplementCreateRequest request = new SupplementCreateRequest(
|
SupplementCreateRequest request = new SupplementCreateRequest(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue