diff --git a/backend/pom.xml b/backend/pom.xml
index f12027a..1449c3a 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -150,6 +150,11 @@
org.jacoco
jacoco-maven-plugin
${jacoco.version}
+
+
+ de/effigenix/infrastructure/stub/**
+
+
prepare-agent
diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListStorageLocationsTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListStorageLocationsTest.java
new file mode 100644
index 0000000..28869b3
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/application/inventory/ListStorageLocationsTest.java
@@ -0,0 +1,234 @@
+package de.effigenix.application.inventory;
+
+import de.effigenix.domain.inventory.*;
+import de.effigenix.shared.common.RepositoryError;
+import de.effigenix.shared.common.Result;
+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.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ListStorageLocations Use Case")
+class ListStorageLocationsTest {
+
+ @Mock private StorageLocationRepository storageLocationRepository;
+
+ private ListStorageLocations listStorageLocations;
+
+ @BeforeEach
+ void setUp() {
+ listStorageLocations = new ListStorageLocations(storageLocationRepository);
+ }
+
+ private StorageLocation activeLocation(String name, StorageType type) {
+ return StorageLocation.reconstitute(
+ StorageLocationId.generate(),
+ new StorageLocationName(name),
+ type, null, true
+ );
+ }
+
+ private StorageLocation inactiveLocation(String name, StorageType type) {
+ return StorageLocation.reconstitute(
+ StorageLocationId.generate(),
+ new StorageLocationName(name),
+ type, null, false
+ );
+ }
+
+ // ==================== Ohne Filter ====================
+
+ @Nested
+ @DisplayName("ohne Filter")
+ class NoFilter {
+
+ @Test
+ @DisplayName("should return all storage locations")
+ void shouldReturnAll() {
+ var locations = List.of(
+ activeLocation("Lager A", StorageType.DRY_STORAGE),
+ inactiveLocation("Lager B", StorageType.COLD_ROOM)
+ );
+ when(storageLocationRepository.findAll()).thenReturn(Result.success(locations));
+
+ var result = listStorageLocations.execute(null, null);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should return empty list when none exist")
+ void shouldReturnEmptyList() {
+ when(storageLocationRepository.findAll()).thenReturn(Result.success(List.of()));
+
+ var result = listStorageLocations.execute(null, null);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should fail when repository fails")
+ void shouldFailWhenRepositoryFails() {
+ when(storageLocationRepository.findAll())
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = listStorageLocations.execute(null, null);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== Active-Filter ====================
+
+ @Nested
+ @DisplayName("active-Filter")
+ class ActiveFilter {
+
+ @Test
+ @DisplayName("should return only active locations when active=true")
+ void shouldReturnOnlyActive() {
+ var locations = List.of(
+ activeLocation("Aktiv", StorageType.DRY_STORAGE),
+ inactiveLocation("Inaktiv", StorageType.COLD_ROOM)
+ );
+ when(storageLocationRepository.findAll()).thenReturn(Result.success(locations));
+
+ var result = listStorageLocations.execute(null, true);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ assertThat(result.unsafeGetValue().get(0).name().value()).isEqualTo("Aktiv");
+ }
+
+ @Test
+ @DisplayName("should return only inactive locations when active=false")
+ void shouldReturnOnlyInactive() {
+ var locations = List.of(
+ activeLocation("Aktiv", StorageType.DRY_STORAGE),
+ inactiveLocation("Inaktiv", StorageType.COLD_ROOM)
+ );
+ when(storageLocationRepository.findAll()).thenReturn(Result.success(locations));
+
+ var result = listStorageLocations.execute(null, false);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ assertThat(result.unsafeGetValue().get(0).name().value()).isEqualTo("Inaktiv");
+ }
+
+ @Test
+ @DisplayName("should return empty list when no locations match active filter")
+ void shouldReturnEmptyWhenNoMatch() {
+ var locations = List.of(
+ activeLocation("Aktiv", StorageType.DRY_STORAGE)
+ );
+ when(storageLocationRepository.findAll()).thenReturn(Result.success(locations));
+
+ var result = listStorageLocations.execute(null, false);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).isEmpty();
+ }
+ }
+
+ // ==================== StorageType-Filter ====================
+
+ @Nested
+ @DisplayName("storageType-Filter")
+ class StorageTypeFilter {
+
+ @Test
+ @DisplayName("should return locations of given storage type")
+ void shouldReturnByStorageType() {
+ var coldRooms = List.of(
+ activeLocation("Kühlraum", StorageType.COLD_ROOM)
+ );
+ when(storageLocationRepository.findByStorageType(StorageType.COLD_ROOM))
+ .thenReturn(Result.success(coldRooms));
+
+ var result = listStorageLocations.execute("COLD_ROOM", null);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ assertThat(result.unsafeGetValue().get(0).storageType()).isEqualTo(StorageType.COLD_ROOM);
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidStorageType for unknown type")
+ void shouldFailForInvalidStorageType() {
+ var result = listStorageLocations.execute("INVALID", null);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(StorageLocationError.InvalidStorageType.class);
+ verifyNoInteractions(storageLocationRepository);
+ }
+ }
+
+ // ==================== Kombinierte Filter ====================
+
+ @Nested
+ @DisplayName("kombinierte Filter (storageType + active)")
+ class CombinedFilter {
+
+ @Test
+ @DisplayName("should return only active locations of given type")
+ void shouldReturnActiveOfType() {
+ var coldRooms = List.of(
+ activeLocation("Kühl Aktiv", StorageType.COLD_ROOM),
+ inactiveLocation("Kühl Inaktiv", StorageType.COLD_ROOM)
+ );
+ when(storageLocationRepository.findByStorageType(StorageType.COLD_ROOM))
+ .thenReturn(Result.success(coldRooms));
+
+ var result = listStorageLocations.execute("COLD_ROOM", true);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ assertThat(result.unsafeGetValue().get(0).name().value()).isEqualTo("Kühl Aktiv");
+ }
+
+ @Test
+ @DisplayName("should return only inactive locations of given type")
+ void shouldReturnInactiveOfType() {
+ var coldRooms = List.of(
+ activeLocation("Kühl Aktiv", StorageType.COLD_ROOM),
+ inactiveLocation("Kühl Inaktiv", StorageType.COLD_ROOM)
+ );
+ when(storageLocationRepository.findByStorageType(StorageType.COLD_ROOM))
+ .thenReturn(Result.success(coldRooms));
+
+ var result = listStorageLocations.execute("COLD_ROOM", false);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ assertThat(result.unsafeGetValue().get(0).name().value()).isEqualTo("Kühl Inaktiv");
+ }
+
+ @Test
+ @DisplayName("should return empty list when no locations match combined filter")
+ void shouldReturnEmptyWhenNoMatchCombined() {
+ var coldRooms = List.of(
+ activeLocation("Kühl Aktiv", StorageType.COLD_ROOM)
+ );
+ when(storageLocationRepository.findByStorageType(StorageType.COLD_ROOM))
+ .thenReturn(Result.success(coldRooms));
+
+ var result = listStorageLocations.execute("COLD_ROOM", false);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).isEmpty();
+ }
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/application/masterdata/ArticleUseCaseTest.java b/backend/src/test/java/de/effigenix/application/masterdata/ArticleUseCaseTest.java
new file mode 100644
index 0000000..96c8d58
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/application/masterdata/ArticleUseCaseTest.java
@@ -0,0 +1,1171 @@
+package de.effigenix.application.masterdata;
+
+import de.effigenix.application.masterdata.command.*;
+import de.effigenix.domain.masterdata.*;
+import de.effigenix.shared.common.Money;
+import de.effigenix.shared.common.RepositoryError;
+import de.effigenix.shared.common.Result;
+import de.effigenix.shared.security.ActorId;
+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.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Article Use Cases")
+class ArticleUseCaseTest {
+
+ @Mock private ArticleRepository articleRepository;
+
+ private final ActorId performedBy = ActorId.of("admin-user");
+ private final String CATEGORY_ID = UUID.randomUUID().toString();
+ private final OffsetDateTime NOW = OffsetDateTime.now(ZoneOffset.UTC);
+
+ // ==================== Helpers ====================
+
+ private Article createValidArticle() {
+ var draft = new ArticleDraft("Testprodukt", "ART-001", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+ return Article.create(draft).unsafeGetValue();
+ }
+
+ private Article reconstitutedArticle(String id) {
+ return Article.reconstitute(
+ ArticleId.of(id),
+ new ArticleName("Testprodukt"),
+ new ArticleNumber("ART-001"),
+ ProductCategoryId.of(CATEGORY_ID),
+ new ArrayList<>(List.of(
+ SalesUnit.reconstitute(
+ SalesUnitId.of("su-1"),
+ Unit.PIECE_FIXED,
+ PriceModel.FIXED,
+ Money.euro(new BigDecimal("9.99"))
+ )
+ )),
+ ArticleStatus.ACTIVE,
+ new HashSet<>(),
+ NOW,
+ NOW
+ );
+ }
+
+ private Article reconstitutedArticleWithTwoUnits(String id) {
+ return Article.reconstitute(
+ ArticleId.of(id),
+ new ArticleName("Testprodukt"),
+ new ArticleNumber("ART-001"),
+ ProductCategoryId.of(CATEGORY_ID),
+ new ArrayList<>(List.of(
+ SalesUnit.reconstitute(
+ SalesUnitId.of("su-1"),
+ Unit.PIECE_FIXED,
+ PriceModel.FIXED,
+ Money.euro(new BigDecimal("9.99"))
+ ),
+ SalesUnit.reconstitute(
+ SalesUnitId.of("su-2"),
+ Unit.KG,
+ PriceModel.WEIGHT_BASED,
+ Money.euro(new BigDecimal("19.99"))
+ )
+ )),
+ ArticleStatus.ACTIVE,
+ new HashSet<>(),
+ NOW,
+ NOW
+ );
+ }
+
+ // ==================== CreateArticle ====================
+
+ @Nested
+ @DisplayName("CreateArticle")
+ class CreateArticleTest {
+
+ private CreateArticle createArticle;
+
+ @BeforeEach
+ void setUp() {
+ createArticle = new CreateArticle(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should create article successfully")
+ void shouldCreateArticle() {
+ when(articleRepository.existsByArticleNumber(any()))
+ .thenReturn(Result.success(false));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new CreateArticleCommand("Testprodukt", "ART-001", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ var article = result.unsafeGetValue();
+ assertThat(article.name().value()).isEqualTo("Testprodukt");
+ assertThat(article.articleNumber().value()).isEqualTo("ART-001");
+ assertThat(article.status()).isEqualTo(ArticleStatus.ACTIVE);
+ assertThat(article.salesUnits()).hasSize(1);
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail when article number already exists")
+ void shouldFailWhenArticleNumberExists() {
+ when(articleRepository.existsByArticleNumber(any()))
+ .thenReturn(Result.success(true));
+
+ var cmd = new CreateArticleCommand("Testprodukt", "ART-001", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNumberAlreadyExists.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when name is blank")
+ void shouldFailWhenNameIsBlank() {
+ var cmd = new CreateArticleCommand("", "ART-001", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ verifyNoInteractions(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when article number is blank")
+ void shouldFailWhenArticleNumberIsBlank() {
+ var cmd = new CreateArticleCommand("Testprodukt", "", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ verifyNoInteractions(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidPrice when price is zero")
+ void shouldFailWhenPriceIsZero() {
+ var cmd = new CreateArticleCommand("Testprodukt", "ART-001", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, BigDecimal.ZERO);
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPrice.class);
+ verifyNoInteractions(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidPriceModelCombination for mismatched unit/priceModel")
+ void shouldFailWhenUnitPriceModelMismatch() {
+ var cmd = new CreateArticleCommand("Testprodukt", "ART-001", CATEGORY_ID,
+ Unit.KG, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPriceModelCombination.class);
+ verifyNoInteractions(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when existsByArticleNumber fails")
+ void shouldFailWhenExistsCheckFails() {
+ when(articleRepository.existsByArticleNumber(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new CreateArticleCommand("Testprodukt", "ART-001", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ when(articleRepository.existsByArticleNumber(any()))
+ .thenReturn(Result.success(false));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new CreateArticleCommand("Testprodukt", "ART-001", CATEGORY_ID,
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = createArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== UpdateArticle ====================
+
+ @Nested
+ @DisplayName("UpdateArticle")
+ class UpdateArticleTest {
+
+ private UpdateArticle updateArticle;
+
+ @BeforeEach
+ void setUp() {
+ updateArticle = new UpdateArticle(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should update article name successfully")
+ void shouldUpdateName() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new UpdateArticleCommand(articleId, "Neuer Name", null);
+ var result = updateArticle.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("Neuer Name");
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should update category successfully")
+ void shouldUpdateCategory() {
+ var articleId = "article-1";
+ var newCategoryId = UUID.randomUUID().toString();
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new UpdateArticleCommand(articleId, null, newCategoryId);
+ var result = updateArticle.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().categoryId().value()).isEqualTo(newCategoryId);
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenNotFound() {
+ var articleId = "nonexistent";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new UpdateArticleCommand(articleId, "Neuer Name", null);
+ var result = updateArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when new name is blank")
+ void shouldFailWhenNewNameIsBlank() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new UpdateArticleCommand(articleId, "", null);
+ var result = updateArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new UpdateArticleCommand(articleId, "Neuer Name", null);
+ var result = updateArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new UpdateArticleCommand(articleId, "Neuer Name", null);
+ var result = updateArticle.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== GetArticle ====================
+
+ @Nested
+ @DisplayName("GetArticle")
+ class GetArticleTest {
+
+ private GetArticle getArticle;
+
+ @BeforeEach
+ void setUp() {
+ getArticle = new GetArticle(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should return article when found")
+ void shouldReturnArticleWhenFound() {
+ var articleId = ArticleId.of("article-1");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle("article-1"))));
+
+ var result = getArticle.execute(articleId);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().id()).isEqualTo(articleId);
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when not found")
+ void shouldFailWhenNotFound() {
+ var articleId = ArticleId.of("nonexistent");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = getArticle.execute(articleId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when repository fails")
+ void shouldFailWhenRepositoryFails() {
+ var articleId = ArticleId.of("article-1");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = getArticle.execute(articleId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== ListArticles ====================
+
+ @Nested
+ @DisplayName("ListArticles")
+ class ListArticlesTest {
+
+ private ListArticles listArticles;
+
+ @BeforeEach
+ void setUp() {
+ listArticles = new ListArticles(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should return all articles")
+ void shouldReturnAllArticles() {
+ var articles = List.of(reconstitutedArticle("a-1"), reconstitutedArticle("a-2"));
+ when(articleRepository.findAll()).thenReturn(Result.success(articles));
+
+ var result = listArticles.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should return empty list when none exist")
+ void shouldReturnEmptyList() {
+ when(articleRepository.findAll()).thenReturn(Result.success(List.of()));
+
+ var result = listArticles.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findAll fails")
+ void shouldFailWhenRepositoryFails() {
+ when(articleRepository.findAll())
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = listArticles.execute();
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should return articles by category")
+ void shouldReturnByCategory() {
+ var catId = ProductCategoryId.of(CATEGORY_ID);
+ var articles = List.of(reconstitutedArticle("a-1"));
+ when(articleRepository.findByCategory(catId)).thenReturn(Result.success(articles));
+
+ var result = listArticles.executeByCategory(catId);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findByCategory fails")
+ void shouldFailWhenFindByCategoryFails() {
+ var catId = ProductCategoryId.of(CATEGORY_ID);
+ when(articleRepository.findByCategory(catId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = listArticles.executeByCategory(catId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should return articles by status")
+ void shouldReturnByStatus() {
+ var articles = List.of(reconstitutedArticle("a-1"));
+ when(articleRepository.findByStatus(ArticleStatus.ACTIVE))
+ .thenReturn(Result.success(articles));
+
+ var result = listArticles.executeByStatus(ArticleStatus.ACTIVE);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findByStatus fails")
+ void shouldFailWhenFindByStatusFails() {
+ when(articleRepository.findByStatus(ArticleStatus.ACTIVE))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = listArticles.executeByStatus(ArticleStatus.ACTIVE);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== ActivateArticle ====================
+
+ @Nested
+ @DisplayName("ActivateArticle")
+ class ActivateArticleTest {
+
+ private ActivateArticle activateArticle;
+
+ @BeforeEach
+ void setUp() {
+ activateArticle = new ActivateArticle(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should activate article successfully")
+ void shouldActivateArticle() {
+ var articleId = ArticleId.of("article-1");
+ var article = Article.reconstitute(
+ articleId, new ArticleName("Test"), new ArticleNumber("ART-001"),
+ ProductCategoryId.of(CATEGORY_ID),
+ new ArrayList<>(List.of(SalesUnit.reconstitute(SalesUnitId.of("su-1"),
+ Unit.PIECE_FIXED, PriceModel.FIXED, Money.euro(new BigDecimal("9.99"))))),
+ ArticleStatus.INACTIVE, new HashSet<>(), NOW, NOW
+ );
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.of(article)));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var result = activateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().status()).isEqualTo(ArticleStatus.ACTIVE);
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenNotFound() {
+ var articleId = ArticleId.of("nonexistent");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = activateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = ArticleId.of("article-1");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = activateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = ArticleId.of("article-1");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle("article-1"))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = activateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== DeactivateArticle ====================
+
+ @Nested
+ @DisplayName("DeactivateArticle")
+ class DeactivateArticleTest {
+
+ private DeactivateArticle deactivateArticle;
+
+ @BeforeEach
+ void setUp() {
+ deactivateArticle = new DeactivateArticle(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should deactivate article successfully")
+ void shouldDeactivateArticle() {
+ var articleId = ArticleId.of("article-1");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle("article-1"))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var result = deactivateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().status()).isEqualTo(ArticleStatus.INACTIVE);
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenNotFound() {
+ var articleId = ArticleId.of("nonexistent");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = deactivateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = ArticleId.of("article-1");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = deactivateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = ArticleId.of("article-1");
+ when(articleRepository.findById(articleId))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle("article-1"))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = deactivateArticle.execute(articleId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== AddSalesUnit ====================
+
+ @Nested
+ @DisplayName("AddSalesUnit")
+ class AddSalesUnitTest {
+
+ private AddSalesUnit addSalesUnit;
+
+ @BeforeEach
+ void setUp() {
+ addSalesUnit = new AddSalesUnit(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should add sales unit successfully")
+ void shouldAddSalesUnit() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new AddSalesUnitCommand(articleId, Unit.KG, PriceModel.WEIGHT_BASED,
+ new BigDecimal("19.99"));
+ var result = addSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().salesUnits()).hasSize(2);
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenNotFound() {
+ var articleId = "nonexistent";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new AddSalesUnitCommand(articleId, Unit.KG, PriceModel.WEIGHT_BASED,
+ new BigDecimal("19.99"));
+ var result = addSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with DuplicateSalesUnitType when unit type already exists")
+ void shouldFailWhenDuplicateUnitType() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new AddSalesUnitCommand(articleId, Unit.PIECE_FIXED, PriceModel.FIXED,
+ new BigDecimal("5.00"));
+ var result = addSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.DuplicateSalesUnitType.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when price is negative")
+ void shouldFailWhenPriceIsNegative() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new AddSalesUnitCommand(articleId, Unit.KG, PriceModel.WEIGHT_BASED,
+ new BigDecimal("-1.00"));
+ var result = addSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidPriceModelCombination for mismatched unit/priceModel")
+ void shouldFailWhenUnitPriceModelMismatch() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new AddSalesUnitCommand(articleId, Unit.KG, PriceModel.FIXED,
+ new BigDecimal("19.99"));
+ var result = addSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPriceModelCombination.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new AddSalesUnitCommand(articleId, Unit.KG, PriceModel.WEIGHT_BASED,
+ new BigDecimal("19.99"));
+ var result = addSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new AddSalesUnitCommand(articleId, Unit.KG, PriceModel.WEIGHT_BASED,
+ new BigDecimal("19.99"));
+ var result = addSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== RemoveSalesUnit ====================
+
+ @Nested
+ @DisplayName("RemoveSalesUnit")
+ class RemoveSalesUnitTest {
+
+ private RemoveSalesUnit removeSalesUnit;
+
+ @BeforeEach
+ void setUp() {
+ removeSalesUnit = new RemoveSalesUnit(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should remove sales unit successfully when article has multiple units")
+ void shouldRemoveSalesUnit() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticleWithTwoUnits(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new RemoveSalesUnitCommand(articleId, "su-2");
+ var result = removeSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().salesUnits()).hasSize(1);
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with MinimumSalesUnitRequired when only one unit remains")
+ void shouldFailWhenOnlyOneUnit() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new RemoveSalesUnitCommand(articleId, "su-1");
+ var result = removeSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.MinimumSalesUnitRequired.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with SalesUnitNotFound when sales unit does not exist")
+ void shouldFailWhenSalesUnitNotFound() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticleWithTwoUnits(articleId))));
+
+ var cmd = new RemoveSalesUnitCommand(articleId, "nonexistent-su");
+ var result = removeSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.SalesUnitNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenArticleNotFound() {
+ var articleId = "nonexistent";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new RemoveSalesUnitCommand(articleId, "su-1");
+ var result = removeSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new RemoveSalesUnitCommand(articleId, "su-1");
+ var result = removeSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticleWithTwoUnits(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new RemoveSalesUnitCommand(articleId, "su-2");
+ var result = removeSalesUnit.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== UpdateSalesUnitPrice ====================
+
+ @Nested
+ @DisplayName("UpdateSalesUnitPrice")
+ class UpdateSalesUnitPriceTest {
+
+ private UpdateSalesUnitPrice updateSalesUnitPrice;
+
+ @BeforeEach
+ void setUp() {
+ updateSalesUnitPrice = new UpdateSalesUnitPrice(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should update sales unit price successfully")
+ void shouldUpdatePrice() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new UpdateSalesUnitPriceCommand(articleId, "su-1", new BigDecimal("14.99"));
+ var result = updateSalesUnitPrice.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ var salesUnit = result.unsafeGetValue().salesUnits().stream()
+ .filter(su -> su.id().value().equals("su-1"))
+ .findFirst().orElseThrow();
+ assertThat(salesUnit.price().amount()).isEqualByComparingTo(new BigDecimal("14.99"));
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenNotFound() {
+ var articleId = "nonexistent";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new UpdateSalesUnitPriceCommand(articleId, "su-1", new BigDecimal("14.99"));
+ var result = updateSalesUnitPrice.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with SalesUnitNotFound when sales unit does not exist")
+ void shouldFailWhenSalesUnitNotFound() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new UpdateSalesUnitPriceCommand(articleId, "nonexistent-su", new BigDecimal("14.99"));
+ var result = updateSalesUnitPrice.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.SalesUnitNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when price is negative")
+ void shouldFailWhenPriceIsNegative() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new UpdateSalesUnitPriceCommand(articleId, "su-1", new BigDecimal("-5.00"));
+ var result = updateSalesUnitPrice.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidPrice when price is zero")
+ void shouldFailWhenPriceIsZero() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+
+ var cmd = new UpdateSalesUnitPriceCommand(articleId, "su-1", BigDecimal.ZERO);
+ var result = updateSalesUnitPrice.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPrice.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new UpdateSalesUnitPriceCommand(articleId, "su-1", new BigDecimal("14.99"));
+ var result = updateSalesUnitPrice.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new UpdateSalesUnitPriceCommand(articleId, "su-1", new BigDecimal("14.99"));
+ var result = updateSalesUnitPrice.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== AssignSupplier ====================
+
+ @Nested
+ @DisplayName("AssignSupplier")
+ class AssignSupplierTest {
+
+ private AssignSupplier assignSupplier;
+
+ @BeforeEach
+ void setUp() {
+ assignSupplier = new AssignSupplier(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should assign supplier successfully")
+ void shouldAssignSupplier() {
+ var articleId = "article-1";
+ var supplierId = UUID.randomUUID().toString();
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new AssignSupplierCommand(articleId, supplierId);
+ var result = assignSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().supplierReferences())
+ .contains(SupplierId.of(supplierId));
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenNotFound() {
+ var articleId = "nonexistent";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new AssignSupplierCommand(articleId, UUID.randomUUID().toString());
+ var result = assignSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new AssignSupplierCommand(articleId, UUID.randomUUID().toString());
+ var result = assignSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new AssignSupplierCommand(articleId, UUID.randomUUID().toString());
+ var result = assignSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== RemoveSupplier ====================
+
+ @Nested
+ @DisplayName("RemoveSupplier")
+ class RemoveSupplierTest {
+
+ private RemoveSupplier removeSupplier;
+
+ @BeforeEach
+ void setUp() {
+ removeSupplier = new RemoveSupplier(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should remove supplier successfully")
+ void shouldRemoveSupplier() {
+ var articleId = "article-1";
+ var supplierId = UUID.randomUUID().toString();
+ var article = reconstitutedArticle(articleId);
+ article.addSupplierReference(SupplierId.of(supplierId));
+
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(article)));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new RemoveSupplierCommand(articleId, supplierId);
+ var result = removeSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().supplierReferences())
+ .doesNotContain(SupplierId.of(supplierId));
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should succeed even when supplier was not assigned (idempotent)")
+ void shouldSucceedWhenSupplierNotAssigned() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.success(null));
+
+ var cmd = new RemoveSupplierCommand(articleId, UUID.randomUUID().toString());
+ var result = removeSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ verify(articleRepository).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ArticleNotFound when article does not exist")
+ void shouldFailWhenNotFound() {
+ var articleId = "nonexistent";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new RemoveSupplierCommand(articleId, UUID.randomUUID().toString());
+ var result = removeSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ArticleNotFound.class);
+ verify(articleRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new RemoveSupplierCommand(articleId, UUID.randomUUID().toString());
+ var result = removeSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var articleId = "article-1";
+ when(articleRepository.findById(ArticleId.of(articleId)))
+ .thenReturn(Result.success(Optional.of(reconstitutedArticle(articleId))));
+ when(articleRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new RemoveSupplierCommand(articleId, UUID.randomUUID().toString());
+ var result = removeSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.RepositoryFailure.class);
+ }
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/application/masterdata/CustomerUseCaseTest.java b/backend/src/test/java/de/effigenix/application/masterdata/CustomerUseCaseTest.java
new file mode 100644
index 0000000..347da5a
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/application/masterdata/CustomerUseCaseTest.java
@@ -0,0 +1,1148 @@
+package de.effigenix.application.masterdata;
+
+import de.effigenix.application.masterdata.command.*;
+import de.effigenix.domain.masterdata.*;
+import de.effigenix.shared.common.*;
+import de.effigenix.shared.security.ActorId;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Customer Use Cases")
+class CustomerUseCaseTest {
+
+ @Mock private CustomerRepository customerRepository;
+
+ private ActorId performedBy;
+
+ @BeforeEach
+ void setUp() {
+ performedBy = ActorId.of("admin-user");
+ }
+
+ // ==================== Helpers ====================
+
+ private static Customer existingB2BCustomer(String id) {
+ return Customer.reconstitute(
+ CustomerId.of(id),
+ new CustomerName("Metzgerei Müller"),
+ CustomerType.B2B,
+ new Address("Hauptstr.", "10", "80331", "München", "DE"),
+ new ContactInfo("+49 89 12345", "info@mueller.de", "Hans Müller"),
+ new PaymentTerms(30, "30 Tage netto"),
+ List.of(),
+ null,
+ Set.of(),
+ CustomerStatus.ACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC).minusDays(10),
+ OffsetDateTime.now(ZoneOffset.UTC).minusDays(1)
+ );
+ }
+
+ private static Customer existingB2CCustomer(String id) {
+ return Customer.reconstitute(
+ CustomerId.of(id),
+ new CustomerName("Max Mustermann"),
+ CustomerType.B2C,
+ new Address("Berliner Str.", "5", "10115", "Berlin", "DE"),
+ new ContactInfo("+49 30 98765", null, null),
+ null,
+ List.of(),
+ null,
+ Set.of(),
+ CustomerStatus.ACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC).minusDays(5),
+ OffsetDateTime.now(ZoneOffset.UTC).minusDays(1)
+ );
+ }
+
+ private static Customer inactiveCustomer(String id) {
+ return Customer.reconstitute(
+ CustomerId.of(id),
+ new CustomerName("Alte Bäckerei"),
+ CustomerType.B2B,
+ new Address("Bahnhofstr.", "1", "60329", "Frankfurt", "DE"),
+ new ContactInfo("+49 69 11111", null, null),
+ null,
+ List.of(),
+ null,
+ Set.of(),
+ CustomerStatus.INACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC).minusDays(30),
+ OffsetDateTime.now(ZoneOffset.UTC).minusDays(2)
+ );
+ }
+
+ private static CreateCustomerCommand validCreateCommand() {
+ return new CreateCustomerCommand(
+ "Neuer Kunde GmbH", CustomerType.B2B,
+ "Industriestr.", "42", "70173", "Stuttgart", "DE",
+ "+49 711 55555", "kontakt@neuer-kunde.de", "Anna Schmidt",
+ 14, "14 Tage netto"
+ );
+ }
+
+ // ==================== CreateCustomer ====================
+
+ @Nested
+ @DisplayName("CreateCustomer")
+ class CreateCustomerTests {
+
+ private CreateCustomer createCustomer;
+
+ @BeforeEach
+ void setUp() {
+ createCustomer = new CreateCustomer(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should create customer successfully")
+ void shouldCreateCustomerSuccessfully() {
+ var cmd = validCreateCommand();
+ when(customerRepository.existsByName(any())).thenReturn(Result.success(false));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = createCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ var customer = result.unsafeGetValue();
+ assertThat(customer.name().value()).isEqualTo("Neuer Kunde GmbH");
+ assertThat(customer.type()).isEqualTo(CustomerType.B2B);
+ assertThat(customer.status()).isEqualTo(CustomerStatus.ACTIVE);
+ assertThat(customer.billingAddress().street()).isEqualTo("Industriestr.");
+ assertThat(customer.contactInfo().phone()).isEqualTo("+49 711 55555");
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should fail when customer name already exists")
+ void shouldFailWhenNameAlreadyExists() {
+ var cmd = validCreateCommand();
+ when(customerRepository.existsByName(any())).thenReturn(Result.success(true));
+
+ var result = createCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNameAlreadyExists.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when name is blank")
+ void shouldFailWhenNameIsBlank() {
+ var cmd = new CreateCustomerCommand(
+ "", CustomerType.B2B,
+ "Str.", null, "12345", "City", "DE",
+ "+49 1", null, null, null, null
+ );
+
+ var result = createCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when existsByName fails")
+ void shouldFailWhenExistsByNameFails() {
+ var cmd = validCreateCommand();
+ when(customerRepository.existsByName(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = createCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var cmd = validCreateCommand();
+ when(customerRepository.existsByName(any())).thenReturn(Result.success(false));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = createCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should create customer without optional payment terms")
+ void shouldCreateWithoutPaymentTerms() {
+ var cmd = new CreateCustomerCommand(
+ "Einfach GmbH", CustomerType.B2C,
+ "Str.", null, "12345", "City", "DE",
+ "+49 1", null, null, null, null
+ );
+ when(customerRepository.existsByName(any())).thenReturn(Result.success(false));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = createCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().paymentTerms()).isNull();
+ }
+ }
+
+ // ==================== UpdateCustomer ====================
+
+ @Nested
+ @DisplayName("UpdateCustomer")
+ class UpdateCustomerTests {
+
+ private UpdateCustomer updateCustomer;
+
+ @BeforeEach
+ void setUp() {
+ updateCustomer = new UpdateCustomer(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should update customer successfully")
+ void shouldUpdateCustomerSuccessfully() {
+ var customerId = "cust-1";
+ var cmd = new UpdateCustomerCommand(
+ customerId, "Neuer Name", "Neue Str.", "99", "99999", "Neustadt", "DE",
+ "+49 999 0000", "neu@test.de", "Neue Person", 60, "60 Tage netto"
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = updateCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("Neuer Name");
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var cmd = new UpdateCustomerCommand(
+ "nonexistent", null, null, null, null, null, null,
+ null, null, null, null, null
+ );
+ when(customerRepository.findById(CustomerId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = updateCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var cmd = new UpdateCustomerCommand(
+ "cust-1", null, null, null, null, null, null,
+ null, null, null, null, null
+ );
+ when(customerRepository.findById(CustomerId.of("cust-1")))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
+
+ var result = updateCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = "cust-1";
+ var cmd = new UpdateCustomerCommand(
+ customerId, "Updated", null, null, null, null, null,
+ null, null, null, null, null
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = updateCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when updated name is blank")
+ void shouldFailWhenUpdatedNameIsBlank() {
+ var customerId = "cust-1";
+ var cmd = new UpdateCustomerCommand(
+ customerId, "", null, null, null, null, null,
+ null, null, null, null, null
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+
+ var result = updateCustomer.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ verify(customerRepository, never()).save(any());
+ }
+ }
+
+ // ==================== GetCustomer ====================
+
+ @Nested
+ @DisplayName("GetCustomer")
+ class GetCustomerTests {
+
+ private GetCustomer getCustomer;
+
+ @BeforeEach
+ void setUp() {
+ getCustomer = new GetCustomer(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should return customer when found")
+ void shouldReturnCustomerWhenFound() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer("cust-1"))));
+
+ var result = getCustomer.execute(customerId);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().id()).isEqualTo(customerId);
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when not found")
+ void shouldFailWhenNotFound() {
+ var customerId = CustomerId.of("nonexistent");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = getCustomer.execute(customerId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when repository fails")
+ void shouldFailWhenRepositoryFails() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = getCustomer.execute(customerId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== ListCustomers ====================
+
+ @Nested
+ @DisplayName("ListCustomers")
+ class ListCustomersTests {
+
+ private ListCustomers listCustomers;
+
+ @BeforeEach
+ void setUp() {
+ listCustomers = new ListCustomers(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should return all customers")
+ void shouldReturnAllCustomers() {
+ var customers = List.of(existingB2BCustomer("c1"), existingB2CCustomer("c2"));
+ when(customerRepository.findAll()).thenReturn(Result.success(customers));
+
+ var result = listCustomers.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should return empty list when no customers exist")
+ void shouldReturnEmptyList() {
+ when(customerRepository.findAll()).thenReturn(Result.success(List.of()));
+
+ var result = listCustomers.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findAll fails")
+ void shouldFailWhenFindAllFails() {
+ when(customerRepository.findAll())
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
+
+ var result = listCustomers.execute();
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should return customers by type")
+ void shouldReturnCustomersByType() {
+ var b2bCustomers = List.of(existingB2BCustomer("c1"));
+ when(customerRepository.findByType(CustomerType.B2B)).thenReturn(Result.success(b2bCustomers));
+
+ var result = listCustomers.executeByType(CustomerType.B2B);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ assertThat(result.unsafeGetValue().getFirst().type()).isEqualTo(CustomerType.B2B);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findByType fails")
+ void shouldFailWhenFindByTypeFails() {
+ when(customerRepository.findByType(CustomerType.B2B))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("error")));
+
+ var result = listCustomers.executeByType(CustomerType.B2B);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should return customers by status")
+ void shouldReturnCustomersByStatus() {
+ var activeCustomers = List.of(existingB2BCustomer("c1"), existingB2CCustomer("c2"));
+ when(customerRepository.findByStatus(CustomerStatus.ACTIVE)).thenReturn(Result.success(activeCustomers));
+
+ var result = listCustomers.executeByStatus(CustomerStatus.ACTIVE);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findByStatus fails")
+ void shouldFailWhenFindByStatusFails() {
+ when(customerRepository.findByStatus(CustomerStatus.ACTIVE))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("error")));
+
+ var result = listCustomers.executeByStatus(CustomerStatus.ACTIVE);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== ActivateCustomer ====================
+
+ @Nested
+ @DisplayName("ActivateCustomer")
+ class ActivateCustomerTests {
+
+ private ActivateCustomer activateCustomer;
+
+ @BeforeEach
+ void setUp() {
+ activateCustomer = new ActivateCustomer(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should activate an inactive customer")
+ void shouldActivateInactiveCustomer() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(inactiveCustomer("cust-1"))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = activateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().status()).isEqualTo(CustomerStatus.ACTIVE);
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var customerId = CustomerId.of("nonexistent");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = activateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = activateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(inactiveCustomer("cust-1"))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = activateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== DeactivateCustomer ====================
+
+ @Nested
+ @DisplayName("DeactivateCustomer")
+ class DeactivateCustomerTests {
+
+ private DeactivateCustomer deactivateCustomer;
+
+ @BeforeEach
+ void setUp() {
+ deactivateCustomer = new DeactivateCustomer(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should deactivate an active customer")
+ void shouldDeactivateActiveCustomer() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer("cust-1"))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = deactivateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().status()).isEqualTo(CustomerStatus.INACTIVE);
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var customerId = CustomerId.of("nonexistent");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = deactivateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = deactivateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer("cust-1"))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = deactivateCustomer.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== AddDeliveryAddress ====================
+
+ @Nested
+ @DisplayName("AddDeliveryAddress")
+ class AddDeliveryAddressTests {
+
+ private AddDeliveryAddress addDeliveryAddress;
+
+ @BeforeEach
+ void setUp() {
+ addDeliveryAddress = new AddDeliveryAddress(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should add delivery address successfully")
+ void shouldAddDeliveryAddressSuccessfully() {
+ var customerId = "cust-1";
+ var cmd = new AddDeliveryAddressCommand(
+ customerId, "Lager Süd", "Lagerstr.", "1", "80000", "München", "DE",
+ "Max Lager", "Tor 3 benutzen"
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = addDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().deliveryAddresses()).hasSize(1);
+ assertThat(result.unsafeGetValue().deliveryAddresses().getFirst().label()).isEqualTo("Lager Süd");
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var cmd = new AddDeliveryAddressCommand(
+ "nonexistent", "Label", "Str.", null, "12345", "City", "DE", null, null
+ );
+ when(customerRepository.findById(CustomerId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = addDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when address is invalid")
+ void shouldFailWhenAddressIsInvalid() {
+ var customerId = "cust-1";
+ var cmd = new AddDeliveryAddressCommand(
+ customerId, "Label", "", null, "", "", "XX",
+ null, null
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+
+ var result = addDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var cmd = new AddDeliveryAddressCommand(
+ "cust-1", "Label", "Str.", null, "12345", "City", "DE", null, null
+ );
+ when(customerRepository.findById(CustomerId.of("cust-1")))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
+
+ var result = addDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = "cust-1";
+ var cmd = new AddDeliveryAddressCommand(
+ customerId, "Label", "Str.", null, "12345", "City", "DE", null, null
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = addDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== RemoveDeliveryAddress ====================
+
+ @Nested
+ @DisplayName("RemoveDeliveryAddress")
+ class RemoveDeliveryAddressTests {
+
+ private RemoveDeliveryAddress removeDeliveryAddress;
+
+ @BeforeEach
+ void setUp() {
+ removeDeliveryAddress = new RemoveDeliveryAddress(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should remove delivery address successfully")
+ void shouldRemoveDeliveryAddressSuccessfully() {
+ var customerId = "cust-1";
+ var customerWithAddr = Customer.reconstitute(
+ CustomerId.of(customerId),
+ new CustomerName("Metzgerei Müller"),
+ CustomerType.B2B,
+ new Address("Hauptstr.", "10", "80331", "München", "DE"),
+ new ContactInfo("+49 89 12345", null, null),
+ null,
+ List.of(new DeliveryAddress("Lager Süd",
+ new Address("Lagerstr.", "1", "80000", "München", "DE"), null, null)),
+ null, Set.of(), CustomerStatus.ACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
+ );
+ var cmd = new RemoveDeliveryAddressCommand(customerId, "Lager Süd");
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(customerWithAddr)));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = removeDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().deliveryAddresses()).isEmpty();
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var cmd = new RemoveDeliveryAddressCommand("nonexistent", "Label");
+ when(customerRepository.findById(CustomerId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = removeDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var cmd = new RemoveDeliveryAddressCommand("cust-1", "Label");
+ when(customerRepository.findById(CustomerId.of("cust-1")))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
+
+ var result = removeDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = "cust-1";
+ var cmd = new RemoveDeliveryAddressCommand(customerId, "Label");
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = removeDeliveryAddress.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== SetPreferences ====================
+
+ @Nested
+ @DisplayName("SetPreferences")
+ class SetPreferencesTests {
+
+ private SetPreferences setPreferences;
+
+ @BeforeEach
+ void setUp() {
+ setPreferences = new SetPreferences(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should set preferences successfully")
+ void shouldSetPreferencesSuccessfully() {
+ var customerId = "cust-1";
+ var prefs = Set.of(CustomerPreference.BIO, CustomerPreference.REGIONAL);
+ var cmd = new SetPreferencesCommand(customerId, prefs);
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = setPreferences.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().preferences())
+ .containsExactlyInAnyOrder(CustomerPreference.BIO, CustomerPreference.REGIONAL);
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should clear preferences with empty set")
+ void shouldClearPreferencesWithEmptySet() {
+ var customerId = "cust-1";
+ var cmd = new SetPreferencesCommand(customerId, Set.of());
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = setPreferences.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().preferences()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var cmd = new SetPreferencesCommand("nonexistent", Set.of(CustomerPreference.BIO));
+ when(customerRepository.findById(CustomerId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = setPreferences.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var cmd = new SetPreferencesCommand("cust-1", Set.of());
+ when(customerRepository.findById(CustomerId.of("cust-1")))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
+
+ var result = setPreferences.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = "cust-1";
+ var cmd = new SetPreferencesCommand(customerId, Set.of(CustomerPreference.HALAL));
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = setPreferences.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== SetFrameContract ====================
+
+ @Nested
+ @DisplayName("SetFrameContract")
+ class SetFrameContractTests {
+
+ private SetFrameContract setFrameContract;
+
+ @BeforeEach
+ void setUp() {
+ setFrameContract = new SetFrameContract(customerRepository);
+ }
+
+ private SetFrameContractCommand validFrameContractCommand(String customerId) {
+ return new SetFrameContractCommand(
+ customerId,
+ LocalDate.now(),
+ LocalDate.now().plusMonths(12),
+ DeliveryRhythm.WEEKLY,
+ List.of(new SetFrameContractCommand.LineItem(
+ "article-1", new BigDecimal("9.99"), new BigDecimal("100"), Unit.KG
+ ))
+ );
+ }
+
+ @Test
+ @DisplayName("should set frame contract on B2B customer successfully")
+ void shouldSetFrameContractOnB2BCustomer() {
+ var customerId = "cust-1";
+ var cmd = validFrameContractCommand(customerId);
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().frameContract()).isNotNull();
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should fail with FrameContractNotAllowed when customer is B2C")
+ void shouldFailWhenCustomerIsB2C() {
+ var customerId = "cust-b2c";
+ var cmd = validFrameContractCommand(customerId);
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2CCustomer(customerId))));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.FrameContractNotAllowed.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var cmd = validFrameContractCommand("nonexistent");
+ when(customerRepository.findById(CustomerId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidFrameContract when validUntil is before validFrom")
+ void shouldFailWhenDatesInvalid() {
+ var customerId = "cust-1";
+ var cmd = new SetFrameContractCommand(
+ customerId,
+ LocalDate.now().plusMonths(12),
+ LocalDate.now(), // before validFrom
+ DeliveryRhythm.WEEKLY,
+ List.of(new SetFrameContractCommand.LineItem(
+ "article-1", new BigDecimal("9.99"), new BigDecimal("100"), Unit.KG
+ ))
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.InvalidFrameContract.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidFrameContract when line items are empty")
+ void shouldFailWhenLineItemsEmpty() {
+ var customerId = "cust-1";
+ var cmd = new SetFrameContractCommand(
+ customerId, LocalDate.now(), LocalDate.now().plusMonths(6),
+ DeliveryRhythm.MONTHLY, List.of()
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.InvalidFrameContract.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var cmd = validFrameContractCommand("cust-1");
+ when(customerRepository.findById(CustomerId.of("cust-1")))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = "cust-1";
+ var cmd = validFrameContractCommand(customerId);
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when price is negative")
+ void shouldFailWhenPriceIsNegative() {
+ var customerId = "cust-1";
+ var cmd = new SetFrameContractCommand(
+ customerId, LocalDate.now(), LocalDate.now().plusMonths(6),
+ DeliveryRhythm.MONTHLY,
+ List.of(new SetFrameContractCommand.LineItem(
+ "article-1", new BigDecimal("-5.00"), new BigDecimal("100"), Unit.KG
+ ))
+ );
+ when(customerRepository.findById(CustomerId.of(customerId)))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer(customerId))));
+
+ var result = setFrameContract.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ verify(customerRepository, never()).save(any());
+ }
+ }
+
+ // ==================== RemoveFrameContract ====================
+
+ @Nested
+ @DisplayName("RemoveFrameContract")
+ class RemoveFrameContractTests {
+
+ private RemoveFrameContract removeFrameContract;
+
+ @BeforeEach
+ void setUp() {
+ removeFrameContract = new RemoveFrameContract(customerRepository);
+ }
+
+ @Test
+ @DisplayName("should remove frame contract successfully")
+ void shouldRemoveFrameContractSuccessfully() {
+ var customerId = CustomerId.of("cust-1");
+ var customerWithContract = existingB2BCustomer("cust-1");
+ // Set a frame contract on the customer via reconstitute
+ var contractCustomer = Customer.reconstitute(
+ customerId,
+ new CustomerName("Metzgerei Müller"),
+ CustomerType.B2B,
+ new Address("Hauptstr.", "10", "80331", "München", "DE"),
+ new ContactInfo("+49 89 12345", null, null),
+ null,
+ List.of(),
+ FrameContract.reconstitute(
+ FrameContractId.generate(),
+ LocalDate.now().minusMonths(1),
+ LocalDate.now().plusMonths(11),
+ DeliveryRhythm.WEEKLY,
+ List.of(new ContractLineItem(
+ ArticleId.of("a1"), Money.euro(new BigDecimal("10.00")),
+ new BigDecimal("50"), Unit.KG))
+ ),
+ Set.of(), CustomerStatus.ACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
+ );
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(contractCustomer)));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = removeFrameContract.execute(customerId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().frameContract()).isNull();
+ verify(customerRepository).save(any(Customer.class));
+ }
+
+ @Test
+ @DisplayName("should succeed even when no frame contract exists")
+ void shouldSucceedWhenNoFrameContract() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer("cust-1"))));
+ when(customerRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = removeFrameContract.execute(customerId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().frameContract()).isNull();
+ }
+
+ @Test
+ @DisplayName("should fail with CustomerNotFound when customer does not exist")
+ void shouldFailWhenCustomerNotFound() {
+ var customerId = CustomerId.of("nonexistent");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = removeFrameContract.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.CustomerNotFound.class);
+ verify(customerRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = removeFrameContract.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var customerId = CustomerId.of("cust-1");
+ when(customerRepository.findById(customerId))
+ .thenReturn(Result.success(Optional.of(existingB2BCustomer("cust-1"))));
+ when(customerRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = removeFrameContract.execute(customerId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.RepositoryFailure.class);
+ }
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/application/masterdata/ProductCategoryUseCaseTest.java b/backend/src/test/java/de/effigenix/application/masterdata/ProductCategoryUseCaseTest.java
new file mode 100644
index 0000000..1edcdbb
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/application/masterdata/ProductCategoryUseCaseTest.java
@@ -0,0 +1,479 @@
+package de.effigenix.application.masterdata;
+
+import de.effigenix.application.masterdata.command.CreateProductCategoryCommand;
+import de.effigenix.application.masterdata.command.UpdateProductCategoryCommand;
+import de.effigenix.domain.masterdata.*;
+import de.effigenix.shared.common.RepositoryError;
+import de.effigenix.shared.common.Result;
+import de.effigenix.shared.security.ActorId;
+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.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ProductCategory Use Cases")
+class ProductCategoryUseCaseTest {
+
+ @Mock private ProductCategoryRepository categoryRepository;
+ @Mock private ArticleRepository articleRepository;
+
+ private ActorId performedBy;
+
+ @BeforeEach
+ void setUp() {
+ performedBy = ActorId.of("admin-user");
+ }
+
+ // ==================== Helpers ====================
+
+ private static ProductCategory existingCategory(String id, String name, String description) {
+ return ProductCategory.reconstitute(
+ ProductCategoryId.of(id),
+ new CategoryName(name),
+ description
+ );
+ }
+
+ private static ProductCategory existingCategory(String id, String name) {
+ return existingCategory(id, name, "Beschreibung für " + name);
+ }
+
+ // ==================== CreateProductCategory ====================
+
+ @Nested
+ @DisplayName("CreateProductCategory")
+ class CreateProductCategoryTests {
+
+ private CreateProductCategory useCase;
+
+ @BeforeEach
+ void setUp() {
+ useCase = new CreateProductCategory(categoryRepository);
+ }
+
+ @Test
+ @DisplayName("should create category successfully")
+ void shouldCreateCategorySuccessfully() {
+ var cmd = new CreateProductCategoryCommand("Backwaren", "Alle Backwaren");
+ when(categoryRepository.existsByName(any(CategoryName.class)))
+ .thenReturn(Result.success(false));
+ when(categoryRepository.save(any(ProductCategory.class)))
+ .thenReturn(Result.success(null));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("Backwaren");
+ assertThat(result.unsafeGetValue().description()).isEqualTo("Alle Backwaren");
+ verify(categoryRepository).existsByName(any(CategoryName.class));
+ verify(categoryRepository).save(any(ProductCategory.class));
+ }
+
+ @Test
+ @DisplayName("should create category with null description")
+ void shouldCreateCategoryWithNullDescription() {
+ var cmd = new CreateProductCategoryCommand("Backwaren", null);
+ when(categoryRepository.existsByName(any(CategoryName.class)))
+ .thenReturn(Result.success(false));
+ when(categoryRepository.save(any(ProductCategory.class)))
+ .thenReturn(Result.success(null));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().description()).isNull();
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when name is blank")
+ void shouldFailWhenNameIsBlank() {
+ var cmd = new CreateProductCategoryCommand(" ", "Beschreibung");
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ verifyNoInteractions(categoryRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when name is null")
+ void shouldFailWhenNameIsNull() {
+ var cmd = new CreateProductCategoryCommand(null, "Beschreibung");
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ verifyNoInteractions(categoryRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when name exceeds 100 characters")
+ void shouldFailWhenNameTooLong() {
+ var longName = "A".repeat(101);
+ var cmd = new CreateProductCategoryCommand(longName, "Beschreibung");
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ verifyNoInteractions(categoryRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with CategoryNameAlreadyExists when name is taken")
+ void shouldFailWhenNameAlreadyExists() {
+ var cmd = new CreateProductCategoryCommand("Backwaren", "Beschreibung");
+ when(categoryRepository.existsByName(any(CategoryName.class)))
+ .thenReturn(Result.success(true));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.CategoryNameAlreadyExists.class);
+ verify(categoryRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when existsByName fails")
+ void shouldFailWhenExistsByNameFails() {
+ var cmd = new CreateProductCategoryCommand("Backwaren", "Beschreibung");
+ when(categoryRepository.existsByName(any(CategoryName.class)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ verify(categoryRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var cmd = new CreateProductCategoryCommand("Backwaren", "Beschreibung");
+ when(categoryRepository.existsByName(any(CategoryName.class)))
+ .thenReturn(Result.success(false));
+ when(categoryRepository.save(any(ProductCategory.class)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== UpdateProductCategory ====================
+
+ @Nested
+ @DisplayName("UpdateProductCategory")
+ class UpdateProductCategoryTests {
+
+ private UpdateProductCategory useCase;
+
+ @BeforeEach
+ void setUp() {
+ useCase = new UpdateProductCategory(categoryRepository);
+ }
+
+ @Test
+ @DisplayName("should update name and description successfully")
+ void shouldUpdateNameAndDescription() {
+ var categoryId = "cat-1";
+ var cmd = new UpdateProductCategoryCommand(categoryId, "Neuer Name", "Neue Beschreibung");
+ when(categoryRepository.findById(ProductCategoryId.of(categoryId)))
+ .thenReturn(Result.success(Optional.of(existingCategory(categoryId, "Alter Name"))));
+ when(categoryRepository.save(any(ProductCategory.class)))
+ .thenReturn(Result.success(null));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("Neuer Name");
+ assertThat(result.unsafeGetValue().description()).isEqualTo("Neue Beschreibung");
+ }
+
+ @Test
+ @DisplayName("should update only name when description is null")
+ void shouldUpdateOnlyName() {
+ var categoryId = "cat-1";
+ var cmd = new UpdateProductCategoryCommand(categoryId, "Neuer Name", null);
+ var original = existingCategory(categoryId, "Alter Name", "Originalbeschreibung");
+ when(categoryRepository.findById(ProductCategoryId.of(categoryId)))
+ .thenReturn(Result.success(Optional.of(original)));
+ when(categoryRepository.save(any(ProductCategory.class)))
+ .thenReturn(Result.success(null));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("Neuer Name");
+ assertThat(result.unsafeGetValue().description()).isEqualTo("Originalbeschreibung");
+ }
+
+ @Test
+ @DisplayName("should update only description when name is null")
+ void shouldUpdateOnlyDescription() {
+ var categoryId = "cat-1";
+ var cmd = new UpdateProductCategoryCommand(categoryId, null, "Neue Beschreibung");
+ when(categoryRepository.findById(ProductCategoryId.of(categoryId)))
+ .thenReturn(Result.success(Optional.of(existingCategory(categoryId, "Alter Name"))));
+ when(categoryRepository.save(any(ProductCategory.class)))
+ .thenReturn(Result.success(null));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("Alter Name");
+ assertThat(result.unsafeGetValue().description()).isEqualTo("Neue Beschreibung");
+ }
+
+ @Test
+ @DisplayName("should fail with CategoryNotFound when category does not exist")
+ void shouldFailWhenCategoryNotFound() {
+ var cmd = new UpdateProductCategoryCommand("nonexistent", "Name", "Beschreibung");
+ when(categoryRepository.findById(ProductCategoryId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.CategoryNotFound.class);
+ verify(categoryRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when new name is blank")
+ void shouldFailWhenNewNameIsBlank() {
+ var categoryId = "cat-1";
+ var cmd = new UpdateProductCategoryCommand(categoryId, " ", "Beschreibung");
+ when(categoryRepository.findById(ProductCategoryId.of(categoryId)))
+ .thenReturn(Result.success(Optional.of(existingCategory(categoryId, "Alter Name"))));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ verify(categoryRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when new name exceeds 100 characters")
+ void shouldFailWhenNewNameTooLong() {
+ var categoryId = "cat-1";
+ var longName = "B".repeat(101);
+ var cmd = new UpdateProductCategoryCommand(categoryId, longName, null);
+ when(categoryRepository.findById(ProductCategoryId.of(categoryId)))
+ .thenReturn(Result.success(Optional.of(existingCategory(categoryId, "Alter Name"))));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ verify(categoryRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var cmd = new UpdateProductCategoryCommand("cat-1", "Name", "Beschreibung");
+ when(categoryRepository.findById(ProductCategoryId.of("cat-1")))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var categoryId = "cat-1";
+ var cmd = new UpdateProductCategoryCommand(categoryId, "Neuer Name", null);
+ when(categoryRepository.findById(ProductCategoryId.of(categoryId)))
+ .thenReturn(Result.success(Optional.of(existingCategory(categoryId, "Alter Name"))));
+ when(categoryRepository.save(any(ProductCategory.class)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = useCase.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== ListProductCategories ====================
+
+ @Nested
+ @DisplayName("ListProductCategories")
+ class ListProductCategoriesTests {
+
+ private ListProductCategories useCase;
+
+ @BeforeEach
+ void setUp() {
+ useCase = new ListProductCategories(categoryRepository);
+ }
+
+ @Test
+ @DisplayName("should return all categories")
+ void shouldReturnAllCategories() {
+ var categories = List.of(
+ existingCategory("cat-1", "Backwaren"),
+ existingCategory("cat-2", "Getränke")
+ );
+ when(categoryRepository.findAll()).thenReturn(Result.success(categories));
+
+ var result = useCase.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should return empty list when no categories exist")
+ void shouldReturnEmptyList() {
+ when(categoryRepository.findAll()).thenReturn(Result.success(List.of()));
+
+ var result = useCase.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when repository fails")
+ void shouldFailWhenRepositoryFails() {
+ when(categoryRepository.findAll())
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = useCase.execute();
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== DeleteProductCategory ====================
+
+ @Nested
+ @DisplayName("DeleteProductCategory")
+ class DeleteProductCategoryTests {
+
+ private DeleteProductCategory useCase;
+
+ @BeforeEach
+ void setUp() {
+ useCase = new DeleteProductCategory(categoryRepository, articleRepository);
+ }
+
+ @Test
+ @DisplayName("should delete category successfully when not in use")
+ void shouldDeleteCategorySuccessfully() {
+ var categoryId = ProductCategoryId.of("cat-1");
+ when(categoryRepository.findById(categoryId))
+ .thenReturn(Result.success(Optional.of(existingCategory("cat-1", "Backwaren"))));
+ when(articleRepository.findByCategory(categoryId))
+ .thenReturn(Result.success(List.of()));
+ when(categoryRepository.delete(any(ProductCategory.class)))
+ .thenReturn(Result.success(null));
+
+ var result = useCase.execute(categoryId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ verify(categoryRepository).delete(any(ProductCategory.class));
+ }
+
+ @Test
+ @DisplayName("should fail with CategoryNotFound when category does not exist")
+ void shouldFailWhenCategoryNotFound() {
+ var categoryId = ProductCategoryId.of("nonexistent");
+ when(categoryRepository.findById(categoryId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = useCase.execute(categoryId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.CategoryNotFound.class);
+ verify(categoryRepository, never()).delete(any());
+ verifyNoInteractions(articleRepository);
+ }
+
+ @Test
+ @DisplayName("should fail with CategoryInUse when articles reference the category")
+ void shouldFailWhenCategoryInUse() {
+ var categoryId = ProductCategoryId.of("cat-1");
+ when(categoryRepository.findById(categoryId))
+ .thenReturn(Result.success(Optional.of(existingCategory("cat-1", "Backwaren"))));
+ // Return a non-empty list (using mock since Article is complex)
+ var mockArticle = mock(Article.class);
+ when(articleRepository.findByCategory(categoryId))
+ .thenReturn(Result.success(List.of(mockArticle)));
+
+ var result = useCase.execute(categoryId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.CategoryInUse.class);
+ verify(categoryRepository, never()).delete(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var categoryId = ProductCategoryId.of("cat-1");
+ when(categoryRepository.findById(categoryId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = useCase.execute(categoryId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findByCategory fails")
+ void shouldFailWhenFindByCategoryFails() {
+ var categoryId = ProductCategoryId.of("cat-1");
+ when(categoryRepository.findById(categoryId))
+ .thenReturn(Result.success(Optional.of(existingCategory("cat-1", "Backwaren"))));
+ when(articleRepository.findByCategory(categoryId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = useCase.execute(categoryId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when delete fails")
+ void shouldFailWhenDeleteFails() {
+ var categoryId = ProductCategoryId.of("cat-1");
+ when(categoryRepository.findById(categoryId))
+ .thenReturn(Result.success(Optional.of(existingCategory("cat-1", "Backwaren"))));
+ when(articleRepository.findByCategory(categoryId))
+ .thenReturn(Result.success(List.of()));
+ when(categoryRepository.delete(any(ProductCategory.class)))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = useCase.execute(categoryId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.RepositoryFailure.class);
+ }
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/application/masterdata/SupplierUseCaseTest.java b/backend/src/test/java/de/effigenix/application/masterdata/SupplierUseCaseTest.java
new file mode 100644
index 0000000..b28772d
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/application/masterdata/SupplierUseCaseTest.java
@@ -0,0 +1,909 @@
+package de.effigenix.application.masterdata;
+
+import de.effigenix.application.masterdata.command.*;
+import de.effigenix.domain.masterdata.*;
+import de.effigenix.shared.common.ContactInfo;
+import de.effigenix.shared.common.RepositoryError;
+import de.effigenix.shared.common.Result;
+import de.effigenix.shared.security.ActorId;
+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.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Supplier Use Cases")
+class SupplierUseCaseTest {
+
+ @Mock private SupplierRepository supplierRepository;
+
+ private final ActorId performedBy = ActorId.of("admin-user");
+
+ // ==================== Helpers ====================
+
+ private static Supplier existingSupplier(String id) {
+ return Supplier.reconstitute(
+ SupplierId.of(id),
+ new SupplierName("Test Supplier"),
+ null,
+ new ContactInfo("+49123456789", null, null),
+ null,
+ List.of(),
+ null,
+ SupplierStatus.ACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC),
+ OffsetDateTime.now(ZoneOffset.UTC)
+ );
+ }
+
+ private static Supplier existingSupplierWithCertificate(String id, QualityCertificate certificate) {
+ return Supplier.reconstitute(
+ SupplierId.of(id),
+ new SupplierName("Test Supplier"),
+ null,
+ new ContactInfo("+49123456789", null, null),
+ null,
+ List.of(certificate),
+ null,
+ SupplierStatus.ACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC),
+ OffsetDateTime.now(ZoneOffset.UTC)
+ );
+ }
+
+ private static Supplier inactiveSupplier(String id) {
+ return Supplier.reconstitute(
+ SupplierId.of(id),
+ new SupplierName("Inactive Supplier"),
+ null,
+ new ContactInfo("+49123456789", null, null),
+ null,
+ List.of(),
+ null,
+ SupplierStatus.INACTIVE,
+ OffsetDateTime.now(ZoneOffset.UTC),
+ OffsetDateTime.now(ZoneOffset.UTC)
+ );
+ }
+
+ private static CreateSupplierCommand validCreateCommand() {
+ return new CreateSupplierCommand(
+ "Acme Supplies", "+49123456789", "info@acme.de", "Max Mustermann",
+ null, null, null, null, null,
+ null, null
+ );
+ }
+
+ private static CreateSupplierCommand validCreateCommandWithAddress() {
+ return new CreateSupplierCommand(
+ "Acme Supplies", "+49123456789", "info@acme.de", "Max Mustermann",
+ "Hauptstr.", "1", "12345", "Berlin", "DE",
+ 30, "30 Tage netto"
+ );
+ }
+
+ // ==================== CreateSupplier ====================
+
+ @Nested
+ @DisplayName("CreateSupplier")
+ class CreateSupplierTest {
+
+ private CreateSupplier createSupplier;
+
+ @BeforeEach
+ void setUp() {
+ createSupplier = new CreateSupplier(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should create supplier with valid minimal data")
+ void shouldCreateSupplierWithValidData() {
+ when(supplierRepository.existsByName(any())).thenReturn(Result.success(false));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = createSupplier.execute(validCreateCommand(), performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("Acme Supplies");
+ assertThat(result.unsafeGetValue().status()).isEqualTo(SupplierStatus.ACTIVE);
+ verify(supplierRepository).save(any(Supplier.class));
+ }
+
+ @Test
+ @DisplayName("should create supplier with full data including address and payment terms")
+ void shouldCreateSupplierWithFullData() {
+ when(supplierRepository.existsByName(any())).thenReturn(Result.success(false));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = createSupplier.execute(validCreateCommandWithAddress(), performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ var supplier = result.unsafeGetValue();
+ assertThat(supplier.address()).isNotNull();
+ assertThat(supplier.paymentTerms()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("should fail when supplier name already exists")
+ void shouldFailWhenNameAlreadyExists() {
+ when(supplierRepository.existsByName(any())).thenReturn(Result.success(true));
+
+ var result = createSupplier.execute(validCreateCommand(), performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNameAlreadyExists.class);
+ verify(supplierRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when name is blank")
+ void shouldFailWhenNameIsBlank() {
+ var cmd = new CreateSupplierCommand(
+ "", "+49123456789", null, null,
+ null, null, null, null, null, null, null
+ );
+
+ var result = createSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ verify(supplierRepository, never()).existsByName(any());
+ verify(supplierRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when email format is invalid")
+ void shouldFailWhenEmailFormatIsInvalid() {
+ var cmd = new CreateSupplierCommand(
+ "Acme Supplies", "+49123456789", "invalid-email", null,
+ null, null, null, null, null, null, null
+ );
+
+ var result = createSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when existsByName fails")
+ void shouldFailWhenExistsByNameFails() {
+ when(supplierRepository.existsByName(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = createSupplier.execute(validCreateCommand(), performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ when(supplierRepository.existsByName(any())).thenReturn(Result.success(false));
+ when(supplierRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = createSupplier.execute(validCreateCommand(), performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== UpdateSupplier ====================
+
+ @Nested
+ @DisplayName("UpdateSupplier")
+ class UpdateSupplierTest {
+
+ private UpdateSupplier updateSupplier;
+
+ @BeforeEach
+ void setUp() {
+ updateSupplier = new UpdateSupplier(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should update supplier name")
+ void shouldUpdateSupplierName() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var cmd = new UpdateSupplierCommand(
+ supplierId, "New Name", null, null, null,
+ null, null, null, null, null, null, null
+ );
+
+ var result = updateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo("New Name");
+ }
+
+ @Test
+ @DisplayName("should fail with SupplierNotFound when supplier does not exist")
+ void shouldFailWhenSupplierNotFound() {
+ when(supplierRepository.findById(SupplierId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new UpdateSupplierCommand(
+ "nonexistent", "New Name", null, null, null,
+ null, null, null, null, null, null, null
+ );
+
+ var result = updateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNotFound.class);
+ verify(supplierRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when new name is blank")
+ void shouldFailWhenNewNameIsBlank() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+
+ var cmd = new UpdateSupplierCommand(
+ supplierId, "", null, null, null,
+ null, null, null, null, null, null, null
+ );
+
+ var result = updateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ verify(supplierRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ when(supplierRepository.findById(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new UpdateSupplierCommand(
+ "supplier-1", "New Name", null, null, null,
+ null, null, null, null, null, null, null
+ );
+
+ var result = updateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+ when(supplierRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var cmd = new UpdateSupplierCommand(
+ supplierId, "New Name", null, null, null,
+ null, null, null, null, null, null, null
+ );
+
+ var result = updateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== GetSupplier ====================
+
+ @Nested
+ @DisplayName("GetSupplier")
+ class GetSupplierTest {
+
+ private GetSupplier getSupplier;
+
+ @BeforeEach
+ void setUp() {
+ getSupplier = new GetSupplier(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should return supplier when found")
+ void shouldReturnSupplierWhenFound() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.of(existingSupplier("supplier-1"))));
+
+ var result = getSupplier.execute(supplierId);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().id()).isEqualTo(supplierId);
+ }
+
+ @Test
+ @DisplayName("should fail with SupplierNotFound when not found")
+ void shouldFailWhenNotFound() {
+ var supplierId = SupplierId.of("nonexistent");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = getSupplier.execute(supplierId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when repository fails")
+ void shouldFailWhenRepositoryFails() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = getSupplier.execute(supplierId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== ListSuppliers ====================
+
+ @Nested
+ @DisplayName("ListSuppliers")
+ class ListSuppliersTest {
+
+ private ListSuppliers listSuppliers;
+
+ @BeforeEach
+ void setUp() {
+ listSuppliers = new ListSuppliers(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should return all suppliers")
+ void shouldReturnAllSuppliers() {
+ var suppliers = List.of(existingSupplier("s-1"), existingSupplier("s-2"));
+ when(supplierRepository.findAll()).thenReturn(Result.success(suppliers));
+
+ var result = listSuppliers.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should return empty list when no suppliers exist")
+ void shouldReturnEmptyList() {
+ when(supplierRepository.findAll()).thenReturn(Result.success(List.of()));
+
+ var result = listSuppliers.execute();
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findAll fails")
+ void shouldFailWhenFindAllFails() {
+ when(supplierRepository.findAll())
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = listSuppliers.execute();
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should return suppliers filtered by status")
+ void shouldReturnSuppliersByStatus() {
+ var activeSuppliers = List.of(existingSupplier("s-1"));
+ when(supplierRepository.findByStatus(SupplierStatus.ACTIVE))
+ .thenReturn(Result.success(activeSuppliers));
+
+ var result = listSuppliers.executeByStatus(SupplierStatus.ACTIVE);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findByStatus fails")
+ void shouldFailWhenFindByStatusFails() {
+ when(supplierRepository.findByStatus(SupplierStatus.ACTIVE))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = listSuppliers.executeByStatus(SupplierStatus.ACTIVE);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== ActivateSupplier ====================
+
+ @Nested
+ @DisplayName("ActivateSupplier")
+ class ActivateSupplierTest {
+
+ private ActivateSupplier activateSupplier;
+
+ @BeforeEach
+ void setUp() {
+ activateSupplier = new ActivateSupplier(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should activate an inactive supplier")
+ void shouldActivateInactiveSupplier() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.of(inactiveSupplier("supplier-1"))));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = activateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().status()).isEqualTo(SupplierStatus.ACTIVE);
+ verify(supplierRepository).save(any(Supplier.class));
+ }
+
+ @Test
+ @DisplayName("should fail with SupplierNotFound when supplier does not exist")
+ void shouldFailWhenSupplierNotFound() {
+ var supplierId = SupplierId.of("nonexistent");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = activateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNotFound.class);
+ verify(supplierRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = activateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.of(inactiveSupplier("supplier-1"))));
+ when(supplierRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = activateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== DeactivateSupplier ====================
+
+ @Nested
+ @DisplayName("DeactivateSupplier")
+ class DeactivateSupplierTest {
+
+ private DeactivateSupplier deactivateSupplier;
+
+ @BeforeEach
+ void setUp() {
+ deactivateSupplier = new DeactivateSupplier(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should deactivate an active supplier")
+ void shouldDeactivateActiveSupplier() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.of(existingSupplier("supplier-1"))));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var result = deactivateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().status()).isEqualTo(SupplierStatus.INACTIVE);
+ verify(supplierRepository).save(any(Supplier.class));
+ }
+
+ @Test
+ @DisplayName("should fail with SupplierNotFound when supplier does not exist")
+ void shouldFailWhenSupplierNotFound() {
+ var supplierId = SupplierId.of("nonexistent");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var result = deactivateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNotFound.class);
+ verify(supplierRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var result = deactivateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var supplierId = SupplierId.of("supplier-1");
+ when(supplierRepository.findById(supplierId))
+ .thenReturn(Result.success(Optional.of(existingSupplier("supplier-1"))));
+ when(supplierRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var result = deactivateSupplier.execute(supplierId, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== RateSupplier ====================
+
+ @Nested
+ @DisplayName("RateSupplier")
+ class RateSupplierTest {
+
+ private RateSupplier rateSupplier;
+
+ @BeforeEach
+ void setUp() {
+ rateSupplier = new RateSupplier(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should rate supplier with valid scores")
+ void shouldRateSupplierWithValidScores() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var cmd = new RateSupplierCommand(supplierId, 4, 5, 3);
+
+ var result = rateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ var rating = result.unsafeGetValue().rating();
+ assertThat(rating).isNotNull();
+ assertThat(rating.qualityScore()).isEqualTo(4);
+ assertThat(rating.deliveryScore()).isEqualTo(5);
+ assertThat(rating.priceScore()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("should fail with SupplierNotFound when supplier does not exist")
+ void shouldFailWhenSupplierNotFound() {
+ when(supplierRepository.findById(SupplierId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new RateSupplierCommand("nonexistent", 4, 5, 3);
+
+ var result = rateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidRating when score is out of range")
+ void shouldFailWhenScoreIsOutOfRange() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+
+ var cmd = new RateSupplierCommand(supplierId, 0, 5, 3);
+
+ var result = rateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.InvalidRating.class);
+ }
+
+ @Test
+ @DisplayName("should fail with InvalidRating when score exceeds maximum")
+ void shouldFailWhenScoreExceedsMax() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+
+ var cmd = new RateSupplierCommand(supplierId, 4, 6, 3);
+
+ var result = rateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.InvalidRating.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ when(supplierRepository.findById(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new RateSupplierCommand("supplier-1", 4, 5, 3);
+
+ var result = rateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+ when(supplierRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var cmd = new RateSupplierCommand(supplierId, 4, 5, 3);
+
+ var result = rateSupplier.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== AddCertificate ====================
+
+ @Nested
+ @DisplayName("AddCertificate")
+ class AddCertificateTest {
+
+ private AddCertificate addCertificate;
+
+ @BeforeEach
+ void setUp() {
+ addCertificate = new AddCertificate(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should add certificate to supplier")
+ void shouldAddCertificateToSupplier() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var cmd = new AddCertificateCommand(
+ supplierId, "ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2027, 12, 31)
+ );
+
+ var result = addCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().certificates()).hasSize(1);
+ assertThat(result.unsafeGetValue().certificates().getFirst().certificateType()).isEqualTo("ISO 9001");
+ }
+
+ @Test
+ @DisplayName("should fail with SupplierNotFound when supplier does not exist")
+ void shouldFailWhenSupplierNotFound() {
+ when(supplierRepository.findById(SupplierId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new AddCertificateCommand(
+ "nonexistent", "ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2027, 12, 31)
+ );
+
+ var result = addCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when certificate type is blank")
+ void shouldFailWhenCertificateTypeIsBlank() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+
+ var cmd = new AddCertificateCommand(
+ supplierId, "", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2027, 12, 31)
+ );
+
+ var result = addCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with ValidationFailure when validUntil is before validFrom")
+ void shouldFailWhenValidUntilBeforeValidFrom() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+
+ var cmd = new AddCertificateCommand(
+ supplierId, "ISO 9001", "TUV", LocalDate.of(2027, 1, 1), LocalDate.of(2025, 12, 31)
+ );
+
+ var result = addCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ when(supplierRepository.findById(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new AddCertificateCommand(
+ "supplier-1", "ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2027, 12, 31)
+ );
+
+ var result = addCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+ when(supplierRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var cmd = new AddCertificateCommand(
+ supplierId, "ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2027, 12, 31)
+ );
+
+ var result = addCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+
+ // ==================== RemoveCertificate ====================
+
+ @Nested
+ @DisplayName("RemoveCertificate")
+ class RemoveCertificateTest {
+
+ private RemoveCertificate removeCertificate;
+
+ @BeforeEach
+ void setUp() {
+ removeCertificate = new RemoveCertificate(supplierRepository);
+ }
+
+ @Test
+ @DisplayName("should remove existing certificate from supplier")
+ void shouldRemoveExistingCertificate() {
+ var supplierId = "supplier-1";
+ var validFrom = LocalDate.of(2025, 1, 1);
+ var certificate = new QualityCertificate("ISO 9001", "TUV", validFrom, LocalDate.of(2027, 12, 31));
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplierWithCertificate(supplierId, certificate))));
+ when(supplierRepository.save(any())).thenReturn(Result.success(null));
+
+ var cmd = new RemoveCertificateCommand(supplierId, "ISO 9001", "TUV", validFrom);
+
+ var result = removeCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().certificates()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should fail with CertificateNotFound when certificate does not exist")
+ void shouldFailWhenCertificateNotFound() {
+ var supplierId = "supplier-1";
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplier(supplierId))));
+
+ var cmd = new RemoveCertificateCommand(
+ supplierId, "ISO 9001", "TUV", LocalDate.of(2025, 1, 1)
+ );
+
+ var result = removeCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.CertificateNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with SupplierNotFound when supplier does not exist")
+ void shouldFailWhenSupplierNotFound() {
+ when(supplierRepository.findById(SupplierId.of("nonexistent")))
+ .thenReturn(Result.success(Optional.empty()));
+
+ var cmd = new RemoveCertificateCommand(
+ "nonexistent", "ISO 9001", "TUV", LocalDate.of(2025, 1, 1)
+ );
+
+ var result = removeCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.SupplierNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when findById fails")
+ void shouldFailWhenFindByIdFails() {
+ when(supplierRepository.findById(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
+
+ var cmd = new RemoveCertificateCommand(
+ "supplier-1", "ISO 9001", "TUV", LocalDate.of(2025, 1, 1)
+ );
+
+ var result = removeCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail with RepositoryFailure when save fails")
+ void shouldFailWhenSaveFails() {
+ var supplierId = "supplier-1";
+ var validFrom = LocalDate.of(2025, 1, 1);
+ var certificate = new QualityCertificate("ISO 9001", "TUV", validFrom, LocalDate.of(2027, 12, 31));
+ when(supplierRepository.findById(SupplierId.of(supplierId)))
+ .thenReturn(Result.success(Optional.of(existingSupplierWithCertificate(supplierId, certificate))));
+ when(supplierRepository.save(any()))
+ .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
+
+ var cmd = new RemoveCertificateCommand(supplierId, "ISO 9001", "TUV", validFrom);
+
+ var result = removeCertificate.execute(cmd, performedBy);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.RepositoryFailure.class);
+ }
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/masterdata/ArticleTest.java b/backend/src/test/java/de/effigenix/domain/masterdata/ArticleTest.java
new file mode 100644
index 0000000..8d7f0d0
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/masterdata/ArticleTest.java
@@ -0,0 +1,730 @@
+package de.effigenix.domain.masterdata;
+
+import de.effigenix.shared.common.Money;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ArticleTest {
+
+ // ==================== Create ====================
+
+ @Nested
+ @DisplayName("create()")
+ class Create {
+
+ @Test
+ @DisplayName("should create Article with valid data (PIECE_FIXED / FIXED)")
+ void shouldCreateWithValidData() {
+ var draft = validDraft();
+
+ var result = Article.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var article = result.unsafeGetValue();
+ assertThat(article.id()).isNotNull();
+ assertThat(article.name().value()).isEqualTo("Testwurst");
+ assertThat(article.articleNumber().value()).isEqualTo("ART-001");
+ assertThat(article.categoryId()).isNotNull();
+ assertThat(article.status()).isEqualTo(ArticleStatus.ACTIVE);
+ assertThat(article.salesUnits()).hasSize(1);
+ assertThat(article.salesUnits().getFirst().unit()).isEqualTo(Unit.PIECE_FIXED);
+ assertThat(article.salesUnits().getFirst().priceModel()).isEqualTo(PriceModel.FIXED);
+ assertThat(article.salesUnits().getFirst().price().amount()).isEqualByComparingTo("9.99");
+ assertThat(article.supplierReferences()).isEmpty();
+ assertThat(article.createdAt()).isNotNull();
+ assertThat(article.updatedAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("should create Article with KG / WEIGHT_BASED")
+ void shouldCreateWithKgWeightBased() {
+ var draft = new ArticleDraft("Hackfleisch", "ART-002", UUID.randomUUID().toString(),
+ Unit.KG, PriceModel.WEIGHT_BASED, new BigDecimal("12.50"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var su = result.unsafeGetValue().salesUnits().getFirst();
+ assertThat(su.unit()).isEqualTo(Unit.KG);
+ assertThat(su.priceModel()).isEqualTo(PriceModel.WEIGHT_BASED);
+ }
+
+ @Test
+ @DisplayName("should create Article with HUNDRED_GRAM / WEIGHT_BASED")
+ void shouldCreateWithHundredGramWeightBased() {
+ var draft = new ArticleDraft("Aufschnitt", "ART-003", UUID.randomUUID().toString(),
+ Unit.HUNDRED_GRAM, PriceModel.WEIGHT_BASED, new BigDecimal("1.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should create Article with PIECE_VARIABLE / WEIGHT_BASED")
+ void shouldCreateWithPieceVariableWeightBased() {
+ var draft = new ArticleDraft("Braten", "ART-004", UUID.randomUUID().toString(),
+ Unit.PIECE_VARIABLE, PriceModel.WEIGHT_BASED, new BigDecimal("15.00"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should fail when name is blank")
+ void shouldFailWhenNameBlank() {
+ var draft = new ArticleDraft("", "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name is null")
+ void shouldFailWhenNameNull() {
+ var draft = new ArticleDraft(null, "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name exceeds 200 characters")
+ void shouldFailWhenNameTooLong() {
+ var longName = "A".repeat(201);
+ var draft = new ArticleDraft(longName, "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should accept name with exactly 200 characters")
+ void shouldAcceptNameWith200Chars() {
+ var name = "A".repeat(200);
+ var draft = new ArticleDraft(name, "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should fail when articleNumber is blank")
+ void shouldFailWhenArticleNumberBlank() {
+ var draft = new ArticleDraft("Testwurst", "", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when articleNumber is null")
+ void shouldFailWhenArticleNumberNull() {
+ var draft = new ArticleDraft("Testwurst", null, UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when articleNumber exceeds 50 characters")
+ void shouldFailWhenArticleNumberTooLong() {
+ var longNumber = "A".repeat(51);
+ var draft = new ArticleDraft("Testwurst", longNumber, UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should accept articleNumber with exactly 50 characters")
+ void shouldAcceptArticleNumberWith50Chars() {
+ var number = "A".repeat(50);
+ var draft = new ArticleDraft("Testwurst", number, UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should throw when price is null (Money.tryEuro does not guard null)")
+ void shouldThrowWhenPriceNull() {
+ var draft = new ArticleDraft("Testwurst", "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, null);
+
+ org.junit.jupiter.api.Assertions.assertThrows(NullPointerException.class,
+ () -> Article.create(draft));
+ }
+
+ @Test
+ @DisplayName("should fail when price is zero")
+ void shouldFailWhenPriceZero() {
+ var draft = new ArticleDraft("Testwurst", "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, BigDecimal.ZERO);
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPrice.class);
+ }
+
+ @Test
+ @DisplayName("should fail when price is negative")
+ void shouldFailWhenPriceNegative() {
+ var draft = new ArticleDraft("Testwurst", "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.FIXED, new BigDecimal("-1.00"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should fail when Unit/PriceModel combination is invalid (PIECE_FIXED + WEIGHT_BASED)")
+ void shouldFailWhenInvalidCombinationPieceFixedWeightBased() {
+ var draft = new ArticleDraft("Testwurst", "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, PriceModel.WEIGHT_BASED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPriceModelCombination.class);
+ }
+
+ @Test
+ @DisplayName("should fail when Unit/PriceModel combination is invalid (KG + FIXED)")
+ void shouldFailWhenInvalidCombinationKgFixed() {
+ var draft = new ArticleDraft("Testwurst", "ART-001", UUID.randomUUID().toString(),
+ Unit.KG, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPriceModelCombination.class);
+ }
+
+ @Test
+ @DisplayName("should fail when unit is null")
+ void shouldFailWhenUnitNull() {
+ var draft = new ArticleDraft("Testwurst", "ART-001", UUID.randomUUID().toString(),
+ null, PriceModel.FIXED, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPriceModelCombination.class);
+ }
+
+ @Test
+ @DisplayName("should fail when priceModel is null")
+ void shouldFailWhenPriceModelNull() {
+ var draft = new ArticleDraft("Testwurst", "ART-001", UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED, null, new BigDecimal("9.99"));
+
+ var result = Article.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPriceModelCombination.class);
+ }
+ }
+
+ // ==================== Update ====================
+
+ @Nested
+ @DisplayName("update()")
+ class Update {
+
+ @Test
+ @DisplayName("should update name")
+ void shouldUpdateName() {
+ var article = createValidArticle();
+ var updateDraft = new ArticleUpdateDraft("Neuer Name", null);
+
+ var result = article.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(article.name().value()).isEqualTo("Neuer Name");
+ }
+
+ @Test
+ @DisplayName("should update categoryId")
+ void shouldUpdateCategoryId() {
+ var article = createValidArticle();
+ var newCategoryId = UUID.randomUUID().toString();
+ var updateDraft = new ArticleUpdateDraft(null, newCategoryId);
+
+ var result = article.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(article.categoryId().value()).isEqualTo(newCategoryId);
+ }
+
+ @Test
+ @DisplayName("should update both name and categoryId")
+ void shouldUpdateBothFields() {
+ var article = createValidArticle();
+ var newCategoryId = UUID.randomUUID().toString();
+ var updateDraft = new ArticleUpdateDraft("Neuer Name", newCategoryId);
+
+ var result = article.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(article.name().value()).isEqualTo("Neuer Name");
+ assertThat(article.categoryId().value()).isEqualTo(newCategoryId);
+ }
+
+ @Test
+ @DisplayName("should not change fields when all null in draft")
+ void shouldNotChangeWhenAllNull() {
+ var article = createValidArticle();
+ var originalName = article.name().value();
+ var originalCategoryId = article.categoryId().value();
+ var updateDraft = new ArticleUpdateDraft(null, null);
+
+ var result = article.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(article.name().value()).isEqualTo(originalName);
+ assertThat(article.categoryId().value()).isEqualTo(originalCategoryId);
+ }
+
+ @Test
+ @DisplayName("should fail when updated name is blank")
+ void shouldFailWhenUpdatedNameBlank() {
+ var article = createValidArticle();
+ var updateDraft = new ArticleUpdateDraft("", null);
+
+ var result = article.update(updateDraft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when updated name exceeds 200 characters")
+ void shouldFailWhenUpdatedNameTooLong() {
+ var article = createValidArticle();
+ var updateDraft = new ArticleUpdateDraft("A".repeat(201), null);
+
+ var result = article.update(updateDraft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt timestamp")
+ void shouldUpdateTimestamp() {
+ var article = createValidArticle();
+ var originalUpdatedAt = article.updatedAt();
+
+ article.update(new ArticleUpdateDraft("Anderer Name", null));
+
+ assertThat(article.updatedAt()).isAfterOrEqualTo(originalUpdatedAt);
+ }
+ }
+
+ // ==================== Activate / Deactivate ====================
+
+ @Nested
+ @DisplayName("activate() / deactivate()")
+ class ActivateDeactivate {
+
+ @Test
+ @DisplayName("should deactivate active article")
+ void shouldDeactivate() {
+ var article = createValidArticle();
+ assertThat(article.status()).isEqualTo(ArticleStatus.ACTIVE);
+
+ article.deactivate();
+
+ assertThat(article.status()).isEqualTo(ArticleStatus.INACTIVE);
+ }
+
+ @Test
+ @DisplayName("should activate inactive article")
+ void shouldActivate() {
+ var article = createValidArticle();
+ article.deactivate();
+
+ article.activate();
+
+ assertThat(article.status()).isEqualTo(ArticleStatus.ACTIVE);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on deactivate")
+ void shouldUpdateTimestampOnDeactivate() {
+ var article = createValidArticle();
+ var originalUpdatedAt = article.updatedAt();
+
+ article.deactivate();
+
+ assertThat(article.updatedAt()).isAfterOrEqualTo(originalUpdatedAt);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on activate")
+ void shouldUpdateTimestampOnActivate() {
+ var article = createValidArticle();
+ article.deactivate();
+ var afterDeactivate = article.updatedAt();
+
+ article.activate();
+
+ assertThat(article.updatedAt()).isAfterOrEqualTo(afterDeactivate);
+ }
+ }
+
+ // ==================== Add Sales Unit ====================
+
+ @Nested
+ @DisplayName("addSalesUnit()")
+ class AddSalesUnit {
+
+ @Test
+ @DisplayName("should add a second SalesUnit with different unit type")
+ void shouldAddSecondSalesUnit() {
+ var article = createValidArticle();
+ var salesUnit = SalesUnit.create(Unit.KG, PriceModel.WEIGHT_BASED, Money.euro(new BigDecimal("12.50")))
+ .unsafeGetValue();
+
+ var result = article.addSalesUnit(salesUnit);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(article.salesUnits()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should fail when adding SalesUnit with duplicate unit type")
+ void shouldFailWhenDuplicateUnitType() {
+ var article = createValidArticle();
+ var duplicate = SalesUnit.create(Unit.PIECE_FIXED, PriceModel.FIXED, Money.euro(new BigDecimal("5.00")))
+ .unsafeGetValue();
+
+ var result = article.addSalesUnit(duplicate);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.DuplicateSalesUnitType.class);
+ assertThat(article.salesUnits()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on add")
+ void shouldUpdateTimestampOnAdd() {
+ var article = createValidArticle();
+ var originalUpdatedAt = article.updatedAt();
+ var salesUnit = SalesUnit.create(Unit.KG, PriceModel.WEIGHT_BASED, Money.euro(new BigDecimal("12.50")))
+ .unsafeGetValue();
+
+ article.addSalesUnit(salesUnit);
+
+ assertThat(article.updatedAt()).isAfterOrEqualTo(originalUpdatedAt);
+ }
+ }
+
+ // ==================== Remove Sales Unit ====================
+
+ @Nested
+ @DisplayName("removeSalesUnit()")
+ class RemoveSalesUnit {
+
+ @Test
+ @DisplayName("should remove SalesUnit when more than one exists")
+ void shouldRemoveWhenMultiple() {
+ var article = createValidArticle();
+ var secondUnit = SalesUnit.create(Unit.KG, PriceModel.WEIGHT_BASED, Money.euro(new BigDecimal("12.50")))
+ .unsafeGetValue();
+ article.addSalesUnit(secondUnit);
+
+ var result = article.removeSalesUnit(secondUnit.id());
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(article.salesUnits()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should fail when removing the last SalesUnit")
+ void shouldFailWhenRemovingLastSalesUnit() {
+ var article = createValidArticle();
+ var onlySalesUnitId = article.salesUnits().getFirst().id();
+
+ var result = article.removeSalesUnit(onlySalesUnitId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.MinimumSalesUnitRequired.class);
+ assertThat(article.salesUnits()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should fail when SalesUnit ID does not exist")
+ void shouldFailWhenSalesUnitNotFound() {
+ var article = createValidArticle();
+ var secondUnit = SalesUnit.create(Unit.KG, PriceModel.WEIGHT_BASED, Money.euro(new BigDecimal("12.50")))
+ .unsafeGetValue();
+ article.addSalesUnit(secondUnit);
+
+ var nonExistentId = SalesUnitId.generate();
+ var result = article.removeSalesUnit(nonExistentId);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.SalesUnitNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on remove")
+ void shouldUpdateTimestampOnRemove() {
+ var article = createValidArticle();
+ var secondUnit = SalesUnit.create(Unit.KG, PriceModel.WEIGHT_BASED, Money.euro(new BigDecimal("12.50")))
+ .unsafeGetValue();
+ article.addSalesUnit(secondUnit);
+ var afterAdd = article.updatedAt();
+
+ article.removeSalesUnit(secondUnit.id());
+
+ assertThat(article.updatedAt()).isAfterOrEqualTo(afterAdd);
+ }
+ }
+
+ // ==================== Update Sales Unit Price ====================
+
+ @Nested
+ @DisplayName("updateSalesUnitPrice()")
+ class UpdateSalesUnitPrice {
+
+ @Test
+ @DisplayName("should update price of existing SalesUnit")
+ void shouldUpdatePrice() {
+ var article = createValidArticle();
+ var salesUnitId = article.salesUnits().getFirst().id();
+ var newPrice = Money.euro(new BigDecimal("19.99"));
+
+ var result = article.updateSalesUnitPrice(salesUnitId, newPrice);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(article.salesUnits().getFirst().price().amount()).isEqualByComparingTo("19.99");
+ }
+
+ @Test
+ @DisplayName("should fail when SalesUnit not found")
+ void shouldFailWhenNotFound() {
+ var article = createValidArticle();
+ var nonExistentId = SalesUnitId.generate();
+
+ var result = article.updateSalesUnitPrice(nonExistentId, Money.euro(new BigDecimal("5.00")));
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.SalesUnitNotFound.class);
+ }
+
+ @Test
+ @DisplayName("should fail when new price is zero")
+ void shouldFailWhenPriceZero() {
+ var article = createValidArticle();
+ var salesUnitId = article.salesUnits().getFirst().id();
+
+ var result = article.updateSalesUnitPrice(salesUnitId, Money.zero());
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ArticleError.InvalidPrice.class);
+ }
+ }
+
+ // ==================== Supplier References ====================
+
+ @Nested
+ @DisplayName("assignSupplier / removeSupplier")
+ class SupplierReferences {
+
+ @Test
+ @DisplayName("should add supplier reference")
+ void shouldAddSupplierReference() {
+ var article = createValidArticle();
+ var supplierId = SupplierId.generate();
+
+ article.addSupplierReference(supplierId);
+
+ assertThat(article.supplierReferences()).containsExactly(supplierId);
+ }
+
+ @Test
+ @DisplayName("should add multiple supplier references")
+ void shouldAddMultipleSuppliers() {
+ var article = createValidArticle();
+ var supplier1 = SupplierId.generate();
+ var supplier2 = SupplierId.generate();
+
+ article.addSupplierReference(supplier1);
+ article.addSupplierReference(supplier2);
+
+ assertThat(article.supplierReferences()).containsExactlyInAnyOrder(supplier1, supplier2);
+ }
+
+ @Test
+ @DisplayName("should not duplicate supplier reference")
+ void shouldNotDuplicateSupplier() {
+ var article = createValidArticle();
+ var supplierId = SupplierId.generate();
+
+ article.addSupplierReference(supplierId);
+ article.addSupplierReference(supplierId);
+
+ assertThat(article.supplierReferences()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should remove supplier reference")
+ void shouldRemoveSupplierReference() {
+ var article = createValidArticle();
+ var supplierId = SupplierId.generate();
+ article.addSupplierReference(supplierId);
+
+ article.removeSupplierReference(supplierId);
+
+ assertThat(article.supplierReferences()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should silently ignore removing non-existent supplier")
+ void shouldIgnoreRemovingNonExistentSupplier() {
+ var article = createValidArticle();
+ var supplierId = SupplierId.generate();
+
+ article.removeSupplierReference(supplierId);
+
+ assertThat(article.supplierReferences()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on add supplier")
+ void shouldUpdateTimestampOnAddSupplier() {
+ var article = createValidArticle();
+ var originalUpdatedAt = article.updatedAt();
+
+ article.addSupplierReference(SupplierId.generate());
+
+ assertThat(article.updatedAt()).isAfterOrEqualTo(originalUpdatedAt);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on remove supplier")
+ void shouldUpdateTimestampOnRemoveSupplier() {
+ var article = createValidArticle();
+ var supplierId = SupplierId.generate();
+ article.addSupplierReference(supplierId);
+ var afterAdd = article.updatedAt();
+
+ article.removeSupplierReference(supplierId);
+
+ assertThat(article.updatedAt()).isAfterOrEqualTo(afterAdd);
+ }
+ }
+
+ // ==================== Equality ====================
+
+ @Nested
+ @DisplayName("equals / hashCode")
+ class Equality {
+
+ @Test
+ @DisplayName("should be equal if same ID")
+ void shouldBeEqualBySameId() {
+ var id = ArticleId.generate();
+ var name = new ArticleName("Testwurst");
+ var number = new ArticleNumber("ART-001");
+ var categoryId = ProductCategoryId.generate();
+ var salesUnit = SalesUnit.reconstitute(
+ SalesUnitId.generate(), Unit.PIECE_FIXED, PriceModel.FIXED, Money.euro(new BigDecimal("9.99")));
+ var now = java.time.OffsetDateTime.now();
+
+ var article1 = Article.reconstitute(id, name, number, categoryId, List.of(salesUnit),
+ ArticleStatus.ACTIVE, Set.of(), now, now);
+ var article2 = Article.reconstitute(id, new ArticleName("Anderer Name"), new ArticleNumber("ART-999"),
+ ProductCategoryId.generate(), List.of(salesUnit), ArticleStatus.INACTIVE, Set.of(), now, now);
+
+ assertThat(article1).isEqualTo(article2);
+ assertThat(article1.hashCode()).isEqualTo(article2.hashCode());
+ }
+
+ @Test
+ @DisplayName("should not be equal if different ID")
+ void shouldNotBeEqualByDifferentId() {
+ var article1 = createValidArticle();
+ var article2 = createValidArticle();
+
+ assertThat(article1).isNotEqualTo(article2);
+ }
+
+ @Test
+ @DisplayName("should not be equal to null")
+ void shouldNotBeEqualToNull() {
+ var article = createValidArticle();
+
+ assertThat(article).isNotEqualTo(null);
+ }
+
+ @Test
+ @DisplayName("should not be equal to different type")
+ void shouldNotBeEqualToDifferentType() {
+ var article = createValidArticle();
+
+ assertThat(article).isNotEqualTo("not an article");
+ }
+
+ @Test
+ @DisplayName("should be equal to itself")
+ void shouldBeEqualToItself() {
+ var article = createValidArticle();
+
+ assertThat(article).isEqualTo(article);
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private static ArticleDraft validDraft() {
+ return new ArticleDraft(
+ "Testwurst",
+ "ART-001",
+ UUID.randomUUID().toString(),
+ Unit.PIECE_FIXED,
+ PriceModel.FIXED,
+ new BigDecimal("9.99")
+ );
+ }
+
+ private static Article createValidArticle() {
+ return Article.create(validDraft()).unsafeGetValue();
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/masterdata/CustomerTest.java b/backend/src/test/java/de/effigenix/domain/masterdata/CustomerTest.java
new file mode 100644
index 0000000..b4ff341
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/masterdata/CustomerTest.java
@@ -0,0 +1,858 @@
+package de.effigenix.domain.masterdata;
+
+import de.effigenix.shared.common.Address;
+import de.effigenix.shared.common.Money;
+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.LocalDate;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CustomerTest {
+
+ // ==================== Create ====================
+
+ @Nested
+ @DisplayName("create()")
+ class Create {
+
+ @Nested
+ @DisplayName("B2C")
+ class B2C {
+
+ @Test
+ @DisplayName("should create B2C customer with valid data")
+ void shouldCreateWithValidData() {
+ var draft = new CustomerDraft(
+ "Max Mustermann", CustomerType.B2C,
+ "+49 123 456", "max@example.com", "Max",
+ "Hauptstr.", "10", "12345", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var customer = result.unsafeGetValue();
+ assertThat(customer.id()).isNotNull();
+ assertThat(customer.name().value()).isEqualTo("Max Mustermann");
+ assertThat(customer.type()).isEqualTo(CustomerType.B2C);
+ assertThat(customer.billingAddress().street()).isEqualTo("Hauptstr.");
+ assertThat(customer.billingAddress().houseNumber()).isEqualTo("10");
+ assertThat(customer.billingAddress().postalCode()).isEqualTo("12345");
+ assertThat(customer.billingAddress().city()).isEqualTo("Berlin");
+ assertThat(customer.billingAddress().country()).isEqualTo("DE");
+ assertThat(customer.contactInfo().phone()).isEqualTo("+49 123 456");
+ assertThat(customer.contactInfo().email()).isEqualTo("max@example.com");
+ assertThat(customer.contactInfo().contactPerson()).isEqualTo("Max");
+ assertThat(customer.paymentTerms()).isNull();
+ assertThat(customer.deliveryAddresses()).isEmpty();
+ assertThat(customer.frameContract()).isNull();
+ assertThat(customer.preferences()).isEmpty();
+ assertThat(customer.status()).isEqualTo(CustomerStatus.ACTIVE);
+ assertThat(customer.createdAt()).isNotNull();
+ assertThat(customer.updatedAt()).isNotNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("B2B")
+ class B2B {
+
+ @Test
+ @DisplayName("should create B2B customer with valid data")
+ void shouldCreateWithValidData() {
+ var draft = new CustomerDraft(
+ "Firma GmbH", CustomerType.B2B,
+ "+49 800 123", "info@firma.de", "Herr Schmidt",
+ "Industriestr.", "5a", "80331", "Muenchen", "DE",
+ 30, "30 Tage netto"
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var customer = result.unsafeGetValue();
+ assertThat(customer.name().value()).isEqualTo("Firma GmbH");
+ assertThat(customer.type()).isEqualTo(CustomerType.B2B);
+ assertThat(customer.paymentTerms()).isNotNull();
+ assertThat(customer.paymentTerms().paymentDueDays()).isEqualTo(30);
+ assertThat(customer.paymentTerms().description()).isEqualTo("30 Tage netto");
+ }
+ }
+
+ @Nested
+ @DisplayName("validation failures")
+ class ValidationFailures {
+
+ @Test
+ @DisplayName("should fail when name is null")
+ void shouldFailWhenNameNull() {
+ var draft = new CustomerDraft(
+ null, CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "12345", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name is blank")
+ void shouldFailWhenNameBlank() {
+ var draft = new CustomerDraft(
+ " ", CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "12345", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name exceeds 200 characters")
+ void shouldFailWhenNameTooLong() {
+ var draft = new CustomerDraft(
+ "A".repeat(201), CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "12345", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should accept name with exactly 200 characters")
+ void shouldAcceptNameWith200Chars() {
+ var draft = new CustomerDraft(
+ "A".repeat(200), CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "12345", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should fail when email format is invalid")
+ void shouldFailWhenEmailInvalid() {
+ var draft = new CustomerDraft(
+ "Test", CustomerType.B2C,
+ "+49 123", "not-an-email", null,
+ "Str.", null, "12345", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when street is blank")
+ void shouldFailWhenStreetBlank() {
+ var draft = new CustomerDraft(
+ "Test", CustomerType.B2C,
+ "+49 123", null, null,
+ "", null, "12345", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when city is blank")
+ void shouldFailWhenCityBlank() {
+ var draft = new CustomerDraft(
+ "Test", CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "12345", "", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when country is not ISO alpha-2")
+ void shouldFailWhenCountryInvalid() {
+ var draft = new CustomerDraft(
+ "Test", CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "12345", "Berlin", "Deutschland",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when postalCode is blank")
+ void shouldFailWhenPostalCodeBlank() {
+ var draft = new CustomerDraft(
+ "Test", CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "", "Berlin", "DE",
+ null, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when paymentDueDays is negative")
+ void shouldFailWhenPaymentDueDaysNegative() {
+ var draft = new CustomerDraft(
+ "Test", CustomerType.B2C,
+ "+49 123", null, null,
+ "Str.", null, "12345", "Berlin", "DE",
+ -1, null
+ );
+
+ var result = Customer.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+ }
+ }
+
+ // ==================== Update ====================
+
+ @Nested
+ @DisplayName("update()")
+ class Update {
+
+ @Test
+ @DisplayName("should update name")
+ void shouldUpdateName() {
+ var customer = createValidCustomer();
+ var draft = new CustomerUpdateDraft(
+ "Neuer Name", null, null, null,
+ null, null, null, null, null,
+ null, null
+ );
+
+ var result = customer.update(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(customer.name().value()).isEqualTo("Neuer Name");
+ }
+
+ @Test
+ @DisplayName("should update contactInfo")
+ void shouldUpdateContactInfo() {
+ var customer = createValidCustomer();
+ var draft = new CustomerUpdateDraft(
+ null, "+49 999 000", "new@example.com", "Frau Mueller",
+ null, null, null, null, null,
+ null, null
+ );
+
+ var result = customer.update(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(customer.contactInfo().phone()).isEqualTo("+49 999 000");
+ assertThat(customer.contactInfo().email()).isEqualTo("new@example.com");
+ assertThat(customer.contactInfo().contactPerson()).isEqualTo("Frau Mueller");
+ }
+
+ @Test
+ @DisplayName("should update billingAddress")
+ void shouldUpdateBillingAddress() {
+ var customer = createValidCustomer();
+ var draft = new CustomerUpdateDraft(
+ null, null, null, null,
+ "Neue Str.", "20", "54321", "Hamburg", "DE",
+ null, null
+ );
+
+ var result = customer.update(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(customer.billingAddress().street()).isEqualTo("Neue Str.");
+ assertThat(customer.billingAddress().city()).isEqualTo("Hamburg");
+ }
+
+ @Test
+ @DisplayName("should update paymentTerms")
+ void shouldUpdatePaymentTerms() {
+ var customer = createValidCustomer();
+ var draft = new CustomerUpdateDraft(
+ null, null, null, null,
+ null, null, null, null, null,
+ 60, "60 Tage netto"
+ );
+
+ var result = customer.update(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(customer.paymentTerms()).isNotNull();
+ assertThat(customer.paymentTerms().paymentDueDays()).isEqualTo(60);
+ }
+
+ @Test
+ @DisplayName("should not change fields when all null in draft")
+ void shouldNotChangeFieldsWhenAllNull() {
+ var customer = createValidCustomer();
+ var originalName = customer.name().value();
+ var originalPhone = customer.contactInfo().phone();
+ var draft = new CustomerUpdateDraft(
+ null, null, null, null,
+ null, null, null, null, null,
+ null, null
+ );
+
+ var result = customer.update(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(customer.name().value()).isEqualTo(originalName);
+ assertThat(customer.contactInfo().phone()).isEqualTo(originalPhone);
+ }
+
+ @Test
+ @DisplayName("should fail update when name is blank")
+ void shouldFailWhenNameBlank() {
+ var customer = createValidCustomer();
+ var draft = new CustomerUpdateDraft(
+ " ", null, null, null,
+ null, null, null, null, null,
+ null, null
+ );
+
+ var result = customer.update(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail update when address city is blank")
+ void shouldFailWhenAddressCityBlank() {
+ var customer = createValidCustomer();
+ var draft = new CustomerUpdateDraft(
+ null, null, null, null,
+ "Str.", null, "12345", "", "DE",
+ null, null
+ );
+
+ var result = customer.update(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt timestamp")
+ void shouldUpdateTimestamp() {
+ var customer = createValidCustomer();
+ var beforeUpdate = customer.updatedAt();
+
+ var draft = new CustomerUpdateDraft(
+ "Anderer Name", null, null, null,
+ null, null, null, null, null,
+ null, null
+ );
+ customer.update(draft);
+
+ assertThat(customer.updatedAt()).isAfterOrEqualTo(beforeUpdate);
+ }
+ }
+
+ // ==================== Activate / Deactivate ====================
+
+ @Nested
+ @DisplayName("activate() / deactivate()")
+ class ActivateDeactivate {
+
+ @Test
+ @DisplayName("should deactivate active customer")
+ void shouldDeactivate() {
+ var customer = createValidCustomer();
+ assertThat(customer.status()).isEqualTo(CustomerStatus.ACTIVE);
+
+ customer.deactivate();
+
+ assertThat(customer.status()).isEqualTo(CustomerStatus.INACTIVE);
+ }
+
+ @Test
+ @DisplayName("should activate inactive customer")
+ void shouldActivate() {
+ var customer = createValidCustomer();
+ customer.deactivate();
+ assertThat(customer.status()).isEqualTo(CustomerStatus.INACTIVE);
+
+ customer.activate();
+
+ assertThat(customer.status()).isEqualTo(CustomerStatus.ACTIVE);
+ }
+
+ @Test
+ @DisplayName("deactivate should update updatedAt")
+ void deactivateShouldUpdateTimestamp() {
+ var customer = createValidCustomer();
+ var before = customer.updatedAt();
+
+ customer.deactivate();
+
+ assertThat(customer.updatedAt()).isAfterOrEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("activate should update updatedAt")
+ void activateShouldUpdateTimestamp() {
+ var customer = createValidCustomer();
+ customer.deactivate();
+ var before = customer.updatedAt();
+
+ customer.activate();
+
+ assertThat(customer.updatedAt()).isAfterOrEqualTo(before);
+ }
+ }
+
+ // ==================== Delivery Addresses ====================
+
+ @Nested
+ @DisplayName("addDeliveryAddress() / removeDeliveryAddress()")
+ class DeliveryAddresses {
+
+ @Test
+ @DisplayName("should add delivery address")
+ void shouldAddDeliveryAddress() {
+ var customer = createValidCustomer();
+ var address = Address.create("Lieferstr.", "1", "99999", "Leipzig", "DE").unsafeGetValue();
+ var deliveryAddress = new DeliveryAddress("Lager Ost", address, "Herr Meier", "Rampe 3");
+
+ customer.addDeliveryAddress(deliveryAddress);
+
+ assertThat(customer.deliveryAddresses()).hasSize(1);
+ assertThat(customer.deliveryAddresses().getFirst().label()).isEqualTo("Lager Ost");
+ assertThat(customer.deliveryAddresses().getFirst().address().city()).isEqualTo("Leipzig");
+ assertThat(customer.deliveryAddresses().getFirst().contactPerson()).isEqualTo("Herr Meier");
+ assertThat(customer.deliveryAddresses().getFirst().deliveryNotes()).isEqualTo("Rampe 3");
+ }
+
+ @Test
+ @DisplayName("should add multiple delivery addresses")
+ void shouldAddMultipleDeliveryAddresses() {
+ var customer = createValidCustomer();
+ var address1 = Address.create("Str. 1", null, "11111", "Stadt A", "DE").unsafeGetValue();
+ var address2 = Address.create("Str. 2", null, "22222", "Stadt B", "DE").unsafeGetValue();
+
+ customer.addDeliveryAddress(new DeliveryAddress("Lager 1", address1, null, null));
+ customer.addDeliveryAddress(new DeliveryAddress("Lager 2", address2, null, null));
+
+ assertThat(customer.deliveryAddresses()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should remove delivery address by label")
+ void shouldRemoveDeliveryAddressByLabel() {
+ var customer = createValidCustomer();
+ var address = Address.create("Lieferstr.", "1", "99999", "Leipzig", "DE").unsafeGetValue();
+ customer.addDeliveryAddress(new DeliveryAddress("Lager Ost", address, null, null));
+ customer.addDeliveryAddress(new DeliveryAddress("Lager West", address, null, null));
+
+ customer.removeDeliveryAddress("Lager Ost");
+
+ assertThat(customer.deliveryAddresses()).hasSize(1);
+ assertThat(customer.deliveryAddresses().getFirst().label()).isEqualTo("Lager West");
+ }
+
+ @Test
+ @DisplayName("should do nothing when removing non-existent label")
+ void shouldDoNothingWhenRemovingNonExistent() {
+ var customer = createValidCustomer();
+ var address = Address.create("Lieferstr.", "1", "99999", "Leipzig", "DE").unsafeGetValue();
+ customer.addDeliveryAddress(new DeliveryAddress("Lager Ost", address, null, null));
+
+ customer.removeDeliveryAddress("Nicht vorhanden");
+
+ assertThat(customer.deliveryAddresses()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("addDeliveryAddress should update updatedAt")
+ void addShouldUpdateTimestamp() {
+ var customer = createValidCustomer();
+ var before = customer.updatedAt();
+ var address = Address.create("Str.", null, "12345", "City", "DE").unsafeGetValue();
+
+ customer.addDeliveryAddress(new DeliveryAddress("L", address, null, null));
+
+ assertThat(customer.updatedAt()).isAfterOrEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("removeDeliveryAddress should update updatedAt")
+ void removeShouldUpdateTimestamp() {
+ var customer = createValidCustomer();
+ var address = Address.create("Str.", null, "12345", "City", "DE").unsafeGetValue();
+ customer.addDeliveryAddress(new DeliveryAddress("L", address, null, null));
+ var before = customer.updatedAt();
+
+ customer.removeDeliveryAddress("L");
+
+ assertThat(customer.updatedAt()).isAfterOrEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("deliveryAddresses should return unmodifiable list")
+ void shouldReturnUnmodifiableList() {
+ var customer = createValidCustomer();
+
+ var list = customer.deliveryAddresses();
+
+ org.junit.jupiter.api.Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> list.add(null)
+ );
+ }
+ }
+
+ // ==================== Frame Contract ====================
+
+ @Nested
+ @DisplayName("setFrameContract()")
+ class SetFrameContract {
+
+ @Test
+ @DisplayName("should set frame contract for B2B customer")
+ void shouldSetFrameContractForB2B() {
+ var customer = createValidB2BCustomer();
+ var contract = createValidFrameContract();
+
+ var result = customer.setFrameContract(contract);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(customer.frameContract()).isNotNull();
+ assertThat(customer.frameContract()).isEqualTo(contract);
+ }
+
+ @Test
+ @DisplayName("should fail setting frame contract for B2C customer")
+ void shouldFailForB2C() {
+ var customer = createValidCustomer();
+ var contract = createValidFrameContract();
+
+ var result = customer.setFrameContract(contract);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(CustomerError.FrameContractNotAllowed.class);
+ assertThat(customer.frameContract()).isNull();
+ }
+
+ @Test
+ @DisplayName("should remove frame contract")
+ void shouldRemoveFrameContract() {
+ var customer = createValidB2BCustomer();
+ var contract = createValidFrameContract();
+ customer.setFrameContract(contract);
+
+ customer.removeFrameContract();
+
+ assertThat(customer.frameContract()).isNull();
+ }
+
+ @Test
+ @DisplayName("hasActiveFrameContract should return true for active contract")
+ void shouldReturnTrueForActiveContract() {
+ var customer = createValidB2BCustomer();
+ var lineItem = new ContractLineItem(
+ new ArticleId("ART-001"), Money.euro(new BigDecimal("10.00")),
+ new BigDecimal("100"), Unit.KG
+ );
+ var contract = FrameContract.create(
+ LocalDate.now().minusDays(1), LocalDate.now().plusDays(30),
+ DeliveryRhythm.WEEKLY, List.of(lineItem)
+ ).unsafeGetValue();
+ customer.setFrameContract(contract);
+
+ assertThat(customer.hasActiveFrameContract()).isTrue();
+ }
+
+ @Test
+ @DisplayName("hasActiveFrameContract should return false when no contract")
+ void shouldReturnFalseWhenNoContract() {
+ var customer = createValidB2BCustomer();
+
+ assertThat(customer.hasActiveFrameContract()).isFalse();
+ }
+
+ @Test
+ @DisplayName("getAgreedPrice should return price for existing article")
+ void shouldReturnAgreedPrice() {
+ var customer = createValidB2BCustomer();
+ var articleId = new ArticleId("ART-001");
+ var price = Money.euro(new BigDecimal("12.50"));
+ var lineItem = new ContractLineItem(articleId, price, new BigDecimal("100"), Unit.KG);
+ var contract = FrameContract.create(
+ LocalDate.now().minusDays(1), LocalDate.now().plusDays(30),
+ DeliveryRhythm.WEEKLY, List.of(lineItem)
+ ).unsafeGetValue();
+ customer.setFrameContract(contract);
+
+ var result = customer.getAgreedPrice(articleId);
+
+ assertThat(result).isPresent();
+ assertThat(result.get()).isEqualTo(price);
+ }
+
+ @Test
+ @DisplayName("getAgreedPrice should return empty for unknown article")
+ void shouldReturnEmptyForUnknownArticle() {
+ var customer = createValidB2BCustomer();
+ var lineItem = new ContractLineItem(
+ new ArticleId("ART-001"), Money.euro(new BigDecimal("10.00")),
+ new BigDecimal("100"), Unit.KG
+ );
+ var contract = FrameContract.create(
+ LocalDate.now().minusDays(1), LocalDate.now().plusDays(30),
+ DeliveryRhythm.WEEKLY, List.of(lineItem)
+ ).unsafeGetValue();
+ customer.setFrameContract(contract);
+
+ var result = customer.getAgreedPrice(new ArticleId("ART-999"));
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("getAgreedPrice should return empty when no contract set")
+ void shouldReturnEmptyWhenNoContract() {
+ var customer = createValidB2BCustomer();
+
+ var result = customer.getAgreedPrice(new ArticleId("ART-001"));
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("setFrameContract should update updatedAt")
+ void shouldUpdateTimestamp() {
+ var customer = createValidB2BCustomer();
+ var before = customer.updatedAt();
+ var contract = createValidFrameContract();
+
+ customer.setFrameContract(contract);
+
+ assertThat(customer.updatedAt()).isAfterOrEqualTo(before);
+ }
+ }
+
+ // ==================== Preferences ====================
+
+ @Nested
+ @DisplayName("setPreferences()")
+ class Preferences {
+
+ @Test
+ @DisplayName("should set preferences")
+ void shouldSetPreferences() {
+ var customer = createValidCustomer();
+
+ customer.setPreferences(Set.of(CustomerPreference.BIO, CustomerPreference.REGIONAL));
+
+ assertThat(customer.preferences()).containsExactlyInAnyOrder(
+ CustomerPreference.BIO, CustomerPreference.REGIONAL
+ );
+ }
+
+ @Test
+ @DisplayName("should replace existing preferences")
+ void shouldReplaceExistingPreferences() {
+ var customer = createValidCustomer();
+ customer.setPreferences(Set.of(CustomerPreference.BIO, CustomerPreference.HALAL));
+
+ customer.setPreferences(Set.of(CustomerPreference.KOSHER));
+
+ assertThat(customer.preferences()).containsExactly(CustomerPreference.KOSHER);
+ }
+
+ @Test
+ @DisplayName("should clear preferences with empty set")
+ void shouldClearPreferencesWithEmptySet() {
+ var customer = createValidCustomer();
+ customer.setPreferences(Set.of(CustomerPreference.BIO));
+
+ customer.setPreferences(Set.of());
+
+ assertThat(customer.preferences()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should add single preference")
+ void shouldAddSinglePreference() {
+ var customer = createValidCustomer();
+
+ customer.addPreference(CustomerPreference.BIO);
+
+ assertThat(customer.preferences()).contains(CustomerPreference.BIO);
+ }
+
+ @Test
+ @DisplayName("should remove single preference")
+ void shouldRemoveSinglePreference() {
+ var customer = createValidCustomer();
+ customer.addPreference(CustomerPreference.BIO);
+ customer.addPreference(CustomerPreference.REGIONAL);
+
+ customer.removePreference(CustomerPreference.BIO);
+
+ assertThat(customer.preferences()).containsExactly(CustomerPreference.REGIONAL);
+ }
+
+ @Test
+ @DisplayName("setPreferences should update updatedAt")
+ void shouldUpdateTimestamp() {
+ var customer = createValidCustomer();
+ var before = customer.updatedAt();
+
+ customer.setPreferences(Set.of(CustomerPreference.TIERWOHL));
+
+ assertThat(customer.updatedAt()).isAfterOrEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("preferences should return unmodifiable set")
+ void shouldReturnUnmodifiableSet() {
+ var customer = createValidCustomer();
+
+ var prefs = customer.preferences();
+
+ org.junit.jupiter.api.Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> prefs.add(CustomerPreference.BIO)
+ );
+ }
+ }
+
+ // ==================== Equality ====================
+
+ @Nested
+ @DisplayName("equals / hashCode")
+ class Equality {
+
+ @Test
+ @DisplayName("should be equal if same ID")
+ void shouldBeEqualBySameId() {
+ var id = CustomerId.generate();
+ var name = new CustomerName("Test");
+ var address = Address.create("Str.", null, "12345", "City", "DE").unsafeGetValue();
+ var contact = new de.effigenix.shared.common.ContactInfo("+49 1", null, null);
+ var now = java.time.OffsetDateTime.now();
+
+ var c1 = Customer.reconstitute(id, name, CustomerType.B2C, address, contact,
+ null, List.of(), null, Set.of(), CustomerStatus.ACTIVE, now, now);
+ var c2 = Customer.reconstitute(id, new CustomerName("Anderer Name"), CustomerType.B2B, address, contact,
+ null, List.of(), null, Set.of(), CustomerStatus.INACTIVE, now, now);
+
+ assertThat(c1).isEqualTo(c2);
+ assertThat(c1.hashCode()).isEqualTo(c2.hashCode());
+ }
+
+ @Test
+ @DisplayName("should not be equal if different ID")
+ void shouldNotBeEqualByDifferentId() {
+ var c1 = createValidCustomer();
+ var c2 = createValidCustomer();
+
+ assertThat(c1).isNotEqualTo(c2);
+ }
+
+ @Test
+ @DisplayName("should not be equal to null")
+ void shouldNotBeEqualToNull() {
+ var customer = createValidCustomer();
+
+ assertThat(customer).isNotEqualTo(null);
+ }
+
+ @Test
+ @DisplayName("should not be equal to different type")
+ void shouldNotBeEqualToDifferentType() {
+ var customer = createValidCustomer();
+
+ assertThat(customer).isNotEqualTo("not a customer");
+ }
+
+ @Test
+ @DisplayName("should be equal to itself")
+ void shouldBeEqualToItself() {
+ var customer = createValidCustomer();
+
+ assertThat(customer).isEqualTo(customer);
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private Customer createValidCustomer() {
+ var draft = new CustomerDraft(
+ "Testkunde B2C", CustomerType.B2C,
+ "+49 123 456", "test@example.com", null,
+ "Teststr.", "1", "12345", "Berlin", "DE",
+ null, null
+ );
+ return Customer.create(draft).unsafeGetValue();
+ }
+
+ private Customer createValidB2BCustomer() {
+ var draft = new CustomerDraft(
+ "Testkunde B2B GmbH", CustomerType.B2B,
+ "+49 800 999", "b2b@example.com", "Herr Test",
+ "Industriestr.", "42", "80331", "Muenchen", "DE",
+ 30, "30 Tage netto"
+ );
+ return Customer.create(draft).unsafeGetValue();
+ }
+
+ private FrameContract createValidFrameContract() {
+ var lineItem = new ContractLineItem(
+ new ArticleId("ART-001"),
+ Money.euro(new BigDecimal("10.00")),
+ new BigDecimal("100"),
+ Unit.KG
+ );
+ return FrameContract.create(
+ LocalDate.now().minusDays(1), LocalDate.now().plusDays(30),
+ DeliveryRhythm.WEEKLY, List.of(lineItem)
+ ).unsafeGetValue();
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/masterdata/ProductCategoryTest.java b/backend/src/test/java/de/effigenix/domain/masterdata/ProductCategoryTest.java
new file mode 100644
index 0000000..0942be2
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/masterdata/ProductCategoryTest.java
@@ -0,0 +1,266 @@
+package de.effigenix.domain.masterdata;
+
+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;
+
+class ProductCategoryTest {
+
+ // ==================== Create ====================
+
+ @Nested
+ @DisplayName("create()")
+ class Create {
+
+ @Test
+ @DisplayName("should create ProductCategory with valid data")
+ void shouldCreateWithValidData() {
+ var draft = new ProductCategoryDraft("Backwaren", "Alle Backwaren-Produkte");
+
+ var result = ProductCategory.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var category = result.unsafeGetValue();
+ assertThat(category.id()).isNotNull();
+ assertThat(category.name().value()).isEqualTo("Backwaren");
+ assertThat(category.description()).isEqualTo("Alle Backwaren-Produkte");
+ }
+
+ @Test
+ @DisplayName("should create ProductCategory without description")
+ void shouldCreateWithoutDescription() {
+ var draft = new ProductCategoryDraft("Backwaren", null);
+
+ var result = ProductCategory.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var category = result.unsafeGetValue();
+ assertThat(category.name().value()).isEqualTo("Backwaren");
+ assertThat(category.description()).isNull();
+ }
+
+ @Test
+ @DisplayName("should fail when name is null")
+ void shouldFailWhenNameNull() {
+ var draft = new ProductCategoryDraft(null, "Beschreibung");
+
+ var result = ProductCategory.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name is blank")
+ void shouldFailWhenNameBlank() {
+ var draft = new ProductCategoryDraft(" ", "Beschreibung");
+
+ var result = ProductCategory.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name is empty")
+ void shouldFailWhenNameEmpty() {
+ var draft = new ProductCategoryDraft("", "Beschreibung");
+
+ var result = ProductCategory.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name exceeds 100 characters")
+ void shouldFailWhenNameTooLong() {
+ var longName = "A".repeat(101);
+ var draft = new ProductCategoryDraft(longName, null);
+
+ var result = ProductCategory.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should accept name with exactly 100 characters")
+ void shouldAcceptNameWith100Chars() {
+ var name = "A".repeat(100);
+ var draft = new ProductCategoryDraft(name, null);
+
+ var result = ProductCategory.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().name().value()).isEqualTo(name);
+ }
+
+ @Test
+ @DisplayName("should generate unique IDs for different categories")
+ void shouldGenerateUniqueIds() {
+ var cat1 = ProductCategory.create(new ProductCategoryDraft("Kategorie 1", null)).unsafeGetValue();
+ var cat2 = ProductCategory.create(new ProductCategoryDraft("Kategorie 2", null)).unsafeGetValue();
+
+ assertThat(cat1.id()).isNotEqualTo(cat2.id());
+ }
+ }
+
+ // ==================== Update ====================
+
+ @Nested
+ @DisplayName("update()")
+ class Update {
+
+ @Test
+ @DisplayName("should update name")
+ void shouldUpdateName() {
+ var category = createValidCategory();
+ var updateDraft = new ProductCategoryUpdateDraft("Neuer Name", null);
+
+ var result = category.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(category.name().value()).isEqualTo("Neuer Name");
+ }
+
+ @Test
+ @DisplayName("should update description")
+ void shouldUpdateDescription() {
+ var category = createValidCategory();
+ var updateDraft = new ProductCategoryUpdateDraft(null, "Neue Beschreibung");
+
+ var result = category.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(category.description()).isEqualTo("Neue Beschreibung");
+ }
+
+ @Test
+ @DisplayName("should update name and description together")
+ void shouldUpdateNameAndDescription() {
+ var category = createValidCategory();
+ var updateDraft = new ProductCategoryUpdateDraft("Neuer Name", "Neue Beschreibung");
+
+ var result = category.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(category.name().value()).isEqualTo("Neuer Name");
+ assertThat(category.description()).isEqualTo("Neue Beschreibung");
+ }
+
+ @Test
+ @DisplayName("should not change name when null in draft")
+ void shouldNotChangeNameWhenNull() {
+ var category = createValidCategory();
+ var originalName = category.name().value();
+ var updateDraft = new ProductCategoryUpdateDraft(null, null);
+
+ var result = category.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(category.name().value()).isEqualTo(originalName);
+ }
+
+ @Test
+ @DisplayName("should not change description when null in draft")
+ void shouldNotChangeDescriptionWhenNull() {
+ var category = createValidCategory();
+ var originalDescription = category.description();
+ var updateDraft = new ProductCategoryUpdateDraft(null, null);
+
+ var result = category.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(category.description()).isEqualTo(originalDescription);
+ }
+
+ @Test
+ @DisplayName("should fail update when name is blank")
+ void shouldFailUpdateWhenNameBlank() {
+ var category = createValidCategory();
+ var originalName = category.name().value();
+ var updateDraft = new ProductCategoryUpdateDraft(" ", null);
+
+ var result = category.update(updateDraft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ assertThat(category.name().value()).isEqualTo(originalName);
+ }
+
+ @Test
+ @DisplayName("should fail update when name exceeds 100 characters")
+ void shouldFailUpdateWhenNameTooLong() {
+ var category = createValidCategory();
+ var originalName = category.name().value();
+ var updateDraft = new ProductCategoryUpdateDraft("A".repeat(101), null);
+
+ var result = category.update(updateDraft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(ProductCategoryError.ValidationFailure.class);
+ assertThat(category.name().value()).isEqualTo(originalName);
+ }
+ }
+
+ // ==================== Equality ====================
+
+ @Nested
+ @DisplayName("equals / hashCode")
+ class Equality {
+
+ @Test
+ @DisplayName("should be equal if same ID")
+ void shouldBeEqualBySameId() {
+ var id = ProductCategoryId.generate();
+ var cat1 = ProductCategory.reconstitute(id, new CategoryName("Backwaren"), "Beschreibung A");
+ var cat2 = ProductCategory.reconstitute(id, new CategoryName("Getranke"), "Beschreibung B");
+
+ assertThat(cat1).isEqualTo(cat2);
+ assertThat(cat1.hashCode()).isEqualTo(cat2.hashCode());
+ }
+
+ @Test
+ @DisplayName("should not be equal if different ID")
+ void shouldNotBeEqualByDifferentId() {
+ var cat1 = createValidCategory();
+ var cat2 = createValidCategory();
+
+ assertThat(cat1).isNotEqualTo(cat2);
+ }
+
+ @Test
+ @DisplayName("should be equal to itself")
+ void shouldBeEqualToItself() {
+ var category = createValidCategory();
+
+ assertThat(category).isEqualTo(category);
+ }
+
+ @Test
+ @DisplayName("should not be equal to null")
+ void shouldNotBeEqualToNull() {
+ var category = createValidCategory();
+
+ assertThat(category).isNotEqualTo(null);
+ }
+
+ @Test
+ @DisplayName("should not be equal to different type")
+ void shouldNotBeEqualToDifferentType() {
+ var category = createValidCategory();
+
+ assertThat(category).isNotEqualTo("not a category");
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private ProductCategory createValidCategory() {
+ var draft = new ProductCategoryDraft("Backwaren", "Alle Backwaren-Produkte");
+ return ProductCategory.create(draft).unsafeGetValue();
+ }
+}
diff --git a/backend/src/test/java/de/effigenix/domain/masterdata/SupplierTest.java b/backend/src/test/java/de/effigenix/domain/masterdata/SupplierTest.java
new file mode 100644
index 0000000..212610c
--- /dev/null
+++ b/backend/src/test/java/de/effigenix/domain/masterdata/SupplierTest.java
@@ -0,0 +1,653 @@
+package de.effigenix.domain.masterdata;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SupplierTest {
+
+ // ==================== Create ====================
+
+ @Nested
+ @DisplayName("create()")
+ class Create {
+
+ @Test
+ @DisplayName("should create Supplier with mandatory fields only")
+ void shouldCreateWithMandatoryFieldsOnly() {
+ var draft = new SupplierDraft(
+ "Lieferant A", "+49 123 456", null, null,
+ null, null, null, null, null, null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var supplier = result.unsafeGetValue();
+ assertThat(supplier.id()).isNotNull();
+ assertThat(supplier.name().value()).isEqualTo("Lieferant A");
+ assertThat(supplier.contactInfo().phone()).isEqualTo("+49 123 456");
+ assertThat(supplier.address()).isNull();
+ assertThat(supplier.paymentTerms()).isNull();
+ assertThat(supplier.certificates()).isEmpty();
+ assertThat(supplier.rating()).isNull();
+ assertThat(supplier.status()).isEqualTo(SupplierStatus.ACTIVE);
+ assertThat(supplier.createdAt()).isNotNull();
+ assertThat(supplier.updatedAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("should create Supplier with all optional fields")
+ void shouldCreateWithAllOptionalFields() {
+ var draft = new SupplierDraft(
+ "Lieferant B", "+49 999 888", "info@lieferant.de", "Max Mustermann",
+ "Hauptstr.", "42", "12345", "Berlin", "DE", 30, "Netto 30 Tage");
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ var supplier = result.unsafeGetValue();
+ assertThat(supplier.name().value()).isEqualTo("Lieferant B");
+ assertThat(supplier.contactInfo().phone()).isEqualTo("+49 999 888");
+ assertThat(supplier.contactInfo().email()).isEqualTo("info@lieferant.de");
+ assertThat(supplier.contactInfo().contactPerson()).isEqualTo("Max Mustermann");
+ assertThat(supplier.address()).isNotNull();
+ assertThat(supplier.address().street()).isEqualTo("Hauptstr.");
+ assertThat(supplier.address().city()).isEqualTo("Berlin");
+ assertThat(supplier.address().country()).isEqualTo("DE");
+ assertThat(supplier.paymentTerms()).isNotNull();
+ assertThat(supplier.paymentTerms().paymentDueDays()).isEqualTo(30);
+ }
+
+ @Test
+ @DisplayName("should fail when name is null")
+ void shouldFailWhenNameNull() {
+ var draft = new SupplierDraft(
+ null, "+49 123 456", null, null,
+ null, null, null, null, null, null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name is blank")
+ void shouldFailWhenNameBlank() {
+ var draft = new SupplierDraft(
+ " ", "+49 123 456", null, null,
+ null, null, null, null, null, null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when name exceeds 200 chars")
+ void shouldFailWhenNameTooLong() {
+ var longName = "A".repeat(201);
+ var draft = new SupplierDraft(
+ longName, "+49 123 456", null, null,
+ null, null, null, null, null, null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should accept name with exactly 200 chars")
+ void shouldAcceptNameWith200Chars() {
+ var name = "A".repeat(200);
+ var draft = new SupplierDraft(
+ name, "+49 123 456", null, null,
+ null, null, null, null, null, null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should fail when email format is invalid")
+ void shouldFailWhenEmailInvalid() {
+ var draft = new SupplierDraft(
+ "Lieferant", "+49 123 456", "invalid-email", null,
+ null, null, null, null, null, null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when address is incomplete (street without city)")
+ void shouldFailWhenAddressIncomplete() {
+ var draft = new SupplierDraft(
+ "Lieferant", "+49 123 456", null, null,
+ "Hauptstr.", null, null, null, null, null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when country code is invalid")
+ void shouldFailWhenCountryCodeInvalid() {
+ var draft = new SupplierDraft(
+ "Lieferant", "+49 123 456", null, null,
+ "Hauptstr.", "1", "12345", "Berlin", "Deutschland", null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail when paymentDueDays is negative")
+ void shouldFailWhenPaymentDueDaysNegative() {
+ var draft = new SupplierDraft(
+ "Lieferant", "+49 123 456", null, null,
+ null, null, null, null, null, -1, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should not create address when street and city are both null")
+ void shouldNotCreateAddressWhenBothNull() {
+ var draft = new SupplierDraft(
+ "Lieferant", "+49 123 456", null, null,
+ null, null, "12345", null, "DE", null, null);
+
+ var result = Supplier.create(draft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(result.unsafeGetValue().address()).isNull();
+ }
+
+ @Test
+ @DisplayName("should trigger address creation when city is set")
+ void shouldTriggerAddressCreationWhenCitySet() {
+ var draft = new SupplierDraft(
+ "Lieferant", "+49 123 456", null, null,
+ null, null, null, "Berlin", null, null, null);
+
+ var result = Supplier.create(draft);
+
+ // Address creation triggered but street is null -> validation failure
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+ }
+
+ // ==================== Update ====================
+
+ @Nested
+ @DisplayName("update()")
+ class Update {
+
+ @Test
+ @DisplayName("should update name")
+ void shouldUpdateName() {
+ var supplier = createValidSupplier();
+ var updateDraft = new SupplierUpdateDraft(
+ "Neuer Name", null, null, null,
+ null, null, null, null, null, null, null);
+
+ var result = supplier.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(supplier.name().value()).isEqualTo("Neuer Name");
+ }
+
+ @Test
+ @DisplayName("should update contact info")
+ void shouldUpdateContactInfo() {
+ var supplier = createValidSupplier();
+ var updateDraft = new SupplierUpdateDraft(
+ null, "+49 111 222", "new@mail.de", "Erika Muster",
+ null, null, null, null, null, null, null);
+
+ var result = supplier.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(supplier.contactInfo().phone()).isEqualTo("+49 111 222");
+ assertThat(supplier.contactInfo().email()).isEqualTo("new@mail.de");
+ assertThat(supplier.contactInfo().contactPerson()).isEqualTo("Erika Muster");
+ }
+
+ @Test
+ @DisplayName("should update address")
+ void shouldUpdateAddress() {
+ var supplier = createValidSupplier();
+ var updateDraft = new SupplierUpdateDraft(
+ null, null, null, null,
+ "Neue Str.", "10", "54321", "Hamburg", "DE", null, null);
+
+ var result = supplier.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(supplier.address()).isNotNull();
+ assertThat(supplier.address().street()).isEqualTo("Neue Str.");
+ assertThat(supplier.address().city()).isEqualTo("Hamburg");
+ }
+
+ @Test
+ @DisplayName("should update payment terms")
+ void shouldUpdatePaymentTerms() {
+ var supplier = createValidSupplier();
+ var updateDraft = new SupplierUpdateDraft(
+ null, null, null, null,
+ null, null, null, null, null, 60, "Netto 60 Tage");
+
+ var result = supplier.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(supplier.paymentTerms()).isNotNull();
+ assertThat(supplier.paymentTerms().paymentDueDays()).isEqualTo(60);
+ }
+
+ @Test
+ @DisplayName("should not change fields when all null in draft")
+ void shouldNotChangeFieldsWhenAllNull() {
+ var supplier = createValidSupplier();
+ var originalName = supplier.name().value();
+ var originalPhone = supplier.contactInfo().phone();
+ var updateDraft = new SupplierUpdateDraft(
+ null, null, null, null,
+ null, null, null, null, null, null, null);
+
+ var result = supplier.update(updateDraft);
+
+ assertThat(result.isSuccess()).isTrue();
+ assertThat(supplier.name().value()).isEqualTo(originalName);
+ assertThat(supplier.contactInfo().phone()).isEqualTo(originalPhone);
+ }
+
+ @Test
+ @DisplayName("should fail update when name is blank")
+ void shouldFailUpdateWhenNameBlank() {
+ var supplier = createValidSupplier();
+ var updateDraft = new SupplierUpdateDraft(
+ "", null, null, null,
+ null, null, null, null, null, null, null);
+
+ var result = supplier.update(updateDraft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should fail update when email is invalid")
+ void shouldFailUpdateWhenEmailInvalid() {
+ var supplier = createValidSupplier();
+ var updateDraft = new SupplierUpdateDraft(
+ null, "+49 123 456", "not-an-email", null,
+ null, null, null, null, null, null, null);
+
+ var result = supplier.update(updateDraft);
+
+ assertThat(result.isFailure()).isTrue();
+ assertThat(result.unsafeGetError()).isInstanceOf(SupplierError.ValidationFailure.class);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt timestamp")
+ void shouldUpdateTimestamp() {
+ var supplier = createValidSupplier();
+ var before = supplier.updatedAt();
+
+ supplier.update(new SupplierUpdateDraft(
+ "Geaendert", null, null, null,
+ null, null, null, null, null, null, null));
+
+ assertThat(supplier.updatedAt()).isAfterOrEqualTo(before);
+ }
+ }
+
+ // ==================== Activate / Deactivate ====================
+
+ @Nested
+ @DisplayName("activate() / deactivate()")
+ class ActivateDeactivate {
+
+ @Test
+ @DisplayName("should deactivate active supplier")
+ void shouldDeactivate() {
+ var supplier = createValidSupplier();
+ assertThat(supplier.status()).isEqualTo(SupplierStatus.ACTIVE);
+
+ supplier.deactivate();
+
+ assertThat(supplier.status()).isEqualTo(SupplierStatus.INACTIVE);
+ }
+
+ @Test
+ @DisplayName("should activate inactive supplier")
+ void shouldActivate() {
+ var supplier = createValidSupplier();
+ supplier.deactivate();
+ assertThat(supplier.status()).isEqualTo(SupplierStatus.INACTIVE);
+
+ supplier.activate();
+
+ assertThat(supplier.status()).isEqualTo(SupplierStatus.ACTIVE);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on deactivate")
+ void shouldUpdateTimestampOnDeactivate() {
+ var supplier = createValidSupplier();
+ var before = supplier.updatedAt();
+
+ supplier.deactivate();
+
+ assertThat(supplier.updatedAt()).isAfterOrEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on activate")
+ void shouldUpdateTimestampOnActivate() {
+ var supplier = createValidSupplier();
+ supplier.deactivate();
+ var before = supplier.updatedAt();
+
+ supplier.activate();
+
+ assertThat(supplier.updatedAt()).isAfterOrEqualTo(before);
+ }
+ }
+
+ // ==================== Rate ====================
+
+ @Nested
+ @DisplayName("rate()")
+ class Rate {
+
+ @Test
+ @DisplayName("should set rating")
+ void shouldSetRating() {
+ var supplier = createValidSupplier();
+ var rating = new SupplierRating(4, 3, 5);
+
+ supplier.rate(rating);
+
+ assertThat(supplier.rating()).isEqualTo(rating);
+ assertThat(supplier.rating().qualityScore()).isEqualTo(4);
+ assertThat(supplier.rating().deliveryScore()).isEqualTo(3);
+ assertThat(supplier.rating().priceScore()).isEqualTo(5);
+ }
+
+ @Test
+ @DisplayName("should overwrite existing rating")
+ void shouldOverwriteExistingRating() {
+ var supplier = createValidSupplier();
+ supplier.rate(new SupplierRating(1, 1, 1));
+
+ var newRating = new SupplierRating(5, 5, 5);
+ supplier.rate(newRating);
+
+ assertThat(supplier.rating()).isEqualTo(newRating);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on rate")
+ void shouldUpdateTimestampOnRate() {
+ var supplier = createValidSupplier();
+ var before = supplier.updatedAt();
+
+ supplier.rate(new SupplierRating(3, 3, 3));
+
+ assertThat(supplier.updatedAt()).isAfterOrEqualTo(before);
+ }
+ }
+
+ // ==================== Certificates ====================
+
+ @Nested
+ @DisplayName("addCertificate() / removeCertificate()")
+ class Certificates {
+
+ @Test
+ @DisplayName("should add certificate")
+ void shouldAddCertificate() {
+ var supplier = createValidSupplier();
+ var cert = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+
+ supplier.addCertificate(cert);
+
+ assertThat(supplier.certificates()).hasSize(1);
+ assertThat(supplier.certificates().getFirst()).isEqualTo(cert);
+ }
+
+ @Test
+ @DisplayName("should not add duplicate certificate")
+ void shouldNotAddDuplicate() {
+ var supplier = createValidSupplier();
+ var cert = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+
+ supplier.addCertificate(cert);
+ supplier.addCertificate(cert);
+
+ assertThat(supplier.certificates()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("should add different certificates")
+ void shouldAddDifferentCertificates() {
+ var supplier = createValidSupplier();
+ var cert1 = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+ var cert2 = new QualityCertificate("ISO 22000", "Dekra", LocalDate.of(2025, 6, 1), LocalDate.of(2027, 6, 1));
+
+ supplier.addCertificate(cert1);
+ supplier.addCertificate(cert2);
+
+ assertThat(supplier.certificates()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("should remove certificate")
+ void shouldRemoveCertificate() {
+ var supplier = createValidSupplier();
+ var cert = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+ supplier.addCertificate(cert);
+
+ supplier.removeCertificate(cert);
+
+ assertThat(supplier.certificates()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should not fail when removing non-existent certificate")
+ void shouldNotFailWhenRemovingNonExistent() {
+ var supplier = createValidSupplier();
+ var cert = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+
+ supplier.removeCertificate(cert);
+
+ assertThat(supplier.certificates()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should return unmodifiable certificate list")
+ void shouldReturnUnmodifiableCertificateList() {
+ var supplier = createValidSupplier();
+ var cert = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+ supplier.addCertificate(cert);
+
+ var certs = supplier.certificates();
+
+ org.junit.jupiter.api.Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> certs.add(new QualityCertificate("HACCP", "Bureau Veritas", null, null))
+ );
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on addCertificate")
+ void shouldUpdateTimestampOnAdd() {
+ var supplier = createValidSupplier();
+ var before = supplier.updatedAt();
+ var cert = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+
+ supplier.addCertificate(cert);
+
+ assertThat(supplier.updatedAt()).isAfterOrEqualTo(before);
+ }
+
+ @Test
+ @DisplayName("should update updatedAt on removeCertificate")
+ void shouldUpdateTimestampOnRemove() {
+ var supplier = createValidSupplier();
+ var cert = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2025, 1, 1), LocalDate.of(2028, 1, 1));
+ supplier.addCertificate(cert);
+ var before = supplier.updatedAt();
+
+ supplier.removeCertificate(cert);
+
+ assertThat(supplier.updatedAt()).isAfterOrEqualTo(before);
+ }
+ }
+
+ // ==================== Expired / Expiring Certificates ====================
+
+ @Nested
+ @DisplayName("expiredCertificates() / certificatesExpiringSoon()")
+ class CertificateQueries {
+
+ @Test
+ @DisplayName("should return expired certificates")
+ void shouldReturnExpiredCertificates() {
+ var supplier = createValidSupplier();
+ var expired = new QualityCertificate("ISO 9001", "TUV", LocalDate.of(2020, 1, 1), LocalDate.of(2022, 1, 1));
+ var valid = new QualityCertificate("HACCP", "Dekra", LocalDate.of(2025, 1, 1), LocalDate.of(2030, 1, 1));
+ supplier.addCertificate(expired);
+ supplier.addCertificate(valid);
+
+ var result = supplier.expiredCertificates();
+
+ assertThat(result).hasSize(1);
+ assertThat(result.getFirst().certificateType()).isEqualTo("ISO 9001");
+ }
+
+ @Test
+ @DisplayName("should return empty list when no certificates are expired")
+ void shouldReturnEmptyWhenNoneExpired() {
+ var supplier = createValidSupplier();
+ var valid = new QualityCertificate("HACCP", "Dekra", LocalDate.of(2025, 1, 1), LocalDate.of(2030, 1, 1));
+ supplier.addCertificate(valid);
+
+ assertThat(supplier.expiredCertificates()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should return certificates expiring within given days")
+ void shouldReturnCertificatesExpiringSoon() {
+ var supplier = createValidSupplier();
+ var expiringSoon = new QualityCertificate("ISO 9001", "TUV",
+ LocalDate.of(2025, 1, 1), LocalDate.now().plusDays(15));
+ var farFuture = new QualityCertificate("HACCP", "Dekra",
+ LocalDate.of(2025, 1, 1), LocalDate.now().plusDays(365));
+ supplier.addCertificate(expiringSoon);
+ supplier.addCertificate(farFuture);
+
+ var result = supplier.certificatesExpiringSoon(30);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.getFirst().certificateType()).isEqualTo("ISO 9001");
+ }
+
+ @Test
+ @DisplayName("should not include certificates without validUntil in expiring soon")
+ void shouldNotIncludeCertsWithoutValidUntil() {
+ var supplier = createValidSupplier();
+ var noExpiry = new QualityCertificate("Bio", "Bioland", null, null);
+ supplier.addCertificate(noExpiry);
+
+ assertThat(supplier.certificatesExpiringSoon(30)).isEmpty();
+ }
+ }
+
+ // ==================== Equality ====================
+
+ @Nested
+ @DisplayName("equals / hashCode")
+ class Equality {
+
+ @Test
+ @DisplayName("should be equal if same ID")
+ void shouldBeEqualBySameId() {
+ var id = SupplierId.generate();
+ var now = OffsetDateTime.now(ZoneOffset.UTC);
+ var name = new SupplierName("Lieferant");
+ var contactInfo = new de.effigenix.shared.common.ContactInfo("+49 123", null, null);
+
+ var s1 = Supplier.reconstitute(id, name, null, contactInfo, null, List.of(), null, SupplierStatus.ACTIVE, now, now);
+ var s2 = Supplier.reconstitute(id, new SupplierName("Anderer Name"), null, contactInfo, null, List.of(), null, SupplierStatus.INACTIVE, now, now);
+
+ assertThat(s1).isEqualTo(s2);
+ assertThat(s1.hashCode()).isEqualTo(s2.hashCode());
+ }
+
+ @Test
+ @DisplayName("should not be equal if different ID")
+ void shouldNotBeEqualByDifferentId() {
+ var s1 = createValidSupplier();
+ var s2 = createValidSupplier();
+
+ assertThat(s1).isNotEqualTo(s2);
+ }
+
+ @Test
+ @DisplayName("should be equal to itself")
+ void shouldBeEqualToItself() {
+ var supplier = createValidSupplier();
+
+ assertThat(supplier).isEqualTo(supplier);
+ }
+
+ @Test
+ @DisplayName("should not be equal to null")
+ void shouldNotBeEqualToNull() {
+ var supplier = createValidSupplier();
+
+ assertThat(supplier).isNotEqualTo(null);
+ }
+
+ @Test
+ @DisplayName("should not be equal to different type")
+ void shouldNotBeEqualToDifferentType() {
+ var supplier = createValidSupplier();
+
+ assertThat(supplier).isNotEqualTo("not a supplier");
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private Supplier createValidSupplier() {
+ var draft = new SupplierDraft(
+ "Test Lieferant", "+49 123 456", "test@example.com", "Max Muster",
+ null, null, null, null, null, null, null);
+ return Supplier.create(draft).unsafeGetValue();
+ }
+}