mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:00:23 +01:00
feat(production): Produktion starten und Rohstoffverbrauch dokumentieren (StartBatch, RecordConsumption)
PLANNED-Chargen können in Produktion genommen werden (IN_PRODUCTION), anschließend wird der Rohstoff-Verbrauch pro InputBatch dokumentiert. Bildet die Grundlage für die Chargen-Genealogie (Tracing).
This commit is contained in:
parent
8c042925eb
commit
a9f5956812
31 changed files with 1733 additions and 11 deletions
|
|
@ -18,6 +18,7 @@ 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;
|
||||
|
|
@ -51,7 +52,8 @@ class FindBatchByNumberTest {
|
|||
LocalDate.of(2026, 3, 1),
|
||||
LocalDate.of(2026, 6, 1),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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;
|
||||
|
|
@ -49,7 +50,8 @@ class GetBatchTest {
|
|||
LocalDate.of(2026, 3, 1),
|
||||
LocalDate.of(2026, 6, 1),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ class ListBatchesTest {
|
|||
PRODUCTION_DATE,
|
||||
LocalDate.of(2026, 6, 1),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.RecordConsumptionCommand;
|
||||
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("RecordConsumption Use Case")
|
||||
class RecordConsumptionTest {
|
||||
|
||||
@Mock private BatchRepository batchRepository;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private RecordConsumption recordConsumption;
|
||||
private ActorId performedBy;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
recordConsumption = new RecordConsumption(batchRepository, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
}
|
||||
|
||||
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(),
|
||||
LocalDate.of(2026, 3, 1),
|
||||
LocalDate.of(2026, 6, 1),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
LocalDate.of(2026, 3, 1),
|
||||
LocalDate.of(2026, 6, 1),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private RecordConsumptionCommand validCommand(String batchId) {
|
||||
return new RecordConsumptionCommand(batchId, "input-batch-1", "article-1", "5.0", "KILOGRAM");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should record consumption when batch is IN_PRODUCTION")
|
||||
void should_RecordConsumption_When_InProduction() {
|
||||
var batchId = BatchId.of("batch-1");
|
||||
var batch = inProductionBatch("batch-1");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
|
||||
when(batchRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().inputBatchId().value()).isEqualTo("input-batch-1");
|
||||
assertThat(result.unsafeGetValue().articleId().value()).isEqualTo("article-1");
|
||||
verify(batchRepository).save(batch);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batch not found")
|
||||
void should_Fail_When_BatchNotFound() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(any())).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
var result = recordConsumption.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 not IN_PRODUCTION")
|
||||
void should_Fail_When_NotInProduction() {
|
||||
var batchId = BatchId.of("batch-1");
|
||||
var batch = plannedBatch("batch-1");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
|
||||
|
||||
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when duplicate inputBatchId")
|
||||
void should_Fail_When_DuplicateInputBatch() {
|
||||
var batchId = BatchId.of("batch-1");
|
||||
var batch = inProductionBatch("batch-1");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
|
||||
when(batchRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// First consumption succeeds
|
||||
recordConsumption.execute(validCommand("batch-1"), performedBy);
|
||||
|
||||
// Second with same inputBatchId fails
|
||||
var result = recordConsumption.execute(validCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.DuplicateInputBatch.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when consumption quantity is invalid")
|
||||
void should_Fail_When_InvalidQuantity() {
|
||||
var batchId = BatchId.of("batch-1");
|
||||
var batch = inProductionBatch("batch-1");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
|
||||
|
||||
var cmd = new RecordConsumptionCommand("batch-1", "input-1", "article-1", "0", "KILOGRAM");
|
||||
|
||||
var result = recordConsumption.execute(cmd, performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with Unauthorized when actor lacks permission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(false);
|
||||
|
||||
var result = recordConsumption.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 with RepositoryFailure on repository error during find")
|
||||
void should_FailWithRepositoryFailure_When_FindFails() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(any()))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = recordConsumption.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 = inProductionBatch("batch-1");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).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 = recordConsumption.execute(validCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.StartBatchCommand;
|
||||
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("StartBatch Use Case")
|
||||
class StartBatchTest {
|
||||
|
||||
@Mock private BatchRepository batchRepository;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private StartBatch startBatch;
|
||||
private ActorId performedBy;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
startBatch = new StartBatch(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(),
|
||||
LocalDate.of(2026, 3, 1),
|
||||
LocalDate.of(2026, 6, 1),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
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(),
|
||||
LocalDate.of(2026, 3, 1),
|
||||
LocalDate.of(2026, 6, 1),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should start batch when PLANNED")
|
||||
void should_StartBatch_When_Planned() {
|
||||
var batchId = BatchId.of("batch-1");
|
||||
var batch = plannedBatch("batch-1");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
|
||||
when(batchRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(BatchStatus.IN_PRODUCTION);
|
||||
verify(batchRepository).save(batch);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batch not found")
|
||||
void should_Fail_When_BatchNotFound() {
|
||||
var batchId = BatchId.of("nonexistent");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
var result = startBatch.execute(new StartBatchCommand("nonexistent"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batch already IN_PRODUCTION")
|
||||
void should_Fail_When_AlreadyInProduction() {
|
||||
var batchId = BatchId.of("batch-1");
|
||||
var batch = inProductionBatch("batch-1");
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
|
||||
|
||||
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with Unauthorized when actor lacks permission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(false);
|
||||
|
||||
var result = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
|
||||
verify(batchRepository, never()).findById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with RepositoryFailure on repository error during find")
|
||||
void should_FailWithRepositoryFailure_When_FindFails() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(batchRepository.findById(any()))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = startBatch.execute(new StartBatchCommand("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_WRITE)).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 = startBatch.execute(new StartBatchCommand("batch-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ 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;
|
||||
|
||||
|
|
@ -173,6 +174,194 @@ class BatchTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("startProduction()")
|
||||
class StartProduction {
|
||||
|
||||
@Test
|
||||
@DisplayName("should transition PLANNED to IN_PRODUCTION")
|
||||
void should_TransitionToInProduction_When_Planned() {
|
||||
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
|
||||
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
|
||||
|
||||
var result = batch.startProduction();
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should update updatedAt on transition")
|
||||
void should_UpdateTimestamp_When_StartingProduction() {
|
||||
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
|
||||
var beforeUpdate = batch.updatedAt();
|
||||
|
||||
batch.startProduction();
|
||||
|
||||
assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeUpdate);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when already IN_PRODUCTION")
|
||||
void should_Fail_When_AlreadyInProduction() {
|
||||
var batch = Batch.reconstitute(
|
||||
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
|
||||
BatchStatus.IN_PRODUCTION,
|
||||
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
||||
PRODUCTION_DATE, BEST_BEFORE_DATE,
|
||||
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
|
||||
var result = batch.startProduction();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
|
||||
var err = (BatchError.InvalidStatusTransition) result.unsafeGetError();
|
||||
assertThat(err.current()).isEqualTo(BatchStatus.IN_PRODUCTION);
|
||||
assertThat(err.target()).isEqualTo(BatchStatus.IN_PRODUCTION);
|
||||
}
|
||||
|
||||
@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(),
|
||||
PRODUCTION_DATE, BEST_BEFORE_DATE,
|
||||
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
|
||||
var result = batch.startProduction();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when CANCELLED")
|
||||
void should_Fail_When_Cancelled() {
|
||||
var batch = Batch.reconstitute(
|
||||
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
|
||||
BatchStatus.CANCELLED,
|
||||
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
||||
PRODUCTION_DATE, BEST_BEFORE_DATE,
|
||||
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
|
||||
var result = batch.startProduction();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recordConsumption()")
|
||||
class RecordConsumption {
|
||||
|
||||
private Batch inProductionBatch() {
|
||||
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
|
||||
batch.startProduction();
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should record consumption when IN_PRODUCTION")
|
||||
void should_RecordConsumption_When_InProduction() {
|
||||
var batch = inProductionBatch();
|
||||
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
|
||||
|
||||
var result = batch.recordConsumption(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(batch.consumptions()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().inputBatchId().value()).isEqualTo("input-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should record multiple different consumptions")
|
||||
void should_RecordMultiple_When_DifferentInputBatches() {
|
||||
var batch = inProductionBatch();
|
||||
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
|
||||
batch.recordConsumption(new ConsumptionDraft("input-2", "article-2", "3.0", "LITER"));
|
||||
|
||||
assertThat(batch.consumptions()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when not IN_PRODUCTION (PLANNED)")
|
||||
void should_Fail_When_Planned() {
|
||||
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
|
||||
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
|
||||
|
||||
var result = batch.recordConsumption(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when not IN_PRODUCTION (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(),
|
||||
PRODUCTION_DATE, BEST_BEFORE_DATE,
|
||||
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
|
||||
|
||||
var result = batch.recordConsumption(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when duplicate inputBatchId")
|
||||
void should_Fail_When_DuplicateInputBatch() {
|
||||
var batch = inProductionBatch();
|
||||
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
|
||||
|
||||
var result = batch.recordConsumption(
|
||||
new ConsumptionDraft("input-1", "article-2", "3.0", "LITER"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.DuplicateInputBatch.class);
|
||||
assertThat(batch.consumptions()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when consumption quantity is invalid")
|
||||
void should_Fail_When_InvalidQuantity() {
|
||||
var batch = inProductionBatch();
|
||||
var draft = new ConsumptionDraft("input-1", "article-1", "0", "KILOGRAM");
|
||||
|
||||
var result = batch.recordConsumption(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
|
||||
assertThat(batch.consumptions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should update updatedAt when recording consumption")
|
||||
void should_UpdateTimestamp_When_RecordingConsumption() {
|
||||
var batch = inProductionBatch();
|
||||
var beforeRecord = batch.updatedAt();
|
||||
|
||||
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
|
||||
|
||||
assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("reconstitute()")
|
||||
class Reconstitute {
|
||||
|
|
@ -189,7 +378,8 @@ class BatchTest {
|
|||
PRODUCTION_DATE,
|
||||
BEST_BEFORE_DATE,
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
OffsetDateTime.now(ZoneOffset.UTC),
|
||||
List.of()
|
||||
);
|
||||
|
||||
assertThat(batch.id().value()).isEqualTo("batch-1");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("Consumption Entity")
|
||||
class ConsumptionTest {
|
||||
|
||||
private ConsumptionDraft validDraft() {
|
||||
return new ConsumptionDraft("input-batch-1", "article-1", "10.5", "KILOGRAM");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("create()")
|
||||
class Create {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create consumption with valid draft")
|
||||
void should_CreateConsumption_When_ValidDraft() {
|
||||
var result = Consumption.create(validDraft());
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var consumption = result.unsafeGetValue();
|
||||
assertThat(consumption.id()).isNotNull();
|
||||
assertThat(consumption.inputBatchId().value()).isEqualTo("input-batch-1");
|
||||
assertThat(consumption.articleId().value()).isEqualTo("article-1");
|
||||
assertThat(consumption.quantityUsed().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
|
||||
assertThat(consumption.quantityUsed().uom().name()).isEqualTo("KILOGRAM");
|
||||
assertThat(consumption.consumedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when inputBatchId is blank")
|
||||
void should_Fail_When_InputBatchIdBlank() {
|
||||
var draft = new ConsumptionDraft("", "article-1", "10", "KILOGRAM");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when inputBatchId is null")
|
||||
void should_Fail_When_InputBatchIdNull() {
|
||||
var draft = new ConsumptionDraft(null, "article-1", "10", "KILOGRAM");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when articleId is blank")
|
||||
void should_Fail_When_ArticleIdBlank() {
|
||||
var draft = new ConsumptionDraft("input-batch-1", "", "10", "KILOGRAM");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when articleId is null")
|
||||
void should_Fail_When_ArticleIdNull() {
|
||||
var draft = new ConsumptionDraft("input-batch-1", null, "10", "KILOGRAM");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when quantity is zero")
|
||||
void should_Fail_When_QuantityZero() {
|
||||
var draft = new ConsumptionDraft("input-batch-1", "article-1", "0", "KILOGRAM");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when quantity is negative")
|
||||
void should_Fail_When_QuantityNegative() {
|
||||
var draft = new ConsumptionDraft("input-batch-1", "article-1", "-5", "KILOGRAM");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when quantity is not a number")
|
||||
void should_Fail_When_QuantityNotANumber() {
|
||||
var draft = new ConsumptionDraft("input-batch-1", "article-1", "abc", "KILOGRAM");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when unit is invalid")
|
||||
void should_Fail_When_UnitInvalid() {
|
||||
var draft = new ConsumptionDraft("input-batch-1", "article-1", "10", "INVALID_UNIT");
|
||||
|
||||
var result = Consumption.create(draft);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
package de.effigenix.infrastructure.production.web;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
|
||||
import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest;
|
||||
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.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
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.*;
|
||||
|
||||
@DisplayName("RecordConsumption Integration Tests")
|
||||
class RecordConsumptionIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private String adminToken;
|
||||
private String viewerToken;
|
||||
|
||||
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
|
||||
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
|
||||
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
|
||||
|
||||
UserEntity admin = createUser("cons.admin", "cons.admin@test.com", Set.of(adminRole), "BRANCH-01");
|
||||
UserEntity viewer = createUser("cons.viewer", "cons.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
|
||||
|
||||
adminToken = generateToken(admin.getId(), "cons.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
|
||||
viewerToken = generateToken(viewer.getId(), "cons.viewer", "USER_READ");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /api/production/batches/{id}/consumptions – Verbrauch dokumentieren")
|
||||
class RecordConsumptionEndpoint {
|
||||
|
||||
@Test
|
||||
@DisplayName("Verbrauch dokumentieren bei IN_PRODUCTION → 201")
|
||||
void recordConsumption_withInProductionBatch_returns201() throws Exception {
|
||||
String batchId = createInProductionBatch();
|
||||
var request = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "10.5", "KILOGRAM");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.inputBatchId").value(request.inputBatchId()))
|
||||
.andExpect(jsonPath("$.articleId").value(request.articleId()))
|
||||
.andExpect(jsonPath("$.quantityUsed").value("10.500000"))
|
||||
.andExpect(jsonPath("$.quantityUsedUnit").value("KILOGRAM"))
|
||||
.andExpect(jsonPath("$.consumedAt").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Verbrauch wird in BatchResponse angezeigt")
|
||||
void recordConsumption_visibleInBatchResponse() throws Exception {
|
||||
String batchId = createInProductionBatch();
|
||||
String inputBatchId = UUID.randomUUID().toString();
|
||||
var request = new RecordConsumptionRequest(inputBatchId, UUID.randomUUID().toString(), "5.0", "LITER");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(get("/api/production/batches/{id}", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.consumptions").isArray())
|
||||
.andExpect(jsonPath("$.consumptions.length()").value(1))
|
||||
.andExpect(jsonPath("$.consumptions[0].inputBatchId").value(inputBatchId));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Mehrere Verbrauchseinträge möglich")
|
||||
void recordConsumption_multipleConsumptions() throws Exception {
|
||||
String batchId = createInProductionBatch();
|
||||
|
||||
var request1 = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
|
||||
var request2 = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "3.0", "LITER");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request1)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request2)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(get("/api/production/batches/{id}", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(jsonPath("$.consumptions.length()").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Doppelte InputBatchId → 409")
|
||||
void recordConsumption_duplicateInputBatch_returns409() throws Exception {
|
||||
String batchId = createInProductionBatch();
|
||||
String inputBatchId = UUID.randomUUID().toString();
|
||||
|
||||
var request = new RecordConsumptionRequest(inputBatchId, UUID.randomUUID().toString(), "5.0", "KILOGRAM");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_DUPLICATE_INPUT_BATCH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge nicht IN_PRODUCTION (PLANNED) → 409")
|
||||
void recordConsumption_plannedBatch_returns409() throws Exception {
|
||||
String batchId = createPlannedBatch();
|
||||
var request = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_NOT_IN_PRODUCTION"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Menge (0) → 400")
|
||||
void recordConsumption_zeroQuantity_returns400() throws Exception {
|
||||
String batchId = createInProductionBatch();
|
||||
var request = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "0", "KILOGRAM");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_CONSUMPTION_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Unit → 400")
|
||||
void recordConsumption_invalidUnit_returns400() throws Exception {
|
||||
String batchId = createInProductionBatch();
|
||||
var request = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "INVALID_UNIT");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_CONSUMPTION_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge nicht gefunden → 404")
|
||||
void recordConsumption_batchNotFound_returns404() throws Exception {
|
||||
var request = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Leere Pflichtfelder → 400 (Bean Validation)")
|
||||
void recordConsumption_blankFields_returns400() throws Exception {
|
||||
var request = new RecordConsumptionRequest("", "", "", "");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Authorization")
|
||||
class AuthTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne BATCH_WRITE → 403")
|
||||
void recordConsumption_withViewerToken_returns403() throws Exception {
|
||||
var request = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + viewerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne Token → 401")
|
||||
void recordConsumption_withoutToken_returns401() throws Exception {
|
||||
var request = new RecordConsumptionRequest(
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), "5.0", "KILOGRAM");
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/consumptions", UUID.randomUUID().toString())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Hilfsmethoden ====================
|
||||
|
||||
private String createPlannedBatch() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
var request = new PlanBatchRequest(recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
return objectMapper.readTree(result.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 " + adminToken))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
return batchId;
|
||||
}
|
||||
|
||||
private String createActiveRecipe() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"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(json))
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package de.effigenix.infrastructure.production.web;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
|
||||
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.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@DisplayName("StartBatch Integration Tests")
|
||||
class StartBatchIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private String adminToken;
|
||||
private String viewerToken;
|
||||
|
||||
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
|
||||
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
|
||||
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
|
||||
|
||||
UserEntity admin = createUser("start.admin", "start.admin@test.com", Set.of(adminRole), "BRANCH-01");
|
||||
UserEntity viewer = createUser("start.viewer", "start.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
|
||||
|
||||
adminToken = generateToken(admin.getId(), "start.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
|
||||
viewerToken = generateToken(viewer.getId(), "start.viewer", "USER_READ");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /api/production/batches/{id}/start – Produktion starten")
|
||||
class StartBatchEndpoint {
|
||||
|
||||
@Test
|
||||
@DisplayName("PLANNED Charge starten → 200 mit IN_PRODUCTION Status")
|
||||
void startBatch_withPlannedBatch_returns200() throws Exception {
|
||||
String batchId = createPlannedBatch();
|
||||
|
||||
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(batchId))
|
||||
.andExpect(jsonPath("$.status").value("IN_PRODUCTION"))
|
||||
.andExpect(jsonPath("$.consumptions").isArray())
|
||||
.andExpect(jsonPath("$.consumptions").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Bereits gestartete Charge → 409")
|
||||
void startBatch_alreadyInProduction_returns409() throws Exception {
|
||||
String batchId = createPlannedBatch();
|
||||
|
||||
// Start once
|
||||
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Start again
|
||||
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge nicht gefunden → 404")
|
||||
void startBatch_notFound_returns404() throws Exception {
|
||||
mockMvc.perform(post("/api/production/batches/{id}/start", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Authorization")
|
||||
class AuthTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne BATCH_WRITE → 403")
|
||||
void startBatch_withViewerToken_returns403() throws Exception {
|
||||
mockMvc.perform(post("/api/production/batches/{id}/start", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + viewerToken))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne Token → 401")
|
||||
void startBatch_withoutToken_returns401() throws Exception {
|
||||
mockMvc.perform(post("/api/production/batches/{id}/start", UUID.randomUUID().toString()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Hilfsmethoden ====================
|
||||
|
||||
private String createPlannedBatch() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
|
||||
}
|
||||
|
||||
private String createActiveRecipe() throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"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(json))
|
||||
.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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue