commit 50a1d39893583cfdef6e376348b1cdad1d0d1125 Author: Backend Agent Date: Fri Apr 10 19:20:31 2026 +0000 feat: initial nutri-collector — data collector service for supplement catalog 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..d0b2585 --- /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 8081 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..2aef180 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,40 @@ +version: "3.9" + +services: + app: + build: . + ports: + - "8081:8081" + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: nutri + DB_USER: nutri + DB_PASSWORD: nutri + COLLECTOR_INPUT_DIR: /data/input + SPRING_PROFILES_ACTIVE: dev + volumes: + - collector-data:/data/input + 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: + collector-data: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7906b48 --- /dev/null +++ b/pom.xml @@ -0,0 +1,142 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + + ru.oa2.mvp + nutri-collector + 0.1.0-SNAPSHOT + nutri-collector + Data collector service for supplement (BAD) catalog — parses and normalizes supplement data + + + 21 + 5.9 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-batch + + + + + org.postgresql + postgresql + runtime + + + + + org.liquibase + liquibase-core + + + + + com.opencsv + opencsv + ${opencsv.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.batch + spring-batch-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/nutricollector/NutriCollectorApplication.java b/src/main/java/ru/oa2/mvp/nutricollector/NutriCollectorApplication.java new file mode 100644 index 0000000..612454e --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/NutriCollectorApplication.java @@ -0,0 +1,14 @@ +package ru.oa2.mvp.nutricollector; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class NutriCollectorApplication { + + public static void main(String[] args) { + SpringApplication.run(NutriCollectorApplication.class, args); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/config/BatchConfig.java b/src/main/java/ru/oa2/mvp/nutricollector/config/BatchConfig.java new file mode 100644 index 0000000..522a170 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/config/BatchConfig.java @@ -0,0 +1,18 @@ +package ru.oa2.mvp.nutricollector.config; + +import org.springframework.boot.autoconfigure.batch.BatchDataSourceScriptDatabaseInitializer; +import org.springframework.boot.autoconfigure.batch.BatchProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +public class BatchConfig { + + @Bean + public BatchDataSourceScriptDatabaseInitializer batchDataSourceInitializer( + DataSource dataSource, BatchProperties properties) { + return new BatchDataSourceScriptDatabaseInitializer(dataSource, properties.getJdbc()); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/controller/CollectorController.java b/src/main/java/ru/oa2/mvp/nutricollector/controller/CollectorController.java new file mode 100644 index 0000000..d29e5d1 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/controller/CollectorController.java @@ -0,0 +1,46 @@ +package ru.oa2.mvp.nutricollector.controller; + +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse; +import ru.oa2.mvp.nutricollector.entity.CollectorRun; +import ru.oa2.mvp.nutricollector.service.CollectorService; +import ru.oa2.mvp.nutricollector.service.SupplementMapper; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/collector") +@RequiredArgsConstructor +@Slf4j +public class CollectorController { + + private final CollectorService collectorService; + private final SupplementMapper mapper; + + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadCsv( + @RequestParam("file") @NotNull MultipartFile file) throws IOException { + log.info("Received CSV upload: {}, size: {} bytes", file.getOriginalFilename(), file.getSize()); + + String source = "upload:" + (file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown"); + CollectorRun run = collectorService.processCsvStream(file.getInputStream(), source); + + return ResponseEntity.status(HttpStatus.OK).body(mapper.toRunResponse(run)); + } + + @GetMapping("/runs") + public ResponseEntity> getRecentRuns() { + List runs = collectorService.getRecentRuns().stream() + .map(mapper::toRunResponse) + .toList(); + return ResponseEntity.ok(runs); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/dto/CollectorRunResponse.java b/src/main/java/ru/oa2/mvp/nutricollector/dto/CollectorRunResponse.java new file mode 100644 index 0000000..4109427 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/dto/CollectorRunResponse.java @@ -0,0 +1,14 @@ +package ru.oa2.mvp.nutricollector.dto; + +import java.time.Instant; +import java.util.UUID; + +public record CollectorRunResponse( + UUID id, + Instant runAt, + String source, + Integer added, + Integer updated, + Integer errors, + String status +) {} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/dto/IngredientData.java b/src/main/java/ru/oa2/mvp/nutricollector/dto/IngredientData.java new file mode 100644 index 0000000..cfc29e5 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/dto/IngredientData.java @@ -0,0 +1,10 @@ +package ru.oa2.mvp.nutricollector.dto; + +import java.math.BigDecimal; + +public record IngredientData( + String name, + BigDecimal amount, + String unit, + Integer dailyValuePercent +) {} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/dto/SupplementCsvRecord.java b/src/main/java/ru/oa2/mvp/nutricollector/dto/SupplementCsvRecord.java new file mode 100644 index 0000000..9afd61a --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/dto/SupplementCsvRecord.java @@ -0,0 +1,52 @@ +package ru.oa2.mvp.nutricollector.dto; + +import com.opencsv.bean.CsvBindByName; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SupplementCsvRecord { + + @CsvBindByName(column = "name") + private String name; + + @CsvBindByName(column = "brand") + private String brand; + + @CsvBindByName(column = "category") + private String category; + + @CsvBindByName(column = "form") + private String form; + + @CsvBindByName(column = "serving_size") + private String servingSize; + + @CsvBindByName(column = "servings_per_container") + private String servingsPerContainer; + + @CsvBindByName(column = "description") + private String description; + + @CsvBindByName(column = "contraindications") + private String contraindications; + + @CsvBindByName(column = "country") + private String country; + + @CsvBindByName(column = "image_url") + private String imageUrl; + + @CsvBindByName(column = "source_url") + private String sourceUrl; + + @CsvBindByName(column = "rating") + private String rating; + + @CsvBindByName(column = "price_range") + private String priceRange; + + @CsvBindByName(column = "ingredients") + private String ingredients; +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/entity/CollectorRun.java b/src/main/java/ru/oa2/mvp/nutricollector/entity/CollectorRun.java new file mode 100644 index 0000000..ddd1f29 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/entity/CollectorRun.java @@ -0,0 +1,46 @@ +package ru.oa2.mvp.nutricollector.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "collector_runs") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CollectorRun { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", updatable = false, nullable = false) + private UUID id; + + @Column(name = "run_at", nullable = false) + private Instant runAt; + + @Column(name = "source") + private String source; + + @Column(name = "added") + private Integer added; + + @Column(name = "updated") + private Integer updated; + + @Column(name = "errors") + private Integer errors; + + @Column(name = "status") + private String status; + + public void setRunAt(Instant runAt) { this.runAt = runAt; } + public void setSource(String source) { this.source = source; } + public void setAdded(Integer added) { this.added = added; } + public void setUpdated(Integer updated) { this.updated = updated; } + public void setErrors(Integer errors) { this.errors = errors; } + public void setStatus(String status) { this.status = status; } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/entity/Ingredient.java b/src/main/java/ru/oa2/mvp/nutricollector/entity/Ingredient.java new file mode 100644 index 0000000..030ca0b --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/entity/Ingredient.java @@ -0,0 +1,43 @@ +package ru.oa2.mvp.nutricollector.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/nutricollector/entity/Supplement.java b/src/main/java/ru/oa2/mvp/nutricollector/entity/Supplement.java new file mode 100644 index 0000000..dd2fdb5 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/entity/Supplement.java @@ -0,0 +1,101 @@ +package ru.oa2.mvp.nutricollector.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/nutricollector/exception/GlobalExceptionHandler.java b/src/main/java/ru/oa2/mvp/nutricollector/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..151a647 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/exception/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +package ru.oa2.mvp.nutricollector.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.validation.FieldError; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @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.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.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"); + return problem; + } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/repository/CollectorRunRepository.java b/src/main/java/ru/oa2/mvp/nutricollector/repository/CollectorRunRepository.java new file mode 100644 index 0000000..730934f --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/repository/CollectorRunRepository.java @@ -0,0 +1,14 @@ +package ru.oa2.mvp.nutricollector.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.oa2.mvp.nutricollector.entity.CollectorRun; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface CollectorRunRepository extends JpaRepository { + + List findTop10ByOrderByRunAtDesc(); +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/repository/IngredientRepository.java b/src/main/java/ru/oa2/mvp/nutricollector/repository/IngredientRepository.java new file mode 100644 index 0000000..ea0a085 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/repository/IngredientRepository.java @@ -0,0 +1,16 @@ +package ru.oa2.mvp.nutricollector.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.oa2.mvp.nutricollector.entity.Ingredient; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface IngredientRepository extends JpaRepository { + + List findBySupplementId(UUID supplementId); + + void deleteBySupplementId(UUID supplementId); +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/repository/SupplementRepository.java b/src/main/java/ru/oa2/mvp/nutricollector/repository/SupplementRepository.java new file mode 100644 index 0000000..67768e6 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/repository/SupplementRepository.java @@ -0,0 +1,18 @@ +package ru.oa2.mvp.nutricollector.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.oa2.mvp.nutricollector.entity.Supplement; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface SupplementRepository extends JpaRepository { + + Optional findByNameAndBrand(String name, String brand); + + Optional findBySourceUrl(String sourceUrl); + + boolean existsBySourceUrl(String sourceUrl); +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/service/CollectorService.java b/src/main/java/ru/oa2/mvp/nutricollector/service/CollectorService.java new file mode 100644 index 0000000..61b5623 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/service/CollectorService.java @@ -0,0 +1,128 @@ +package ru.oa2.mvp.nutricollector.service; + +import com.opencsv.bean.CsvToBeanBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.oa2.mvp.nutricollector.dto.IngredientData; +import ru.oa2.mvp.nutricollector.dto.SupplementCsvRecord; +import ru.oa2.mvp.nutricollector.entity.CollectorRun; +import ru.oa2.mvp.nutricollector.entity.Ingredient; +import ru.oa2.mvp.nutricollector.entity.Supplement; +import ru.oa2.mvp.nutricollector.repository.CollectorRunRepository; +import ru.oa2.mvp.nutricollector.repository.IngredientRepository; +import ru.oa2.mvp.nutricollector.repository.SupplementRepository; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CollectorService { + + private final SupplementRepository supplementRepository; + private final IngredientRepository ingredientRepository; + private final CollectorRunRepository collectorRunRepository; + private final SupplementMapper mapper; + private final IngredientParser ingredientParser; + + @Transactional + public CollectorRun processCsvStream(InputStream inputStream, String source) { + log.info("Starting collection from source: {}", source); + + int added = 0; + int updated = 0; + int errors = 0; + + try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + List records = new CsvToBeanBuilder(reader) + .withType(SupplementCsvRecord.class) + .withIgnoreLeadingWhiteSpace(true) + .withIgnoreEmptyLine(true) + .build() + .parse(); + + log.info("Parsed {} CSV records from source: {}", records.size(), source); + + for (SupplementCsvRecord record : records) { + try { + if (record.getName() == null || record.getName().isBlank()) { + log.warn("Skipping record with empty name"); + errors++; + continue; + } + + boolean isUpdate = processRecord(record); + if (isUpdate) { + updated++; + } else { + added++; + } + } catch (Exception e) { + log.error("Error processing record '{}': {}", record.getName(), e.getMessage()); + errors++; + } + } + } catch (Exception e) { + log.error("Failed to read CSV from source: {} — {}", source, e.getMessage()); + return saveRun(source, added, updated, errors, "FAILED"); + } + + String status = errors > 0 ? "COMPLETED_WITH_ERRORS" : "SUCCESS"; + log.info("Collection complete for source '{}': added={}, updated={}, errors={}, status={}", + source, added, updated, errors, status); + + return saveRun(source, added, updated, errors, status); + } + + private boolean processRecord(SupplementCsvRecord record) { + var existing = supplementRepository.findBySourceUrl(record.getSourceUrl()); + + if (existing.isPresent()) { + Supplement supplement = existing.get(); + mapper.updateFromCsvRecord(supplement, record); + updateIngredients(supplement, record.getIngredients()); + supplementRepository.save(supplement); + return true; + } else { + Supplement supplement = mapper.fromCsvRecord(record); + supplement = supplementRepository.save(supplement); + updateIngredients(supplement, record.getIngredients()); + return false; + } + } + + private void updateIngredients(Supplement supplement, String ingredientsString) { + ingredientRepository.deleteBySupplementId(supplement.getId()); + ingredientRepository.flush(); + + List ingredientDataList = ingredientParser.parse(ingredientsString); + for (IngredientData data : ingredientDataList) { + Ingredient ingredient = mapper.fromIngredientData(data); + supplement.addIngredient(ingredient); + ingredientRepository.save(ingredient); + } + } + + private CollectorRun saveRun(String source, int added, int updated, int errors, String status) { + CollectorRun run = CollectorRun.builder() + .runAt(Instant.now()) + .source(source) + .added(added) + .updated(updated) + .errors(errors) + .status(status) + .build(); + return collectorRunRepository.save(run); + } + + public List getRecentRuns() { + return collectorRunRepository.findTop10ByOrderByRunAtDesc(); + } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/service/FileCollectorScheduler.java b/src/main/java/ru/oa2/mvp/nutricollector/service/FileCollectorScheduler.java new file mode 100644 index 0000000..3021e67 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/service/FileCollectorScheduler.java @@ -0,0 +1,64 @@ +package ru.oa2.mvp.nutricollector.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FileCollectorScheduler { + + private final CollectorService collectorService; + + @Value("${collector.input-dir:#{null}}") + private String inputDir; + + @Scheduled(cron = "${collector.schedule:0 0 */6 * * *}") + public void collectFromFiles() { + if (inputDir == null || inputDir.isBlank()) { + log.debug("No input directory configured (collector.input-dir), skipping file collection"); + return; + } + + Path dir = Path.of(inputDir); + if (!Files.isDirectory(dir)) { + log.warn("Input directory does not exist: {}", inputDir); + return; + } + + log.info("Scanning input directory for CSV files: {}", inputDir); + + try (DirectoryStream stream = Files.newDirectoryStream(dir, "*.csv")) { + for (Path csvFile : stream) { + processFile(csvFile); + } + } catch (IOException e) { + log.error("Error scanning input directory: {}", e.getMessage()); + } + } + + private void processFile(Path csvFile) { + String filename = csvFile.getFileName().toString(); + log.info("Processing file: {}", filename); + + try (InputStream is = Files.newInputStream(csvFile)) { + collectorService.processCsvStream(is, "file:" + filename); + + Path processedDir = csvFile.getParent().resolve("processed"); + Files.createDirectories(processedDir); + Files.move(csvFile, processedDir.resolve(filename)); + log.info("Moved processed file to: processed/{}", filename); + } catch (IOException e) { + log.error("Error processing file '{}': {}", filename, e.getMessage()); + } + } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/service/IngredientParser.java b/src/main/java/ru/oa2/mvp/nutricollector/service/IngredientParser.java new file mode 100644 index 0000000..9240514 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/service/IngredientParser.java @@ -0,0 +1,80 @@ +package ru.oa2.mvp.nutricollector.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import ru.oa2.mvp.nutricollector.dto.IngredientData; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Slf4j +public class IngredientParser { + + private static final Pattern INGREDIENT_WITH_AMOUNT = Pattern.compile( + "(.+?)\\s*[-–:]\\s*(\\d+(?:[.,]\\d+)?)\\s*(\\w+)?\\s*(?:\\((\\d+)%\\s*DV\\))?\\s*$"); + private static final Pattern INGREDIENT_NAME_ONLY = Pattern.compile( + "(.+?)\\s*(?:\\((\\d+)%\\s*DV\\))?\\s*$"); + + public List parse(String ingredientsString) { + if (ingredientsString == null || ingredientsString.isBlank()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + String[] parts = ingredientsString.split("[;|]"); + + for (String part : parts) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) continue; + + try { + Matcher matcher = INGREDIENT_WITH_AMOUNT.matcher(trimmed); + if (matcher.matches()) { + String name = matcher.group(1).trim(); + BigDecimal amount = parseBigDecimal(matcher.group(2)); + String unit = matcher.group(3); + Integer dailyValue = parseInteger(matcher.group(4)); + if (!name.isEmpty()) { + result.add(new IngredientData(name, amount, unit, dailyValue)); + } + } else { + Matcher nameMatcher = INGREDIENT_NAME_ONLY.matcher(trimmed); + if (nameMatcher.matches()) { + String name = nameMatcher.group(1).trim(); + Integer dailyValue = parseInteger(nameMatcher.group(2)); + if (!name.isEmpty()) { + result.add(new IngredientData(name, null, null, dailyValue)); + } + } + } + } catch (Exception e) { + log.warn("Failed to parse ingredient: '{}' — {}", trimmed, e.getMessage()); + } + } + + return result; + } + + private BigDecimal parseBigDecimal(String value) { + if (value == null || value.isBlank()) return null; + try { + return new BigDecimal(value.replace(',', '.').trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private Integer parseInteger(String value) { + if (value == null || value.isBlank()) return null; + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/ru/oa2/mvp/nutricollector/service/SupplementMapper.java b/src/main/java/ru/oa2/mvp/nutricollector/service/SupplementMapper.java new file mode 100644 index 0000000..7078156 --- /dev/null +++ b/src/main/java/ru/oa2/mvp/nutricollector/service/SupplementMapper.java @@ -0,0 +1,90 @@ +package ru.oa2.mvp.nutricollector.service; + +import org.springframework.stereotype.Component; +import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse; +import ru.oa2.mvp.nutricollector.dto.IngredientData; +import ru.oa2.mvp.nutricollector.dto.SupplementCsvRecord; +import ru.oa2.mvp.nutricollector.entity.CollectorRun; +import ru.oa2.mvp.nutricollector.entity.Ingredient; +import ru.oa2.mvp.nutricollector.entity.Supplement; + +import java.math.BigDecimal; +import java.util.List; + +@Component +public class SupplementMapper { + + public Supplement fromCsvRecord(SupplementCsvRecord csv) { + Supplement supplement = Supplement.builder() + .name(csv.getName()) + .brand(csv.getBrand()) + .category(csv.getCategory()) + .form(csv.getForm()) + .servingSize(csv.getServingSize()) + .servingsPerContainer(parseInteger(csv.getServingsPerContainer())) + .description(csv.getDescription()) + .contraindications(csv.getContraindications()) + .country(csv.getCountry()) + .imageUrl(csv.getImageUrl()) + .sourceUrl(csv.getSourceUrl()) + .rating(parseBigDecimal(csv.getRating())) + .priceRange(csv.getPriceRange()) + .build(); + return supplement; + } + + public void updateFromCsvRecord(Supplement existing, SupplementCsvRecord csv) { + existing.setName(csv.getName()); + existing.setBrand(csv.getBrand()); + existing.setCategory(csv.getCategory()); + existing.setForm(csv.getForm()); + existing.setServingSize(csv.getServingSize()); + existing.setServingsPerContainer(parseInteger(csv.getServingsPerContainer())); + existing.setDescription(csv.getDescription()); + existing.setContraindications(csv.getContraindications()); + existing.setCountry(csv.getCountry()); + existing.setImageUrl(csv.getImageUrl()); + existing.setSourceUrl(csv.getSourceUrl()); + existing.setRating(parseBigDecimal(csv.getRating())); + existing.setPriceRange(csv.getPriceRange()); + } + + public Ingredient fromIngredientData(IngredientData data) { + return Ingredient.builder() + .name(data.name()) + .amount(data.amount()) + .unit(data.unit()) + .dailyValuePercent(data.dailyValuePercent()) + .build(); + } + + public CollectorRunResponse toRunResponse(CollectorRun run) { + return new CollectorRunResponse( + run.getId(), + run.getRunAt(), + run.getSource(), + run.getAdded(), + run.getUpdated(), + run.getErrors(), + run.getStatus() + ); + } + + private Integer parseInteger(String value) { + if (value == null || value.isBlank()) return null; + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private BigDecimal parseBigDecimal(String value) { + if (value == null || value.isBlank()) return null; + try { + return new BigDecimal(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..4701fd8 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,11 @@ +spring: + jpa: + properties: + hibernate: + format_sql: true + show-sql: true + +logging: + level: + ru.oa2.mvp.nutricollector: 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..01aeb7b --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,10 @@ +spring: + datasource: + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + +logging: + level: + ru.oa2.mvp.nutricollector: 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..1aff3a8 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,58 @@ +spring: + application: + name: nutri-collector + + 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: 5 + minimum-idle: 1 + + 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 + + batch: + jdbc: + initialize-schema: always + job: + enabled: false + + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB + +server: + port: ${SERVER_PORT:8081} + +collector: + input-dir: ${COLLECTOR_INPUT_DIR:} + schedule: ${COLLECTOR_SCHEDULE:0 0 */6 * * *} + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when_authorized + +logging: + level: + ru.oa2.mvp.nutricollector: INFO + org.hibernate.SQL: WARN + org.springframework.batch: 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..33ee237 --- /dev/null +++ b/src/main/resources/db/changelog/V001_create_supplements_table.yaml @@ -0,0 +1,87 @@ +databaseChangeLog: + - changeSet: + id: 001-create-supplements-table + author: backend-agent + preConditions: + - onFail: MARK_RAN + - not: + - tableExists: + tableName: supplements + 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 + - createIndex: + indexName: idx_supplements_source_url + tableName: supplements + columns: + - column: + name: source_url 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..8613b42 --- /dev/null +++ b/src/main/resources/db/changelog/V002_create_ingredients_table.yaml @@ -0,0 +1,54 @@ +databaseChangeLog: + - changeSet: + id: 002-create-ingredients-table + author: backend-agent + preConditions: + - onFail: MARK_RAN + - not: + - tableExists: + tableName: ingredients + 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_create_collector_runs_table.yaml b/src/main/resources/db/changelog/V003_create_collector_runs_table.yaml new file mode 100644 index 0000000..184dcc4 --- /dev/null +++ b/src/main/resources/db/changelog/V003_create_collector_runs_table.yaml @@ -0,0 +1,42 @@ +databaseChangeLog: + - changeSet: + id: 003-create-collector-runs-table + author: backend-agent + changes: + - createTable: + tableName: collector_runs + columns: + - column: + name: id + type: uuid + defaultValueComputed: gen_random_uuid() + constraints: + primaryKey: true + nullable: false + - column: + name: run_at + type: timestamp with time zone + constraints: + nullable: false + - column: + name: source + type: varchar(500) + - column: + name: added + type: int + - column: + name: updated + type: int + - column: + name: errors + type: int + - column: + name: status + type: varchar(50) + - createIndex: + indexName: idx_collector_runs_run_at + tableName: collector_runs + columns: + - column: + name: run_at + descending: true 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..2a79085 --- /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_create_collector_runs_table.yaml diff --git a/src/test/java/ru/oa2/mvp/nutricollector/NutriCollectorApplicationTests.java b/src/test/java/ru/oa2/mvp/nutricollector/NutriCollectorApplicationTests.java new file mode 100644 index 0000000..ab4aa27 --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutricollector/NutriCollectorApplicationTests.java @@ -0,0 +1,31 @@ +package ru.oa2.mvp.nutricollector; + +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 NutriCollectorApplicationTests { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("nutri") + .withUsername("nutri") + .withPassword("nutri"); + + @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/nutricollector/controller/CollectorControllerTest.java b/src/test/java/ru/oa2/mvp/nutricollector/controller/CollectorControllerTest.java new file mode 100644 index 0000000..3b3e7ff --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutricollector/controller/CollectorControllerTest.java @@ -0,0 +1,86 @@ +package ru.oa2.mvp.nutricollector.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse; +import ru.oa2.mvp.nutricollector.entity.CollectorRun; +import ru.oa2.mvp.nutricollector.service.CollectorService; +import ru.oa2.mvp.nutricollector.service.SupplementMapper; + +import java.io.InputStream; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CollectorController.class) +class CollectorControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private CollectorService collectorService; + + @MockitoBean + private SupplementMapper mapper; + + @Test + void shouldUploadCsvSuccessfully() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "supplements.csv", "text/csv", + "name,brand\nVitamin C,Nature Made".getBytes()); + + CollectorRun run = CollectorRun.builder() + .runAt(Instant.now()) + .source("upload:supplements.csv") + .added(1).updated(0).errors(0) + .status("SUCCESS") + .build(); + + UUID runId = UUID.randomUUID(); + CollectorRunResponse response = new CollectorRunResponse( + runId, run.getRunAt(), "upload:supplements.csv", 1, 0, 0, "SUCCESS"); + + when(collectorService.processCsvStream(any(InputStream.class), eq("upload:supplements.csv"))) + .thenReturn(run); + when(mapper.toRunResponse(run)).thenReturn(response); + + mockMvc.perform(multipart("/api/v1/collector/upload").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.added").value(1)) + .andExpect(jsonPath("$.errors").value(0)); + } + + @Test + void shouldReturnRecentRuns() throws Exception { + UUID id = UUID.randomUUID(); + Instant now = Instant.now(); + CollectorRun run = CollectorRun.builder() + .id(id).runAt(now).source("test").added(5).updated(2).errors(0).status("SUCCESS") + .build(); + CollectorRunResponse response = new CollectorRunResponse(id, now, "test", 5, 2, 0, "SUCCESS"); + + when(collectorService.getRecentRuns()).thenReturn(List.of(run)); + when(mapper.toRunResponse(run)).thenReturn(response); + + mockMvc.perform(get("/api/v1/collector/runs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].source").value("test")) + .andExpect(jsonPath("$[0].added").value(5)) + .andExpect(jsonPath("$[0].status").value("SUCCESS")); + } +} diff --git a/src/test/java/ru/oa2/mvp/nutricollector/service/CollectorServiceTest.java b/src/test/java/ru/oa2/mvp/nutricollector/service/CollectorServiceTest.java new file mode 100644 index 0000000..c5575bb --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutricollector/service/CollectorServiceTest.java @@ -0,0 +1,114 @@ +package ru.oa2.mvp.nutricollector.service; + +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 ru.oa2.mvp.nutricollector.entity.CollectorRun; +import ru.oa2.mvp.nutricollector.entity.Supplement; +import ru.oa2.mvp.nutricollector.repository.CollectorRunRepository; +import ru.oa2.mvp.nutricollector.repository.IngredientRepository; +import ru.oa2.mvp.nutricollector.repository.SupplementRepository; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CollectorServiceTest { + + @Mock + private SupplementRepository supplementRepository; + + @Mock + private IngredientRepository ingredientRepository; + + @Mock + private CollectorRunRepository collectorRunRepository; + + @Mock + private SupplementMapper mapper; + + @Mock + private IngredientParser ingredientParser; + + @InjectMocks + private CollectorService collectorService; + + @Test + void shouldProcessCsvAndCreateNewSupplements() { + String csv = "name,brand,category,source_url,ingredients\n" + + "Vitamin C,Nature Made,Vitamins,https://example.com/vc,Vitamin C - 500 mg\n"; + + InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8)); + + Supplement newSupplement = Supplement.builder().name("Vitamin C").build(); + when(mapper.fromCsvRecord(any())).thenReturn(newSupplement); + when(supplementRepository.findBySourceUrl("https://example.com/vc")).thenReturn(Optional.empty()); + when(supplementRepository.save(any(Supplement.class))).thenReturn(newSupplement); + when(ingredientParser.parse(any())).thenReturn(List.of()); + when(collectorRunRepository.save(any(CollectorRun.class))).thenAnswer(i -> i.getArgument(0)); + + CollectorRun result = collectorService.processCsvStream(is, "test"); + + assertThat(result.getStatus()).isEqualTo("SUCCESS"); + assertThat(result.getAdded()).isEqualTo(1); + assertThat(result.getUpdated()).isEqualTo(0); + assertThat(result.getErrors()).isEqualTo(0); + } + + @Test + void shouldUpdateExistingSupplement() { + String csv = "name,brand,category,source_url,ingredients\n" + + "Vitamin C,Nature Made,Vitamins,https://example.com/vc,\n"; + + InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8)); + + Supplement existing = Supplement.builder().name("Vitamin C").build(); + when(supplementRepository.findBySourceUrl("https://example.com/vc")).thenReturn(Optional.of(existing)); + when(supplementRepository.save(any(Supplement.class))).thenReturn(existing); + when(ingredientParser.parse(any())).thenReturn(List.of()); + when(collectorRunRepository.save(any(CollectorRun.class))).thenAnswer(i -> i.getArgument(0)); + + CollectorRun result = collectorService.processCsvStream(is, "test"); + + assertThat(result.getStatus()).isEqualTo("SUCCESS"); + assertThat(result.getAdded()).isEqualTo(0); + assertThat(result.getUpdated()).isEqualTo(1); + verify(mapper).updateFromCsvRecord(eq(existing), any()); + } + + @Test + void shouldSkipRecordWithEmptyName() { + String csv = "name,brand,category,source_url\n" + + ",Nature Made,Vitamins,https://example.com/vc\n"; + + InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8)); + + when(collectorRunRepository.save(any(CollectorRun.class))).thenAnswer(i -> i.getArgument(0)); + + CollectorRun result = collectorService.processCsvStream(is, "test"); + + assertThat(result.getErrors()).isEqualTo(1); + assertThat(result.getAdded()).isEqualTo(0); + } + + @Test + void shouldReturnRecentRuns() { + CollectorRun run = CollectorRun.builder().source("test").status("SUCCESS").build(); + when(collectorRunRepository.findTop10ByOrderByRunAtDesc()).thenReturn(List.of(run)); + + List result = collectorService.getRecentRuns(); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getSource()).isEqualTo("test"); + } +} diff --git a/src/test/java/ru/oa2/mvp/nutricollector/service/IngredientParserTest.java b/src/test/java/ru/oa2/mvp/nutricollector/service/IngredientParserTest.java new file mode 100644 index 0000000..925f5a9 --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutricollector/service/IngredientParserTest.java @@ -0,0 +1,88 @@ +package ru.oa2.mvp.nutricollector.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.oa2.mvp.nutricollector.dto.IngredientData; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class IngredientParserTest { + + private IngredientParser parser; + + @BeforeEach + void setUp() { + parser = new IngredientParser(); + } + + @Test + void shouldParseSimpleIngredientWithAmountAndUnit() { + List result = parser.parse("Vitamin C - 500 mg"); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("Vitamin C"); + assertThat(result.getFirst().amount()).isEqualByComparingTo(new BigDecimal("500")); + assertThat(result.getFirst().unit()).isEqualTo("mg"); + } + + @Test + void shouldParseMultipleIngredientsSeparatedBySemicolon() { + List result = parser.parse("Vitamin C - 500 mg; Zinc - 15 mg; Vitamin D - 25 mcg"); + + assertThat(result).hasSize(3); + assertThat(result.get(0).name()).isEqualTo("Vitamin C"); + assertThat(result.get(1).name()).isEqualTo("Zinc"); + assertThat(result.get(2).name()).isEqualTo("Vitamin D"); + } + + @Test + void shouldParseIngredientWithDailyValue() { + List result = parser.parse("Vitamin C - 90 mg (100% DV)"); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("Vitamin C"); + assertThat(result.getFirst().amount()).isEqualByComparingTo(new BigDecimal("90")); + assertThat(result.getFirst().unit()).isEqualTo("mg"); + assertThat(result.getFirst().dailyValuePercent()).isEqualTo(100); + } + + @Test + void shouldParseIngredientWithoutAmount() { + List result = parser.parse("Proprietary Blend"); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().name()).isEqualTo("Proprietary Blend"); + assertThat(result.getFirst().amount()).isNull(); + assertThat(result.getFirst().unit()).isNull(); + } + + @Test + void shouldReturnEmptyListForNullInput() { + assertThat(parser.parse(null)).isEmpty(); + } + + @Test + void shouldReturnEmptyListForBlankInput() { + assertThat(parser.parse(" ")).isEmpty(); + } + + @Test + void shouldHandlePipeSeparator() { + List result = parser.parse("Vitamin A - 900 mcg | Vitamin E - 15 mg"); + + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("Vitamin A"); + assertThat(result.get(1).name()).isEqualTo("Vitamin E"); + } + + @Test + void shouldHandleDecimalAmounts() { + List result = parser.parse("Vitamin B12 - 2,4 mcg"); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().amount()).isEqualByComparingTo(new BigDecimal("2.4")); + } +} diff --git a/src/test/java/ru/oa2/mvp/nutricollector/service/SupplementMapperTest.java b/src/test/java/ru/oa2/mvp/nutricollector/service/SupplementMapperTest.java new file mode 100644 index 0000000..067a1f6 --- /dev/null +++ b/src/test/java/ru/oa2/mvp/nutricollector/service/SupplementMapperTest.java @@ -0,0 +1,120 @@ +package ru.oa2.mvp.nutricollector.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse; +import ru.oa2.mvp.nutricollector.dto.IngredientData; +import ru.oa2.mvp.nutricollector.dto.SupplementCsvRecord; +import ru.oa2.mvp.nutricollector.entity.CollectorRun; +import ru.oa2.mvp.nutricollector.entity.Ingredient; +import ru.oa2.mvp.nutricollector.entity.Supplement; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class SupplementMapperTest { + + private SupplementMapper mapper; + + @BeforeEach + void setUp() { + mapper = new SupplementMapper(); + } + + @Test + void shouldMapCsvRecordToSupplement() { + SupplementCsvRecord csv = new SupplementCsvRecord(); + csv.setName("Omega-3 Fish Oil"); + csv.setBrand("Nordic Naturals"); + csv.setCategory("Fish Oil"); + csv.setForm("Softgel"); + csv.setServingSize("2 softgels"); + csv.setServingsPerContainer("60"); + csv.setRating("4.5"); + csv.setCountry("Norway"); + + Supplement result = mapper.fromCsvRecord(csv); + + assertThat(result.getName()).isEqualTo("Omega-3 Fish Oil"); + assertThat(result.getBrand()).isEqualTo("Nordic Naturals"); + assertThat(result.getCategory()).isEqualTo("Fish Oil"); + assertThat(result.getForm()).isEqualTo("Softgel"); + assertThat(result.getServingSize()).isEqualTo("2 softgels"); + assertThat(result.getServingsPerContainer()).isEqualTo(60); + assertThat(result.getRating()).isEqualByComparingTo(new BigDecimal("4.5")); + assertThat(result.getCountry()).isEqualTo("Norway"); + } + + @Test + void shouldUpdateExistingSupplementFromCsvRecord() { + Supplement existing = Supplement.builder() + .name("Old Name") + .brand("Old Brand") + .build(); + + SupplementCsvRecord csv = new SupplementCsvRecord(); + csv.setName("New Name"); + csv.setBrand("New Brand"); + csv.setCategory("Vitamins"); + + mapper.updateFromCsvRecord(existing, csv); + + assertThat(existing.getName()).isEqualTo("New Name"); + assertThat(existing.getBrand()).isEqualTo("New Brand"); + assertThat(existing.getCategory()).isEqualTo("Vitamins"); + } + + @Test + void shouldMapIngredientData() { + IngredientData data = new IngredientData("Vitamin C", new BigDecimal("500"), "mg", 555); + + Ingredient result = mapper.fromIngredientData(data); + + assertThat(result.getName()).isEqualTo("Vitamin C"); + assertThat(result.getAmount()).isEqualByComparingTo(new BigDecimal("500")); + assertThat(result.getUnit()).isEqualTo("mg"); + assertThat(result.getDailyValuePercent()).isEqualTo(555); + } + + @Test + void shouldMapCollectorRunToResponse() { + UUID id = UUID.randomUUID(); + Instant now = Instant.now(); + CollectorRun run = CollectorRun.builder() + .id(id) + .runAt(now) + .source("test-source") + .added(10) + .updated(5) + .errors(1) + .status("COMPLETED_WITH_ERRORS") + .build(); + + CollectorRunResponse response = mapper.toRunResponse(run); + + assertThat(response.id()).isEqualTo(id); + assertThat(response.runAt()).isEqualTo(now); + assertThat(response.source()).isEqualTo("test-source"); + assertThat(response.added()).isEqualTo(10); + assertThat(response.updated()).isEqualTo(5); + assertThat(response.errors()).isEqualTo(1); + assertThat(response.status()).isEqualTo("COMPLETED_WITH_ERRORS"); + } + + @Test + void shouldHandleInvalidNumericValues() { + SupplementCsvRecord csv = new SupplementCsvRecord(); + csv.setName("Test Supplement"); + csv.setServingsPerContainer("not-a-number"); + csv.setRating("invalid"); + + Supplement result = mapper.fromCsvRecord(csv); + + assertThat(result.getName()).isEqualTo("Test Supplement"); + assertThat(result.getServingsPerContainer()).isNull(); + assertThat(result.getRating()).isNull(); + } +}