From ad33eed2f4b669d7297a8047a115b1f0ea12e422 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Wed, 25 Feb 2026 22:37:20 +0100 Subject: [PATCH] feat(production): Produktionsauftrag umterminieren und abfragen (US-P17) Reschedule (PLANNED/RELEASED) mit Datumsvalidierung und List-Endpoint mit optionaler Filterung nach Datum/Status als Full Vertical Slice. Lasttests um neue Szenarien erweitert. --- .../production/ListProductionOrders.java | 64 +++++ .../production/RescheduleProductionOrder.java | 60 ++++ .../RescheduleProductionOrderCommand.java | 6 + .../domain/production/ProductionOrder.java | 15 +- .../production/ProductionOrderRepository.java | 7 + .../ProductionUseCaseConfiguration.java | 15 + .../JdbcProductionOrderRepository.java | 48 ++++ .../controller/ProductionOrderController.java | 67 ++++- .../dto/RescheduleProductionOrderRequest.java | 8 + .../stub/StubProductionOrderRepository.java | 17 ++ .../production/ListProductionOrdersTest.java | 140 +++++++++ .../RescheduleProductionOrderTest.java | 169 +++++++++++ .../production/ProductionOrderTest.java | 124 ++++++++ ...ductionOrderControllerIntegrationTest.java | 269 ++++++++++++++++++ .../loadtest/scenario/ProductionScenario.java | 48 +++- .../simulation/FullWorkloadSimulation.java | 7 +- .../loadtest/util/JsonBodyBuilder.java | 5 + 17 files changed, 1061 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/production/ListProductionOrders.java create mode 100644 backend/src/main/java/de/effigenix/application/production/RescheduleProductionOrder.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/RescheduleProductionOrderCommand.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RescheduleProductionOrderRequest.java create mode 100644 backend/src/test/java/de/effigenix/application/production/ListProductionOrdersTest.java create mode 100644 backend/src/test/java/de/effigenix/application/production/RescheduleProductionOrderTest.java diff --git a/backend/src/main/java/de/effigenix/application/production/ListProductionOrders.java b/backend/src/main/java/de/effigenix/application/production/ListProductionOrders.java new file mode 100644 index 0000000..a7e1aae --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/ListProductionOrders.java @@ -0,0 +1,64 @@ +package de.effigenix.application.production; + +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; + +import java.time.LocalDate; +import java.util.List; + +public class ListProductionOrders { + + private final ProductionOrderRepository productionOrderRepository; + private final AuthorizationPort authorizationPort; + + public ListProductionOrders( + ProductionOrderRepository productionOrderRepository, + AuthorizationPort authorizationPort + ) { + this.productionOrderRepository = productionOrderRepository; + this.authorizationPort = authorizationPort; + } + + public Result> execute(ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to list production orders")); + } + + return wrapResult(productionOrderRepository.findAll()); + } + + public Result> executeByDateRange(LocalDate from, LocalDate to, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to list production orders")); + } + + return wrapResult(productionOrderRepository.findByDateRange(from, to)); + } + + public Result> executeByStatus(ProductionOrderStatus status, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to list production orders")); + } + + return wrapResult(productionOrderRepository.findByStatus(status)); + } + + public Result> executeByDateRangeAndStatus( + LocalDate from, LocalDate to, ProductionOrderStatus status, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to list production orders")); + } + + return wrapResult(productionOrderRepository.findByDateRangeAndStatus(from, to, status)); + } + + private Result> wrapResult(Result> repoResult) { + return switch (repoResult) { + case Result.Failure(var err) -> Result.failure(new ProductionOrderError.RepositoryFailure(err.message())); + case Result.Success(var orders) -> Result.success(orders); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/RescheduleProductionOrder.java b/backend/src/main/java/de/effigenix/application/production/RescheduleProductionOrder.java new file mode 100644 index 0000000..6828e0a --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/RescheduleProductionOrder.java @@ -0,0 +1,60 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.RescheduleProductionOrderCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.persistence.UnitOfWork; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; + +public class RescheduleProductionOrder { + + private final ProductionOrderRepository productionOrderRepository; + private final AuthorizationPort authorizationPort; + private final UnitOfWork unitOfWork; + + public RescheduleProductionOrder( + ProductionOrderRepository productionOrderRepository, + AuthorizationPort authorizationPort, + UnitOfWork unitOfWork + ) { + this.productionOrderRepository = productionOrderRepository; + this.authorizationPort = authorizationPort; + this.unitOfWork = unitOfWork; + } + + public Result execute(RescheduleProductionOrderCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to reschedule production orders")); + } + + var orderId = ProductionOrderId.of(cmd.productionOrderId()); + ProductionOrder order; + switch (productionOrderRepository.findById(orderId)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.RepositoryFailure(err.message())); + } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new ProductionOrderError.ProductionOrderNotFound(orderId)); + } + order = opt.get(); + } + } + + switch (order.reschedule(cmd.newPlannedDate())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + return unitOfWork.executeAtomically(() -> { + switch (productionOrderRepository.save(order)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + return Result.success(order); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/command/RescheduleProductionOrderCommand.java b/backend/src/main/java/de/effigenix/application/production/command/RescheduleProductionOrderCommand.java new file mode 100644 index 0000000..d910c10 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/RescheduleProductionOrderCommand.java @@ -0,0 +1,6 @@ +package de.effigenix.application.production.command; + +import java.time.LocalDate; + +public record RescheduleProductionOrderCommand(String productionOrderId, LocalDate newPlannedDate) { +} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java index fe6bf44..1129aa2 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java @@ -25,6 +25,7 @@ import java.time.ZoneOffset; * 10. Only IN_PROGRESS → COMPLETED transition allowed via complete() (Batch must be COMPLETED – enforced by Use Case) * 11. Only PLANNED or RELEASED → CANCELLED transition allowed via cancel(reason) * 12. CancelledReason is set exactly once during cancel() and must not be blank + * 13. Reschedule only in PLANNED or RELEASED, new date must not be in the past */ public class ProductionOrder { @@ -33,7 +34,7 @@ public class ProductionOrder { private ProductionOrderStatus status; private BatchId batchId; private final Quantity plannedQuantity; - private final LocalDate plannedDate; + private LocalDate plannedDate; private final Priority priority; private final String notes; private String cancelledReason; @@ -193,6 +194,18 @@ public class ProductionOrder { return Result.success(null); } + public Result reschedule(LocalDate newDate) { + if (status != ProductionOrderStatus.PLANNED && status != ProductionOrderStatus.RELEASED) { + return Result.failure(new ProductionOrderError.InvalidStatusTransition(status, status)); + } + if (newDate.isBefore(LocalDate.now())) { + return Result.failure(new ProductionOrderError.PlannedDateInPast(newDate)); + } + this.plannedDate = newDate; + this.updatedAt = OffsetDateTime.now(ZoneOffset.UTC); + return Result.success(null); + } + public Result cancel(String reason) { if (reason == null || reason.isBlank()) { return Result.failure(new ProductionOrderError.ValidationFailure("Cancellation reason must not be blank")); diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.java index a930504..19821ab 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.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,11 @@ public interface ProductionOrderRepository { Result> findAll(); + Result> findByDateRange(LocalDate from, LocalDate to); + + Result> findByStatus(ProductionOrderStatus status); + + Result> findByDateRangeAndStatus(LocalDate from, LocalDate to, ProductionOrderStatus status); + Result save(ProductionOrder productionOrder); } 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 f34255b..f58a9c3 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -7,7 +7,9 @@ import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.CancelProductionOrder; import de.effigenix.application.production.CompleteProductionOrder; import de.effigenix.application.production.CreateProductionOrder; +import de.effigenix.application.production.ListProductionOrders; import de.effigenix.application.production.ReleaseProductionOrder; +import de.effigenix.application.production.RescheduleProductionOrder; import de.effigenix.application.production.StartProductionOrder; import de.effigenix.application.production.CancelBatch; import de.effigenix.application.production.CompleteBatch; @@ -177,4 +179,17 @@ public class ProductionUseCaseConfiguration { UnitOfWork unitOfWork) { return new CancelProductionOrder(productionOrderRepository, authorizationPort, unitOfWork); } + + @Bean + public RescheduleProductionOrder rescheduleProductionOrder(ProductionOrderRepository productionOrderRepository, + AuthorizationPort authorizationPort, + UnitOfWork unitOfWork) { + return new RescheduleProductionOrder(productionOrderRepository, authorizationPort, unitOfWork); + } + + @Bean + public ListProductionOrders listProductionOrders(ProductionOrderRepository productionOrderRepository, + AuthorizationPort authorizationPort) { + return new ListProductionOrders(productionOrderRepository, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java index 512005f..8c0523b 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JdbcProductionOrderRepository.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Repository; import java.sql.ResultSet; import java.sql.SQLException; +import java.time.LocalDate; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; @@ -56,6 +57,51 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository } } + @Override + public Result> findByDateRange(LocalDate from, LocalDate to) { + try { + var orders = jdbc.sql("SELECT * FROM production_orders WHERE planned_date BETWEEN :from AND :to ORDER BY planned_date, created_at DESC") + .param("from", from) + .param("to", to) + .query(this::mapRow) + .list(); + return Result.success(orders); + } catch (Exception e) { + logger.trace("Database error in findByDateRange", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByStatus(ProductionOrderStatus status) { + try { + var orders = jdbc.sql("SELECT * FROM production_orders WHERE status = :status ORDER BY planned_date, created_at DESC") + .param("status", status.name()) + .query(this::mapRow) + .list(); + return Result.success(orders); + } catch (Exception e) { + logger.trace("Database error in findByStatus", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findByDateRangeAndStatus(LocalDate from, LocalDate to, ProductionOrderStatus status) { + try { + var orders = jdbc.sql("SELECT * FROM production_orders WHERE planned_date BETWEEN :from AND :to AND status = :status ORDER BY planned_date, created_at DESC") + .param("from", from) + .param("to", to) + .param("status", status.name()) + .query(this::mapRow) + .list(); + return Result.success(orders); + } catch (Exception e) { + logger.trace("Database error in findByDateRangeAndStatus", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override public Result save(ProductionOrder order) { try { @@ -63,6 +109,7 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository UPDATE production_orders SET status = :status, batch_id = :batchId, priority = :priority, notes = :notes, cancelled_reason = :cancelledReason, + planned_date = :plannedDate, updated_at = :updatedAt, version = version + 1 WHERE id = :id AND version = :version """) @@ -72,6 +119,7 @@ public class JdbcProductionOrderRepository implements ProductionOrderRepository .param("priority", order.priority().name()) .param("notes", order.notes()) .param("cancelledReason", order.cancelledReason()) + .param("plannedDate", order.plannedDate()) .param("updatedAt", order.updatedAt()) .param("version", order.version()) .update(); diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java index 0fd29ae..76cc17e 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java @@ -3,17 +3,22 @@ package de.effigenix.infrastructure.production.web.controller; import de.effigenix.application.production.CancelProductionOrder; import de.effigenix.application.production.CompleteProductionOrder; import de.effigenix.application.production.CreateProductionOrder; +import de.effigenix.application.production.ListProductionOrders; import de.effigenix.application.production.ReleaseProductionOrder; +import de.effigenix.application.production.RescheduleProductionOrder; import de.effigenix.application.production.StartProductionOrder; import de.effigenix.application.production.command.CancelProductionOrderCommand; import de.effigenix.application.production.command.CompleteProductionOrderCommand; import de.effigenix.application.production.command.CreateProductionOrderCommand; import de.effigenix.application.production.command.ReleaseProductionOrderCommand; +import de.effigenix.application.production.command.RescheduleProductionOrderCommand; import de.effigenix.application.production.command.StartProductionOrderCommand; import de.effigenix.domain.production.ProductionOrderError; +import de.effigenix.domain.production.ProductionOrderStatus; import de.effigenix.infrastructure.production.web.dto.CancelProductionOrderRequest; import de.effigenix.infrastructure.production.web.dto.CreateProductionOrderRequest; import de.effigenix.infrastructure.production.web.dto.ProductionOrderResponse; +import de.effigenix.infrastructure.production.web.dto.RescheduleProductionOrderRequest; import de.effigenix.infrastructure.production.web.dto.StartProductionOrderRequest; import de.effigenix.shared.security.ActorId; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -21,12 +26,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/production-orders") @SecurityRequirement(name = "Bearer Authentication") @@ -37,20 +46,57 @@ public class ProductionOrderController { private final CreateProductionOrder createProductionOrder; private final ReleaseProductionOrder releaseProductionOrder; + private final RescheduleProductionOrder rescheduleProductionOrder; private final StartProductionOrder startProductionOrder; private final CompleteProductionOrder completeProductionOrder; private final CancelProductionOrder cancelProductionOrder; + private final ListProductionOrders listProductionOrders; public ProductionOrderController(CreateProductionOrder createProductionOrder, ReleaseProductionOrder releaseProductionOrder, + RescheduleProductionOrder rescheduleProductionOrder, StartProductionOrder startProductionOrder, CompleteProductionOrder completeProductionOrder, - CancelProductionOrder cancelProductionOrder) { + CancelProductionOrder cancelProductionOrder, + ListProductionOrders listProductionOrders) { this.createProductionOrder = createProductionOrder; this.releaseProductionOrder = releaseProductionOrder; + this.rescheduleProductionOrder = rescheduleProductionOrder; this.startProductionOrder = startProductionOrder; this.completeProductionOrder = completeProductionOrder; this.cancelProductionOrder = cancelProductionOrder; + this.listProductionOrders = listProductionOrders; + } + + @GetMapping + @PreAuthorize("hasAuthority('PRODUCTION_ORDER_READ')") + public ResponseEntity> listProductionOrders( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(required = false) String status, + Authentication authentication + ) { + logger.info("Listing production orders by actor: {}", authentication.getName()); + + var actor = ActorId.of(authentication.getName()); + ProductionOrderStatus statusEnum = status != null ? ProductionOrderStatus.valueOf(status) : null; + + var result = (dateFrom != null && dateTo != null && statusEnum != null) + ? listProductionOrders.executeByDateRangeAndStatus(dateFrom, dateTo, statusEnum, actor) + : (dateFrom != null && dateTo != null) + ? listProductionOrders.executeByDateRange(dateFrom, dateTo, actor) + : (statusEnum != null) + ? listProductionOrders.executeByStatus(statusEnum, actor) + : listProductionOrders.execute(actor); + + if (result.isFailure()) { + throw new ProductionOrderDomainErrorException(result.unsafeGetError()); + } + + var responses = result.unsafeGetValue().stream() + .map(ProductionOrderResponse::from) + .toList(); + return ResponseEntity.ok(responses); } @PostMapping @@ -80,6 +126,25 @@ public class ProductionOrderController { .body(ProductionOrderResponse.from(result.unsafeGetValue())); } + @PostMapping("/{id}/reschedule") + @PreAuthorize("hasAuthority('PRODUCTION_ORDER_WRITE')") + public ResponseEntity rescheduleProductionOrder( + @PathVariable String id, + @Valid @RequestBody RescheduleProductionOrderRequest request, + Authentication authentication + ) { + logger.info("Rescheduling production order: {} to {} by actor: {}", id, request.newPlannedDate(), authentication.getName()); + + var cmd = new RescheduleProductionOrderCommand(id, request.newPlannedDate()); + var result = rescheduleProductionOrder.execute(cmd, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new ProductionOrderDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(ProductionOrderResponse.from(result.unsafeGetValue())); + } + @PostMapping("/{id}/release") @PreAuthorize("hasAuthority('PRODUCTION_ORDER_WRITE')") public ResponseEntity releaseProductionOrder( diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RescheduleProductionOrderRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RescheduleProductionOrderRequest.java new file mode 100644 index 0000000..4e01153 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RescheduleProductionOrderRequest.java @@ -0,0 +1,8 @@ +package de.effigenix.infrastructure.production.web.dto; + +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record RescheduleProductionOrderRequest(@NotNull LocalDate newPlannedDate) { +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubProductionOrderRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubProductionOrderRepository.java index dc0504a..a413972 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubProductionOrderRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubProductionOrderRepository.java @@ -3,11 +3,13 @@ package de.effigenix.infrastructure.stub; import de.effigenix.domain.production.ProductionOrder; import de.effigenix.domain.production.ProductionOrderId; import de.effigenix.domain.production.ProductionOrderRepository; +import de.effigenix.domain.production.ProductionOrderStatus; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -28,6 +30,21 @@ public class StubProductionOrderRepository implements ProductionOrderRepository return Result.failure(STUB_ERROR); } + @Override + public Result> findByDateRange(LocalDate from, LocalDate to) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result> findByStatus(ProductionOrderStatus status) { + return Result.failure(STUB_ERROR); + } + + @Override + public Result> findByDateRangeAndStatus(LocalDate from, LocalDate to, ProductionOrderStatus status) { + return Result.failure(STUB_ERROR); + } + @Override public Result save(ProductionOrder productionOrder) { return Result.failure(STUB_ERROR); diff --git a/backend/src/test/java/de/effigenix/application/production/ListProductionOrdersTest.java b/backend/src/test/java/de/effigenix/application/production/ListProductionOrdersTest.java new file mode 100644 index 0000000..f05de6d --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/ListProductionOrdersTest.java @@ -0,0 +1,140 @@ +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.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ListProductionOrders Use Case") +class ListProductionOrdersTest { + + @Mock private ProductionOrderRepository productionOrderRepository; + @Mock private AuthorizationPort authPort; + + private ListProductionOrders listProductionOrders; + private ActorId performedBy; + + private static final LocalDate PLANNED_DATE = LocalDate.now().plusDays(7); + + @BeforeEach + void setUp() { + listProductionOrders = new ListProductionOrders(productionOrderRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private ProductionOrder sampleOrder() { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-1"), + ProductionOrderStatus.PLANNED, + null, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PLANNED_DATE, + Priority.NORMAL, + null, + null, + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 1L + ); + } + + @Test + @DisplayName("should list all orders") + void should_ListAll() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)).thenReturn(true); + when(productionOrderRepository.findAll()).thenReturn(Result.success(List.of(sampleOrder()))); + + var result = listProductionOrders.execute(performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(productionOrderRepository).findAll(); + } + + @Test + @DisplayName("should delegate to findByDateRange") + void should_DelegateToFindByDateRange() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)).thenReturn(true); + var from = LocalDate.now(); + var to = LocalDate.now().plusDays(30); + when(productionOrderRepository.findByDateRange(from, to)).thenReturn(Result.success(List.of(sampleOrder()))); + + var result = listProductionOrders.executeByDateRange(from, to, performedBy); + + assertThat(result.isSuccess()).isTrue(); + verify(productionOrderRepository).findByDateRange(from, to); + } + + @Test + @DisplayName("should delegate to findByStatus") + void should_DelegateToFindByStatus() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)).thenReturn(true); + when(productionOrderRepository.findByStatus(ProductionOrderStatus.PLANNED)) + .thenReturn(Result.success(List.of(sampleOrder()))); + + var result = listProductionOrders.executeByStatus(ProductionOrderStatus.PLANNED, performedBy); + + assertThat(result.isSuccess()).isTrue(); + verify(productionOrderRepository).findByStatus(ProductionOrderStatus.PLANNED); + } + + @Test + @DisplayName("should delegate to findByDateRangeAndStatus") + void should_DelegateToFindByDateRangeAndStatus() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)).thenReturn(true); + var from = LocalDate.now(); + var to = LocalDate.now().plusDays(30); + when(productionOrderRepository.findByDateRangeAndStatus(from, to, ProductionOrderStatus.PLANNED)) + .thenReturn(Result.success(List.of(sampleOrder()))); + + var result = listProductionOrders.executeByDateRangeAndStatus(from, to, ProductionOrderStatus.PLANNED, performedBy); + + assertThat(result.isSuccess()).isTrue(); + verify(productionOrderRepository).findByDateRangeAndStatus(from, to, ProductionOrderStatus.PLANNED); + } + + @Test + @DisplayName("should fail when unauthorized") + void should_Fail_When_Unauthorized() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)).thenReturn(false); + + var result = listProductionOrders.execute(performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.Unauthorized.class); + verify(productionOrderRepository, never()).findAll(); + } + + @Test + @DisplayName("should fail when repository returns error") + void should_Fail_When_RepositoryError() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_READ)).thenReturn(true); + when(productionOrderRepository.findAll()) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = listProductionOrders.execute(performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/RescheduleProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/RescheduleProductionOrderTest.java new file mode 100644 index 0000000..e2efc72 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/RescheduleProductionOrderTest.java @@ -0,0 +1,169 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.RescheduleProductionOrderCommand; +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.persistence.UnitOfWork; +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 java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RescheduleProductionOrder Use Case") +class RescheduleProductionOrderTest { + + @Mock private ProductionOrderRepository productionOrderRepository; + @Mock private AuthorizationPort authPort; + @Mock private UnitOfWork unitOfWork; + + private RescheduleProductionOrder rescheduleProductionOrder; + private ActorId performedBy; + + private static final LocalDate PLANNED_DATE = LocalDate.now().plusDays(7); + private static final LocalDate NEW_DATE = LocalDate.now().plusDays(14); + + @BeforeEach + void setUp() { + rescheduleProductionOrder = new RescheduleProductionOrder(productionOrderRepository, authPort, unitOfWork); + performedBy = ActorId.of("admin-user"); + lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + } + + private RescheduleProductionOrderCommand validCommand() { + return new RescheduleProductionOrderCommand("order-1", NEW_DATE); + } + + private ProductionOrder orderWithStatus(ProductionOrderStatus status) { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-1"), + status, + status == ProductionOrderStatus.IN_PROGRESS ? BatchId.of("batch-1") : null, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PLANNED_DATE, + Priority.NORMAL, + null, + null, + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 1L + ); + } + + @Test + @DisplayName("should reschedule PLANNED order") + void should_Reschedule_PlannedOrder() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(orderWithStatus(ProductionOrderStatus.PLANNED)))); + when(productionOrderRepository.save(any())).thenReturn(Result.success(null)); + + var result = rescheduleProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().plannedDate()).isEqualTo(NEW_DATE); + verify(productionOrderRepository).save(any(ProductionOrder.class)); + } + + @Test + @DisplayName("should reschedule RELEASED order") + void should_Reschedule_ReleasedOrder() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(orderWithStatus(ProductionOrderStatus.RELEASED)))); + when(productionOrderRepository.save(any())).thenReturn(Result.success(null)); + + var result = rescheduleProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().plannedDate()).isEqualTo(NEW_DATE); + verify(productionOrderRepository).save(any(ProductionOrder.class)); + } + + @Test + @DisplayName("should fail when order is IN_PROGRESS") + void should_Fail_When_InProgress() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(orderWithStatus(ProductionOrderStatus.IN_PROGRESS)))); + + var result = rescheduleProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidStatusTransition.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when new date is in the past") + void should_Fail_When_DateInPast() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(orderWithStatus(ProductionOrderStatus.PLANNED)))); + + var pastDateCmd = new RescheduleProductionOrderCommand("order-1", LocalDate.now().minusDays(1)); + var result = rescheduleProductionOrder.execute(pastDateCmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.PlannedDateInPast.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when order not found") + void should_Fail_When_OrderNotFound() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = rescheduleProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ProductionOrderNotFound.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when unauthorized") + void should_Fail_When_Unauthorized() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(false); + + var result = rescheduleProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.Unauthorized.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when repository returns error") + void should_Fail_When_RepositoryError() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = rescheduleProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java index 0d55e14..dd41bf3 100644 --- a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java @@ -706,6 +706,130 @@ class ProductionOrderTest { } } + @Nested + @DisplayName("reschedule()") + class Reschedule { + + private ProductionOrder orderWithStatus(ProductionOrderStatus status) { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-123"), + status, + status == ProductionOrderStatus.IN_PROGRESS || status == ProductionOrderStatus.COMPLETED + ? BatchId.of("batch-1") : null, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + FUTURE_DATE, + Priority.NORMAL, + null, + null, + OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), + OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), + 1L + ); + } + + @Test + @DisplayName("should reschedule PLANNED order") + void should_Reschedule_When_Planned() { + var order = orderWithStatus(ProductionOrderStatus.PLANNED); + var beforeUpdate = order.updatedAt(); + var newDate = LocalDate.now().plusDays(14); + + var result = order.reschedule(newDate); + + assertThat(result.isSuccess()).isTrue(); + assertThat(order.plannedDate()).isEqualTo(newDate); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.PLANNED); + assertThat(order.updatedAt()).isAfter(beforeUpdate); + } + + @Test + @DisplayName("should reschedule RELEASED order") + void should_Reschedule_When_Released() { + var order = orderWithStatus(ProductionOrderStatus.RELEASED); + var beforeUpdate = order.updatedAt(); + var newDate = LocalDate.now().plusDays(14); + + var result = order.reschedule(newDate); + + assertThat(result.isSuccess()).isTrue(); + assertThat(order.plannedDate()).isEqualTo(newDate); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.RELEASED); + assertThat(order.updatedAt()).isAfter(beforeUpdate); + } + + @Test + @DisplayName("should accept today as new date") + void should_Succeed_When_RescheduledToToday() { + var order = orderWithStatus(ProductionOrderStatus.PLANNED); + var today = LocalDate.now(); + + var result = order.reschedule(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(order.plannedDate()).isEqualTo(today); + } + + @Test + @DisplayName("should fail when rescheduling IN_PROGRESS order") + void should_Fail_When_InProgress() { + var order = orderWithStatus(ProductionOrderStatus.IN_PROGRESS); + + var result = order.reschedule(LocalDate.now().plusDays(14)); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.IN_PROGRESS); + } + + @Test + @DisplayName("should fail when rescheduling COMPLETED order") + void should_Fail_When_Completed() { + var order = orderWithStatus(ProductionOrderStatus.COMPLETED); + + var result = order.reschedule(LocalDate.now().plusDays(14)); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.COMPLETED); + } + + @Test + @DisplayName("should fail when rescheduling CANCELLED order") + void should_Fail_When_Cancelled() { + var order = orderWithStatus(ProductionOrderStatus.CANCELLED); + + var result = order.reschedule(LocalDate.now().plusDays(14)); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.CANCELLED); + } + + @Test + @DisplayName("should fail when new date is in the past") + void should_Fail_When_DateInPast() { + var order = orderWithStatus(ProductionOrderStatus.PLANNED); + var yesterday = LocalDate.now().minusDays(1); + + var result = order.reschedule(yesterday); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.PlannedDateInPast) result.unsafeGetError(); + assertThat(err.date()).isEqualTo(yesterday); + } + + @Test + @DisplayName("should not change status on reschedule") + void should_NotChangeStatus_When_Rescheduled() { + var order = orderWithStatus(ProductionOrderStatus.RELEASED); + + order.reschedule(LocalDate.now().plusDays(30)); + + assertThat(order.status()).isEqualTo(ProductionOrderStatus.RELEASED); + } + } + @Nested @DisplayName("reconstitute()") class Reconstitute { diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java index 1fe373f..d00f3ac 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java @@ -16,6 +16,7 @@ import java.time.LocalDate; 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.*; @@ -829,6 +830,274 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { } } + @Nested + @DisplayName("POST /api/production/production-orders/{id}/reschedule – Umterminieren") + class RescheduleProductionOrderEndpoint { + + @Test + @DisplayName("PLANNED Order umterminieren → 200") + void reschedule_plannedOrder_returns200() throws Exception { + String orderId = createPlannedOrder(); + LocalDate newDate = LocalDate.now().plusDays(30); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(newDate); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.plannedDate").value(newDate.toString())) + .andExpect(jsonPath("$.status").value("PLANNED")); + } + + @Test + @DisplayName("RELEASED Order umterminieren → 200") + void reschedule_releasedOrder_returns200() throws Exception { + String orderId = createReleasedOrder(); + LocalDate newDate = LocalDate.now().plusDays(30); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(newDate); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.plannedDate").value(newDate.toString())) + .andExpect(jsonPath("$.status").value("RELEASED")); + } + + @Test + @DisplayName("IN_PROGRESS Order umterminieren → 409") + void reschedule_inProgressOrder_returns409() throws Exception { + String orderId = createInProgressOrder(); + LocalDate newDate = LocalDate.now().plusDays(30); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(newDate); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Vergangenes Datum → 400") + void reschedule_pastDate_returns400() throws Exception { + String orderId = createPlannedOrder(); + LocalDate pastDate = LocalDate.now().minusDays(1); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(pastDate); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_PLANNED_DATE_IN_PAST")); + } + + @Test + @DisplayName("COMPLETED Order umterminieren → 409") + void reschedule_completedOrder_returns409() throws Exception { + String[] orderAndBatch = createInProgressOrderWithCompletedBatch(); + String orderId = orderAndBatch[0]; + + // Complete the order + mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("CANCELLED Order umterminieren → 409") + void reschedule_cancelledOrder_returns409() throws Exception { + String orderId = createPlannedOrder(); + + // Cancel the order + String cancelJson = """ + {"reason": "Testgrund"} + """; + mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isOk()); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Reschedule auf heute (Grenzwert) → 200") + void reschedule_today_returns200() throws Exception { + String orderId = createPlannedOrder(); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(LocalDate.now()); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.plannedDate").value(LocalDate.now().toString())); + } + + @Test + @DisplayName("Nicht existierende Order → 400") + void reschedule_notFound_returns400() throws Exception { + String json = """ + {"newPlannedDate": "%s"} + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", java.util.UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND")); + } + + @Test + @DisplayName("Nicht autorisiert → 403") + void reschedule_unauthorized_returns403() throws Exception { + String orderId = createPlannedOrder(); + LocalDate newDate = LocalDate.now().plusDays(30); + + String json = """ + {"newPlannedDate": "%s"} + """.formatted(newDate); + + mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId) + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /api/production/production-orders – Auflisten") + class ListProductionOrdersEndpoint { + + @Test + @DisplayName("Alle Orders auflisten → 200") + void listAll_returns200() throws Exception { + createPlannedOrder(); + createPlannedOrder(); + + mockMvc.perform(get("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(2))); + } + + @Test + @DisplayName("Nach Status filtern → 200") + void listByStatus_returns200() throws Exception { + createPlannedOrder(); + createReleasedOrder(); + + mockMvc.perform(get("/api/production/production-orders") + .param("status", "PLANNED") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].status").value("PLANNED")); + } + + @Test + @DisplayName("Nach Datumsbereich filtern → 200") + void listByDateRange_returns200() throws Exception { + createPlannedOrder(); + + mockMvc.perform(get("/api/production/production-orders") + .param("dateFrom", LocalDate.now().toString()) + .param("dateTo", LocalDate.now().plusDays(30).toString()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(1))); + } + + @Test + @DisplayName("Nach Datumsbereich und Status filtern → 200") + void listByDateRangeAndStatus_returns200() throws Exception { + createPlannedOrder(); + + mockMvc.perform(get("/api/production/production-orders") + .param("dateFrom", LocalDate.now().toString()) + .param("dateTo", LocalDate.now().plusDays(30).toString()) + .param("status", "PLANNED") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].status").value("PLANNED")); + } + + @Test + @DisplayName("Leere Ergebnisse bei nicht matchendem Datumsbereich → 200 mit []") + void listByDateRange_noMatch_returnsEmptyList() throws Exception { + createPlannedOrder(); + + mockMvc.perform(get("/api/production/production-orders") + .param("dateFrom", LocalDate.now().plusYears(10).toString()) + .param("dateTo", LocalDate.now().plusYears(11).toString()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("Nur dateFrom ohne dateTo → alle auflisten (kein Range-Filter)") + void listWithOnlyDateFrom_returnsAll() throws Exception { + createPlannedOrder(); + + mockMvc.perform(get("/api/production/production-orders") + .param("dateFrom", LocalDate.now().toString()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(1))); + } + + @Test + @DisplayName("Nicht autorisiert → 403") + void list_unauthorized_returns403() throws Exception { + mockMvc.perform(get("/api/production/production-orders") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + } + // ==================== Hilfsmethoden ==================== private String createPlannedOrder() throws Exception { diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java index 21fbfec..85ce7a4 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java @@ -147,6 +147,35 @@ public final class ProductionScenario { ); } + public static ChainBuilder rescheduleProductionOrder() { + return exec( + http("Produktionsauftrag umterminieren") + .post("/api/production/production-orders/#{productionOrderId}/reschedule") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody(JsonBodyBuilder.rescheduleProductionOrderBody())) + .check(status().in(200, 400, 409, 500)) + ); + } + + public static ChainBuilder listProductionOrders() { + return exec( + http("Produktionsaufträge auflisten") + .get("/api/production/production-orders") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder listProductionOrdersByStatus() { + return exec( + http("Produktionsaufträge nach Status") + .get("/api/production/production-orders?status=PLANNED") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + // ---- Zusammengesetztes Szenario ---- /** @@ -216,13 +245,20 @@ public final class ProductionScenario { exec(completeProductionOrder()) ) .pause(1, 2) - // Neuen Auftrag anlegen und direkt stornieren + // Neuen Auftrag anlegen, umterminieren und dann stornieren .exec(createProductionOrder()) .pause(1, 2) .doIf(session -> session.contains("productionOrderId")).then( - exec(cancelProductionOrder()) + exec(rescheduleProductionOrder()) + .pause(1, 2) + .exec(cancelProductionOrder()) ) .pause(1, 2) + // Produktionsaufträge auflisten (ungefiltert + nach Status) + .exec(listProductionOrders()) + .pause(1, 2) + .exec(listProductionOrdersByStatus()) + .pause(1, 2) // Nochmal Chargen-Liste prüfen .exec(listBatches()); } @@ -235,9 +271,11 @@ public final class ProductionScenario { .exec(AuthenticationScenario.login("admin", "admin123")) .repeat(15).on( randomSwitch().on( - percent(40.0).then(listRecipes()), - percent(30.0).then(getRandomRecipe()), - percent(30.0).then(listBatches()) + percent(30.0).then(listRecipes()), + percent(20.0).then(getRandomRecipe()), + percent(20.0).then(listBatches()), + percent(15.0).then(listProductionOrders()), + percent(15.0).then(listProductionOrdersByStatus()) ).pause(1, 3) ); } diff --git a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java index f4eff1e..0272c31 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java @@ -119,7 +119,12 @@ public class FullWorkloadSimulation extends Simulation { details("Produktionsauftrag freigeben").responseTime().mean().lt(50), details("Produktionsauftrag abschließen").responseTime().mean().lt(50), details("Produktionsauftrag stornieren").responseTime().mean().lt(50), - details("Bestandsbewegung erfassen").responseTime().mean().lt(50) + details("Produktionsauftrag umterminieren").responseTime().mean().lt(50), + details("Bestandsbewegung erfassen").responseTime().mean().lt(50), + + // Produktionsaufträge-Listen: mean < 35ms + details("Produktionsaufträge auflisten").responseTime().mean().lt(35), + details("Produktionsaufträge nach Status").responseTime().mean().lt(35) ); } } diff --git a/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java b/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java index 030cac1..736c1c9 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/util/JsonBodyBuilder.java @@ -99,4 +99,9 @@ public final class JsonBodyBuilder { {"recipeId":"%s","plannedQuantity":"20","plannedQuantityUnit":"KG","plannedDate":"%s","priority":"NORMAL"}""" .formatted(recipeId, LocalDate.now().plusDays(1)); } + + public static String rescheduleProductionOrderBody() { + return """ + {"newPlannedDate":"%s"}""".formatted(LocalDate.now().plusDays(7)); + } }