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

feat(production): Chargen abfragen und suchen (GetBatch, ListBatches, FindBatchByNumber)

Full Vertical Slice für Batch-Lese-Endpoints:
- GET /api/production/batches/{id}
- GET /api/production/batches (Filter: status, productionDate, articleId)
- GET /api/production/batches/by-number/{batchNumber}

articleId-Filter löst über RecipeRepository.findByArticleId() die
zugehörigen Recipes auf und sucht dann Batches per findByRecipeIds().

Closes #34
This commit is contained in:
Sebastian Frick 2026-02-20 09:08:39 +01:00
parent fef3baa0ae
commit 1c65ac7795
20 changed files with 1348 additions and 1 deletions

View file

@ -0,0 +1,106 @@
package de.effigenix.application.production;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("FindBatchByNumber Use Case")
class FindBatchByNumberTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
private FindBatchByNumber findBatchByNumber;
private ActorId performedBy;
private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(LocalDate.of(2026, 3, 1), 1);
@BeforeEach
void setUp() {
findBatchByNumber = new FindBatchByNumber(batchRepository, authPort);
performedBy = ActorId.of("admin-user");
}
private Batch sampleBatch() {
return Batch.reconstitute(
BatchId.of("batch-1"),
BATCH_NUMBER,
RecipeId.of("recipe-1"),
BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC)
);
}
@Test
@DisplayName("should return batch when batch number exists")
void should_ReturnBatch_When_BatchNumberExists() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByBatchNumber(BATCH_NUMBER)).thenReturn(Result.success(Optional.of(sampleBatch())));
var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().batchNumber()).isEqualTo(BATCH_NUMBER);
}
@Test
@DisplayName("should fail with BatchNotFoundByNumber when batch number does not exist")
void should_FailWithBatchNotFoundByNumber_When_BatchNumberDoesNotExist() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByBatchNumber(BATCH_NUMBER)).thenReturn(Result.success(Optional.empty()));
var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFoundByNumber.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository returns error")
void should_FailWithRepositoryFailure_When_RepositoryReturnsError() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByBatchNumber(BATCH_NUMBER))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized_When_ActorLacksPermission() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false);
var result = findBatchByNumber.execute(BATCH_NUMBER, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findByBatchNumber(any());
}
}

View file

@ -0,0 +1,108 @@
package de.effigenix.application.production;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("GetBatch Use Case")
class GetBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
private GetBatch getBatch;
private ActorId performedBy;
@BeforeEach
void setUp() {
getBatch = new GetBatch(batchRepository, authPort);
performedBy = ActorId.of("admin-user");
}
private Batch sampleBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC)
);
}
@Test
@DisplayName("should return batch when batch exists")
void should_ReturnBatch_When_BatchExists() {
var batchId = BatchId.of("batch-1");
var batch = sampleBatch("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
var result = getBatch.execute(batchId, performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().id()).isEqualTo(batchId);
}
@Test
@DisplayName("should fail with BatchNotFound when batch does not exist")
void should_FailWithBatchNotFound_When_BatchDoesNotExist() {
var batchId = BatchId.of("nonexistent");
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.empty()));
var result = getBatch.execute(batchId, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository returns error")
void should_FailWithRepositoryFailure_When_RepositoryReturnsError() {
var batchId = BatchId.of("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findById(batchId))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = getBatch.execute(batchId, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized_When_ActorLacksPermission() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false);
var result = getBatch.execute(BatchId.of("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findById(any());
}
}

View file

@ -0,0 +1,319 @@
package de.effigenix.application.production;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("ListBatches Use Case")
class ListBatchesTest {
@Mock private BatchRepository batchRepository;
@Mock private RecipeRepository recipeRepository;
@Mock private AuthorizationPort authPort;
private ListBatches listBatches;
private ActorId performedBy;
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
private static final RepositoryError DB_ERROR = new RepositoryError.DatabaseError("connection lost");
@BeforeEach
void setUp() {
listBatches = new ListBatches(batchRepository, recipeRepository, authPort);
performedBy = ActorId.of("admin-user");
}
private Batch sampleBatch(String id, BatchStatus status) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(PRODUCTION_DATE, 1),
RecipeId.of("recipe-1"),
status,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
PRODUCTION_DATE,
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC)
);
}
private Recipe sampleRecipe(String id) {
return Recipe.reconstitute(
RecipeId.of(id), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
"Beschreibung", new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
"article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
);
}
@Nested
@DisplayName("execute alle Batches")
class ExecuteAll {
@Test
@DisplayName("should return all batches")
void should_ReturnAllBatches() {
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED), sampleBatch("b2", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findAll()).thenReturn(Result.success(batches));
var result = listBatches.execute(performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
}
@Test
@DisplayName("should return empty list when no batches exist")
void should_ReturnEmptyList_When_NoBatchesExist() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findAll()).thenReturn(Result.success(List.of()));
var result = listBatches.execute(performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should fail with RepositoryFailure when findAll fails")
void should_FailWithRepositoryFailure_When_FindAllFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findAll()).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.execute(performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false);
var result = listBatches.execute(performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findAll();
}
}
@Nested
@DisplayName("executeByStatus nach Status filtern")
class ExecuteByStatus {
@Test
@DisplayName("should return batches filtered by status")
void should_ReturnBatches_FilteredByStatus() {
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(batches));
var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
}
@Test
@DisplayName("should return empty list when no batches match status")
void should_ReturnEmptyList_When_NoBatchesMatchStatus() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.success(List.of()));
var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should fail with RepositoryFailure when findByStatus fails")
void should_FailWithRepositoryFailure_When_FindByStatusFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByStatus(BatchStatus.PLANNED)).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false);
var result = listBatches.executeByStatus(BatchStatus.PLANNED, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findByStatus(any());
}
}
@Nested
@DisplayName("executeByProductionDate nach Datum filtern")
class ExecuteByProductionDate {
@Test
@DisplayName("should return batches filtered by production date")
void should_ReturnBatches_FilteredByProductionDate() {
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(batches));
var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
}
@Test
@DisplayName("should return empty list when no batches match date")
void should_ReturnEmptyList_When_NoBatchesMatchDate() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.success(List.of()));
var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should fail with RepositoryFailure when findByProductionDate fails")
void should_FailWithRepositoryFailure_When_FindByProductionDateFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(batchRepository.findByProductionDate(PRODUCTION_DATE)).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false);
var result = listBatches.executeByProductionDate(PRODUCTION_DATE, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findByProductionDate(any());
}
}
@Nested
@DisplayName("executeByArticleId über Recipe-Lookup filtern")
class ExecuteByArticleId {
@Test
@DisplayName("should return batches filtered by articleId via recipe lookup")
void should_ReturnBatches_FilteredByArticleId() {
var recipe = sampleRecipe("recipe-1");
var batches = List.of(sampleBatch("b1", BatchStatus.PLANNED));
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe)));
when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(batches));
var result = listBatches.executeByArticleId("article-123", performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
}
@Test
@DisplayName("should return empty list when no recipes match articleId")
void should_ReturnEmptyList_When_NoRecipesMatchArticleId() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("unknown-article")).thenReturn(Result.success(List.of()));
var result = listBatches.executeByArticleId("unknown-article", performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
verify(batchRepository, never()).findByRecipeIds(any());
}
@Test
@DisplayName("should return empty list when recipes found but no batches match")
void should_ReturnEmptyList_When_RecipesFoundButNoBatches() {
var recipe = sampleRecipe("recipe-1");
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe)));
when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.success(List.of()));
var result = listBatches.executeByArticleId("article-123", performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should fail with RepositoryFailure when recipe repository fails")
void should_FailWithRepositoryFailure_When_RecipeRepositoryFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.executeByArticleId("article-123", performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
verify(batchRepository, never()).findByRecipeIds(any());
}
@Test
@DisplayName("should fail with RepositoryFailure when batch repository fails after recipe lookup")
void should_FailWithRepositoryFailure_When_BatchRepositoryFailsAfterRecipeLookup() {
var recipe = sampleRecipe("recipe-1");
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(recipeRepository.findByArticleId("article-123")).thenReturn(Result.success(List.of(recipe)));
when(batchRepository.findByRecipeIds(List.of(RecipeId.of("recipe-1")))).thenReturn(Result.failure(DB_ERROR));
var result = listBatches.executeByArticleId("article-123", performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false);
var result = listBatches.executeByArticleId("article-123", performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(recipeRepository, never()).findByArticleId(any());
verify(batchRepository, never()).findByRecipeIds(any());
}
}
}

View file

@ -0,0 +1,189 @@
package de.effigenix.infrastructure.production.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.util.Set;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("GetBatch Integration Tests")
class GetBatchIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
@BeforeEach
void setUp() {
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
UserEntity admin = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRole), "BRANCH-01");
UserEntity viewer = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
adminToken = generateToken(admin.getId(), "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
viewerToken = generateToken(viewer.getId(), "batch.viewer", "USER_READ");
}
@Test
@DisplayName("Batch per ID abrufen → 200 mit vollständigen Daten")
void getBatch_existingId_returns200WithFullDetails() throws Exception {
String batchId = createBatch();
mockMvc.perform(get("/api/production/batches/{id}", batchId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(batchId))
.andExpect(jsonPath("$.batchNumber").isNotEmpty())
.andExpect(jsonPath("$.recipeId").isNotEmpty())
.andExpect(jsonPath("$.status").value("PLANNED"))
.andExpect(jsonPath("$.plannedQuantity").isNotEmpty())
.andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.productionDate").value("2026-03-01"))
.andExpect(jsonPath("$.bestBeforeDate").value("2026-06-01"));
}
@Test
@DisplayName("Nicht existierender Batch → 404")
void getBatch_nonExistentId_returns404() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}", "nonexistent-id")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Batch abrufen ohne BATCH_READ → 403")
void getBatch_withoutPermission_returns403() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}", "any-id")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Batch abrufen ohne Token → 401")
void getBatch_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}", "any-id"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Batch per Number abrufen → 200")
void findByNumber_existingNumber_returns200() throws Exception {
createBatch();
mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2026-03-01-001")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001"))
.andExpect(jsonPath("$.status").value("PLANNED"));
}
@Test
@DisplayName("Nicht existierende BatchNumber → 404")
void findByNumber_nonExistent_returns404() throws Exception {
mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2099-01-01-999")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND_BY_NUMBER"));
}
@Test
@DisplayName("BatchNumber-Suche ohne BATCH_READ → 403")
void findByNumber_withoutPermission_returns403() throws Exception {
mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2026-03-01-001")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("BatchNumber-Suche ohne Token → 401")
void findByNumber_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "P-2026-03-01-001"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ungültiges BatchNumber-Format → 400")
void findByNumber_invalidFormat_returns400() throws Exception {
mockMvc.perform(get("/api/production/batches/by-number/{batchNumber}", "INVALID-FORMAT")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR"));
}
// ==================== Hilfsmethoden ====================
private String createBatch() throws Exception {
String recipeId = createActiveRecipe();
String json = """
{
"recipeId": "%s",
"plannedQuantity": "100",
"plannedQuantityUnit": "KILOGRAM",
"productionDate": "2026-03-01",
"bestBeforeDate": "2026-06-01"
}
""".formatted(recipeId);
var result = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createActiveRecipe() throws Exception {
String recipeJson = """
{
"name": "Test-Rezept-%s",
"version": 1,
"type": "FINISHED_PRODUCT",
"description": "Testrezept",
"yieldPercentage": 85,
"shelfLifeDays": 14,
"outputQuantity": "100",
"outputUom": "KILOGRAM",
"articleId": "article-123"
}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/recipes")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(recipeJson))
.andExpect(status().isCreated())
.andReturn();
String recipeId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
String ingredientJson = """
{"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false}
""".formatted(UUID.randomUUID().toString());
mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(ingredientJson))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
return recipeId;
}
}

View file

@ -0,0 +1,209 @@
package de.effigenix.infrastructure.production.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.util.Set;
import java.util.UUID;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@DisplayName("ListBatches Integration Tests")
class ListBatchesIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
@BeforeEach
void setUp() {
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
UserEntity admin = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRole), "BRANCH-01");
UserEntity viewer = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
adminToken = generateToken(admin.getId(), "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
viewerToken = generateToken(viewer.getId(), "batch.viewer", "USER_READ");
}
@Test
@DisplayName("Alle Batches auflisten → 200 mit Summary-Format")
void listBatches_returnsAllWithSummaryFormat() throws Exception {
createBatch("2026-03-01");
createBatch("2026-03-02");
mockMvc.perform(get("/api/production/batches")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].id").isNotEmpty())
.andExpect(jsonPath("$[0].batchNumber").isNotEmpty())
.andExpect(jsonPath("$[0].status").value("PLANNED"));
}
@Test
@DisplayName("Leere Liste → 200 mit leerem Array")
void listBatches_empty_returns200WithEmptyArray() throws Exception {
mockMvc.perform(get("/api/production/batches")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(0)));
}
@Test
@DisplayName("Batches nach Status filtern → 200 nur passende")
void listBatches_filteredByStatus_returnsOnlyMatching() throws Exception {
createBatch("2026-03-01");
mockMvc.perform(get("/api/production/batches")
.param("status", "PLANNED")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].status").value("PLANNED"));
}
@Test
@DisplayName("Ungültiger Status-Parameter → 400")
void listBatches_invalidStatus_returns400() throws Exception {
mockMvc.perform(get("/api/production/batches")
.param("status", "INVALID")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR"));
}
@Test
@DisplayName("Batches nach Produktionsdatum filtern → 200")
void listBatches_filteredByProductionDate_returnsOnlyMatching() throws Exception {
createBatch("2026-03-01");
createBatch("2026-03-02");
mockMvc.perform(get("/api/production/batches")
.param("productionDate", "2026-03-01")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].productionDate").value("2026-03-01"));
}
@Test
@DisplayName("Batches nach articleId filtern → 200")
void listBatches_filteredByArticleId_returnsOnlyMatching() throws Exception {
createBatch("2026-03-01");
mockMvc.perform(get("/api/production/batches")
.param("articleId", "article-123")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)));
}
@Test
@DisplayName("Unbekannte articleId → 200 leeres Array")
void listBatches_unknownArticleId_returnsEmptyArray() throws Exception {
mockMvc.perform(get("/api/production/batches")
.param("articleId", "unknown-article")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(0)));
}
@Test
@DisplayName("Mehrere Filter gleichzeitig → 400")
void listBatches_multipleFilters_returns400() throws Exception {
mockMvc.perform(get("/api/production/batches")
.param("status", "PLANNED")
.param("articleId", "article-123")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR"));
}
@Test
@DisplayName("Batches auflisten ohne BATCH_READ → 403")
void listBatches_withoutPermission_returns403() throws Exception {
mockMvc.perform(get("/api/production/batches")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Batches auflisten ohne Token → 401")
void listBatches_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/production/batches"))
.andExpect(status().isUnauthorized());
}
// ==================== Hilfsmethoden ====================
private void createBatch(String productionDate) throws Exception {
String recipeId = createActiveRecipe();
String json = """
{
"recipeId": "%s",
"plannedQuantity": "100",
"plannedQuantityUnit": "KILOGRAM",
"productionDate": "%s",
"bestBeforeDate": "2026-12-01"
}
""".formatted(recipeId, productionDate);
mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated());
}
private String createActiveRecipe() throws Exception {
String recipeJson = """
{
"name": "Test-Rezept-%s",
"version": 1,
"type": "FINISHED_PRODUCT",
"description": "Testrezept",
"yieldPercentage": 85,
"shelfLifeDays": 14,
"outputQuantity": "100",
"outputUom": "KILOGRAM",
"articleId": "article-123"
}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/recipes")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(recipeJson))
.andExpect(status().isCreated())
.andReturn();
String recipeId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
String ingredientJson = """
{"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false}
""".formatted(UUID.randomUUID().toString());
mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(ingredientJson))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
return recipeId;
}
}