From 1c65ac779517ade281a44eeab34a89fee544f7a9 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Fri, 20 Feb 2026 09:08:39 +0100 Subject: [PATCH] feat(production): Chargen abfragen und suchen (GetBatch, ListBatches, FindBatchByNumber) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Vertical Slice für Batch-Lese-Endpoints: - GET /api/production/batches/{id} - GET /api/production/batches (Filter: status, productionDate, articleId) - GET /api/production/batches/by-number/{batchNumber} articleId-Filter löst über RecipeRepository.findByArticleId() die zugehörigen Recipes auf und sucht dann Batches per findByRecipeIds(). Closes #34 --- .../production/FindBatchByNumber.java | 36 ++ .../application/production/GetBatch.java | 36 ++ .../application/production/ListBatches.java | 87 +++++ .../domain/production/BatchError.java | 5 + .../domain/production/BatchRepository.java | 9 + .../domain/production/RecipeRepository.java | 2 + .../ProductionUseCaseConfiguration.java | 19 ++ .../repository/BatchJpaRepository.java | 12 + .../repository/JpaBatchRepository.java | 53 +++ .../repository/JpaRecipeRepository.java | 13 + .../repository/RecipeJpaRepository.java | 2 + .../web/controller/BatchController.java | 104 +++++- .../web/dto/BatchSummaryResponse.java | 34 ++ .../ProductionErrorHttpStatusMapper.java | 1 + .../stub/StubRecipeRepository.java | 5 + .../production/FindBatchByNumberTest.java | 106 ++++++ .../application/production/GetBatchTest.java | 108 ++++++ .../production/ListBatchesTest.java | 319 ++++++++++++++++++ .../web/GetBatchIntegrationTest.java | 189 +++++++++++ .../web/ListBatchesIntegrationTest.java | 209 ++++++++++++ 20 files changed, 1348 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/de/effigenix/application/production/FindBatchByNumber.java create mode 100644 backend/src/main/java/de/effigenix/application/production/GetBatch.java create mode 100644 backend/src/main/java/de/effigenix/application/production/ListBatches.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchSummaryResponse.java create mode 100644 backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java create mode 100644 backend/src/test/java/de/effigenix/application/production/GetBatchTest.java create mode 100644 backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/production/web/GetBatchIntegrationTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/production/web/ListBatchesIntegrationTest.java diff --git a/backend/src/main/java/de/effigenix/application/production/FindBatchByNumber.java b/backend/src/main/java/de/effigenix/application/production/FindBatchByNumber.java new file mode 100644 index 0000000..c9552b2 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/FindBatchByNumber.java @@ -0,0 +1,36 @@ +package de.effigenix.application.production; + +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +public class FindBatchByNumber { + + private final BatchRepository batchRepository; + private final AuthorizationPort authorizationPort; + + public FindBatchByNumber(BatchRepository batchRepository, AuthorizationPort authorizationPort) { + this.batchRepository = batchRepository; + this.authorizationPort = authorizationPort; + } + + public Result execute(BatchNumber batchNumber, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); + } + + switch (batchRepository.findByBatchNumber(batchNumber)) { + case Result.Failure(var err) -> + { return Result.failure(new BatchError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new BatchError.BatchNotFoundByNumber(batchNumber)); + } + return Result.success(opt.get()); + } + } + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/GetBatch.java b/backend/src/main/java/de/effigenix/application/production/GetBatch.java new file mode 100644 index 0000000..dea734f --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/GetBatch.java @@ -0,0 +1,36 @@ +package de.effigenix.application.production; + +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +public class GetBatch { + + private final BatchRepository batchRepository; + private final AuthorizationPort authorizationPort; + + public GetBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) { + this.batchRepository = batchRepository; + this.authorizationPort = authorizationPort; + } + + public Result execute(BatchId batchId, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); + } + + switch (batchRepository.findById(batchId)) { + case Result.Failure(var err) -> + { return Result.failure(new BatchError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new BatchError.BatchNotFound(batchId)); + } + return Result.success(opt.get()); + } + } + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/ListBatches.java b/backend/src/main/java/de/effigenix/application/production/ListBatches.java new file mode 100644 index 0000000..d27e55d --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/ListBatches.java @@ -0,0 +1,87 @@ +package de.effigenix.application.production; + +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Transactional(readOnly = true) +public class ListBatches { + + private final BatchRepository batchRepository; + private final RecipeRepository recipeRepository; + private final AuthorizationPort authorizationPort; + + public ListBatches(BatchRepository batchRepository, RecipeRepository recipeRepository, + AuthorizationPort authorizationPort) { + this.batchRepository = batchRepository; + this.recipeRepository = recipeRepository; + this.authorizationPort = authorizationPort; + } + + public Result> execute(ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); + } + + switch (batchRepository.findAll()) { + case Result.Failure(var err) -> + { return Result.failure(new BatchError.RepositoryFailure(err.message())); } + case Result.Success(var batches) -> + { return Result.success(batches); } + } + } + + public Result> executeByStatus(BatchStatus status, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); + } + + switch (batchRepository.findByStatus(status)) { + case Result.Failure(var err) -> + { return Result.failure(new BatchError.RepositoryFailure(err.message())); } + case Result.Success(var batches) -> + { return Result.success(batches); } + } + } + + public Result> executeByProductionDate(LocalDate date, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); + } + + switch (batchRepository.findByProductionDate(date)) { + case Result.Failure(var err) -> + { return Result.failure(new BatchError.RepositoryFailure(err.message())); } + case Result.Success(var batches) -> + { return Result.success(batches); } + } + } + + public Result> executeByArticleId(String articleId, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to read batches")); + } + + switch (recipeRepository.findByArticleId(articleId)) { + case Result.Failure(var err) -> + { return Result.failure(new BatchError.RepositoryFailure(err.message())); } + case Result.Success(var recipes) -> { + if (recipes.isEmpty()) { + return Result.success(List.of()); + } + List recipeIds = recipes.stream().map(Recipe::id).toList(); + switch (batchRepository.findByRecipeIds(recipeIds)) { + case Result.Failure(var batchErr) -> + { return Result.failure(new BatchError.RepositoryFailure(batchErr.message())); } + case Result.Success(var batches) -> + { return Result.success(batches); } + } + } + } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchError.java b/backend/src/main/java/de/effigenix/domain/production/BatchError.java index 375b0ee..d9ab94d 100644 --- a/backend/src/main/java/de/effigenix/domain/production/BatchError.java +++ b/backend/src/main/java/de/effigenix/domain/production/BatchError.java @@ -25,6 +25,11 @@ public sealed interface BatchError { @Override public String message() { return "Recipe '" + recipeId.value() + "' is not in ACTIVE status"; } } + record BatchNotFoundByNumber(BatchNumber batchNumber) implements BatchError { + @Override public String code() { return "BATCH_NOT_FOUND_BY_NUMBER"; } + @Override public String message() { return "Batch with number '" + batchNumber.value() + "' not found"; } + } + record ValidationFailure(String message) implements BatchError { @Override public String code() { return "BATCH_VALIDATION_ERROR"; } } diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java index 2d56e77..65a769a 100644 --- a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java +++ b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java @@ -3,6 +3,7 @@ package de.effigenix.domain.production; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -12,5 +13,13 @@ public interface BatchRepository { Result> findAll(); + Result> findByBatchNumber(BatchNumber batchNumber); + + Result> findByStatus(BatchStatus status); + + Result> findByProductionDate(LocalDate date); + + Result> findByRecipeIds(List recipeIds); + Result save(Batch batch); } diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java b/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java index 20aedd1..dea2779 100644 --- a/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java @@ -19,4 +19,6 @@ public interface RecipeRepository { Result existsByNameAndVersion(String name, int version); Result> findByStatus(RecipeStatus status); + + Result> findByArticleId(String articleId); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index 65bd7fb..a16a8b0 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -5,6 +5,9 @@ import de.effigenix.application.production.ArchiveRecipe; import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.CreateRecipe; +import de.effigenix.application.production.FindBatchByNumber; +import de.effigenix.application.production.GetBatch; +import de.effigenix.application.production.ListBatches; import de.effigenix.application.production.PlanBatch; import de.effigenix.application.production.RecipeCycleChecker; import de.effigenix.application.production.GetRecipe; @@ -77,4 +80,20 @@ public class ProductionUseCaseConfiguration { BatchNumberGenerator batchNumberGenerator, AuthorizationPort authorizationPort) { return new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authorizationPort); } + + @Bean + public GetBatch getBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) { + return new GetBatch(batchRepository, authorizationPort); + } + + @Bean + public ListBatches listBatches(BatchRepository batchRepository, RecipeRepository recipeRepository, + AuthorizationPort authorizationPort) { + return new ListBatches(batchRepository, recipeRepository, authorizationPort); + } + + @Bean + public FindBatchByNumber findBatchByNumber(BatchRepository batchRepository, AuthorizationPort authorizationPort) { + return new FindBatchByNumber(batchRepository, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java index 24bd249..f457871 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java @@ -3,5 +3,17 @@ package de.effigenix.infrastructure.production.persistence.repository; import de.effigenix.infrastructure.production.persistence.entity.BatchEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + public interface BatchJpaRepository extends JpaRepository { + + Optional findByBatchNumber(String batchNumber); + + List findByStatus(String status); + + List findByProductionDate(LocalDate productionDate); + + List findByRecipeIdIn(List recipeIds); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java index 5c2133c..0048ed1 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -54,6 +55,58 @@ public class JpaBatchRepository implements BatchRepository { } } + @Override + public Result> findByBatchNumber(BatchNumber batchNumber) { + try { + Optional result = jpaRepository.findByBatchNumber(batchNumber.value()) + .map(mapper::toDomain); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByBatchNumber", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByStatus(BatchStatus status) { + try { + List result = jpaRepository.findByStatus(status.name()).stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByStatus", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByProductionDate(LocalDate date) { + try { + List result = jpaRepository.findByProductionDate(date).stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByProductionDate", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByRecipeIds(List recipeIds) { + try { + List ids = recipeIds.stream().map(RecipeId::value).toList(); + List result = jpaRepository.findByRecipeIdIn(ids).stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByRecipeIds", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override @Transactional public Result save(Batch batch) { diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java index e42371e..d3f929e 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java @@ -91,6 +91,19 @@ public class JpaRecipeRepository implements RecipeRepository { } } + @Override + public Result> findByArticleId(String articleId) { + try { + List result = jpaRepository.findByArticleId(articleId).stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findByArticleId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override public Result existsByNameAndVersion(String name, int version) { try { diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java index 5110d7d..042e134 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java @@ -10,4 +10,6 @@ public interface RecipeJpaRepository extends JpaRepository List findByStatus(String status); boolean existsByNameAndVersion(String name, int version); + + List findByArticleId(String articleId); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java index d30a33e..bb75bf6 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java @@ -1,9 +1,16 @@ package de.effigenix.infrastructure.production.web.controller; +import de.effigenix.application.production.FindBatchByNumber; +import de.effigenix.application.production.GetBatch; +import de.effigenix.application.production.ListBatches; import de.effigenix.application.production.PlanBatch; import de.effigenix.application.production.command.PlanBatchCommand; import de.effigenix.domain.production.BatchError; +import de.effigenix.domain.production.BatchId; +import de.effigenix.domain.production.BatchNumber; +import de.effigenix.domain.production.BatchStatus; import de.effigenix.infrastructure.production.web.dto.BatchResponse; +import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse; import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest; import de.effigenix.shared.security.ActorId; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -11,12 +18,16 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.List; + @RestController @RequestMapping("/api/production/batches") @SecurityRequirement(name = "Bearer Authentication") @@ -26,9 +37,91 @@ public class BatchController { private static final Logger logger = LoggerFactory.getLogger(BatchController.class); private final PlanBatch planBatch; + private final GetBatch getBatch; + private final ListBatches listBatches; + private final FindBatchByNumber findBatchByNumber; - public BatchController(PlanBatch planBatch) { + public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches, + FindBatchByNumber findBatchByNumber) { this.planBatch = planBatch; + this.getBatch = getBatch; + this.listBatches = listBatches; + this.findBatchByNumber = findBatchByNumber; + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('BATCH_READ')") + public ResponseEntity getBatch( + @PathVariable("id") String id, + Authentication authentication + ) { + var actorId = ActorId.of(authentication.getName()); + var result = getBatch.execute(BatchId.of(id), actorId); + + if (result.isFailure()) { + throw new BatchDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue())); + } + + @GetMapping + @PreAuthorize("hasAuthority('BATCH_READ')") + public ResponseEntity> listBatches( + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "productionDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate productionDate, + @RequestParam(value = "articleId", required = false) String articleId, + Authentication authentication + ) { + var actorId = ActorId.of(authentication.getName()); + + var result = switch (filterType(status, productionDate, articleId)) { + case "ambiguous" -> throw new BatchDomainErrorException( + new BatchError.ValidationFailure("Only one filter allowed at a time: status, productionDate, or articleId")); + case "status" -> { + try { + yield listBatches.executeByStatus(BatchStatus.valueOf(status), actorId); + } catch (IllegalArgumentException e) { + throw new BatchDomainErrorException( + new BatchError.ValidationFailure("Invalid status: " + status)); + } + } + case "productionDate" -> listBatches.executeByProductionDate(productionDate, actorId); + case "articleId" -> listBatches.executeByArticleId(articleId, actorId); + default -> listBatches.execute(actorId); + }; + + if (result.isFailure()) { + throw new BatchDomainErrorException(result.unsafeGetError()); + } + + var summaries = result.unsafeGetValue().stream() + .map(BatchSummaryResponse::from) + .toList(); + return ResponseEntity.ok(summaries); + } + + @GetMapping("/by-number/{batchNumber}") + @PreAuthorize("hasAuthority('BATCH_READ')") + public ResponseEntity findByNumber( + @PathVariable("batchNumber") String batchNumber, + Authentication authentication + ) { + var actorId = ActorId.of(authentication.getName()); + + var parsedNumber = BatchNumber.of(batchNumber); + if (parsedNumber.isFailure()) { + throw new BatchDomainErrorException( + new BatchError.ValidationFailure("Invalid batch number format: " + batchNumber)); + } + + var result = findBatchByNumber.execute(parsedNumber.unsafeGetValue(), actorId); + + if (result.isFailure()) { + throw new BatchDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue())); } @PostMapping @@ -57,6 +150,15 @@ public class BatchController { .body(BatchResponse.from(result.unsafeGetValue())); } + private static String filterType(String status, LocalDate productionDate, String articleId) { + int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0); + if (count > 1) return "ambiguous"; + if (status != null) return "status"; + if (productionDate != null) return "productionDate"; + if (articleId != null) return "articleId"; + return "none"; + } + public static class BatchDomainErrorException extends RuntimeException { private final BatchError error; diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchSummaryResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchSummaryResponse.java new file mode 100644 index 0000000..974d31d --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchSummaryResponse.java @@ -0,0 +1,34 @@ +package de.effigenix.infrastructure.production.web.dto; + +import de.effigenix.domain.production.Batch; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +public record BatchSummaryResponse( + String id, + String batchNumber, + String recipeId, + String status, + String plannedQuantity, + String plannedQuantityUnit, + LocalDate productionDate, + LocalDate bestBeforeDate, + OffsetDateTime createdAt, + OffsetDateTime updatedAt +) { + public static BatchSummaryResponse from(Batch batch) { + return new BatchSummaryResponse( + batch.id().value(), + batch.batchNumber().value(), + batch.recipeId().value(), + batch.status().name(), + batch.plannedQuantity().amount().toPlainString(), + batch.plannedQuantity().uom().name(), + batch.productionDate(), + batch.bestBeforeDate(), + batch.createdAt(), + batch.updatedAt() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java index 403baf2..3c51f94 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java @@ -29,6 +29,7 @@ public final class ProductionErrorHttpStatusMapper { public static int toHttpStatus(BatchError error) { return switch (error) { case BatchError.BatchNotFound e -> 404; + case BatchError.BatchNotFoundByNumber e -> 404; case BatchError.InvalidPlannedQuantity e -> 400; case BatchError.InvalidDates e -> 400; case BatchError.RecipeNotActive e -> 409; diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java index 38611a3..71fdc4b 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubRecipeRepository.java @@ -48,4 +48,9 @@ public class StubRecipeRepository implements RecipeRepository { public Result> findByStatus(RecipeStatus status) { return Result.failure(STUB_ERROR); } + + @Override + public Result> findByArticleId(String articleId) { + return Result.failure(STUB_ERROR); + } } diff --git a/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java b/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java new file mode 100644 index 0000000..90bb6d7 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java @@ -0,0 +1,106 @@ +package de.effigenix.application.production; + +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FindBatchByNumber Use Case") +class FindBatchByNumberTest { + + @Mock private BatchRepository batchRepository; + @Mock private AuthorizationPort authPort; + + private FindBatchByNumber findBatchByNumber; + private ActorId performedBy; + + private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(LocalDate.of(2026, 3, 1), 1); + + @BeforeEach + void setUp() { + findBatchByNumber = new FindBatchByNumber(batchRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private Batch sampleBatch() { + return Batch.reconstitute( + BatchId.of("batch-1"), + BATCH_NUMBER, + RecipeId.of("recipe-1"), + BatchStatus.PLANNED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + LocalDate.of(2026, 3, 1), + LocalDate.of(2026, 6, 1), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + @Test + @DisplayName("should return batch when batch number exists") + void should_ReturnBatch_When_BatchNumberExists() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByBatchNumber(BATCH_NUMBER)).thenReturn(Result.success(Optional.of(sampleBatch()))); + + var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().batchNumber()).isEqualTo(BATCH_NUMBER); + } + + @Test + @DisplayName("should fail with BatchNotFoundByNumber when batch number does not exist") + void should_FailWithBatchNotFoundByNumber_When_BatchNumberDoesNotExist() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByBatchNumber(BATCH_NUMBER)).thenReturn(Result.success(Optional.empty())); + + var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFoundByNumber.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository returns error") + void should_FailWithRepositoryFailure_When_RepositoryReturnsError() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByBatchNumber(BATCH_NUMBER)) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized_When_ActorLacksPermission() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false); + + var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(batchRepository, never()).findByBatchNumber(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java b/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java new file mode 100644 index 0000000..51ca874 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java @@ -0,0 +1,108 @@ +package de.effigenix.application.production; + +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetBatch Use Case") +class GetBatchTest { + + @Mock private BatchRepository batchRepository; + @Mock private AuthorizationPort authPort; + + private GetBatch getBatch; + private ActorId performedBy; + + @BeforeEach + void setUp() { + getBatch = new GetBatch(batchRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private Batch sampleBatch(String id) { + return Batch.reconstitute( + BatchId.of(id), + BatchNumber.generate(LocalDate.of(2026, 3, 1), 1), + RecipeId.of("recipe-1"), + BatchStatus.PLANNED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + LocalDate.of(2026, 3, 1), + LocalDate.of(2026, 6, 1), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + @Test + @DisplayName("should return batch when batch exists") + void should_ReturnBatch_When_BatchExists() { + var batchId = BatchId.of("batch-1"); + var batch = sampleBatch("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + + var result = getBatch.execute(batchId, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().id()).isEqualTo(batchId); + } + + @Test + @DisplayName("should fail with BatchNotFound when batch does not exist") + void should_FailWithBatchNotFound_When_BatchDoesNotExist() { + var batchId = BatchId.of("nonexistent"); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.empty())); + + var result = getBatch.execute(batchId, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class); + } + + @Test + @DisplayName("should fail with RepositoryFailure when repository returns error") + void should_FailWithRepositoryFailure_When_RepositoryReturnsError() { + var batchId = BatchId.of("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findById(batchId)) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = getBatch.execute(batchId, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized_When_ActorLacksPermission() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false); + + var result = getBatch.execute(BatchId.of("batch-1"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(batchRepository, never()).findById(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java new file mode 100644 index 0000000..117b10a --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java @@ -0,0 +1,319 @@ +package de.effigenix.application.production; + +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ListBatches Use Case") +class ListBatchesTest { + + @Mock private BatchRepository batchRepository; + @Mock private RecipeRepository recipeRepository; + @Mock private AuthorizationPort authPort; + + private ListBatches listBatches; + private ActorId performedBy; + + private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1); + private static final RepositoryError DB_ERROR = new RepositoryError.DatabaseError("connection lost"); + + @BeforeEach + void setUp() { + listBatches = new ListBatches(batchRepository, recipeRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private Batch sampleBatch(String id, BatchStatus status) { + return Batch.reconstitute( + BatchId.of(id), + BatchNumber.generate(PRODUCTION_DATE, 1), + RecipeId.of("recipe-1"), + status, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PRODUCTION_DATE, + LocalDate.of(2026, 6, 1), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + private Recipe sampleRecipe(String id) { + return Recipe.reconstitute( + RecipeId.of(id), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, + "Beschreibung", new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), + OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + @Nested + @DisplayName("execute – alle Batches") + class ExecuteAll { + + @Test + @DisplayName("should return all batches") + void should_ReturnAllBatches() { + var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED), sampleBatch("b2", BatchStatus.PLANNED)); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findAll()).thenReturn(Result.success(batches)); + + var result = listBatches.execute(performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(2); + } + + @Test + @DisplayName("should return empty list when no batches exist") + void should_ReturnEmptyList_When_NoBatchesExist() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findAll()).thenReturn(Result.success(List.of())); + + var result = listBatches.execute(performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findAll fails") + void should_FailWithRepositoryFailure_When_FindAllFails() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findAll()).thenReturn(Result.failure(DB_ERROR)); + + var result = listBatches.execute(performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false); + + var result = listBatches.execute(performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(batchRepository, never()).findAll(); + } + } + + @Nested + @DisplayName("executeByStatus – nach Status filtern") + class ExecuteByStatus { + + @Test + @DisplayName("should return batches filtered by status") + void should_ReturnBatches_FilteredByStatus() { + var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED)); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(batches)); + + var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + } + + @Test + @DisplayName("should return empty list when no batches match status") + void should_ReturnEmptyList_When_NoBatchesMatchStatus() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(List.of())); + + var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findByStatus fails") + void should_FailWithRepositoryFailure_When_FindByStatusFails() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.failure(DB_ERROR)); + + var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false); + + var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(batchRepository, never()).findByStatus(any()); + } + } + + @Nested + @DisplayName("executeByProductionDate – nach Datum filtern") + class ExecuteByProductionDate { + + @Test + @DisplayName("should return batches filtered by production date") + void should_ReturnBatches_FilteredByProductionDate() { + var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED)); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(batches)); + + var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + } + + @Test + @DisplayName("should return empty list when no batches match date") + void should_ReturnEmptyList_When_NoBatchesMatchDate() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(List.of())); + + var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findByProductionDate fails") + void should_FailWithRepositoryFailure_When_FindByProductionDateFails() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.failure(DB_ERROR)); + + var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false); + + var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(batchRepository, never()).findByProductionDate(any()); + } + } + + @Nested + @DisplayName("executeByArticleId – über Recipe-Lookup filtern") + class ExecuteByArticleId { + + @Test + @DisplayName("should return batches filtered by articleId via recipe lookup") + void should_ReturnBatches_FilteredByArticleId() { + var recipe = sampleRecipe("recipe-1"); + var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED)); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe))); + when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(batches)); + + var result = listBatches.executeByArticleId("article-123", performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + } + + @Test + @DisplayName("should return empty list when no recipes match articleId") + void should_ReturnEmptyList_When_NoRecipesMatchArticleId() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(recipeRepository.findByArticleId("unknown-article")).thenReturn(Result.success(List.of())); + + var result = listBatches.executeByArticleId("unknown-article", performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + verify(batchRepository, never()).findByRecipeIds(any()); + } + + @Test + @DisplayName("should return empty list when recipes found but no batches match") + void should_ReturnEmptyList_When_RecipesFoundButNoBatches() { + var recipe = sampleRecipe("recipe-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe))); + when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(List.of())); + + var result = listBatches.executeByArticleId("article-123", performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should fail with RepositoryFailure when recipe repository fails") + void should_FailWithRepositoryFailure_When_RecipeRepositoryFails() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.failure(DB_ERROR)); + + var result = listBatches.executeByArticleId("article-123", performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + verify(batchRepository, never()).findByRecipeIds(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when batch repository fails after recipe lookup") + void should_FailWithRepositoryFailure_When_BatchRepositoryFailsAfterRecipeLookup() { + var recipe = sampleRecipe("recipe-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true); + when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe))); + when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.failure(DB_ERROR)); + + var result = listBatches.executeByArticleId("article-123", performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized() { + when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false); + + var result = listBatches.executeByArticleId("article-123", performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(recipeRepository, never()).findByArticleId(any()); + verify(batchRepository, never()).findByRecipeIds(any()); + } + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/GetBatchIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/GetBatchIntegrationTest.java new file mode 100644 index 0000000..26b4334 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/GetBatchIntegrationTest.java @@ -0,0 +1,189 @@ +package de.effigenix.infrastructure.production.web; + +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.infrastructure.AbstractIntegrationTest; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.Set; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("GetBatch Integration Tests") +class GetBatchIntegrationTest extends AbstractIntegrationTest { + + private String adminToken; + private String viewerToken; + + @BeforeEach + void setUp() { + RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin"); + RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer"); + + UserEntity admin = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRole), "BRANCH-01"); + UserEntity viewer = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRole), "BRANCH-01"); + + adminToken = generateToken(admin.getId(), "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ"); + viewerToken = generateToken(viewer.getId(), "batch.viewer", "USER_READ"); + } + + @Test + @DisplayName("Batch per ID abrufen → 200 mit vollständigen Daten") + void getBatch_existingId_returns200WithFullDetails() throws Exception { + String batchId = createBatch(); + + mockMvc.perform(get("/api/production/batches/{id}", batchId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(batchId)) + .andExpect(jsonPath("$.batchNumber").isNotEmpty()) + .andExpect(jsonPath("$.recipeId").isNotEmpty()) + .andExpect(jsonPath("$.status").value("PLANNED")) + .andExpect(jsonPath("$.plannedQuantity").isNotEmpty()) + .andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM")) + .andExpect(jsonPath("$.productionDate").value("2026-03-01")) + .andExpect(jsonPath("$.bestBeforeDate").value("2026-06-01")); + } + + @Test + @DisplayName("Nicht existierender Batch → 404") + void getBatch_nonExistentId_returns404() throws Exception { + mockMvc.perform(get("/api/production/batches/{id}", "nonexistent-id") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND")); + } + + @Test + @DisplayName("Batch abrufen ohne BATCH_READ → 403") + void getBatch_withoutPermission_returns403() throws Exception { + mockMvc.perform(get("/api/production/batches/{id}", "any-id") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Batch abrufen ohne Token → 401") + void getBatch_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/production/batches/{id}", "any-id")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Batch per Number abrufen → 200") + void findByNumber_existingNumber_returns200() throws Exception { + createBatch(); + + mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2026-03-01-001") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001")) + .andExpect(jsonPath("$.status").value("PLANNED")); + } + + @Test + @DisplayName("Nicht existierende BatchNumber → 404") + void findByNumber_nonExistent_returns404() throws Exception { + mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2099-01-01-999") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND_BY_NUMBER")); + } + + @Test + @DisplayName("BatchNumber-Suche ohne BATCH_READ → 403") + void findByNumber_withoutPermission_returns403() throws Exception { + mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2026-03-01-001") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("BatchNumber-Suche ohne Token → 401") + void findByNumber_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2026-03-01-001")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Ungültiges BatchNumber-Format → 400") + void findByNumber_invalidFormat_returns400() throws Exception { + mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "INVALID-FORMAT") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR")); + } + + // ==================== Hilfsmethoden ==================== + + private String createBatch() throws Exception { + String recipeId = createActiveRecipe(); + + String json = """ + { + "recipeId": "%s", + "plannedQuantity": "100", + "plannedQuantityUnit": "KILOGRAM", + "productionDate": "2026-03-01", + "bestBeforeDate": "2026-06-01" + } + """.formatted(recipeId); + + var result = mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String createActiveRecipe() throws Exception { + String recipeJson = """ + { + "name": "Test-Rezept-%s", + "version": 1, + "type": "FINISHED_PRODUCT", + "description": "Testrezept", + "yieldPercentage": 85, + "shelfLifeDays": 14, + "outputQuantity": "100", + "outputUom": "KILOGRAM", + "articleId": "article-123" + } + """.formatted(UUID.randomUUID().toString().substring(0, 8)); + + var result = mockMvc.perform(post("/api/recipes") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(recipeJson)) + .andExpect(status().isCreated()) + .andReturn(); + + String recipeId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + + String ingredientJson = """ + {"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(ingredientJson)) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + return recipeId; + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/ListBatchesIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/ListBatchesIntegrationTest.java new file mode 100644 index 0000000..b08ddfe --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/ListBatchesIntegrationTest.java @@ -0,0 +1,209 @@ +package de.effigenix.infrastructure.production.web; + +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.infrastructure.AbstractIntegrationTest; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.Set; +import java.util.UUID; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("ListBatches Integration Tests") +class ListBatchesIntegrationTest extends AbstractIntegrationTest { + + private String adminToken; + private String viewerToken; + + @BeforeEach + void setUp() { + RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin"); + RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer"); + + UserEntity admin = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRole), "BRANCH-01"); + UserEntity viewer = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRole), "BRANCH-01"); + + adminToken = generateToken(admin.getId(), "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ"); + viewerToken = generateToken(viewer.getId(), "batch.viewer", "USER_READ"); + } + + @Test + @DisplayName("Alle Batches auflisten → 200 mit Summary-Format") + void listBatches_returnsAllWithSummaryFormat() throws Exception { + createBatch("2026-03-01"); + createBatch("2026-03-02"); + + mockMvc.perform(get("/api/production/batches") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].id").isNotEmpty()) + .andExpect(jsonPath("$[0].batchNumber").isNotEmpty()) + .andExpect(jsonPath("$[0].status").value("PLANNED")); + } + + @Test + @DisplayName("Leere Liste → 200 mit leerem Array") + void listBatches_empty_returns200WithEmptyArray() throws Exception { + mockMvc.perform(get("/api/production/batches") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @DisplayName("Batches nach Status filtern → 200 nur passende") + void listBatches_filteredByStatus_returnsOnlyMatching() throws Exception { + createBatch("2026-03-01"); + + mockMvc.perform(get("/api/production/batches") + .param("status", "PLANNED") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].status").value("PLANNED")); + } + + @Test + @DisplayName("Ungültiger Status-Parameter → 400") + void listBatches_invalidStatus_returns400() throws Exception { + mockMvc.perform(get("/api/production/batches") + .param("status", "INVALID") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR")); + } + + @Test + @DisplayName("Batches nach Produktionsdatum filtern → 200") + void listBatches_filteredByProductionDate_returnsOnlyMatching() throws Exception { + createBatch("2026-03-01"); + createBatch("2026-03-02"); + + mockMvc.perform(get("/api/production/batches") + .param("productionDate", "2026-03-01") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].productionDate").value("2026-03-01")); + } + + @Test + @DisplayName("Batches nach articleId filtern → 200") + void listBatches_filteredByArticleId_returnsOnlyMatching() throws Exception { + createBatch("2026-03-01"); + + mockMvc.perform(get("/api/production/batches") + .param("articleId", "article-123") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); + } + + @Test + @DisplayName("Unbekannte articleId → 200 leeres Array") + void listBatches_unknownArticleId_returnsEmptyArray() throws Exception { + mockMvc.perform(get("/api/production/batches") + .param("articleId", "unknown-article") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @DisplayName("Mehrere Filter gleichzeitig → 400") + void listBatches_multipleFilters_returns400() throws Exception { + mockMvc.perform(get("/api/production/batches") + .param("status", "PLANNED") + .param("articleId", "article-123") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR")); + } + + @Test + @DisplayName("Batches auflisten ohne BATCH_READ → 403") + void listBatches_withoutPermission_returns403() throws Exception { + mockMvc.perform(get("/api/production/batches") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Batches auflisten ohne Token → 401") + void listBatches_withoutToken_returns401() throws Exception { + mockMvc.perform(get("/api/production/batches")) + .andExpect(status().isUnauthorized()); + } + + // ==================== Hilfsmethoden ==================== + + private void createBatch(String productionDate) throws Exception { + String recipeId = createActiveRecipe(); + + String json = """ + { + "recipeId": "%s", + "plannedQuantity": "100", + "plannedQuantityUnit": "KILOGRAM", + "productionDate": "%s", + "bestBeforeDate": "2026-12-01" + } + """.formatted(recipeId, productionDate); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()); + } + + private String createActiveRecipe() throws Exception { + String recipeJson = """ + { + "name": "Test-Rezept-%s", + "version": 1, + "type": "FINISHED_PRODUCT", + "description": "Testrezept", + "yieldPercentage": 85, + "shelfLifeDays": 14, + "outputQuantity": "100", + "outputUom": "KILOGRAM", + "articleId": "article-123" + } + """.formatted(UUID.randomUUID().toString().substring(0, 8)); + + var result = mockMvc.perform(post("/api/recipes") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(recipeJson)) + .andExpect(status().isCreated()) + .andReturn(); + + String recipeId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + + String ingredientJson = """ + {"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(ingredientJson)) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + return recipeId; + } +}