diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupErrorCondition.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupErrorCondition.java index ffd047dbd..6c3eb60c7 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupErrorCondition.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupErrorCondition.java @@ -15,9 +15,6 @@ import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Rollout; import org.eclipse.hawkbit.repository.model.RolloutGroup; -/** - * Evaluates if the {@link RolloutGroup#getErrorConditionExp()} is reached. - */ @Slf4j public class ThresholdRolloutGroupErrorCondition implements RolloutGroupConditionEvaluator { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/scheduler/JpaRolloutExecutor.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/scheduler/JpaRolloutExecutor.java index a99174c6e..931e2071d 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/scheduler/JpaRolloutExecutor.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/scheduler/JpaRolloutExecutor.java @@ -445,10 +445,11 @@ public class JpaRolloutExecutor implements RolloutExecutor { if (isError) { log.info("Rollout {} {} has error, calling error action", rollout.getName(), rollout.getId()); callErrorAction(rollout, rolloutGroup); - } else { - // not in error so check finished state, do we need to start the next group? - checkSuccessCondition(rollout, rolloutGroup, evalProxy, rolloutGroup.getSuccessCondition()); - if (!(rolloutGroup == lastGroup && rolloutGroup.isDynamic()) && isRolloutGroupComplete(rollout, rolloutGroup)) { + } else {// not in error so check success condition and group completed + // 'success' is either group completed or success condition reached - execute 'success' Action + final boolean groupCompleted = !(rolloutGroup == lastGroup && rolloutGroup.isDynamic()) && isRolloutGroupComplete(rollout, rolloutGroup); + checkSuccessCondition(rollout, rolloutGroup, evalProxy, rolloutGroup.getSuccessCondition(), groupCompleted); + if (groupCompleted) { rolloutGroup.setStatus(RolloutGroupStatus.FINISHED); rolloutGroupRepository.save(rolloutGroup); } @@ -466,11 +467,9 @@ public class JpaRolloutExecutor implements RolloutExecutor { } private long countTargetsFrom(final JpaRolloutGroup rolloutGroup) { - if (rolloutGroup.isDynamic()) { - return countByActionsInRolloutGroup(rolloutGroup.getId()); - } else { - return rolloutGroupManagement.countTargetsOfRolloutsGroup(rolloutGroup.getId()); - } + // Use action-based count for all groups: deleting an action removes the target from the count, + // keeping totalTargets consistent with the actual denominator used by condition evaluators. + return countByActionsInRolloutGroup(rolloutGroup.getId()); } private void callErrorAction(final Rollout rollout, final RolloutGroup rolloutGroup) { @@ -507,14 +506,13 @@ public class JpaRolloutExecutor implements RolloutExecutor { } private void checkSuccessCondition(final Rollout rollout, final RolloutGroup rolloutGroup, final RolloutGroup evalProxy, - final RolloutGroupSuccessCondition successCondition) { + final RolloutGroupSuccessCondition successCondition, final boolean groupCompleted) { log.trace("Checking finish condition {} on rolloutgroup {}", successCondition, rolloutGroup); try { - final boolean isFinished = evaluationManager + if (groupCompleted || evaluationManager .getSuccessConditionEvaluator(successCondition) - .eval(rollout, evalProxy, rolloutGroup.getSuccessConditionExp()); - if (isFinished) { - log.debug("Rollout group {} is finished, starting next group", rolloutGroup); + .eval(rollout, evalProxy, rolloutGroup.getSuccessConditionExp())) { + log.debug("Rollout group {} fulfills SuccessCondition or is Finished, executing Success Action", rolloutGroup); evaluationManager.getSuccessActionEvaluator(rolloutGroup.getSuccessAction()).exec(rollout, rolloutGroup); } else { log.debug("Rollout group {} is still running", rolloutGroup); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/RolloutGroupConditionTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/RolloutGroupConditionTest.java new file mode 100644 index 000000000..823113d5c --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/RolloutGroupConditionTest.java @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.jpa.management; + +import static org.eclipse.hawkbit.repository.test.util.SecurityContextSwitch.callAs; +import static org.eclipse.hawkbit.repository.test.util.SecurityContextSwitch.withUser; + +import java.util.List; + +import lombok.SneakyThrows; +import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; +import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest; +import org.eclipse.hawkbit.repository.jpa.model.JpaAction; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.Action.ActionStatusCreate; +import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.Rollout; +import org.eclipse.hawkbit.repository.model.Rollout.RolloutStatus; +import org.eclipse.hawkbit.repository.model.RolloutGroup; +import org.eclipse.hawkbit.repository.model.RolloutGroup.RolloutGroupStatus; +import org.eclipse.hawkbit.repository.model.RolloutGroup.RolloutGroupSuccessAction; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.test.context.TestPropertySource; + +/** + * Integration tests for rollout group condition evaluation — group cascade behavior. + * + * Every test uses a 2-group rollout so both groups' final state is verified: + * - group 1: the group under test + * - group 2: verifies cascade (SCHEDULED = did not start, RUNNING = was triggered) + * + * Success-action tests use PAUSE: PauseRolloutGroupSuccessAction only fires when a + * scheduled child group exists, making success-action execution observable as + * RolloutStatus.PAUSED without advancing to group 2. + * + * Decision-log rules under test: + * - Success Action fires on SuccessCondition OR GroupFINISHED + * - CANCEL not mapped to Error (ignored in condition evaluation) + * - Target's Action count used as Group Target count + */ +@TestPropertySource(properties = { "hawkbit.server.repository.dynamicRolloutsMinInvolvePeriodMS=-1" }) +class RolloutGroupConditionTest extends AbstractJpaIntegrationTest { + + @BeforeEach + void reset() { + this.approvalStrategy.setApprovalNeeded(false); + } + + // ----------------------------------------------------------------------- + // CANCELED ignored; group completion triggers success action + // ----------------------------------------------------------------------- + + @SneakyThrows + @Test + void canceledActionIgnoredGroupCompletionTriggersSuccessAction() { + final String targetPrefix = "cancelTarget"; + final DistributionSet ds = testdataFactory.createDistributionSetLocked("cancelTargetDs"); + testdataFactory.createTargets(targetPrefix, 10); + + final Rollout rollout = rolloutWith2Groups( + "cancelTargetRollout", targetPrefix, ds, "100", RolloutGroupSuccessAction.PAUSE, "10"); + final List groups = getGroups(rollout); + + rolloutManagement.start(rollout.getId()); + rolloutHandler.handleAll(); + + final List running = assertAndGetRunning(rollout, 5).getContent(); + running.subList(0, 4).forEach(this::finishAction); + setCanceled(running.get(4)); + + rolloutHandler.handleAll(); + + assertGroup(groups.get(0), false, RolloutGroupStatus.FINISHED, 5); + assertGroup(groups.get(1), false, RolloutGroupStatus.SCHEDULED, 5); + assertRollout(rollout, false, RolloutStatus.PAUSED, 2, 10); + } + + @SneakyThrows + @Test + void groupCompleteNeitherConditionFulfilledTriggersSuccessAction() { + final String targetPrefix = "subthreshold"; + final DistributionSet ds = testdataFactory.createDistributionSetLocked("subthresholdDs"); + testdataFactory.createTargets(targetPrefix, 10); + + final Rollout rollout = rolloutWith2Groups( + "subthreshRollout", targetPrefix, ds, "100", RolloutGroupSuccessAction.PAUSE, "50"); + final List groups = getGroups(rollout); + + rolloutManagement.start(rollout.getId()); + rolloutHandler.handleAll(); + + final List running = assertAndGetRunning(rollout, 5).getContent(); + // 3/5 SUCCESS, 2/5 ERRORS, neither condition fulfilled (S:100, E:50) + // but Group Finishes 5/5 -> execute SuccessAction#PAUSE + running.subList(0, 3).forEach(this::finishAction); + running.subList(3, 5).forEach(this::reportError); + + rolloutHandler.handleAll(); + + assertGroup(groups.get(0), false, RolloutGroupStatus.FINISHED, 5); + assertGroup(groups.get(1), false, RolloutGroupStatus.SCHEDULED, 5); + assertRollout(rollout, false, RolloutStatus.PAUSED, 2, 10); + } + + @SneakyThrows + @Test + void successConditionMetBeforeGroupFinishedTriggersSuccessAction() { + final String targetPrefix = "scBefore"; + final DistributionSet ds = testdataFactory.createDistributionSetLocked("scBeforeDs"); + testdataFactory.createTargets(targetPrefix, 10); + + final Rollout rollout = rolloutWith2Groups( + "scBeforeRollout", targetPrefix, ds, "60", RolloutGroupSuccessAction.PAUSE, "90"); + final List groups = getGroups(rollout); + + rolloutManagement.start(rollout.getId()); + rolloutHandler.handleAll(); + + final List group1Actions = assertAndGetRunning(rollout, 5).getContent(); + // 3/5 SUCCESS, 2/5 RUNNING -> fulfill 60% SuccessCondition => Trigger SuccessAction#PAUSE + group1Actions.subList(0, 3).forEach(this::finishAction); + + rolloutHandler.handleAll(); + + assertGroup(groups.get(0), false, RolloutGroupStatus.RUNNING, 5); + assertGroup(groups.get(1), false, RolloutGroupStatus.SCHEDULED, 5); + assertRollout(rollout, false, RolloutStatus.PAUSED, 2, 10); + } + + @SneakyThrows + @Test + void deletedActionUpdatesGroupCountAndSuccessFires() { + final String targetPrefix = "denomSuccess"; + final DistributionSet ds = testdataFactory.createDistributionSetLocked("denomSuccessDs"); + testdataFactory.createTargets(targetPrefix, 10); + + final Rollout rollout = rolloutWith2Groups( + "denomSuccessRollout", targetPrefix, ds, "100", RolloutGroupSuccessAction.PAUSE, "10"); + final List groups = getGroups(rollout); + + rolloutManagement.start(rollout.getId()); + rolloutHandler.handleAll(); + + final List running = assertAndGetRunning(rollout, 5).getContent(); + // 4/5 SUCCESS, 1 DELETE -> 4/4 SUCCESS fulfill SuccessCondition 100% => Trigger SuccessAction#PAUSE + actionRepository.deleteById(running.get(4).getId()); + running.subList(0, 4).forEach(this::finishAction); + + rolloutHandler.handleAll(); + + assertGroup(groups.get(0), false, RolloutGroupStatus.FINISHED, 4); + assertGroup(groups.get(1), false, RolloutGroupStatus.SCHEDULED, 5); + assertRollout(rollout, false, RolloutStatus.PAUSED, 2, 9); + } + + @SneakyThrows + @Test + void deletedActionUpdatesErrorDenominator() { + final String targetPrefix = "denomError"; + final DistributionSet ds = testdataFactory.createDistributionSetLocked("denomErrorDs"); + testdataFactory.createTargets(targetPrefix, 10); + + final Rollout rollout = rolloutWith2Groups( + "denomErrorRollout", targetPrefix, ds, "100", RolloutGroupSuccessAction.PAUSE, "23"); + final List groups = getGroups(rollout); + + rolloutManagement.start(rollout.getId()); + rolloutHandler.handleAll(); + + final List running = assertAndGetRunning(rollout, 5).getContent(); + // 3/5 SUCCESS, 1 DELETE -> 3/4 SUCCESS, 1/4 ERROR fulfill ErrorCondition >23% => Trigger ErrorAction#PAUSE + actionRepository.deleteById(running.get(4).getId()); + running.subList(0, 3).forEach(this::finishAction); + reportError(running.get(3)); + + rolloutHandler.handleAll(); + + assertGroup(groups.get(0), false, RolloutGroupStatus.ERROR, 4); + assertGroup(groups.get(1), false, RolloutGroupStatus.SCHEDULED, 5); + assertRollout(rollout, false, RolloutStatus.PAUSED, 2, 9); + } + + private Rollout rolloutWith2Groups( + final String name, final String targetPrefix, final DistributionSet ds, + final String successCondition, final RolloutGroupSuccessAction successAction, + final String errorCondition) throws Exception { + return callAs( + withUser(name + "User", "READ_DISTRIBUTION_SET", "READ_TARGET", "READ_ROLLOUT", "CREATE_ROLLOUT"), + () -> testdataFactory.createRolloutByVariables(name, name, 2, + "controllerid==" + targetPrefix + "*", ds, + successCondition, successAction, errorCondition, null, null, false, false)); + } + + private List getGroups(final Rollout rollout) { + return rolloutGroupManagement.findByRollout( + rollout.getId(), new OffsetBasedPageRequest(0, 10, Sort.by(Direction.ASC, "id"))).getContent(); + } + + private void setCanceled(final JpaAction action) { + action.setStatus(Action.Status.CANCELED); + action.setActive(false); + actionRepository.save(action); + } + + private void reportError(final Action action) { + controllerManagement.addUpdateActionStatus( + ActionStatusCreate.builder().actionId(action.getId()).status(Action.Status.ERROR).build()); + } +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/RolloutManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/RolloutManagementTest.java index 47fbd37ca..e6148b68c 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/RolloutManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/RolloutManagementTest.java @@ -1004,10 +1004,9 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { rolloutOne = reloadRollout(rolloutOne); changeStatusForRunningActions(rolloutOne, Status.ERROR, 2); - changeStatusForRunningActions(rolloutOne, Status.FINISHED, 3); + changeStatusForRunningActions(rolloutOne, Status.FINISHED, 1); rolloutHandler.handleAll(); - // verify: 40% error and 60% finished -> should not move to next group - // because successCondition 80% + // verify: 2 RUNNING remain → group not complete → success action not triggered regardless of thresholds final List rolloutGruops = rolloutGroupManagement.findByRollout(rolloutOne.getId(), PAGE) .getContent(); final Map expectedTargetCountStatus = createInitStatusMap(); @@ -1675,11 +1674,11 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { rolloutHandler.handleAll(); assertRolloutGroup(rolloutGroupIds.get(0), RolloutGroupStatus.FINISHED, true, amountTargetsInGroup1, Status.CANCELED); - assertRolloutGroup(rolloutGroupIds.get(1), RolloutGroupStatus.SCHEDULED, false, amountTargetsInGroup2, - Status.SCHEDULED); + // group 1 complete (all CANCELED) → success action fires immediately → group 2 starts in same cycle + assertRolloutGroup(rolloutGroupIds.get(1), RolloutGroupStatus.RUNNING, false, amountTargetsInGroup2, + Status.RUNNING); - // verify actions of second rule are directly in RUNNING state, since - // confirmation is not required for this group + // verify state is stable across another scheduler cycle rolloutHandler.handleAll(); assertRolloutGroup(rolloutGroupIds.get(0), RolloutGroupStatus.FINISHED, true, amountTargetsInGroup1, Status.CANCELED); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupErrorConditionTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupErrorConditionTest.java new file mode 100644 index 000000000..b92c03962 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupErrorConditionTest.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.jpa.rollout.condition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import org.eclipse.hawkbit.repository.jpa.repository.ActionRepository; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.Rollout; +import org.eclipse.hawkbit.repository.model.RolloutGroup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ThresholdRolloutGroupErrorConditionTest { + + private static final long ROLLOUT_ID = 1L; + private static final long GROUP_ID = 10L; + + @Mock + private ActionRepository actionRepository; + @Mock + private Rollout rollout; + @Mock + private RolloutGroup rolloutGroup; + + private ThresholdRolloutGroupErrorCondition condition; + + @BeforeEach + void setUp() { + condition = new ThresholdRolloutGroupErrorCondition(actionRepository); + when(rollout.getId()).thenReturn(ROLLOUT_ID); + when(rolloutGroup.getId()).thenReturn(GROUP_ID); + when(rolloutGroup.getTotalTargets()).thenReturn(5); + } + + @Test + void errorStatusExceedsThreshold() { + // 2 ERROR / 5 = 40% > 10% + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.ERROR)).thenReturn(2L); + assertThat(condition.eval(rollout, rolloutGroup, "10")).isTrue(); + } + + @Test + void errorStatusBelowThreshold() { + // 1 ERROR / 5 = 20% < 50% + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.ERROR)).thenReturn(1L); + assertThat(condition.eval(rollout, rolloutGroup, "50")).isFalse(); + } + + @Test + void noErrorsDoesNotFire() { + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.ERROR)).thenReturn(0L); + assertThat(condition.eval(rollout, rolloutGroup, "10")).isFalse(); + } + + @Test + void exactlyAtThresholdDoesNotFire() { + // strict >: 1/5 = 20%, threshold=20% → not exceeded + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.ERROR)).thenReturn(1L); + assertThat(condition.eval(rollout, rolloutGroup, "20")).isFalse(); + } + + @Test + void zeroTotalTargetsDoesNotFire() { + when(rolloutGroup.getTotalTargets()).thenReturn(0); + assertThat(condition.eval(rollout, rolloutGroup, "10")).isFalse(); + } + + @Test + void invalidThresholdExpressionDoesNotFire() { + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.ERROR)).thenReturn(1L); + assertThat(condition.eval(rollout, rolloutGroup, "notANumber")).isFalse(); + } +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupSuccessConditionTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupSuccessConditionTest.java new file mode 100644 index 000000000..dcb0b0a4f --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rollout/condition/ThresholdRolloutGroupSuccessConditionTest.java @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.jpa.rollout.condition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import org.eclipse.hawkbit.repository.jpa.repository.ActionRepository; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.Rollout; +import org.eclipse.hawkbit.repository.model.RolloutGroup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class ThresholdRolloutGroupSuccessConditionTest { + + private static final long ROLLOUT_ID = 1L; + private static final long GROUP_ID = 10L; + + @Mock + private ActionRepository actionRepository; + @Mock + private Rollout rollout; + @Mock + private RolloutGroup rolloutGroup; + + private ThresholdRolloutGroupSuccessCondition condition; + + @BeforeEach + void setUp() { + condition = new ThresholdRolloutGroupSuccessCondition(actionRepository); + when(rollout.getId()).thenReturn(ROLLOUT_ID); + when(rolloutGroup.getId()).thenReturn(GROUP_ID); + when(rolloutGroup.getTotalTargets()).thenReturn(5); + } + + @Test + void finishedRatioMeetsThreshold() { + // 4 FINISHED / 5 = 80% >= 80% + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.FINISHED)).thenReturn(4L); + assertThat(condition.eval(rollout, rolloutGroup, "80")).isTrue(); + } + + @Test + void finishedRatioBelowThreshold() { + // 3 FINISHED / 5 = 60% < 80% + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.FINISHED)).thenReturn(3L); + assertThat(condition.eval(rollout, rolloutGroup, "80")).isFalse(); + } + + @Test + void exactlyAtThresholdFires() { + // uses >=: 1/5 = 20%, threshold=20% → fires (contrast: error uses >) + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.FINISHED)).thenReturn(1L); + assertThat(condition.eval(rollout, rolloutGroup, "20")).isTrue(); + } + + @Test + void zeroTotalTargetsReturnsTrue() { + // opposite of error condition: no targets = group considered done + when(rolloutGroup.getTotalTargets()).thenReturn(0); + assertThat(condition.eval(rollout, rolloutGroup, "100")).isTrue(); + } + + @Test + void downloadOnlyUsesDownloadedStatus() { + when(rollout.getActionType()).thenReturn(Action.ActionType.DOWNLOAD_ONLY); + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.DOWNLOADED)).thenReturn(5L); + assertThat(condition.eval(rollout, rolloutGroup, "100")).isTrue(); + } + + @Test + void downloadOnlyDoesNotCountFinishedStatus() { + when(rollout.getActionType()).thenReturn(Action.ActionType.DOWNLOAD_ONLY); + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.DOWNLOADED)).thenReturn(0L); + assertThat(condition.eval(rollout, rolloutGroup, "100")).isFalse(); + } + + @Test + void noFinishedActionsDoesNotFire() { + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.FINISHED)).thenReturn(0L); + assertThat(condition.eval(rollout, rolloutGroup, "10")).isFalse(); + } + + @Test + void invalidThresholdExpressionReturnsFalse() { + when(actionRepository.countByRolloutIdAndRolloutGroupIdAndStatus(ROLLOUT_ID, GROUP_ID, Action.Status.FINISHED)).thenReturn(5L); + assertThat(condition.eval(rollout, rolloutGroup, "notANumber")).isFalse(); + } +}