From 506d1ba76138bb3d8dd673a4dedb74c28245d1c3 Mon Sep 17 00:00:00 2001 From: Backend Agent Date: Fri, 10 Apr 2026 17:03:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20nutri-api=20=E2=80=94=20Sprin?= =?UTF-8?q?g=20Boot=20REST=20API=20for=20supplement=20catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Supplement and Ingredient entities with UUID primary keys - CRUD REST API with pagination, filtering, and full-text search - Liquibase migrations with PostgreSQL tsvector full-text index - SpringDoc OpenAPI documentation - RFC 7807 Problem Details error handling - Unit tests for service, mapper, and controller layers (23 tests) - Dockerfile (multi-stage) and docker-compose.yaml for local dev - Application profiles: default, dev, prod --- .gitignore | 10 + Dockerfile | 12 ++ docker-compose.yaml | 36 ++++ pom.xml | 131 +++++++++++++ .../oa2/mvp/nutriapi/NutriApiApplication.java | 12 ++ .../mvp/nutriapi/config/OpenApiConfig.java | 19 ++ .../controller/SupplementController.java | 87 +++++++++ .../mvp/nutriapi/dto/IngredientRequest.java | 19 ++ .../mvp/nutriapi/dto/IngredientResponse.java | 12 ++ .../ru/oa2/mvp/nutriapi/dto/PageResponse.java | 12 ++ .../nutriapi/dto/SupplementCreateRequest.java | 49 +++++ .../mvp/nutriapi/dto/SupplementResponse.java | 26 +++ .../nutriapi/dto/SupplementUpdateRequest.java | 47 +++++ .../oa2/mvp/nutriapi/entity/Ingredient.java | 43 +++++ .../oa2/mvp/nutriapi/entity/Supplement.java | 101 ++++++++++ .../exception/GlobalExceptionHandler.java | 67 +++++++ .../exception/ResourceNotFoundException.java | 10 + .../repository/IngredientRepository.java | 14 ++ .../repository/SupplementRepository.java | 31 +++ .../nutriapi/service/SupplementMapper.java | 101 ++++++++++ .../nutriapi/service/SupplementService.java | 92 +++++++++ src/main/resources/application-dev.yaml | 10 + src/main/resources/application-prod.yaml | 10 + src/main/resources/application.yaml | 49 +++++ .../V001_create_supplements_table.yaml | 76 ++++++++ .../V002_create_ingredients_table.yaml | 49 +++++ .../V003_add_fulltext_search_index.yaml | 19 ++ .../db/changelog/db.changelog-master.yaml | 7 + .../nutriapi/NutriApiApplicationTests.java | 31 +++ .../controller/SupplementControllerTest.java | 162 ++++++++++++++++ .../service/SupplementMapperTest.java | 101 ++++++++++ .../service/SupplementServiceTest.java | 180 ++++++++++++++++++ 32 files changed, 1625 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 pom.xml create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/NutriApiApplication.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/config/OpenApiConfig.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/controller/SupplementController.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientRequest.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientResponse.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/dto/PageResponse.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementCreateRequest.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementResponse.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementUpdateRequest.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/entity/Ingredient.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/entity/Supplement.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/exception/ResourceNotFoundException.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/repository/IngredientRepository.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/repository/SupplementRepository.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/service/SupplementMapper.java create mode 100644 src/main/java/ru/oa2/mvp/nutriapi/service/SupplementService.java create mode 100644 src/main/resources/application-dev.yaml create mode 100644 src/main/resources/application-prod.yaml create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/db/changelog/V001_create_supplements_table.yaml create mode 100644 src/main/resources/db/changelog/V002_create_ingredients_table.yaml create mode 100644 src/main/resources/db/changelog/V003_add_fulltext_search_index.yaml create mode 100644 src/main/resources/db/changelog/db.changelog-master.yaml create mode 100644 src/test/java/ru/oa2/mvp/nutriapi/NutriApiApplicationTests.java create mode 100644 src/test/java/ru/oa2/mvp/nutriapi/controller/SupplementControllerTest.java create mode 100644 src/test/java/ru/oa2/mvp/nutriapi/service/SupplementMapperTest.java create mode 100644 src/test/java/ru/oa2/mvp/nutriapi/service/SupplementServiceTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f034ed7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +target/ +*.class +*.jar +*.war +*.iml +.idea/ +.vscode/ +*.log +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0c5d3a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN apk add --no-cache maven && \ + mvn clean package -DskipTests -q + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..436e6d5 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,36 @@ +version: "3.9" + +services: + app: + build: . + ports: + - "8080:8080" + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: nutri + DB_USER: nutri + DB_PASSWORD: nutri + SPRING_PROFILES_ACTIVE: dev + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: nutri + POSTGRES_USER: nutri + POSTGRES_PASSWORD: nutri + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nutri -d nutri"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + pgdata: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f6956ee --- /dev/null +++ b/pom.xml @@ -0,0 +1,131 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + + ru.oa2.mvp + nutri-api + 0.1.0-SNAPSHOT + nutri-api + REST API service for supplement (BAD) catalog + + + 21 + 2.8.5 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.postgresql + postgresql + runtime + + + + + org.liquibase + liquibase-core + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + + + + + org.testcontainers + testcontainers-bom + 1.20.4 + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/ru/oa2/mvp/nutriapi/NutriApiApplication.java b/src/main/java/ru/oa2/mvp/nutriapi/NutriApiApplication.java new file mode 100644 index 0000000..0a6aad5 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/NutriApiApplication.java @@ -0,0 +1,12 @@ +package ru.oa2.mvp.nutriapi; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class NutriApiApplication { + + public static void main(String[] args) { + SpringApplication.run(NutriApiApplication.class, args); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/config/OpenApiConfig.java b/src/main/java/ru/oa2/mvp/nutriapi/config/OpenApiConfig.java new file mode 100644 index 0000000..229e1f6 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/config/OpenApiConfig.java @@ -0,0 +1,19 @@ +package ru.oa2.mvp.nutriapi.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Nutri API") + .version("0.1.0") + .description("REST API for supplement (BAD) catalog — search, browse, and manage supplements")); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/controller/SupplementController.java b/src/main/java/ru/oa2/mvp/nutriapi/controller/SupplementController.java new file mode 100644 index 0000000..9ff2ea4 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/controller/SupplementController.java @@ -0,0 +1,87 @@ +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import ru.oa2.mvp.nutriapi.dto.*; +import ru.oa2.mvp.nutriapi.service.SupplementService; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/supplements") +@RequiredArgsConstructor +@Tag(name = "Supplements", description = "Supplement catalog API") +public class SupplementController { + + private final SupplementService supplementService; + + @GetMapping + @Operation(summary = "List supplements with pagination and optional filtering") + public PageResponse 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) { + + Sort sort = sortDir.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + PageRequest pageable = PageRequest.of(page, size, sort); + + if (category != null) { + return supplementService.findByCategory(category, pageable); + } + if (brand != null) { + return supplementService.findByBrand(brand, pageable); + } + return supplementService.findAll(pageable); + } + + @GetMapping("/{id}") + @Operation(summary = "Get supplement by ID") + public SupplementResponse getById(@PathVariable UUID id) { + return supplementService.findById(id); + } + + @GetMapping("/search") + @Operation(summary = "Full-text search across supplements") + public PageResponse search( + @Parameter(description = "Search query") @RequestParam String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return supplementService.search(q, PageRequest.of(page, size)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a new supplement") + public SupplementResponse create(@Valid @RequestBody SupplementCreateRequest request) { + return supplementService.create(request); + } + + @PutMapping("/{id}") + @Operation(summary = "Update an existing supplement") + public SupplementResponse update( + @PathVariable UUID id, + @Valid @RequestBody SupplementUpdateRequest request) { + return supplementService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete a supplement") + public ResponseEntity delete(@PathVariable UUID id) { + supplementService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientRequest.java b/src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientRequest.java new file mode 100644 index 0000000..0e18fb8 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientRequest.java @@ -0,0 +1,19 @@ +package ru.oa2.mvp.nutriapi.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public record IngredientRequest( + @NotBlank(message = "Ingredient name is required") + @Size(max = 255) + String name, + + BigDecimal amount, + + @Size(max = 50) + String unit, + + Integer dailyValuePercent +) {} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientResponse.java b/src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientResponse.java new file mode 100644 index 0000000..ee48520 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/dto/IngredientResponse.java @@ -0,0 +1,12 @@ +package ru.oa2.mvp.nutriapi.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record IngredientResponse( + UUID id, + String name, + BigDecimal amount, + String unit, + Integer dailyValuePercent +) {} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/dto/PageResponse.java b/src/main/java/ru/oa2/mvp/nutriapi/dto/PageResponse.java new file mode 100644 index 0000000..2b39767 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/dto/PageResponse.java @@ -0,0 +1,12 @@ +package ru.oa2.mvp.nutriapi.dto; + +import java.util.List; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean last +) {} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementCreateRequest.java b/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementCreateRequest.java new file mode 100644 index 0000000..d66f721 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementCreateRequest.java @@ -0,0 +1,49 @@ +package ru.oa2.mvp.nutriapi.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.util.List; + +public record SupplementCreateRequest( + @NotBlank(message = "Name is required") + @Size(max = 500, message = "Name must be at most 500 characters") + String name, + + @Size(max = 255) + String brand, + + @Size(max = 255) + String category, + + @Size(max = 100) + String form, + + @Size(max = 100) + String servingSize, + + Integer servingsPerContainer, + + String description, + + String contraindications, + + @Size(max = 100) + String country, + + @Size(max = 1024) + String imageUrl, + + @Size(max = 1024) + String sourceUrl, + + BigDecimal rating, + + @Size(max = 50) + String priceRange, + + @Valid + List ingredients +) {} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementResponse.java b/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementResponse.java new file mode 100644 index 0000000..8a2e84d --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementResponse.java @@ -0,0 +1,26 @@ +package ru.oa2.mvp.nutriapi.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record SupplementResponse( + UUID id, + String name, + String brand, + String category, + String form, + String servingSize, + Integer servingsPerContainer, + String description, + String contraindications, + String country, + String imageUrl, + String sourceUrl, + BigDecimal rating, + String priceRange, + List ingredients, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementUpdateRequest.java b/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementUpdateRequest.java new file mode 100644 index 0000000..0ab4ba9 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/dto/SupplementUpdateRequest.java @@ -0,0 +1,47 @@ +package ru.oa2.mvp.nutriapi.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.util.List; + +public record SupplementUpdateRequest( + @Size(max = 500, message = "Name must be at most 500 characters") + String name, + + @Size(max = 255) + String brand, + + @Size(max = 255) + String category, + + @Size(max = 100) + String form, + + @Size(max = 100) + String servingSize, + + Integer servingsPerContainer, + + String description, + + String contraindications, + + @Size(max = 100) + String country, + + @Size(max = 1024) + String imageUrl, + + @Size(max = 1024) + String sourceUrl, + + BigDecimal rating, + + @Size(max = 50) + String priceRange, + + @Valid + List ingredients +) {} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/entity/Ingredient.java b/src/main/java/ru/oa2/mvp/nutriapi/entity/Ingredient.java new file mode 100644 index 0000000..9e266f7 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/entity/Ingredient.java @@ -0,0 +1,43 @@ +package ru.oa2.mvp.nutriapi.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.util.UUID; + +@Entity +@Table(name = "ingredients") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Ingredient { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", updatable = false, nullable = false) + private UUID id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "amount", precision = 10, scale = 4) + private BigDecimal amount; + + @Column(name = "unit") + private String unit; + + @Column(name = "daily_value_percent") + private Integer dailyValuePercent; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "supplement_id", nullable = false) + @Setter + private Supplement supplement; + + public void setName(String name) { this.name = name; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public void setUnit(String unit) { this.unit = unit; } + public void setDailyValuePercent(Integer dailyValuePercent) { this.dailyValuePercent = dailyValuePercent; } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/entity/Supplement.java b/src/main/java/ru/oa2/mvp/nutriapi/entity/Supplement.java new file mode 100644 index 0000000..a0470b3 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/entity/Supplement.java @@ -0,0 +1,101 @@ +package ru.oa2.mvp.nutriapi.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "supplements") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Supplement { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", updatable = false, nullable = false) + private UUID id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "brand") + private String brand; + + @Column(name = "category") + private String category; + + @Column(name = "form") + private String form; + + @Column(name = "serving_size") + private String servingSize; + + @Column(name = "servings_per_container") + private Integer servingsPerContainer; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "contraindications", columnDefinition = "TEXT") + private String contraindications; + + @Column(name = "country") + private String country; + + @Column(name = "image_url") + private String imageUrl; + + @Column(name = "source_url") + private String sourceUrl; + + @Column(name = "rating", precision = 3, scale = 2) + private BigDecimal rating; + + @Column(name = "price_range") + private String priceRange; + + @OneToMany(mappedBy = "supplement", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List ingredients = new ArrayList<>(); + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; + + public void setName(String name) { this.name = name; } + public void setBrand(String brand) { this.brand = brand; } + public void setCategory(String category) { this.category = category; } + public void setForm(String form) { this.form = form; } + public void setServingSize(String servingSize) { this.servingSize = servingSize; } + public void setServingsPerContainer(Integer servingsPerContainer) { this.servingsPerContainer = servingsPerContainer; } + public void setDescription(String description) { this.description = description; } + public void setContraindications(String contraindications) { this.contraindications = contraindications; } + public void setCountry(String country) { this.country = country; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + public void setSourceUrl(String sourceUrl) { this.sourceUrl = sourceUrl; } + public void setRating(BigDecimal rating) { this.rating = rating; } + public void setPriceRange(String priceRange) { this.priceRange = priceRange; } + + public void addIngredient(Ingredient ingredient) { + ingredients.add(ingredient); + ingredient.setSupplement(this); + } + + public void removeIngredient(Ingredient ingredient) { + ingredients.remove(ingredient); + ingredient.setSupplement(null); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/exception/GlobalExceptionHandler.java b/src/main/java/ru/oa2/mvp/nutriapi/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..cc2926c --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/exception/GlobalExceptionHandler.java @@ -0,0 +1,67 @@ +package ru.oa2.mvp.nutriapi.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ProblemDetail handleNotFound(ResourceNotFoundException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + problem.setTitle("Resource Not Found"); + problem.setType(URI.create("https://api.nutri.oa2.ru/errors/not-found")); + problem.setProperty("timestamp", Instant.now()); + return problem; + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + Map errors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.toMap( + FieldError::getField, + fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value", + (a, b) -> a + )); + + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, "Validation failed"); + problem.setTitle("Validation Error"); + problem.setType(URI.create("https://api.nutri.oa2.ru/errors/validation")); + problem.setProperty("timestamp", Instant.now()); + problem.setProperty("errors", errors); + return problem; + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleBadRequest(IllegalArgumentException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, ex.getMessage()); + problem.setTitle("Bad Request"); + problem.setType(URI.create("https://api.nutri.oa2.ru/errors/bad-request")); + problem.setProperty("timestamp", Instant.now()); + return problem; + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); + problem.setTitle("Internal Server Error"); + problem.setType(URI.create("https://api.nutri.oa2.ru/errors/internal")); + problem.setProperty("timestamp", Instant.now()); + return problem; + } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/exception/ResourceNotFoundException.java b/src/main/java/ru/oa2/mvp/nutriapi/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..c5525c4 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/exception/ResourceNotFoundException.java @@ -0,0 +1,10 @@ +package ru.oa2.mvp.nutriapi.exception; + +import java.util.UUID; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String resource, UUID id) { + super(resource + " not found with id: " + id); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/repository/IngredientRepository.java b/src/main/java/ru/oa2/mvp/nutriapi/repository/IngredientRepository.java new file mode 100644 index 0000000..e824f00 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/repository/IngredientRepository.java @@ -0,0 +1,14 @@ +package ru.oa2.mvp.nutriapi.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.oa2.mvp.nutriapi.entity.Ingredient; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface IngredientRepository extends JpaRepository { + + List findBySupplementId(UUID supplementId); +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/repository/SupplementRepository.java b/src/main/java/ru/oa2/mvp/nutriapi/repository/SupplementRepository.java new file mode 100644 index 0000000..9ecf791 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/repository/SupplementRepository.java @@ -0,0 +1,31 @@ +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.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import ru.oa2.mvp.nutriapi.entity.Supplement; + +import java.util.UUID; + +@Repository +public interface SupplementRepository extends JpaRepository { + + Page findByCategory(String category, Pageable pageable); + + Page findByBrand(String brand, Pageable pageable); + + @Query(value = """ + SELECT s.* FROM supplements s + WHERE s.search_vector @@ plainto_tsquery('russian', :query) + ORDER BY ts_rank(s.search_vector, plainto_tsquery('russian', :query)) DESC + """, + countQuery = """ + SELECT count(*) FROM supplements s + WHERE s.search_vector @@ plainto_tsquery('russian', :query) + """, + nativeQuery = true) + Page fullTextSearch(@Param("query") String query, Pageable pageable); +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/service/SupplementMapper.java b/src/main/java/ru/oa2/mvp/nutriapi/service/SupplementMapper.java new file mode 100644 index 0000000..a6f57ef --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/service/SupplementMapper.java @@ -0,0 +1,101 @@ +package ru.oa2.mvp.nutriapi.service; + +import org.springframework.stereotype.Component; +import ru.oa2.mvp.nutriapi.dto.*; +import ru.oa2.mvp.nutriapi.entity.Ingredient; +import ru.oa2.mvp.nutriapi.entity.Supplement; + +import java.util.Collections; +import java.util.List; + +@Component +public class SupplementMapper { + + public SupplementResponse toResponse(Supplement entity) { + return new SupplementResponse( + entity.getId(), + entity.getName(), + entity.getBrand(), + entity.getCategory(), + entity.getForm(), + entity.getServingSize(), + entity.getServingsPerContainer(), + entity.getDescription(), + entity.getContraindications(), + entity.getCountry(), + entity.getImageUrl(), + entity.getSourceUrl(), + entity.getRating(), + entity.getPriceRange(), + entity.getIngredients() != null + ? entity.getIngredients().stream().map(this::toIngredientResponse).toList() + : Collections.emptyList(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + public Supplement toEntity(SupplementCreateRequest request) { + Supplement supplement = Supplement.builder() + .name(request.name()) + .brand(request.brand()) + .category(request.category()) + .form(request.form()) + .servingSize(request.servingSize()) + .servingsPerContainer(request.servingsPerContainer()) + .description(request.description()) + .contraindications(request.contraindications()) + .country(request.country()) + .imageUrl(request.imageUrl()) + .sourceUrl(request.sourceUrl()) + .rating(request.rating()) + .priceRange(request.priceRange()) + .build(); + + if (request.ingredients() != null) { + request.ingredients().forEach(ir -> supplement.addIngredient(toIngredientEntity(ir))); + } + + return supplement; + } + + public void updateEntity(Supplement entity, SupplementUpdateRequest request) { + if (request.name() != null) entity.setName(request.name()); + if (request.brand() != null) entity.setBrand(request.brand()); + if (request.category() != null) entity.setCategory(request.category()); + if (request.form() != null) entity.setForm(request.form()); + if (request.servingSize() != null) entity.setServingSize(request.servingSize()); + if (request.servingsPerContainer() != null) entity.setServingsPerContainer(request.servingsPerContainer()); + if (request.description() != null) entity.setDescription(request.description()); + if (request.contraindications() != null) entity.setContraindications(request.contraindications()); + if (request.country() != null) entity.setCountry(request.country()); + if (request.imageUrl() != null) entity.setImageUrl(request.imageUrl()); + if (request.sourceUrl() != null) entity.setSourceUrl(request.sourceUrl()); + if (request.rating() != null) entity.setRating(request.rating()); + if (request.priceRange() != null) entity.setPriceRange(request.priceRange()); + + if (request.ingredients() != null) { + entity.getIngredients().clear(); + request.ingredients().forEach(ir -> entity.addIngredient(toIngredientEntity(ir))); + } + } + + public IngredientResponse toIngredientResponse(Ingredient entity) { + return new IngredientResponse( + entity.getId(), + entity.getName(), + entity.getAmount(), + entity.getUnit(), + entity.getDailyValuePercent() + ); + } + + private Ingredient toIngredientEntity(IngredientRequest request) { + return Ingredient.builder() + .name(request.name()) + .amount(request.amount()) + .unit(request.unit()) + .dailyValuePercent(request.dailyValuePercent()) + .build(); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutriapi/service/SupplementService.java b/src/main/java/ru/oa2/mvp/nutriapi/service/SupplementService.java new file mode 100644 index 0000000..66d553e --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutriapi/service/SupplementService.java @@ -0,0 +1,92 @@ +package ru.oa2.mvp.nutriapi.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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 java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SupplementService { + + private final SupplementRepository supplementRepository; + private final SupplementMapper supplementMapper; + + @Transactional(readOnly = true) + public PageResponse findAll(Pageable pageable) { + Page page = supplementRepository.findAll(pageable); + return toPageResponse(page); + } + + @Transactional(readOnly = true) + public SupplementResponse findById(UUID id) { + Supplement supplement = supplementRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Supplement", id)); + return supplementMapper.toResponse(supplement); + } + + @Transactional(readOnly = true) + public PageResponse findByCategory(String category, Pageable pageable) { + Page page = supplementRepository.findByCategory(category, pageable); + return toPageResponse(page); + } + + @Transactional(readOnly = true) + public PageResponse findByBrand(String brand, Pageable pageable) { + Page page = supplementRepository.findByBrand(brand, pageable); + return toPageResponse(page); + } + + @Transactional(readOnly = true) + public PageResponse search(String query, Pageable pageable) { + Page page = supplementRepository.fullTextSearch(query, pageable); + return toPageResponse(page); + } + + @Transactional + public SupplementResponse create(SupplementCreateRequest request) { + Supplement supplement = supplementMapper.toEntity(request); + supplement = supplementRepository.save(supplement); + log.info("Created supplement: id={}, name={}", supplement.getId(), supplement.getName()); + return supplementMapper.toResponse(supplement); + } + + @Transactional + public SupplementResponse update(UUID id, SupplementUpdateRequest request) { + Supplement supplement = supplementRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Supplement", id)); + supplementMapper.updateEntity(supplement, request); + supplement = supplementRepository.save(supplement); + log.info("Updated supplement: id={}", id); + return supplementMapper.toResponse(supplement); + } + + @Transactional + public void delete(UUID id) { + if (!supplementRepository.existsById(id)) { + throw new ResourceNotFoundException("Supplement", id); + } + supplementRepository.deleteById(id); + log.info("Deleted supplement: id={}", id); + } + + private PageResponse toPageResponse(Page page) { + return new PageResponse<>( + page.getContent().stream().map(supplementMapper::toResponse).toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isLast() + ); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..9b91f22 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,10 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/nutri + username: nutri + password: nutri + +logging: + level: + ru.oa2.mvp.nutriapi: DEBUG + org.hibernate.SQL: DEBUG diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..632ee37 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,10 @@ +spring: + datasource: + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + +logging: + level: + ru.oa2.mvp.nutriapi: INFO + org.hibernate.SQL: WARN diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..c82a6e1 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,49 @@ +spring: + application: + name: nutri-api + + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:nutri} + username: ${DB_USER:nutri} + password: ${DB_PASSWORD:nutri} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + default_schema: public + format_sql: true + + liquibase: + change-log: classpath:db/changelog/db.changelog-master.yaml + +server: + port: ${SERVER_PORT:8080} + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + operationsSorter: method + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when_authorized + +logging: + level: + ru.oa2.mvp.nutriapi: INFO + org.hibernate.SQL: WARN diff --git a/src/main/resources/db/changelog/V001_create_supplements_table.yaml b/src/main/resources/db/changelog/V001_create_supplements_table.yaml new file mode 100644 index 0000000..33dcf72 --- /dev/null +++ b/src/main/resources/db/changelog/V001_create_supplements_table.yaml @@ -0,0 +1,76 @@ +databaseChangeLog: + - changeSet: + id: 001-create-supplements-table + author: backend-agent + changes: + - createTable: + tableName: supplements + columns: + - column: + name: id + type: uuid + defaultValueComputed: gen_random_uuid() + constraints: + primaryKey: true + nullable: false + - column: + name: name + type: varchar(500) + constraints: + nullable: false + - column: + name: brand + type: varchar(255) + - column: + name: category + type: varchar(255) + - column: + name: form + type: varchar(100) + - column: + name: serving_size + type: varchar(100) + - column: + name: servings_per_container + type: int + - column: + name: description + type: text + - column: + name: contraindications + type: text + - column: + name: country + type: varchar(100) + - column: + name: image_url + type: varchar(1024) + - column: + name: source_url + type: varchar(1024) + - column: + name: rating + type: decimal(3,2) + - column: + name: price_range + type: varchar(50) + - column: + name: created_at + type: timestamp with time zone + defaultValueComputed: now() + - column: + name: updated_at + type: timestamp with time zone + defaultValueComputed: now() + - createIndex: + indexName: idx_supplements_category + tableName: supplements + columns: + - column: + name: category + - createIndex: + indexName: idx_supplements_brand + tableName: supplements + columns: + - column: + name: brand diff --git a/src/main/resources/db/changelog/V002_create_ingredients_table.yaml b/src/main/resources/db/changelog/V002_create_ingredients_table.yaml new file mode 100644 index 0000000..fd88c0c --- /dev/null +++ b/src/main/resources/db/changelog/V002_create_ingredients_table.yaml @@ -0,0 +1,49 @@ +databaseChangeLog: + - changeSet: + id: 002-create-ingredients-table + author: backend-agent + changes: + - createTable: + tableName: ingredients + columns: + - column: + name: id + type: uuid + defaultValueComputed: gen_random_uuid() + constraints: + primaryKey: true + nullable: false + - column: + name: name + type: varchar(255) + constraints: + nullable: false + - column: + name: amount + type: decimal(10,4) + - column: + name: unit + type: varchar(50) + - column: + name: daily_value_percent + type: int + - column: + name: supplement_id + type: uuid + constraints: + nullable: false + foreignKeyName: fk_ingredients_supplement + references: supplements(id) + deleteCascade: true + - createIndex: + indexName: idx_ingredients_supplement_id + tableName: ingredients + columns: + - column: + name: supplement_id + - createIndex: + indexName: idx_ingredients_name + tableName: ingredients + columns: + - column: + name: name diff --git a/src/main/resources/db/changelog/V003_add_fulltext_search_index.yaml b/src/main/resources/db/changelog/V003_add_fulltext_search_index.yaml new file mode 100644 index 0000000..d92a10e --- /dev/null +++ b/src/main/resources/db/changelog/V003_add_fulltext_search_index.yaml @@ -0,0 +1,19 @@ +databaseChangeLog: + - changeSet: + id: 003-add-fulltext-search-index + author: backend-agent + changes: + - sql: + sql: > + ALTER TABLE supplements + ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('russian', coalesce(name, '')), 'A') || + setweight(to_tsvector('russian', coalesce(brand, '')), 'B') || + setweight(to_tsvector('russian', coalesce(category, '')), 'B') || + setweight(to_tsvector('russian', coalesce(description, '')), 'C') + ) STORED; + - sql: + sql: > + CREATE INDEX IF NOT EXISTS idx_supplements_search_vector + ON supplements USING gin(search_vector); diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..185a3b1 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,7 @@ +databaseChangeLog: + - include: + file: db/changelog/V001_create_supplements_table.yaml + - include: + file: db/changelog/V002_create_ingredients_table.yaml + - include: + file: db/changelog/V003_add_fulltext_search_index.yaml diff --git a/src/test/java/ru/oa2/mvp/nutriapi/NutriApiApplicationTests.java b/src/test/java/ru/oa2/mvp/nutriapi/NutriApiApplicationTests.java new file mode 100644 index 0000000..7822d2d --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutriapi/NutriApiApplicationTests.java @@ -0,0 +1,31 @@ +package ru.oa2.mvp.nutriapi; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Testcontainers +class NutriApiApplicationTests { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("nutri_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Test + void contextLoads() { + } +} diff --git a/src/test/java/ru/oa2/mvp/nutriapi/controller/SupplementControllerTest.java b/src/test/java/ru/oa2/mvp/nutriapi/controller/SupplementControllerTest.java new file mode 100644 index 0000000..07ebdb0 --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutriapi/controller/SupplementControllerTest.java @@ -0,0 +1,162 @@ +package ru.oa2.mvp.nutriapi.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import ru.oa2.mvp.nutriapi.dto.*; +import ru.oa2.mvp.nutriapi.exception.ResourceNotFoundException; +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.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(SupplementController.class) +class SupplementControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private SupplementService supplementService; + + private final UUID testId = UUID.randomUUID(); + + private SupplementResponse sampleResponse() { + return new SupplementResponse( + testId, "Vitamin C", "TestBrand", "Vitamins", + "Tablet", "1 tablet", 90, "Vitamin C supplement", null, + "USA", null, null, null, "$", + Collections.emptyList(), Instant.now(), Instant.now() + ); + } + + @Test + void list_returnsPageOfSupplements() throws Exception { + PageResponse page = new PageResponse<>( + List.of(sampleResponse()), 0, 20, 1, 1, true + ); + when(supplementService.findAll(any())).thenReturn(page); + + mockMvc.perform(get("/api/v1/supplements")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].name").value("Vitamin C")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void getById_existingSupplement_returnsOk() throws Exception { + when(supplementService.findById(testId)).thenReturn(sampleResponse()); + + mockMvc.perform(get("/api/v1/supplements/{id}", testId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Vitamin C")) + .andExpect(jsonPath("$.brand").value("TestBrand")); + } + + @Test + void getById_nonExisting_returns404() throws Exception { + when(supplementService.findById(testId)) + .thenThrow(new ResourceNotFoundException("Supplement", testId)); + + mockMvc.perform(get("/api/v1/supplements/{id}", testId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.title").value("Resource Not Found")); + } + + @Test + void create_validRequest_returns201() throws Exception { + SupplementCreateRequest request = new SupplementCreateRequest( + "Omega-3", "FishOil", "Fatty Acids", null, + null, null, null, null, null, null, null, null, null, null + ); + when(supplementService.create(any())).thenReturn(sampleResponse()); + + mockMvc.perform(post("/api/v1/supplements") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + void create_invalidRequest_returns400() throws Exception { + String invalidJson = """ + {"name": ""} + """; + + mockMvc.perform(post("/api/v1/supplements") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andExpect(status().isBadRequest()); + } + + @Test + void update_existingSupplement_returnsOk() throws Exception { + SupplementUpdateRequest request = new SupplementUpdateRequest( + "Updated Name", null, null, null, null, null, + null, null, null, null, null, null, null, null + ); + when(supplementService.update(eq(testId), any())).thenReturn(sampleResponse()); + + mockMvc.perform(put("/api/v1/supplements/{id}", testId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void delete_existingSupplement_returns204() throws Exception { + mockMvc.perform(delete("/api/v1/supplements/{id}", testId)) + .andExpect(status().isNoContent()); + } + + @Test + void delete_nonExisting_returns404() throws Exception { + doThrow(new ResourceNotFoundException("Supplement", testId)) + .when(supplementService).delete(testId); + + mockMvc.perform(delete("/api/v1/supplements/{id}", testId)) + .andExpect(status().isNotFound()); + } + + @Test + void search_returnsResults() throws Exception { + PageResponse page = new PageResponse<>( + List.of(sampleResponse()), 0, 20, 1, 1, true + ); + when(supplementService.search(eq("vitamin"), any())).thenReturn(page); + + mockMvc.perform(get("/api/v1/supplements/search") + .param("q", "vitamin")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].name").value("Vitamin C")); + } + + @Test + void list_withCategoryFilter_returnsFiltered() throws Exception { + PageResponse 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()); + } +} diff --git a/src/test/java/ru/oa2/mvp/nutriapi/service/SupplementMapperTest.java b/src/test/java/ru/oa2/mvp/nutriapi/service/SupplementMapperTest.java new file mode 100644 index 0000000..9b9c9dc --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutriapi/service/SupplementMapperTest.java @@ -0,0 +1,101 @@ +package ru.oa2.mvp.nutriapi.service; + +import org.junit.jupiter.api.Test; +import ru.oa2.mvp.nutriapi.dto.*; +import ru.oa2.mvp.nutriapi.entity.Ingredient; +import ru.oa2.mvp.nutriapi.entity.Supplement; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class SupplementMapperTest { + + private final SupplementMapper mapper = new SupplementMapper(); + + @Test + void toResponse_mapsAllFields() { + Supplement supplement = Supplement.builder() + .id(UUID.randomUUID()) + .name("Vitamin D3") + .brand("SunBrand") + .category("Vitamins") + .form("Capsule") + .servingSize("1 capsule") + .servingsPerContainer(120) + .description("Vitamin D3 supplement") + .country("USA") + .rating(new BigDecimal("4.80")) + .priceRange("$$") + .build(); + + Ingredient ingredient = Ingredient.builder() + .id(UUID.randomUUID()) + .name("Vitamin D3") + .amount(new BigDecimal("5000")) + .unit("IU") + .dailyValuePercent(1250) + .build(); + supplement.addIngredient(ingredient); + + SupplementResponse response = mapper.toResponse(supplement); + + assertThat(response.name()).isEqualTo("Vitamin D3"); + assertThat(response.brand()).isEqualTo("SunBrand"); + assertThat(response.ingredients()).hasSize(1); + assertThat(response.ingredients().get(0).name()).isEqualTo("Vitamin D3"); + assertThat(response.ingredients().get(0).unit()).isEqualTo("IU"); + } + + @Test + void toEntity_mapsFromCreateRequest() { + SupplementCreateRequest request = new SupplementCreateRequest( + "Magnesium", "MagBrand", "Minerals", "Tablet", + "2 tablets", 30, "Magnesium citrate", null, + "Germany", null, null, new BigDecimal("4.20"), "$", + List.of(new IngredientRequest("Magnesium Citrate", new BigDecimal("400"), "mg", 95)) + ); + + Supplement entity = mapper.toEntity(request); + + assertThat(entity.getName()).isEqualTo("Magnesium"); + assertThat(entity.getBrand()).isEqualTo("MagBrand"); + assertThat(entity.getIngredients()).hasSize(1); + assertThat(entity.getIngredients().get(0).getName()).isEqualTo("Magnesium Citrate"); + assertThat(entity.getIngredients().get(0).getSupplement()).isEqualTo(entity); + } + + @Test + void updateEntity_updatesOnlyNonNullFields() { + Supplement entity = Supplement.builder() + .name("Old Name") + .brand("Old Brand") + .category("Old Category") + .build(); + + SupplementUpdateRequest request = new SupplementUpdateRequest( + "New Name", null, "New Category", null, null, null, + null, null, null, null, null, null, null, null + ); + + mapper.updateEntity(entity, request); + + assertThat(entity.getName()).isEqualTo("New Name"); + assertThat(entity.getBrand()).isEqualTo("Old Brand"); // not updated since request.brand() is null + assertThat(entity.getCategory()).isEqualTo("New Category"); + } + + @Test + void toEntity_withNullIngredients_createsEmptyList() { + SupplementCreateRequest request = new SupplementCreateRequest( + "Simple Supp", null, null, null, null, null, + null, null, null, null, null, null, null, null + ); + + Supplement entity = mapper.toEntity(request); + + assertThat(entity.getIngredients()).isEmpty(); + } +} diff --git a/src/test/java/ru/oa2/mvp/nutriapi/service/SupplementServiceTest.java b/src/test/java/ru/oa2/mvp/nutriapi/service/SupplementServiceTest.java new file mode 100644 index 0000000..c019b93 --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutriapi/service/SupplementServiceTest.java @@ -0,0 +1,180 @@ +package ru.oa2.mvp.nutriapi.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +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 java.math.BigDecimal; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SupplementServiceTest { + + @Mock + private SupplementRepository supplementRepository; + + @Mock + private SupplementMapper supplementMapper; + + @InjectMocks + private SupplementService supplementService; + + private UUID testId; + private Supplement testSupplement; + private SupplementResponse testResponse; + + @BeforeEach + void setUp() { + testId = UUID.randomUUID(); + testSupplement = Supplement.builder() + .id(testId) + .name("Vitamin C") + .brand("TestBrand") + .category("Vitamins") + .build(); + + testResponse = new SupplementResponse( + testId, "Vitamin C", "TestBrand", "Vitamins", + null, null, null, null, null, null, null, null, + null, null, Collections.emptyList(), Instant.now(), Instant.now() + ); + } + + @Test + void findById_existingSupplement_returnsResponse() { + when(supplementRepository.findById(testId)).thenReturn(Optional.of(testSupplement)); + when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse); + + SupplementResponse result = supplementService.findById(testId); + + assertThat(result.id()).isEqualTo(testId); + assertThat(result.name()).isEqualTo("Vitamin C"); + verify(supplementRepository).findById(testId); + } + + @Test + void findById_nonExistingSupplement_throwsNotFound() { + when(supplementRepository.findById(testId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> supplementService.findById(testId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining(testId.toString()); + } + + @Test + void findAll_returnsPageResponse() { + PageRequest pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(testSupplement), pageable, 1); + when(supplementRepository.findAll(pageable)).thenReturn(page); + when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse); + + PageResponse result = supplementService.findAll(pageable); + + assertThat(result.content()).hasSize(1); + assertThat(result.totalElements()).isEqualTo(1); + assertThat(result.page()).isZero(); + } + + @Test + void create_validRequest_returnsSavedSupplement() { + SupplementCreateRequest request = new SupplementCreateRequest( + "Omega-3", "FishOil", "Fatty Acids", "Capsule", + "1 capsule", 60, "Fish oil supplement", null, + "Norway", null, null, new BigDecimal("4.50"), "$$", + List.of(new IngredientRequest("EPA", new BigDecimal("360"), "mg", null)) + ); + + Supplement newSupplement = Supplement.builder().name("Omega-3").build(); + Supplement saved = Supplement.builder().id(UUID.randomUUID()).name("Omega-3").build(); + SupplementResponse savedResponse = new SupplementResponse( + saved.getId(), "Omega-3", "FishOil", "Fatty Acids", + "Capsule", "1 capsule", 60, "Fish oil supplement", null, + "Norway", null, null, new BigDecimal("4.50"), "$$", + Collections.emptyList(), Instant.now(), Instant.now() + ); + + when(supplementMapper.toEntity(request)).thenReturn(newSupplement); + when(supplementRepository.save(newSupplement)).thenReturn(saved); + when(supplementMapper.toResponse(saved)).thenReturn(savedResponse); + + SupplementResponse result = supplementService.create(request); + + assertThat(result.name()).isEqualTo("Omega-3"); + verify(supplementRepository).save(any(Supplement.class)); + } + + @Test + void update_existingSupplement_returnsUpdated() { + SupplementUpdateRequest request = new SupplementUpdateRequest( + "Vitamin C Updated", null, null, null, null, null, + null, null, null, null, null, null, null, null + ); + + when(supplementRepository.findById(testId)).thenReturn(Optional.of(testSupplement)); + when(supplementRepository.save(testSupplement)).thenReturn(testSupplement); + when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse); + + SupplementResponse result = supplementService.update(testId, request); + + assertThat(result).isNotNull(); + verify(supplementMapper).updateEntity(testSupplement, request); + verify(supplementRepository).save(testSupplement); + } + + @Test + void update_nonExistingSupplement_throwsNotFound() { + when(supplementRepository.findById(testId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> supplementService.update(testId, new SupplementUpdateRequest( + null, null, null, null, null, null, + null, null, null, null, null, null, null, null))) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void delete_existingSupplement_deletes() { + when(supplementRepository.existsById(testId)).thenReturn(true); + + supplementService.delete(testId); + + verify(supplementRepository).deleteById(testId); + } + + @Test + void delete_nonExistingSupplement_throwsNotFound() { + when(supplementRepository.existsById(testId)).thenReturn(false); + + assertThatThrownBy(() -> supplementService.delete(testId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void findByCategory_returnsFiltered() { + PageRequest pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(testSupplement), pageable, 1); + when(supplementRepository.findByCategory("Vitamins", pageable)).thenReturn(page); + when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse); + + PageResponse result = supplementService.findByCategory("Vitamins", pageable); + + assertThat(result.content()).hasSize(1); + } +}