1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 17:49:57 +01:00

feat(production): Charge stornieren (CancelBatch)

PLANNED und IN_PRODUCTION Chargen können mit Angabe eines
Stornierungsgrundes storniert werden. COMPLETED und bereits
CANCELLED Chargen werden abgelehnt.
This commit is contained in:
Sebastian Frick 2026-02-23 14:07:02 +01:00
parent a08e4194ab
commit 3c660650e5
28 changed files with 783 additions and 4 deletions

View file

@ -0,0 +1,213 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.CancelBatchCommand;
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 java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("CancelBatch Use Case")
class CancelBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
private CancelBatch cancelBatch;
private ActorId performedBy;
@BeforeEach
void setUp() {
cancelBatch = new CancelBatch(batchRepository, authPort);
performedBy = ActorId.of("admin-user");
}
private Batch plannedBatch(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(),
null, null, null,
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null, null, null,
0L, List.of()
);
}
private Batch inProductionBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null, null, null,
0L, List.of()
);
}
private CancelBatchCommand validCommand(String batchId) {
return new CancelBatchCommand(batchId, "Qualitätsproblem erkannt");
}
@Test
@DisplayName("should cancel PLANNED batch")
void should_CancelBatch_When_Planned() {
var batchId = BatchId.of("batch-1");
var batch = plannedBatch("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
when(batchRepository.save(any())).thenReturn(Result.success(null));
var result = cancelBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(BatchStatus.CANCELLED);
assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Qualitätsproblem erkannt");
assertThat(result.unsafeGetValue().cancelledAt()).isNotNull();
verify(batchRepository).save(batch);
}
@Test
@DisplayName("should cancel IN_PRODUCTION batch")
void should_CancelBatch_When_InProduction() {
var batchId = BatchId.of("batch-1");
var batch = inProductionBatch("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
when(batchRepository.save(any())).thenReturn(Result.success(null));
var result = cancelBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(BatchStatus.CANCELLED);
verify(batchRepository).save(batch);
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized_When_ActorLacksPermission() {
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(false);
var result = cancelBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findById(any());
}
@Test
@DisplayName("should fail when batch not found")
void should_Fail_When_BatchNotFound() {
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true);
when(batchRepository.findById(any())).thenReturn(Result.success(Optional.empty()));
var result = cancelBatch.execute(validCommand("nonexistent"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when batch is COMPLETED")
void should_Fail_When_BatchIsCompleted() {
var batchId = BatchId.of("batch-1");
var batch = Batch.reconstitute(
batchId,
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null, null, null,
0L, List.of()
);
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
var result = cancelBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when reason is blank")
void should_Fail_When_ReasonBlank() {
var batchId = BatchId.of("batch-1");
var batch = plannedBatch("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
var cmd = new CancelBatchCommand("batch-1", " ");
var result = cancelBatch.execute(cmd, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during find")
void should_FailWithRepositoryFailure_When_FindFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true);
when(batchRepository.findById(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = cancelBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during save")
void should_FailWithRepositoryFailure_When_SaveFails() {
var batchId = BatchId.of("batch-1");
var batch = plannedBatch("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
when(batchRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("write error")));
var result = cancelBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
}

View file

@ -54,6 +54,7 @@ class CompleteBatchTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
@ -73,6 +74,7 @@ class CompleteBatchTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}
@ -160,6 +162,7 @@ class CompleteBatchTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);

View file

@ -55,6 +55,7 @@ class FindBatchByNumberTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}

View file

@ -53,6 +53,7 @@ class GetBatchTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}

View file

@ -57,6 +57,7 @@ class ListBatchesTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}

View file

@ -54,6 +54,7 @@ class RecordConsumptionTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}
@ -71,6 +72,7 @@ class RecordConsumptionTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}

View file

@ -54,6 +54,7 @@ class StartBatchTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}
@ -71,6 +72,7 @@ class StartBatchTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null, null,
0L, List.of()
);
}

View file

@ -212,6 +212,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
null,
null,
0L, List.of()
);
@ -235,6 +237,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
null,
null,
0L, List.of()
);
@ -255,6 +259,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
null,
null,
0L, List.of()
);
@ -321,6 +327,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
null,
null,
0L, List.of()
);
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
@ -447,6 +455,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
null,
null,
0L, List.of()
);
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
@ -517,6 +527,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
null,
null,
0L, List.of()
);
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
@ -621,6 +633,167 @@ class BatchTest {
}
}
@Nested
@DisplayName("cancel()")
class Cancel {
@Test
@DisplayName("should cancel PLANNED batch")
void should_Cancel_When_Planned() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var draft = new CancelBatchDraft("Fehlplanung");
var result = batch.cancel(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(batch.status()).isEqualTo(BatchStatus.CANCELLED);
assertThat(batch.cancellationReason()).isEqualTo("Fehlplanung");
assertThat(batch.cancelledAt()).isNotNull();
}
@Test
@DisplayName("should cancel IN_PRODUCTION batch")
void should_Cancel_When_InProduction() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
batch.startProduction();
var draft = new CancelBatchDraft("Qualitätsproblem");
var result = batch.cancel(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(batch.status()).isEqualTo(BatchStatus.CANCELLED);
assertThat(batch.cancellationReason()).isEqualTo("Qualitätsproblem");
}
@Test
@DisplayName("should fail when COMPLETED")
void should_Fail_When_Completed() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null, null,
0L, List.of()
);
var result = batch.cancel(new CancelBatchDraft("Grund"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
var err = (BatchError.InvalidStatusTransition) result.unsafeGetError();
assertThat(err.current()).isEqualTo(BatchStatus.COMPLETED);
assertThat(err.target()).isEqualTo(BatchStatus.CANCELLED);
}
@Test
@DisplayName("should fail when already CANCELLED")
void should_Fail_When_AlreadyCancelled() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.CANCELLED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null, null,
0L, List.of()
);
var result = batch.cancel(new CancelBatchDraft("Grund"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when reason is null")
void should_Fail_When_ReasonNull() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var result = batch.cancel(new CancelBatchDraft(null));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class);
}
@Test
@DisplayName("should fail when reason is blank")
void should_Fail_When_ReasonBlank() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var result = batch.cancel(new CancelBatchDraft(" "));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class);
}
@Test
@DisplayName("should fail when reason exceeds 500 characters")
void should_Fail_When_ReasonTooLong() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var longReason = "A".repeat(501);
var result = batch.cancel(new CancelBatchDraft(longReason));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonTooLong.class);
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
}
@Test
@DisplayName("should accept reason with exactly 500 characters")
void should_Accept_When_ReasonExactly500() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var reason = "A".repeat(500);
var result = batch.cancel(new CancelBatchDraft(reason));
assertThat(result.isSuccess()).isTrue();
assertThat(batch.cancellationReason()).isEqualTo(reason);
}
@Test
@DisplayName("should strip whitespace from reason")
void should_StripWhitespace_When_Cancelling() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var result = batch.cancel(new CancelBatchDraft(" Fehlplanung "));
assertThat(result.isSuccess()).isTrue();
assertThat(batch.cancellationReason()).isEqualTo("Fehlplanung");
}
@Test
@DisplayName("should set cancelledAt and updatedAt timestamps")
void should_SetTimestamps_When_Cancelling() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var beforeCancel = batch.updatedAt();
batch.cancel(new CancelBatchDraft("Grund"));
assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeCancel);
assertThat(batch.cancelledAt()).isNotNull();
assertThat(batch.cancelledAt()).isEqualTo(batch.updatedAt());
}
@Test
@DisplayName("should not mutate state on validation failure")
void should_NotMutateState_When_ValidationFails() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var beforeUpdate = batch.updatedAt();
batch.cancel(new CancelBatchDraft(null));
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
assertThat(batch.cancellationReason()).isNull();
assertThat(batch.cancelledAt()).isNull();
assertThat(batch.updatedAt()).isEqualTo(beforeUpdate);
}
}
@Nested
@DisplayName("reconstitute()")
class Reconstitute {
@ -640,6 +813,8 @@ class BatchTest {
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
null,
null,
0L, List.of()
);

View file

@ -520,6 +520,156 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
}
}
@Nested
@DisplayName("POST /api/production/batches/{id}/cancel Charge stornieren")
class CancelBatchEndpoint {
private String cancelToken;
@BeforeEach
void setUpCancelToken() {
cancelToken = generateToken(UUID.randomUUID().toString(), "cancel.admin",
"BATCH_WRITE,BATCH_READ,BATCH_CANCEL,RECIPE_WRITE,RECIPE_READ");
}
private String createPlannedBatch() throws Exception {
String recipeId = createActiveRecipeWith(cancelToken);
var planRequest = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var planResult = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + cancelToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(planRequest)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
}
private String createInProductionBatch() throws Exception {
String batchId = createPlannedBatch();
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + cancelToken))
.andExpect(status().isOk());
return batchId;
}
@Test
@DisplayName("PLANNED Charge stornieren → 200, Status CANCELLED")
void cancelBatch_planned_returns200() throws Exception {
String batchId = createPlannedBatch();
String cancelJson = """
{"reason": "Fehlplanung"}
""";
mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
.header("Authorization", "Bearer " + cancelToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("CANCELLED"))
.andExpect(jsonPath("$.cancellationReason").value("Fehlplanung"))
.andExpect(jsonPath("$.cancelledAt").isNotEmpty());
}
@Test
@DisplayName("IN_PRODUCTION Charge stornieren → 200, Status CANCELLED")
void cancelBatch_inProduction_returns200() throws Exception {
String batchId = createInProductionBatch();
String cancelJson = """
{"reason": "Qualitätsproblem"}
""";
mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
.header("Authorization", "Bearer " + cancelToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("CANCELLED"))
.andExpect(jsonPath("$.cancellationReason").value("Qualitätsproblem"));
}
@Test
@DisplayName("Bereits stornierte Charge erneut stornieren → 409")
void cancelBatch_alreadyCancelled_returns409() throws Exception {
String batchId = createPlannedBatch();
String cancelJson = """
{"reason": "Erster Grund"}
""";
mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
.header("Authorization", "Bearer " + cancelToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isOk());
mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
.header("Authorization", "Bearer " + cancelToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Reason leer → 400 (Bean Validation)")
void cancelBatch_blankReason_returns400() throws Exception {
String cancelJson = """
{"reason": ""}
""";
mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + cancelToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Batch nicht gefunden → 404")
void cancelBatch_notFound_returns404() throws Exception {
String cancelJson = """
{"reason": "Grund"}
""";
mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + cancelToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Ohne Token → 401")
void cancelBatch_withoutToken_returns401() throws Exception {
String cancelJson = """
{"reason": "Grund"}
""";
mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ohne BATCH_CANCEL → 403")
void cancelBatch_withoutPermission_returns403() throws Exception {
String cancelJson = """
{"reason": "Grund"}
""";
mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(cancelJson))
.andExpect(status().isForbidden());
}
}
// ==================== Hilfsmethoden ====================
private String createActiveRecipe() throws Exception {