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:
parent
85f96d685e
commit
fa6c0c2d70
32 changed files with 3229 additions and 9 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue