1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 12:09:35 +01:00

feat(inventory): Bestandsbewegung erfassen (StockMovement) – Issue #15

Immutables StockMovement-Aggregate als Audit-Trail für jede Bestandsveränderung.
Domain-Invarianten: positive Quantity, Reason bei WASTE/ADJUSTMENT,
ReferenceDocumentId bei INTER_BRANCH_TRANSFER, Direction-Ableitung aus MovementType.

Domain: StockMovement, MovementType (8 Typen), MovementDirection, StockMovementError
Application: RecordStockMovement, GetStockMovement, ListStockMovements
Infrastructure: JPA-Persistence, REST-Controller (POST/GET), Liquibase 028+029
Tests: ~40 Domain-Unit-Tests, 18 Application-Tests, ~27 Integrationstests
Loadtest: Gatling-Szenarien für Bestandsbewegungen (Seeding, Read, Write)
This commit is contained in:
Sebastian Frick 2026-02-24 22:58:57 +01:00
parent 85f96d685e
commit fa6c0c2d70
32 changed files with 3229 additions and 9 deletions

View file

@ -0,0 +1,125 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
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.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("GetStockMovement Use Case")
class GetStockMovementTest {
@Mock private StockMovementRepository repository;
@Mock private AuthorizationPort authPort;
private GetStockMovement useCase;
private StockMovement existingMovement;
private final ActorId actor = ActorId.of("user-1");
@BeforeEach
void setUp() {
useCase = new GetStockMovement(repository, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
existingMovement = StockMovement.reconstitute(
StockMovementId.of("movement-1"),
StockId.of("stock-1"),
ArticleId.of("article-1"),
StockBatchId.of("batch-1"),
new BatchReference("CHARGE-001", BatchType.PRODUCED),
MovementType.GOODS_RECEIPT,
MovementDirection.IN,
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
null, null, "user-1", Instant.now()
);
}
@Test
@DisplayName("should return movement when found")
void shouldReturnMovementWhenFound() {
when(repository.findById(StockMovementId.of("movement-1")))
.thenReturn(Result.success(Optional.of(existingMovement)));
var result = useCase.execute("movement-1", actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().id().value()).isEqualTo("movement-1");
}
@Test
@DisplayName("should fail with StockMovementNotFound when not found")
void shouldFailWhenNotFound() {
when(repository.findById(StockMovementId.of("movement-1")))
.thenReturn(Result.success(Optional.empty()));
var result = useCase.execute("movement-1", actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.StockMovementNotFound.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when repository fails")
void shouldFailWhenRepositoryFails() {
when(repository.findById(StockMovementId.of("movement-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = useCase.execute("movement-1", actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with StockMovementNotFound when id is null")
void shouldFailWhenIdIsNull() {
var result = useCase.execute(null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.StockMovementNotFound.class);
}
@Test
@DisplayName("should fail with StockMovementNotFound when id is blank")
void shouldFailWhenIdIsBlank() {
var result = useCase.execute(" ", actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.StockMovementNotFound.class);
}
@Nested
@DisplayName("Authorization")
class Authorization {
@Test
@DisplayName("should fail with Unauthorized when actor lacks STOCK_MOVEMENT_READ")
void shouldFailWhenUnauthorized() {
when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false);
var result = useCase.execute("movement-1", actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class);
}
}
}

View file

@ -0,0 +1,226 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
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.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("ListStockMovements Use Case")
class ListStockMovementsTest {
@Mock private StockMovementRepository repository;
@Mock private AuthorizationPort authPort;
private ListStockMovements useCase;
private StockMovement sampleMovement;
private final ActorId actor = ActorId.of("user-1");
@BeforeEach
void setUp() {
useCase = new ListStockMovements(repository, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
sampleMovement = StockMovement.reconstitute(
StockMovementId.generate(),
StockId.of("stock-1"),
ArticleId.of("article-1"),
StockBatchId.of("batch-1"),
new BatchReference("CHARGE-001", BatchType.PRODUCED),
MovementType.GOODS_RECEIPT,
MovementDirection.IN,
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
null, null, "user-1", Instant.now()
);
}
@Nested
@DisplayName("No filters")
class NoFilter {
@Test
@DisplayName("should return all movements when no filter")
void shouldReturnAll() {
when(repository.findAll()).thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(repository).findAll();
}
@Test
@DisplayName("should return empty list when no movements exist")
void shouldReturnEmptyList() {
when(repository.findAll()).thenReturn(Result.success(List.of()));
var result = useCase.execute(null, null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should fail when repository fails")
void shouldFailWhenRepositoryFails() {
when(repository.findAll()).thenReturn(
Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = useCase.execute(null, null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
}
}
@Nested
@DisplayName("Filter by stockId")
class StockIdFilter {
@Test
@DisplayName("should filter by stockId")
void shouldFilterByStockId() {
when(repository.findAllByStockId(StockId.of("stock-1")))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute("stock-1", null, null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(repository).findAllByStockId(StockId.of("stock-1"));
verify(repository, never()).findAll();
}
@Test
@DisplayName("should fail with InvalidStockId when format invalid")
void shouldFailWhenStockIdInvalid() {
var result = useCase.execute(" ", null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class);
}
}
@Nested
@DisplayName("Filter by articleId")
class ArticleIdFilter {
@Test
@DisplayName("should filter by articleId")
void shouldFilterByArticleId() {
when(repository.findAllByArticleId(ArticleId.of("article-1")))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, "article-1", null, actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(repository).findAllByArticleId(ArticleId.of("article-1"));
}
@Test
@DisplayName("should fail with InvalidArticleId when format invalid")
void shouldFailWhenArticleIdInvalid() {
var result = useCase.execute(null, " ", null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class);
}
}
@Nested
@DisplayName("Filter by movementType")
class MovementTypeFilter {
@Test
@DisplayName("should filter by movementType")
void shouldFilterByMovementType() {
when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT))
.thenReturn(Result.success(List.of(sampleMovement)));
var result = useCase.execute(null, null, "GOODS_RECEIPT", actor);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
verify(repository).findAllByMovementType(MovementType.GOODS_RECEIPT);
}
@Test
@DisplayName("should fail with InvalidMovementType when type invalid")
void shouldFailWhenMovementTypeInvalid() {
var result = useCase.execute(null, null, "INVALID_TYPE", actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class);
}
}
@Nested
@DisplayName("Filter priority")
class FilterPriority {
@Test
@DisplayName("stockId takes priority over articleId and movementType")
void stockIdTakesPriority() {
when(repository.findAllByStockId(StockId.of("stock-1")))
.thenReturn(Result.success(List.of()));
var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByStockId(StockId.of("stock-1"));
verify(repository, never()).findAllByArticleId(any());
verify(repository, never()).findAllByMovementType(any());
}
@Test
@DisplayName("articleId takes priority over movementType")
void articleIdTakesPriorityOverMovementType() {
when(repository.findAllByArticleId(ArticleId.of("article-1")))
.thenReturn(Result.success(List.of()));
var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", actor);
assertThat(result.isSuccess()).isTrue();
verify(repository).findAllByArticleId(ArticleId.of("article-1"));
verify(repository, never()).findAllByMovementType(any());
}
}
@Nested
@DisplayName("Authorization")
class Authorization {
@Test
@DisplayName("should fail with Unauthorized when actor lacks STOCK_MOVEMENT_READ")
void shouldFailWhenUnauthorized() {
when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false);
var result = useCase.execute(null, null, null, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class);
verify(repository, never()).findAll();
}
}
}

View file

@ -0,0 +1,106 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.RecordStockMovementCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class RecordStockMovementTest {
private StockMovementRepository repository;
private AuthorizationPort authPort;
private RecordStockMovement useCase;
private final ActorId actor = ActorId.of("user-1");
@BeforeEach
void setUp() {
repository = mock(StockMovementRepository.class);
authPort = mock(AuthorizationPort.class);
useCase = new RecordStockMovement(repository, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
}
private RecordStockMovementCommand validCommand() {
return new RecordStockMovementCommand(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
}
@Test
@DisplayName("should record stock movement successfully")
void shouldRecordSuccessfully() {
when(repository.save(any())).thenReturn(Result.success(null));
var result = useCase.execute(validCommand(), actor);
assertThat(result.isSuccess()).isTrue();
var movement = result.unsafeGetValue();
assertThat(movement.movementType()).isEqualTo(MovementType.GOODS_RECEIPT);
assertThat(movement.direction()).isEqualTo(MovementDirection.IN);
var captor = org.mockito.ArgumentCaptor.forClass(StockMovement.class);
verify(repository).save(captor.capture());
assertThat(captor.getValue().id()).isEqualTo(movement.id());
}
@Test
@DisplayName("should return domain error when validation fails")
void shouldReturnDomainErrorOnValidationFailure() {
var cmd = new RecordStockMovementCommand(
null, "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = useCase.execute(cmd, actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class);
verify(repository, never()).save(any());
}
@Test
@DisplayName("should return repository failure on save error")
void shouldReturnRepositoryFailureOnSaveError() {
when(repository.save(any())).thenReturn(
Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = useCase.execute(validCommand(), actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
}
@Nested
@DisplayName("Authorization")
class Authorization {
@Test
@DisplayName("should fail with Unauthorized when actor lacks STOCK_MOVEMENT_WRITE")
void shouldFailWhenUnauthorized() {
when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_WRITE)).thenReturn(false);
var result = useCase.execute(validCommand(), actor);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class);
verify(repository, never()).save(any());
}
}
}

View file

@ -0,0 +1,722 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class StockMovementTest {
private StockMovementDraft validDraft(String movementType, String direction) {
return new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
movementType, direction,
"10.5", "KILOGRAM",
null, null,
"user-1"
);
}
private StockMovementDraft validDraft(String movementType) {
return validDraft(movementType, null);
}
// ==================== Factory: record() ====================
@Nested
@DisplayName("record() Positive Cases")
class PositiveCases {
@Test
@DisplayName("should record GOODS_RECEIPT with direction IN")
void shouldRecordGoodsReceipt() {
var result = StockMovement.record(validDraft("GOODS_RECEIPT"));
assertThat(result.isSuccess()).isTrue();
var movement = result.unsafeGetValue();
assertThat(movement.id()).isNotNull();
assertThat(movement.movementType()).isEqualTo(MovementType.GOODS_RECEIPT);
assertThat(movement.direction()).isEqualTo(MovementDirection.IN);
assertThat(movement.isIncoming()).isTrue();
assertThat(movement.isOutgoing()).isFalse();
assertThat(movement.quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
}
@Test
@DisplayName("should record PRODUCTION_OUTPUT with direction IN")
void shouldRecordProductionOutput() {
var result = StockMovement.record(validDraft("PRODUCTION_OUTPUT"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN);
}
@Test
@DisplayName("should record RETURN with direction IN")
void shouldRecordReturn() {
var result = StockMovement.record(validDraft("RETURN"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN);
}
@Test
@DisplayName("should record PRODUCTION_CONSUMPTION with direction OUT")
void shouldRecordProductionConsumption() {
var result = StockMovement.record(validDraft("PRODUCTION_CONSUMPTION"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT);
}
@Test
@DisplayName("should record SALE with direction OUT")
void shouldRecordSale() {
var result = StockMovement.record(validDraft("SALE"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT);
}
@Test
@DisplayName("should record WASTE with reason and direction OUT")
void shouldRecordWaste() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"WASTE", null,
"5.0", "KILOGRAM",
"Expired product", null,
"user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT);
assertThat(result.unsafeGetValue().reason()).isEqualTo("Expired product");
}
@Test
@DisplayName("should record ADJUSTMENT IN with reason")
void shouldRecordAdjustmentIn() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", "IN",
"2.0", "KILOGRAM",
"Inventory count correction", null,
"user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN);
assertThat(result.unsafeGetValue().reason()).isEqualTo("Inventory count correction");
}
@Test
@DisplayName("should record ADJUSTMENT OUT with reason")
void shouldRecordAdjustmentOut() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", "OUT",
"1.0", "KILOGRAM",
"Inventory count correction", null,
"user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT);
}
@Test
@DisplayName("should record INTER_BRANCH_TRANSFER with referenceDocumentId")
void shouldRecordInterBranchTransfer() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"INTER_BRANCH_TRANSFER", null,
"3.0", "KILOGRAM",
null, "TRANSFER-001",
"user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.OUT);
assertThat(result.unsafeGetValue().referenceDocumentId()).isEqualTo("TRANSFER-001");
}
@Test
@DisplayName("should ignore direction field for non-ADJUSTMENT types")
void shouldIgnoreDirectionForNonAdjustment() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", "OUT",
"10.0", "KILOGRAM",
null, null,
"user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().direction()).isEqualTo(MovementDirection.IN);
}
}
// ==================== Factory: record() Negative Cases ====================
@Nested
@DisplayName("record() Validation Errors")
class ValidationErrors {
@Test
@DisplayName("should fail when stockId is null")
void shouldFailWhenStockIdNull() {
var draft = new StockMovementDraft(
null, "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class);
}
@Test
@DisplayName("should fail when stockId is blank")
void shouldFailWhenStockIdBlank() {
var draft = new StockMovementDraft(
"", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class);
}
@Test
@DisplayName("should fail when articleId is blank")
void shouldFailWhenArticleIdBlank() {
var draft = new StockMovementDraft(
"stock-1", "", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class);
}
@Test
@DisplayName("should fail when articleId is null")
void shouldFailWhenArticleIdNull() {
var draft = new StockMovementDraft(
"stock-1", null, "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class);
}
@Test
@DisplayName("should fail when stockBatchId is blank")
void shouldFailWhenStockBatchIdBlank() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockBatchId.class);
}
@Test
@DisplayName("should fail when stockBatchId is null")
void shouldFailWhenStockBatchIdNull() {
var draft = new StockMovementDraft(
"stock-1", "article-1", null,
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockBatchId.class);
}
@Test
@DisplayName("should fail when batchId is null")
void shouldFailWhenBatchIdNull() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
null, "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when batchId is blank")
void shouldFailWhenBatchIdBlank() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when batchType is null")
void shouldFailWhenBatchTypeNull() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", null,
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when batchType is invalid")
void shouldFailWhenBatchTypeInvalid() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "INVALID",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class);
}
@Test
@DisplayName("should fail when movementType is null")
void shouldFailWhenMovementTypeNull() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
null, null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class);
}
@Test
@DisplayName("should fail when movementType is invalid")
void shouldFailWhenMovementTypeInvalid() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"INVALID_TYPE", null,
"10.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class);
}
@Test
@DisplayName("should fail when ADJUSTMENT has blank direction")
void shouldFailWhenAdjustmentHasBlankDirection() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", " ",
"10.0", "KILOGRAM",
"Correction", null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDirection.class);
}
@Test
@DisplayName("should fail when ADJUSTMENT has no direction")
void shouldFailWhenAdjustmentHasNoDirection() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", null,
"10.0", "KILOGRAM",
"Correction", null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDirection.class);
}
@Test
@DisplayName("should fail when ADJUSTMENT has invalid direction")
void shouldFailWhenAdjustmentHasInvalidDirection() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", "SIDEWAYS",
"10.0", "KILOGRAM",
"Correction", null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDirection.class);
}
@Test
@DisplayName("should fail when quantityAmount is null")
void shouldFailWhenQuantityAmountNull() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
null, "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when quantityUnit is null")
void shouldFailWhenQuantityUnitNull() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", null,
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when quantity is negative")
void shouldFailWhenQuantityNegative() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"-5.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when quantity is zero")
void shouldFailWhenQuantityZero() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when quantityAmount is not a number")
void shouldFailWhenQuantityAmountNotNumber() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"abc", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when quantityUnit is invalid")
void shouldFailWhenQuantityUnitInvalid() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "INVALID_UNIT",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail when WASTE has no reason")
void shouldFailWhenWasteHasNoReason() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"WASTE", null,
"5.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class);
}
@Test
@DisplayName("should fail when WASTE has blank reason")
void shouldFailWhenWasteHasBlankReason() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"WASTE", null,
"5.0", "KILOGRAM",
" ", null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class);
}
@Test
@DisplayName("should fail when ADJUSTMENT has no reason")
void shouldFailWhenAdjustmentHasNoReason() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", "IN",
"2.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class);
}
@Test
@DisplayName("should fail when INTER_BRANCH_TRANSFER has no referenceDocumentId")
void shouldFailWhenTransferHasNoReferenceDocument() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"INTER_BRANCH_TRANSFER", null,
"3.0", "KILOGRAM",
null, null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReferenceDocumentRequired.class);
}
@Test
@DisplayName("should fail when INTER_BRANCH_TRANSFER has blank referenceDocumentId")
void shouldFailWhenTransferHasBlankReferenceDocument() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"INTER_BRANCH_TRANSFER", null,
"3.0", "KILOGRAM",
null, " ", "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReferenceDocumentRequired.class);
}
@Test
@DisplayName("should fail when performedBy is null")
void shouldFailWhenPerformedByNull() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, null
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidPerformedBy.class);
}
@Test
@DisplayName("should fail when performedBy is blank")
void shouldFailWhenPerformedByBlank() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null, " "
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidPerformedBy.class);
}
@Test
@DisplayName("should fail when ADJUSTMENT has blank reason")
void shouldFailWhenAdjustmentHasBlankReason() {
var draft = new StockMovementDraft(
"stock-1", "article-1", "batch-id-1",
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", "IN",
"2.0", "KILOGRAM",
" ", null, "user-1"
);
var result = StockMovement.record(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.ReasonRequired.class);
}
}
// ==================== reconstitute() ====================
@Nested
@DisplayName("reconstitute()")
class Reconstitute {
@Test
@DisplayName("should reconstitute all fields from persistence")
void shouldReconstituteAllFields() {
var id = StockMovementId.generate();
var stockId = StockId.of("stock-1");
var articleId = ArticleId.of("article-1");
var stockBatchId = StockBatchId.of("batch-id-1");
var batchRef = new BatchReference("CHARGE-001", BatchType.PRODUCED);
var quantity = Quantity.reconstitute(new BigDecimal("10.5"), UnitOfMeasure.KILOGRAM);
var performedAt = Instant.now();
var movement = StockMovement.reconstitute(
id, stockId, articleId, stockBatchId, batchRef,
MovementType.WASTE, MovementDirection.OUT, quantity,
"Expired", "REF-001", "user-1", performedAt
);
assertThat(movement.id()).isEqualTo(id);
assertThat(movement.stockId()).isEqualTo(stockId);
assertThat(movement.articleId()).isEqualTo(articleId);
assertThat(movement.stockBatchId()).isEqualTo(stockBatchId);
assertThat(movement.batchReference()).isEqualTo(batchRef);
assertThat(movement.movementType()).isEqualTo(MovementType.WASTE);
assertThat(movement.direction()).isEqualTo(MovementDirection.OUT);
assertThat(movement.quantity()).isEqualTo(quantity);
assertThat(movement.reason()).isEqualTo("Expired");
assertThat(movement.referenceDocumentId()).isEqualTo("REF-001");
assertThat(movement.performedBy()).isEqualTo("user-1");
assertThat(movement.performedAt()).isEqualTo(performedAt);
assertThat(movement.isOutgoing()).isTrue();
assertThat(movement.isIncoming()).isFalse();
}
@Test
@DisplayName("should reconstitute with null optional fields")
void shouldReconstituteWithNullOptionals() {
var movement = StockMovement.reconstitute(
StockMovementId.generate(),
StockId.of("stock-1"),
ArticleId.of("article-1"),
StockBatchId.of("batch-id-1"),
new BatchReference("CHARGE-001", BatchType.PURCHASED),
MovementType.GOODS_RECEIPT,
MovementDirection.IN,
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.PIECE),
null, null, "user-1", Instant.now()
);
assertThat(movement.reason()).isNull();
assertThat(movement.referenceDocumentId()).isNull();
assertThat(movement.isIncoming()).isTrue();
}
}
// ==================== equals/hashCode ====================
@Nested
@DisplayName("equals() and hashCode()")
class Identity {
@Test
@DisplayName("movements with same ID are equal")
void sameIdAreEqual() {
var id = StockMovementId.generate();
var performedAt = Instant.now();
var qty = Quantity.reconstitute(new BigDecimal("1"), UnitOfMeasure.KILOGRAM);
var batchRef = new BatchReference("C1", BatchType.PRODUCED);
var m1 = StockMovement.reconstitute(id, StockId.of("s1"), ArticleId.of("a1"),
StockBatchId.of("b1"), batchRef, MovementType.SALE, MovementDirection.OUT,
qty, null, null, "u1", performedAt);
var m2 = StockMovement.reconstitute(id, StockId.of("s2"), ArticleId.of("a2"),
StockBatchId.of("b2"), batchRef, MovementType.RETURN, MovementDirection.IN,
qty, null, null, "u2", performedAt);
assertThat(m1).isEqualTo(m2);
assertThat(m1.hashCode()).isEqualTo(m2.hashCode());
}
@Test
@DisplayName("movements with different ID are not equal")
void differentIdAreNotEqual() {
var performedAt = Instant.now();
var qty = Quantity.reconstitute(new BigDecimal("1"), UnitOfMeasure.KILOGRAM);
var batchRef = new BatchReference("C1", BatchType.PRODUCED);
var m1 = StockMovement.reconstitute(StockMovementId.generate(), StockId.of("s1"),
ArticleId.of("a1"), StockBatchId.of("b1"), batchRef,
MovementType.SALE, MovementDirection.OUT, qty, null, null, "u1", performedAt);
var m2 = StockMovement.reconstitute(StockMovementId.generate(), StockId.of("s1"),
ArticleId.of("a1"), StockBatchId.of("b1"), batchRef,
MovementType.SALE, MovementDirection.OUT, qty, null, null, "u1", performedAt);
assertThat(m1).isNotEqualTo(m2);
}
}
}

View file

@ -0,0 +1,603 @@
package de.effigenix.infrastructure.inventory.web;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
import de.effigenix.infrastructure.inventory.web.dto.RecordStockMovementRequest;
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.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("StockMovement Controller Integration Tests")
class StockMovementControllerIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String viewerToken;
private String storageLocationId;
private String stockId;
private String articleId;
private String stockBatchId;
@BeforeEach
void setUp() throws Exception {
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
UserEntity admin = createUser("movement.admin", "movement.admin@test.com", Set.of(adminRole), "BRANCH-01");
UserEntity viewer = createUser("movement.viewer", "movement.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
adminToken = generateToken(admin.getId(), "movement.admin",
"STOCK_WRITE,STOCK_READ,STOCK_MOVEMENT_WRITE,STOCK_MOVEMENT_READ");
viewerToken = generateToken(viewer.getId(), "movement.viewer", "USER_READ");
storageLocationId = createStorageLocation();
articleId = UUID.randomUUID().toString();
stockId = createStock(articleId);
stockBatchId = addBatchToStock(stockId);
}
// ==================== Bewegung erfassen ====================
@Nested
@DisplayName("POST /api/inventory/stock-movements")
class RecordMovement {
@Test
@DisplayName("GOODS_RECEIPT erfassen → 201")
void recordGoodsReceipt_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.5", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.stockId").value(stockId))
.andExpect(jsonPath("$.articleId").value(articleId))
.andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT"))
.andExpect(jsonPath("$.direction").value("IN"))
.andExpect(jsonPath("$.quantityAmount").value(10.5))
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.performedBy").isNotEmpty())
.andExpect(jsonPath("$.performedAt").isNotEmpty());
}
@Test
@DisplayName("WASTE ohne Reason → 400")
void recordWasteWithoutReason_returns400() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"WASTE", null,
"5.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("REASON_REQUIRED"));
}
@Test
@DisplayName("WASTE mit Reason → 201")
void recordWasteWithReason_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"WASTE", null,
"2.0", "KILOGRAM",
"Expired product", null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("WASTE"))
.andExpect(jsonPath("$.direction").value("OUT"))
.andExpect(jsonPath("$.reason").value("Expired product"));
}
@Test
@DisplayName("ADJUSTMENT IN mit Reason → 201")
void recordAdjustmentIn_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", "IN",
"1.0", "KILOGRAM",
"Inventory correction", null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("ADJUSTMENT"))
.andExpect(jsonPath("$.direction").value("IN"));
}
@Test
@DisplayName("INTER_BRANCH_TRANSFER ohne referenceDocumentId → 400")
void recordTransferWithoutRefDoc_returns400() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"INTER_BRANCH_TRANSFER", null,
"3.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("REFERENCE_DOCUMENT_REQUIRED"));
}
@Test
@DisplayName("PRODUCTION_OUTPUT erfassen → 201 (IN)")
void recordProductionOutput_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"PRODUCTION_OUTPUT", null,
"8.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("PRODUCTION_OUTPUT"))
.andExpect(jsonPath("$.direction").value("IN"));
}
@Test
@DisplayName("PRODUCTION_CONSUMPTION erfassen → 201 (OUT)")
void recordProductionConsumption_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"PRODUCTION_CONSUMPTION", null,
"4.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("PRODUCTION_CONSUMPTION"))
.andExpect(jsonPath("$.direction").value("OUT"));
}
@Test
@DisplayName("SALE erfassen → 201 (OUT)")
void recordSale_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"SALE", null,
"2.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("SALE"))
.andExpect(jsonPath("$.direction").value("OUT"));
}
@Test
@DisplayName("RETURN erfassen → 201 (IN)")
void recordReturn_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"RETURN", null,
"1.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("RETURN"))
.andExpect(jsonPath("$.direction").value("IN"));
}
@Test
@DisplayName("INTER_BRANCH_TRANSFER mit referenceDocumentId → 201 (OUT)")
void recordTransferWithRefDoc_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"INTER_BRANCH_TRANSFER", null,
"3.0", "KILOGRAM",
null, "TRANSFER-001"
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("INTER_BRANCH_TRANSFER"))
.andExpect(jsonPath("$.direction").value("OUT"))
.andExpect(jsonPath("$.referenceDocumentId").value("TRANSFER-001"));
}
@Test
@DisplayName("ADJUSTMENT OUT mit Reason → 201")
void recordAdjustmentOut_returns201() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", "OUT",
"0.5", "KILOGRAM",
"Shrinkage correction", null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.movementType").value("ADJUSTMENT"))
.andExpect(jsonPath("$.direction").value("OUT"))
.andExpect(jsonPath("$.reason").value("Shrinkage correction"));
}
@Test
@DisplayName("ADJUSTMENT ohne Direction → 400")
void recordAdjustmentWithoutDirection_returns400() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"ADJUSTMENT", null,
"1.0", "KILOGRAM",
"Correction", null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_DIRECTION"));
}
@Test
@DisplayName("Negative Menge → 400")
void recordNegativeQuantity_returns400() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"-5.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_QUANTITY"));
}
@Test
@DisplayName("Ungültige Einheit → 400")
void recordInvalidUnit_returns400() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "INVALID_UNIT",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_QUANTITY"));
}
@Test
@DisplayName("Ungültiger movementType → 400")
void recordInvalidMovementType_returns400() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"INVALID_TYPE", null,
"10.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_MOVEMENT_TYPE"));
}
@Test
@DisplayName("Ohne STOCK_MOVEMENT_WRITE → 403")
void recordWithoutPermission_returns403() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Ohne Token → 401")
void recordWithoutToken_returns401() throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
"GOODS_RECEIPT", null,
"10.0", "KILOGRAM",
null, null
);
mockMvc.perform(post("/api/inventory/stock-movements")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}
// ==================== Bewegungen abfragen ====================
@Nested
@DisplayName("GET /api/inventory/stock-movements")
class ListMovements {
@Test
@DisplayName("Alle Bewegungen auflisten → 200")
void listAll_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("Nach stockId filtern → 200")
void filterByStockId_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("stockId", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
@DisplayName("Nach movementType filtern → 200")
void filterByMovementType_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("movementType", "GOODS_RECEIPT")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
@DisplayName("Nach articleId filtern → 200")
void filterByArticleId_returns200() throws Exception {
recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements")
.param("articleId", articleId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("Ungültiger movementType-Filter → 400")
void filterByInvalidMovementType_returns400() throws Exception {
mockMvc.perform(get("/api/inventory/stock-movements")
.param("movementType", "INVALID")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_MOVEMENT_TYPE"));
}
@Test
@DisplayName("Ohne STOCK_MOVEMENT_READ → 403")
void listWithoutPermission_returns403() throws Exception {
mockMvc.perform(get("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Leere Liste → 200 mit []")
void listEmpty_returns200() throws Exception {
mockMvc.perform(get("/api/inventory/stock-movements")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
}
// ==================== Einzelne Bewegung ====================
@Nested
@DisplayName("GET /api/inventory/stock-movements/{id}")
class GetMovement {
@Test
@DisplayName("Einzelne Bewegung per ID → 200")
void getById_returns200() throws Exception {
String movementId = recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements/{id}", movementId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(movementId))
.andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT"));
}
@Test
@DisplayName("Nicht vorhandene ID → 404")
void getByInvalidId_returns404() throws Exception {
mockMvc.perform(get("/api/inventory/stock-movements/{id}", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STOCK_MOVEMENT_NOT_FOUND"));
}
@Test
@DisplayName("Ohne STOCK_MOVEMENT_READ → 403")
void getWithoutPermission_returns403() throws Exception {
mockMvc.perform(get("/api/inventory/stock-movements/{id}", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Vollständige Response-Struktur verifizieren")
void getById_fullResponseValidation() throws Exception {
String movementId = recordMovement("GOODS_RECEIPT");
mockMvc.perform(get("/api/inventory/stock-movements/{id}", movementId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(movementId))
.andExpect(jsonPath("$.stockId").value(stockId))
.andExpect(jsonPath("$.articleId").value(articleId))
.andExpect(jsonPath("$.stockBatchId").value(stockBatchId))
.andExpect(jsonPath("$.batchId").value("CHARGE-001"))
.andExpect(jsonPath("$.batchType").value("PRODUCED"))
.andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT"))
.andExpect(jsonPath("$.direction").value("IN"))
.andExpect(jsonPath("$.quantityAmount").value(10.0))
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.performedBy").isNotEmpty())
.andExpect(jsonPath("$.performedAt").isNotEmpty());
}
}
// ==================== Helpers ====================
private String createStorageLocation() throws Exception {
String json = """
{"name": "Testlager-%s", "storageType": "DRY_STORAGE"}
""".formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/inventory/storage-locations")
.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 createStock(String artId) throws Exception {
var request = new CreateStockRequest(artId, storageLocationId, null, null, null);
var result = mockMvc.perform(post("/api/inventory/stocks")
.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 addBatchToStock(String sId) throws Exception {
var request = new AddStockBatchRequest(
"BATCH-" + UUID.randomUUID().toString().substring(0, 8),
"PRODUCED", "100", "KILOGRAM", "2026-12-31");
var result = mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", sId)
.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 recordMovement(String movementType) throws Exception {
var request = new RecordStockMovementRequest(
stockId, articleId, stockBatchId,
"CHARGE-001", "PRODUCED",
movementType, null,
"10.0", "KILOGRAM",
null, null
);
var result = mockMvc.perform(post("/api/inventory/stock-movements")
.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();
}
}