diff --git a/backend/src/test/java/de/effigenix/application/production/ActivateRecipeTest.java b/backend/src/test/java/de/effigenix/application/production/ActivateRecipeTest.java new file mode 100644 index 0000000..6d67b00 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/ActivateRecipeTest.java @@ -0,0 +1,135 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.ActivateRecipeCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +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("ActivateRecipe Use Case") +class ActivateRecipeTest { + + @Mock private RecipeRepository recipeRepository; + @Mock private AuthorizationPort authPort; + + private ActivateRecipe activateRecipe; + private ActorId performedBy; + + @BeforeEach + void setUp() { + activateRecipe = new ActivateRecipe(recipeRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private Recipe draftRecipeWithIngredient() { + var recipe = Recipe.create(new RecipeDraft( + "Bratwurst", 1, RecipeType.FINISHED_PRODUCT, + null, 85, 14, "100", "KILOGRAM" + )).unsafeGetValue(); + recipe.addIngredient(new IngredientDraft(1, "article-123", "5.5", "KILOGRAM", null, false)); + return recipe; + } + + private Recipe draftRecipeWithoutIngredient() { + return Recipe.create(new RecipeDraft( + "Bratwurst", 1, RecipeType.FINISHED_PRODUCT, + null, 85, 14, "100", "KILOGRAM" + )).unsafeGetValue(); + } + + private Recipe activeRecipe() { + return Recipe.reconstitute( + RecipeId.of("recipe-1"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ACTIVE, List.of(), List.of(), + LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Test + @DisplayName("should_ActivateRecipe_When_DraftWithIngredients") + void should_ActivateRecipe_When_DraftWithIngredients() { + var recipe = draftRecipeWithIngredient(); + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(recipe.id())).thenReturn(Result.success(Optional.of(recipe))); + when(recipeRepository.save(any())).thenReturn(Result.success(null)); + + var result = activateRecipe.execute(new ActivateRecipeCommand(recipe.id().value()), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(RecipeStatus.ACTIVE); + verify(recipeRepository).save(argThat(r -> r.status() == RecipeStatus.ACTIVE)); + } + + @Test + @DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission") + void should_FailWithUnauthorized_When_ActorLacksPermission() { + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(false); + + var result = activateRecipe.execute(new ActivateRecipeCommand("recipe-1"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.Unauthorized.class); + verify(recipeRepository, never()).save(any()); + } + + @Test + @DisplayName("should_FailWithRecipeNotFound_When_RecipeDoesNotExist") + void should_FailWithRecipeNotFound_When_RecipeDoesNotExist() { + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("nonexistent"))).thenReturn(Result.success(Optional.empty())); + + var result = activateRecipe.execute(new ActivateRecipeCommand("nonexistent"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.RecipeNotFound.class); + verify(recipeRepository, never()).save(any()); + } + + @Test + @DisplayName("should_FailWithNoIngredients_When_DraftHasNoIngredients") + void should_FailWithNoIngredients_When_DraftHasNoIngredients() { + var recipe = draftRecipeWithoutIngredient(); + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(recipe.id())).thenReturn(Result.success(Optional.of(recipe))); + + var result = activateRecipe.execute(new ActivateRecipeCommand(recipe.id().value()), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NoIngredients.class); + verify(recipeRepository, never()).save(any()); + } + + @Test + @DisplayName("should_FailWithInvalidStatusTransition_When_AlreadyActive") + void should_FailWithInvalidStatusTransition_When_AlreadyActive() { + var recipe = activeRecipe(); + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))).thenReturn(Result.success(Optional.of(recipe))); + + var result = activateRecipe.execute(new ActivateRecipeCommand("recipe-1"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidStatusTransition.class); + verify(recipeRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/ArchiveRecipeTest.java b/backend/src/test/java/de/effigenix/application/production/ArchiveRecipeTest.java new file mode 100644 index 0000000..adfe970 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/ArchiveRecipeTest.java @@ -0,0 +1,132 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.ArchiveRecipeCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +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("ArchiveRecipe Use Case") +class ArchiveRecipeTest { + + @Mock private RecipeRepository recipeRepository; + @Mock private AuthorizationPort authPort; + + private ArchiveRecipe archiveRecipe; + private ActorId performedBy; + + @BeforeEach + void setUp() { + archiveRecipe = new ArchiveRecipe(recipeRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private Recipe activeRecipe() { + return Recipe.reconstitute( + RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ACTIVE, List.of(), List.of(), + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private Recipe draftRecipe() { + return Recipe.create(new RecipeDraft( + "Bratwurst", 1, RecipeType.FINISHED_PRODUCT, + null, 85, 14, "100", "KILOGRAM" + )).unsafeGetValue(); + } + + @Test + @DisplayName("should_ArchiveRecipe_When_RecipeIsActive") + void should_ArchiveRecipe_When_RecipeIsActive() { + var recipe = activeRecipe(); + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))).thenReturn(Result.success(Optional.of(recipe))); + when(recipeRepository.save(any())).thenReturn(Result.success(null)); + + var result = archiveRecipe.execute(new ArchiveRecipeCommand("recipe-1"), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(RecipeStatus.ARCHIVED); + verify(recipeRepository).save(argThat(r -> r.status() == RecipeStatus.ARCHIVED)); + } + + @Test + @DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission") + void should_FailWithUnauthorized_When_ActorLacksPermission() { + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(false); + + var result = archiveRecipe.execute(new ArchiveRecipeCommand("recipe-1"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.Unauthorized.class); + verify(recipeRepository, never()).save(any()); + } + + @Test + @DisplayName("should_FailWithRecipeNotFound_When_RecipeDoesNotExist") + void should_FailWithRecipeNotFound_When_RecipeDoesNotExist() { + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("nonexistent"))).thenReturn(Result.success(Optional.empty())); + + var result = archiveRecipe.execute(new ArchiveRecipeCommand("nonexistent"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.RecipeNotFound.class); + verify(recipeRepository, never()).save(any()); + } + + @Test + @DisplayName("should_FailWithInvalidStatusTransition_When_RecipeIsDraft") + void should_FailWithInvalidStatusTransition_When_RecipeIsDraft() { + var recipe = draftRecipe(); + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(recipe.id())).thenReturn(Result.success(Optional.of(recipe))); + + var result = archiveRecipe.execute(new ArchiveRecipeCommand(recipe.id().value()), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidStatusTransition.class); + verify(recipeRepository, never()).save(any()); + } + + @Test + @DisplayName("should_FailWithInvalidStatusTransition_When_AlreadyArchived") + void should_FailWithInvalidStatusTransition_When_AlreadyArchived() { + var recipe = Recipe.reconstitute( + RecipeId.of("recipe-2"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ARCHIVED, List.of(), List.of(), + LocalDateTime.now(), LocalDateTime.now() + ); + when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-2"))).thenReturn(Result.success(Optional.of(recipe))); + + var result = archiveRecipe.execute(new ArchiveRecipeCommand("recipe-2"), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidStatusTransition.class); + verify(recipeRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java index 5c8a42b..ee484d0 100644 --- a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java @@ -542,6 +542,66 @@ class RecipeTest { } } + @Nested + @DisplayName("archive()") + class Archive { + + @Test + @DisplayName("should archive ACTIVE recipe") + void should_Archive_When_ActiveStatus() { + var recipe = Recipe.reconstitute( + RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ACTIVE, List.of(), List.of(), + java.time.LocalDateTime.now(), java.time.LocalDateTime.now() + ); + var updatedBefore = recipe.updatedAt(); + + var result = recipe.archive(); + + assertThat(result.isSuccess()).isTrue(); + assertThat(recipe.status()).isEqualTo(RecipeStatus.ARCHIVED); + assertThat(recipe.updatedAt()).isAfter(updatedBefore); + } + + @Test + @DisplayName("should fail when recipe is in DRAFT status") + void should_Fail_When_DraftStatus() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + + var result = recipe.archive(); + + assertThat(result.isFailure()).isTrue(); + var error = result.unsafeGetError(); + assertThat(error).isInstanceOf(RecipeError.InvalidStatusTransition.class); + var transition = (RecipeError.InvalidStatusTransition) error; + assertThat(transition.current()).isEqualTo(RecipeStatus.DRAFT); + assertThat(transition.target()).isEqualTo(RecipeStatus.ARCHIVED); + } + + @Test + @DisplayName("should fail when recipe is already ARCHIVED") + void should_Fail_When_AlreadyArchived() { + var recipe = Recipe.reconstitute( + RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ARCHIVED, List.of(), List.of(), + java.time.LocalDateTime.now(), java.time.LocalDateTime.now() + ); + + var result = recipe.archive(); + + assertThat(result.isFailure()).isTrue(); + var error = result.unsafeGetError(); + assertThat(error).isInstanceOf(RecipeError.InvalidStatusTransition.class); + var transition = (RecipeError.InvalidStatusTransition) error; + assertThat(transition.current()).isEqualTo(RecipeStatus.ARCHIVED); + assertThat(transition.target()).isEqualTo(RecipeStatus.ARCHIVED); + } + } + @Nested @DisplayName("Equality") class Equality {