feat: initial nutri-collector — data collector service for supplement catalog

This commit is contained in:
Backend Agent 2026-04-10 19:20:31 +00:00
commit 50a1d39893
33 changed files with 1715 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
target/
*.class
*.jar
*.war
*.iml
.idea/
.vscode/
*.log
.env
.DS_Store

12
Dockerfile Normal file
View File

@ -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"]

40
docker-compose.yaml Normal file
View File

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

142
pom.xml Normal file
View File

@ -0,0 +1,142 @@
<?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-collector</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>nutri-collector</name>
<description>Data collector service for supplement (BAD) catalog — parses and normalizes supplement data</description>
<properties>
<java.version>21</java.version>
<opencsv.version>5.9</opencsv.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>
<!-- Spring Batch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</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>
<!-- OpenCSV -->
<dependency>
<groupId>com.opencsv</groupId>
<artifactId>opencsv</artifactId>
<version>${opencsv.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>
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-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>

View File

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

View File

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

View File

@ -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<CollectorRunResponse> 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<List<CollectorRunResponse>> getRecentRuns() {
List<CollectorRunResponse> runs = collectorService.getRecentRuns().stream()
.map(mapper::toRunResponse)
.toList();
return ResponseEntity.ok(runs);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CollectorRun, UUID> {
List<CollectorRun> findTop10ByOrderByRunAtDesc();
}

View File

@ -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<Ingredient, UUID> {
List<Ingredient> findBySupplementId(UUID supplementId);
void deleteBySupplementId(UUID supplementId);
}

View File

@ -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<Supplement, UUID> {
Optional<Supplement> findByNameAndBrand(String name, String brand);
Optional<Supplement> findBySourceUrl(String sourceUrl);
boolean existsBySourceUrl(String sourceUrl);
}

View File

@ -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<SupplementCsvRecord> records = new CsvToBeanBuilder<SupplementCsvRecord>(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<IngredientData> 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<CollectorRun> getRecentRuns() {
return collectorRunRepository.findTop10ByOrderByRunAtDesc();
}
}

View File

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

View File

@ -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<IngredientData> parse(String ingredientsString) {
if (ingredientsString == null || ingredientsString.isBlank()) {
return Collections.emptyList();
}
List<IngredientData> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CollectorRun> result = collectorService.getRecentRuns();
assertThat(result).hasSize(1);
assertThat(result.getFirst().getSource()).isEqualTo("test");
}
}

View File

@ -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<IngredientData> 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<IngredientData> 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<IngredientData> 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<IngredientData> 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<IngredientData> 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<IngredientData> result = parser.parse("Vitamin B12 - 2,4 mcg");
assertThat(result).hasSize(1);
assertThat(result.getFirst().amount()).isEqualByComparingTo(new BigDecimal("2.4"));
}
}

View File

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