feat: initial nutri-api — Spring Boot REST API for supplement catalog
- 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
This commit is contained in:
commit
506d1ba761
|
|
@ -0,0 +1,10 @@
|
|||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.iml
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.4</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>ru.oa2.mvp</groupId>
|
||||
<artifactId>nutri-api</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<name>nutri-api</name>
|
||||
<description>REST API service for supplement (BAD) catalog</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<springdoc.version>2.8.5</springdoc.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Data JPA -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- PostgreSQL -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Liquibase -->
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Actuator -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Testcontainers -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-bom</artifactId>
|
||||
<version>1.20.4</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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) {
|
||||
|
||||
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<SupplementResponse> 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<Void> delete(@PathVariable UUID id) {
|
||||
supplementService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package ru.oa2.mvp.nutriapi.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> content,
|
||||
int page,
|
||||
int size,
|
||||
long totalElements,
|
||||
int totalPages,
|
||||
boolean last
|
||||
) {}
|
||||
|
|
@ -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<IngredientRequest> ingredients
|
||||
) {}
|
||||
|
|
@ -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<IngredientResponse> ingredients,
|
||||
Instant createdAt,
|
||||
Instant updatedAt
|
||||
) {}
|
||||
|
|
@ -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<IngredientRequest> ingredients
|
||||
) {}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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<Ingredient> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Ingredient, UUID> {
|
||||
|
||||
List<Ingredient> findBySupplementId(UUID supplementId);
|
||||
}
|
||||
|
|
@ -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<Supplement, UUID> {
|
||||
|
||||
Page<Supplement> findByCategory(String category, Pageable pageable);
|
||||
|
||||
Page<Supplement> 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<Supplement> fullTextSearch(@Param("query") String query, Pageable pageable);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SupplementResponse> findAll(Pageable pageable) {
|
||||
Page<Supplement> 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<SupplementResponse> findByCategory(String category, Pageable pageable) {
|
||||
Page<Supplement> page = supplementRepository.findByCategory(category, pageable);
|
||||
return toPageResponse(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SupplementResponse> findByBrand(String brand, Pageable pageable) {
|
||||
Page<Supplement> page = supplementRepository.findByBrand(brand, pageable);
|
||||
return toPageResponse(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SupplementResponse> search(String query, Pageable pageable) {
|
||||
Page<Supplement> 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<SupplementResponse> toPageResponse(Page<Supplement> page) {
|
||||
return new PageResponse<>(
|
||||
page.getContent().stream().map(supplementMapper::toResponse).toList(),
|
||||
page.getNumber(),
|
||||
page.getSize(),
|
||||
page.getTotalElements(),
|
||||
page.getTotalPages(),
|
||||
page.isLast()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SupplementResponse> 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<SupplementResponse> 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<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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
|
||||
when(supplementRepository.findAll(pageable)).thenReturn(page);
|
||||
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
|
||||
|
||||
PageResponse<SupplementResponse> 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<Supplement> page = new PageImpl<>(List.of(testSupplement), pageable, 1);
|
||||
when(supplementRepository.findByCategory("Vitamins", pageable)).thenReturn(page);
|
||||
when(supplementMapper.toResponse(testSupplement)).thenReturn(testResponse);
|
||||
|
||||
PageResponse<SupplementResponse> result = supplementService.findByCategory("Vitamins", pageable);
|
||||
|
||||
assertThat(result.content()).hasSize(1);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue