diff --git a/cloud/flamingock-cloud/src/test/java/io/flamingock/cloud/planner/CloudExecutionPlanMapperTest.java b/cloud/flamingock-cloud/src/test/java/io/flamingock/cloud/planner/CloudExecutionPlanMapperTest.java new file mode 100644 index 000000000..0820cbbdb --- /dev/null +++ b/cloud/flamingock-cloud/src/test/java/io/flamingock/cloud/planner/CloudExecutionPlanMapperTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cloud.planner; + +import io.flamingock.api.StageType; +import io.flamingock.cloud.api.response.ChangeResponse; +import io.flamingock.cloud.api.response.ExecutionPlanResponse; +import io.flamingock.cloud.api.response.StageResponse; +import io.flamingock.cloud.api.vo.CloudChangeAction; +import io.flamingock.cloud.api.vo.CloudExecutionAction; +import io.flamingock.internal.common.core.recovery.ManualInterventionRequiredException; +import io.flamingock.internal.common.core.recovery.action.ChangeAction; +import io.flamingock.internal.core.change.loaded.AbstractLoadedChange; +import io.flamingock.internal.core.change.loaded.LoadedChangeBuilder; +import io.flamingock.internal.core.change.executable.ExecutableChange; +import io.flamingock.internal.core.pipeline.execution.ExecutableStage; +import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage; +import io.flamingock.internal.core.pipeline.loaded.stage.DefaultLoadedStage; +import io.flamingock.internal.core.plan.ExecutionPlan; +import io.flamingock.core.cloud.changes._001__CloudChange1; +import io.flamingock.core.cloud.changes._002__CloudChange2; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class CloudExecutionPlanMapperTest { + + private static AbstractLoadedChange change1; + private static AbstractLoadedChange change2; + + @BeforeAll + static void setup() { + change1 = LoadedChangeBuilder.getCodeBuilderInstance(_001__CloudChange1.class).build(); + change2 = LoadedChangeBuilder.getCodeBuilderInstance(_002__CloudChange2.class).build(); + } + + @Test + @DisplayName("Should map APPLY action from cloud response to internal APPLY") + void shouldMapApplyAction() { + List loadedStages = Arrays.asList(buildStage("stage-1", change1)); + ExecutionPlanResponse response = buildResponse( + buildStageResponse("stage-1", 0, changeResponse(change1.getId(), CloudChangeAction.APPLY)) + ); + + List result = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages); + + assertEquals(1, result.size()); + ExecutableChange execChange = result.get(0).getChanges().get(0); + assertEquals(ChangeAction.APPLY, execChange.getAction()); + } + + @Test + @DisplayName("Should map SKIP action from cloud response to internal SKIP") + void shouldMapSkipAction() { + List loadedStages = Arrays.asList(buildStage("stage-1", change1)); + ExecutionPlanResponse response = buildResponse( + buildStageResponse("stage-1", 0, changeResponse(change1.getId(), CloudChangeAction.SKIP)) + ); + + List result = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages); + + assertEquals(1, result.size()); + ExecutableChange execChange = result.get(0).getChanges().get(0); + assertEquals(ChangeAction.SKIP, execChange.getAction()); + } + + @Test + @DisplayName("Should map MANUAL_INTERVENTION action from cloud response to internal MANUAL_INTERVENTION") + void shouldMapManualInterventionAction() { + List loadedStages = Arrays.asList(buildStage("stage-1", change1)); + ExecutionPlanResponse response = buildResponse( + buildStageResponse("stage-1", 0, changeResponse(change1.getId(), CloudChangeAction.MANUAL_INTERVENTION)) + ); + + List result = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages); + + assertEquals(1, result.size()); + ExecutableChange execChange = result.get(0).getChanges().get(0); + assertEquals(ChangeAction.MANUAL_INTERVENTION, execChange.getAction()); + } + + @Test + @DisplayName("Should default to SKIP when change is not present in the cloud response") + void shouldDefaultToSkipWhenChangeNotInResponse() { + List loadedStages = Arrays.asList(buildStage("stage-1", change1, change2)); + ExecutionPlanResponse response = buildResponse( + buildStageResponse("stage-1", 0, changeResponse(change1.getId(), CloudChangeAction.APPLY)) + ); + + List result = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages); + + Map actions = result.get(0).getChanges().stream() + .collect(Collectors.toMap(ExecutableChange::getId, ExecutableChange::getAction)); + assertEquals(ChangeAction.APPLY, actions.get(change1.getId())); + assertEquals(ChangeAction.SKIP, actions.get(change2.getId())); + } + + @Test + @DisplayName("Should correctly map mixed actions in a single stage") + void shouldMapMixedActions() { + List loadedStages = Arrays.asList(buildStage("stage-1", change1, change2)); + ExecutionPlanResponse response = buildResponse( + buildStageResponse("stage-1", 0, + changeResponse(change1.getId(), CloudChangeAction.APPLY), + changeResponse(change2.getId(), CloudChangeAction.MANUAL_INTERVENTION)) + ); + + List result = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages); + + Map actions = result.get(0).getChanges().stream() + .collect(Collectors.toMap(ExecutableChange::getId, ExecutableChange::getAction)); + assertEquals(ChangeAction.APPLY, actions.get(change1.getId())); + assertEquals(ChangeAction.MANUAL_INTERVENTION, actions.get(change2.getId())); + } + + @Test + @DisplayName("Should only include stages that are present in the cloud response") + void shouldFilterStagesNotInResponse() { + List loadedStages = Arrays.asList( + buildStage("stage-1", change1), + buildStage("stage-2", change2) + ); + ExecutionPlanResponse response = buildResponse( + buildStageResponse("stage-1", 0, changeResponse(change1.getId(), CloudChangeAction.APPLY)) + ); + + List result = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages); + + assertEquals(1, result.size()); + assertEquals("stage-1", result.get(0).getName()); + } + + @Test + @DisplayName("Should throw ManualInterventionRequiredException when validate() is called on plan with MANUAL_INTERVENTION") + void shouldThrowManualInterventionOnValidate() { + List loadedStages = Arrays.asList(buildStage("stage-1", change1)); + ExecutionPlanResponse response = buildResponse( + buildStageResponse("stage-1", 0, changeResponse(change1.getId(), CloudChangeAction.MANUAL_INTERVENTION)) + ); + + List stages = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages); + ExecutionPlan plan = ExecutionPlan.newExecution("exec-1", null, stages); + + assertThrows(ManualInterventionRequiredException.class, plan::validate); + } + + private static DefaultLoadedStage buildStage(String name, AbstractLoadedChange... changes) { + return new DefaultLoadedStage(name, StageType.DEFAULT, Arrays.asList(changes)); + } + + private static ExecutionPlanResponse buildResponse(StageResponse... stages) { + return new ExecutionPlanResponse(CloudExecutionAction.EXECUTE, "exec-1", null, Arrays.asList(stages)); + } + + private static StageResponse buildStageResponse(String name, int order, ChangeResponse... changes) { + return new StageResponse(name, order, Arrays.asList(changes)); + } + + private static ChangeResponse changeResponse(String id, CloudChangeAction action) { + return new ChangeResponse(id, action); + } +}