From 8a9bf849a9eec17d61182f2516fc0cf7755124d4 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Tue, 24 Feb 2026 09:42:46 +0100 Subject: [PATCH] =?UTF-8?q?test:=20Unit-Tests=20f=C3=BCr=20Masterdata-Doma?= =?UTF-8?q?in=20und=20Application=20Layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain-Tests: Article, Customer, ProductCategory, Supplier Application-Tests: ArticleUseCase, CustomerUseCase, ProductCategoryUseCase, SupplierUseCase, ListStorageLocations JaCoCo: Stub-Paket von Coverage-Analyse ausgeschlossen --- backend/pom.xml | 5 + .../inventory/ListStorageLocationsTest.java | 234 ++++ .../masterdata/ArticleUseCaseTest.java | 1171 +++++++++++++++++ .../masterdata/CustomerUseCaseTest.java | 1148 ++++++++++++++++ .../ProductCategoryUseCaseTest.java | 479 +++++++ .../masterdata/SupplierUseCaseTest.java | 909 +++++++++++++ .../domain/masterdata/ArticleTest.java | 730 ++++++++++ .../domain/masterdata/CustomerTest.java | 858 ++++++++++++ .../masterdata/ProductCategoryTest.java | 266 ++++ .../domain/masterdata/SupplierTest.java | 653 +++++++++ 10 files changed, 6453 insertions(+) create mode 100644 backend/src/test/java/de/effigenix/application/inventory/ListStorageLocationsTest.java create mode 100644 backend/src/test/java/de/effigenix/application/masterdata/ArticleUseCaseTest.java create mode 100644 backend/src/test/java/de/effigenix/application/masterdata/CustomerUseCaseTest.java create mode 100644 backend/src/test/java/de/effigenix/application/masterdata/ProductCategoryUseCaseTest.java create mode 100644 backend/src/test/java/de/effigenix/application/masterdata/SupplierUseCaseTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/masterdata/ArticleTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/masterdata/CustomerTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/masterdata/ProductCategoryTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/masterdata/SupplierTest.java 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(); + } +}