Compare commits

..

1 Commits

Author SHA1 Message Date
Backend Agent 461eacfdbd 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
2026-04-10 19:33:51 +00:00
10 changed files with 537 additions and 28 deletions

View File

@ -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()));
}
}

View File

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

View File

@ -0,0 +1,6 @@
package ru.oa2.mvp.nutriapi.dto;
public record FilterOption(
String value,
long count
) {}

View File

@ -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
) {}

View File

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

View File

@ -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]));
};
}
}

View File

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

View File

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

View File

@ -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());
}
}

View File

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