From cadf125c7008e9839c11834de40813ea1a5fc1c6 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Mon, 27 Oct 2025 14:53:52 +0100 Subject: [PATCH 1/4] Add support for Condition script response --- .../pir/impl/common/PirRunStateHandler.kt | 27 ++----------------- .../com/duckduckgo/pir/impl/di/PirModule.kt | 7 ++--- .../scripts/models/PirScriptResponseParams.kt | 5 ++++ 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/PirRunStateHandler.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/PirRunStateHandler.kt index 0f6698beb2fa..8ead44d0261c 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/PirRunStateHandler.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/PirRunStateHandler.kt @@ -38,13 +38,7 @@ import com.duckduckgo.pir.impl.models.ExtractedProfile import com.duckduckgo.pir.impl.pixels.PirPixelSender import com.duckduckgo.pir.impl.scheduling.JobRecordUpdater import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse -import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ClickResponse -import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExpectationResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExtractedResponse -import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.FillFormResponse -import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.GetCaptchaInfoResponse -import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.NavigateResponse -import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.SolveCaptchaResponse import com.duckduckgo.pir.impl.store.PirEventsRepository import com.duckduckgo.pir.impl.store.PirRepository import com.duckduckgo.pir.impl.store.PirSchedulingRepository @@ -56,10 +50,9 @@ import com.duckduckgo.pir.impl.store.db.EmailConfirmationEventType.EMAIL_CONFIRM import com.duckduckgo.pir.impl.store.db.PirBrokerScanLog import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi -import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import kotlinx.coroutines.withContext import javax.inject.Inject +import javax.inject.Named interface PirRunStateHandler { suspend fun handleState(pirRunState: PirRunState) @@ -170,24 +163,8 @@ class RealPirRunStateHandler @Inject constructor( private val jobRecordUpdater: JobRecordUpdater, private val pirSchedulingRepository: PirSchedulingRepository, private val currentTimeProvider: CurrentTimeProvider, + @Named("pir") private val moshi: Moshi, ) : PirRunStateHandler { - private val moshi: Moshi by lazy { - Moshi - .Builder() - .add( - PolymorphicJsonAdapterFactory - .of(PirSuccessResponse::class.java, "actionType") - .withSubtype(NavigateResponse::class.java, "navigate") - .withSubtype(ExtractedResponse::class.java, "extract") - .withSubtype(GetCaptchaInfoResponse::class.java, "getCaptchaInfo") - .withSubtype(SolveCaptchaResponse::class.java, "solveCaptcha") - .withSubtype(ClickResponse::class.java, "click") - .withSubtype(ExpectationResponse::class.java, "expectation") - .withSubtype(FillFormResponse::class.java, "fillForm"), - ).add(KotlinJsonAdapterFactory()) - .build() - } - private val pirSuccessAdapter by lazy { moshi.adapter(PirSuccessResponse::class.java) } override suspend fun handleState(pirRunState: PirRunState) = diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt index 47e66631351f..8e6e5392e425 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt @@ -42,6 +42,7 @@ import com.duckduckgo.pir.impl.scripts.models.PirScriptRequestData.SolveCaptcha import com.duckduckgo.pir.impl.scripts.models.PirScriptRequestData.UserProfile import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ClickResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ConditionResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExpectationResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExtractedResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.FillFormResponse @@ -217,8 +218,7 @@ class PirModule { .withSubtype(BrokerAction.SolveCaptcha::class.java, "solveCaptcha") .withSubtype(BrokerAction.EmailConfirmation::class.java, "emailConfirmation") .withSubtype(BrokerAction.Condition::class.java, "condition"), - ) - .add( + ).add( PolymorphicJsonAdapterFactory.of(BrokerStep::class.java, "stepType") .withSubtype(ScanStep::class.java, "scan") .withSubtype(OptOutStep::class.java, "optOut"), @@ -230,7 +230,8 @@ class PirModule { .withSubtype(SolveCaptchaResponse::class.java, "solveCaptcha") .withSubtype(ClickResponse::class.java, "click") .withSubtype(ExpectationResponse::class.java, "expectation") - .withSubtype(FillFormResponse::class.java, "fillForm"), + .withSubtype(FillFormResponse::class.java, "fillForm") + .withSubtype(ConditionResponse::class.java, "condition"), ) .add(KotlinJsonAdapterFactory()) .build() diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt index 9fdb5ee0f57f..ec874150aabf 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt @@ -127,4 +127,9 @@ sealed class PirSuccessResponse( data class AdditionalData( val additionalData: String, ) + data class ConditionResponse( + override val actionID: String, + override val actionType: String, + val actions: List = emptyList(), + ) : PirSuccessResponse(actionID, actionType) } From 933a498e274019491766d7d1db728ef84e5103fe Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Mon, 27 Oct 2025 16:24:44 +0100 Subject: [PATCH 2/4] Implement handling for condition response --- ...nditionExpectationSucceededEventHandler.kt | 72 +++++++++++++++++++ .../actions/JsActionSuccessEventHandler.kt | 24 +++++++ .../actions/PirActionsRunnerStateEngine.kt | 4 ++ 3 files changed, 100 insertions(+) create mode 100644 pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandler.kt diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandler.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandler.kt new file mode 100644 index 000000000000..32d26c014841 --- /dev/null +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandler.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * 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 com.duckduckgo.pir.impl.common.actions + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.impl.common.BrokerStepsParser.BrokerStep +import com.duckduckgo.pir.impl.common.actions.EventHandler.Next +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ConditionExpectationSucceeded +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ExecuteBrokerStepAction +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.State +import com.duckduckgo.pir.impl.scripts.models.PirScriptRequestData.UserProfile +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlin.reflect.KClass + +@ContributesMultibinding( + scope = AppScope::class, + boundType = EventHandler::class, +) +class ConditionExpectationSucceededEventHandler @Inject constructor() : EventHandler { + override val event: KClass = ConditionExpectationSucceeded::class + + override suspend fun invoke( + state: State, + event: Event, + ): Next { + val actionsToAppend = (event as ConditionExpectationSucceeded).conditionActions + val currentBrokerStep = state.brokerStepsToExecute[state.currentBrokerStepIndex] + + val updatedBrokerSteps = state.brokerStepsToExecute.toMutableList() + val updatedBrokerActions = currentBrokerStep.actions.toMutableList().apply { + this.addAll( + state.currentActionIndex + 1, + actionsToAppend, + ) + } + val updatedBrokerStep = when (currentBrokerStep) { + is BrokerStep.ScanStep -> currentBrokerStep.copy(actions = updatedBrokerActions) + is BrokerStep.OptOutStep -> currentBrokerStep.copy(actions = updatedBrokerActions) + is BrokerStep.EmailConfirmationStep -> currentBrokerStep.copy(actions = updatedBrokerActions) + } + + updatedBrokerSteps[state.currentBrokerStepIndex] = updatedBrokerStep + + return Next( + nextState = state.copy( + currentActionIndex = state.currentActionIndex + 1, + brokerStepsToExecute = updatedBrokerSteps, + ), + nextEvent = ExecuteBrokerStepAction( + UserProfile( + userProfile = state.profileQuery, + ), + ), + ) + } +} diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt index a5679f2d25fc..09dc182eb666 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt @@ -25,6 +25,7 @@ import com.duckduckgo.pir.impl.common.PirRunStateHandler.PirRunState.BrokerOptOu import com.duckduckgo.pir.impl.common.PirRunStateHandler.PirRunState.BrokerScanActionSucceeded import com.duckduckgo.pir.impl.common.actions.EventHandler.Next import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ConditionExpectationSucceeded import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ExecuteBrokerStepAction import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.JsActionSuccess import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.EvaluateJs @@ -33,6 +34,7 @@ import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEf import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.State import com.duckduckgo.pir.impl.scripts.models.PirScriptRequestData.UserProfile import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ClickResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ConditionResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExpectationResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExtractedResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.FillFormResponse @@ -151,6 +153,28 @@ class JsActionSuccessEventHandler @Inject constructor( ), ) } + + is ConditionResponse -> { + if (pirSuccessResponse.actions.isNotEmpty()) { + Next( + nextState = baseSuccessState, + nextEvent = ConditionExpectationSucceeded( + pirSuccessResponse.actions, + ), + ) + } else { + Next( + nextState = baseSuccessState.copy( + currentActionIndex = baseSuccessState.currentActionIndex + 1, + ), + nextEvent = ExecuteBrokerStepAction( + UserProfile( + userProfile = baseSuccessState.profileQuery, + ), + ), + ) + } + } } } } diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/PirActionsRunnerStateEngine.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/PirActionsRunnerStateEngine.kt index aa413f9bd757..20c58983680d 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/PirActionsRunnerStateEngine.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/PirActionsRunnerStateEngine.kt @@ -122,6 +122,10 @@ interface PirActionsRunnerStateEngine { val actionId: String, val responseData: ResponseData?, ) : Event() + + data class ConditionExpectationSucceeded( + val conditionActions: List, + ) : Event() } /** From 4ebf62d311c4f0b548495d5183358b0e38480123 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Wed, 29 Oct 2025 11:13:44 +0100 Subject: [PATCH 3/4] Fix response object --- .../impl/common/actions/JsActionSuccessEventHandler.kt | 4 ++-- .../pir/impl/scripts/models/PirScriptResponseParams.kt | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt index 09dc182eb666..8cacc7160bbb 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt @@ -155,11 +155,11 @@ class JsActionSuccessEventHandler @Inject constructor( } is ConditionResponse -> { - if (pirSuccessResponse.actions.isNotEmpty()) { + if (pirSuccessResponse.response != null && pirSuccessResponse.response.actions.isNotEmpty()) { Next( nextState = baseSuccessState, nextEvent = ConditionExpectationSucceeded( - pirSuccessResponse.actions, + pirSuccessResponse.response.actions, ), ) } else { diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt index ec874150aabf..b9b2ec50c46a 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scripts/models/PirScriptResponseParams.kt @@ -127,9 +127,14 @@ sealed class PirSuccessResponse( data class AdditionalData( val additionalData: String, ) + data class ConditionResponse( override val actionID: String, override val actionType: String, - val actions: List = emptyList(), - ) : PirSuccessResponse(actionID, actionType) + val response: ResponseData = ResponseData(), + ) : PirSuccessResponse(actionID, actionType) { + data class ResponseData( + val actions: List = emptyList(), + ) + } } From 0c43362728b2c93a8ec9ecc58aeeb9c018a02f11 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Wed, 29 Oct 2025 16:36:03 +0100 Subject: [PATCH 4/4] Fix and add tests --- .../actions/JsActionSuccessEventHandler.kt | 2 +- .../impl/common/RealPirRunStateHandlerTest.kt | 3 + ...ionExpectationSucceededEventHandlerTest.kt | 402 ++++++++++++ .../JsActionSuccessEventHandlerTest.kt | 606 ++++++++++++++++++ 4 files changed, 1012 insertions(+), 1 deletion(-) create mode 100644 pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandlerTest.kt create mode 100644 pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandlerTest.kt diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt index 8cacc7160bbb..7c9a8606988b 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandler.kt @@ -155,7 +155,7 @@ class JsActionSuccessEventHandler @Inject constructor( } is ConditionResponse -> { - if (pirSuccessResponse.response != null && pirSuccessResponse.response.actions.isNotEmpty()) { + if (pirSuccessResponse.response.actions.isNotEmpty()) { Next( nextState = baseSuccessState, nextEvent = ConditionExpectationSucceeded( diff --git a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/RealPirRunStateHandlerTest.kt b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/RealPirRunStateHandlerTest.kt index e2539df769f9..6c6d653b4690 100644 --- a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/RealPirRunStateHandlerTest.kt +++ b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/RealPirRunStateHandlerTest.kt @@ -47,6 +47,7 @@ import com.duckduckgo.pir.impl.store.db.BrokerScanEventType.BROKER_SUCCESS import com.duckduckgo.pir.impl.store.db.EmailConfirmationEventType.EMAIL_CONFIRMATION_FAILED import com.duckduckgo.pir.impl.store.db.EmailConfirmationEventType.EMAIL_CONFIRMATION_SUCCESS import com.duckduckgo.pir.impl.store.db.PirBrokerScanLog +import com.squareup.moshi.Moshi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -71,6 +72,7 @@ class RealPirRunStateHandlerTest { private val mockJobRecordUpdater: JobRecordUpdater = mock() private val mockSchedulingRepository: PirSchedulingRepository = mock() private val mockCurrentTimeProvider: CurrentTimeProvider = mock() + private val moshi: Moshi = Moshi.Builder().build() @Before fun setUp() { @@ -83,6 +85,7 @@ class RealPirRunStateHandlerTest { jobRecordUpdater = mockJobRecordUpdater, pirSchedulingRepository = mockSchedulingRepository, currentTimeProvider = mockCurrentTimeProvider, + moshi = moshi, ) whenever(mockCurrentTimeProvider.currentTimeMillis()).thenReturn(testEventTimeInMillis) diff --git a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandlerTest.kt b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandlerTest.kt new file mode 100644 index 000000000000..f5a612052fdb --- /dev/null +++ b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/ConditionExpectationSucceededEventHandlerTest.kt @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * 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 com.duckduckgo.pir.impl.common.actions + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.pir.impl.common.BrokerStepsParser.BrokerStep.EmailConfirmationStep +import com.duckduckgo.pir.impl.common.BrokerStepsParser.BrokerStep.OptOutStep +import com.duckduckgo.pir.impl.common.BrokerStepsParser.BrokerStep.ScanStep +import com.duckduckgo.pir.impl.common.PirJob.RunType +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ConditionExpectationSucceeded +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ExecuteBrokerStepAction +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.State +import com.duckduckgo.pir.impl.models.ExtractedProfile +import com.duckduckgo.pir.impl.models.ProfileQuery +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.EmailData +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.JobAttemptData +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.LinkFetchData +import com.duckduckgo.pir.impl.scripts.models.BrokerAction +import com.duckduckgo.pir.impl.scripts.models.PirScriptRequestData.UserProfile +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ConditionExpectationSucceededEventHandlerTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + private lateinit var testee: ConditionExpectationSucceededEventHandler + + // Test data + private val testBrokerName = "test-broker" + private val testProfileQueryId = 123L + private val testCurrentActionIndex = 1 + + private val testProfileQuery = + ProfileQuery( + id = testProfileQueryId, + firstName = "John", + lastName = "Doe", + city = "New York", + state = "NY", + addresses = emptyList(), + birthYear = 1990, + fullName = "John Doe", + age = 33, + deprecated = false, + ) + + private val testExtractedProfile = + ExtractedProfile( + profileQueryId = testProfileQueryId, + brokerName = testBrokerName, + name = "John Doe", + ) + + private val testEmailConfirmationJob = + EmailConfirmationJobRecord( + brokerName = testBrokerName, + userProfileId = testProfileQueryId, + extractedProfileId = 456L, + emailData = EmailData( + email = "john@example.com", + attemptId = "test-attempt-id", + ), + linkFetchData = LinkFetchData( + emailConfirmationLink = "https://example.com/confirm", + linkFetchAttemptCount = 0, + lastLinkFetchDateInMillis = 0L, + ), + jobAttemptData = JobAttemptData( + jobAttemptCount = 0, + lastJobAttemptDateInMillis = 0L, + lastJobAttemptActionId = "", + ), + dateCreatedInMillis = 10000000L, + ) + + private val existingAction1 = + BrokerAction.Navigate( + id = "action-1", + url = "https://example.com", + ) + + private val existingAction2 = + BrokerAction.Click( + id = "action-2", + elements = emptyList(), + selector = null, + ) + + private val existingAction3 = + BrokerAction.Click( + id = "action-3", + elements = emptyList(), + selector = null, + ) + + private val conditionAction1 = + BrokerAction.FillForm( + id = "condition-action-1", + elements = emptyList(), + selector = "form-selector", + ) + + private val conditionAction2 = + BrokerAction.Click( + id = "condition-action-2", + elements = emptyList(), + selector = null, + ) + + @Before + fun setUp() { + testee = ConditionExpectationSucceededEventHandler() + } + + @Test + fun whenEventIsConditionExpectationSucceededThenEventTypeIsCorrect() { + assertEquals(ConditionExpectationSucceeded::class, testee.event) + } + + @Test + fun whenConditionActionsWithScanStepThenInsertsActionsAndReturnsNextEvent() = runTest { + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(existingAction1, existingAction2, existingAction3), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + ) + val conditionActions = listOf(conditionAction1, conditionAction2) + val event = ConditionExpectationSucceeded(conditionActions = conditionActions) + + val result = testee.invoke(state, event) + + // Verify actions are inserted at currentActionIndex + 1 (after action-2) + val updatedScanStep = result.nextState.brokerStepsToExecute[0] as ScanStep + val expectedActions = + listOf( + existingAction1, // action-1 + existingAction2, // action-2 (current action) + conditionAction1, // inserted action + conditionAction2, // inserted action + existingAction3, // action-3 + ) + assertEquals(expectedActions, updatedScanStep.actions) + + // Verify state is updated correctly + assertEquals(testCurrentActionIndex + 1, result.nextState.currentActionIndex) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + assertNull(result.sideEffect) + } + + @Test + fun whenConditionActionsWithOptOutStepThenInsertsActionsAndReturnsNextEvent() = runTest { + val optOutStep = + OptOutStep( + brokerName = testBrokerName, + stepType = "optout", + actions = listOf(existingAction1, existingAction2), + optOutType = "form", + profileToOptOut = testExtractedProfile, + ) + val state = + State( + runType = RunType.OPTOUT, + brokerStepsToExecute = listOf(optOutStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + ) + val conditionActions = listOf(conditionAction1) + val event = ConditionExpectationSucceeded(conditionActions = conditionActions) + + val result = testee.invoke(state, event) + + val updatedOptOutStep = result.nextState.brokerStepsToExecute[0] as OptOutStep + val expectedActions = + listOf( + existingAction1, + existingAction2, + conditionAction1, + ) + assertEquals(expectedActions, updatedOptOutStep.actions) + + assertEquals(testCurrentActionIndex + 1, result.nextState.currentActionIndex) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + } + + @Test + fun whenConditionActionsWithEmailConfirmationStepThenInsertsActionsAndReturnsNextEvent() = + runTest { + val emailConfirmationStep = + EmailConfirmationStep( + brokerName = testBrokerName, + stepType = "emailConfirmation", + actions = listOf(existingAction1, existingAction2), + emailConfirmationJob = testEmailConfirmationJob, + profileToOptOut = testExtractedProfile, + ) + val state = + State( + runType = RunType.EMAIL_CONFIRMATION, + brokerStepsToExecute = listOf(emailConfirmationStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + ) + val conditionActions = listOf(conditionAction1) + val event = ConditionExpectationSucceeded(conditionActions = conditionActions) + + val result = testee.invoke(state, event) + + val updatedEmailConfirmationStep = + result.nextState.brokerStepsToExecute[0] as EmailConfirmationStep + val expectedActions = + listOf( + existingAction1, + existingAction2, + conditionAction1, + ) + assertEquals(expectedActions, updatedEmailConfirmationStep.actions) + + assertEquals(testCurrentActionIndex + 1, result.nextState.currentActionIndex) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + } + + @Test + fun whenConditionActionsInsertedAtBeginningThenActionsAreInCorrectOrder() = runTest { + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(existingAction1, existingAction2), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = 0, // At the beginning + ) + val conditionActions = listOf(conditionAction1) + val event = ConditionExpectationSucceeded(conditionActions = conditionActions) + + val result = testee.invoke(state, event) + + val updatedScanStep = result.nextState.brokerStepsToExecute[0] as ScanStep + val expectedActions = + listOf( + existingAction1, + conditionAction1, // Inserted after index 0 + existingAction2, + ) + assertEquals(expectedActions, updatedScanStep.actions) + assertEquals(1, result.nextState.currentActionIndex) + } + + @Test + fun whenConditionActionsInsertedAtEndThenActionsAreAppended() = runTest { + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(existingAction1, existingAction2), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = 1, // At the last action + ) + val conditionActions = listOf(conditionAction1, conditionAction2) + val event = ConditionExpectationSucceeded(conditionActions = conditionActions) + + val result = testee.invoke(state, event) + + val updatedScanStep = result.nextState.brokerStepsToExecute[0] as ScanStep + val expectedActions = + listOf( + existingAction1, + existingAction2, + conditionAction1, // Inserted at end + conditionAction2, // Inserted at end + ) + assertEquals(expectedActions, updatedScanStep.actions) + assertEquals(2, result.nextState.currentActionIndex) + } + + @Test + fun whenMultipleBrokerStepsThenOnlyCurrentStepIsUpdated() = runTest { + val scanStep1 = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(existingAction1), + scanType = "initial", + ) + val scanStep2 = + ScanStep( + brokerName = "$testBrokerName-2", + stepType = "scan", + actions = listOf(existingAction2), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep1, scanStep2), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = 0, + ) + val conditionActions = listOf(conditionAction1) + val event = ConditionExpectationSucceeded(conditionActions = conditionActions) + + val result = testee.invoke(state, event) + + // Only the first step should be updated + val updatedScanStep1 = result.nextState.brokerStepsToExecute[0] as ScanStep + val unchangedScanStep2 = result.nextState.brokerStepsToExecute[1] as ScanStep + + assertEquals(2, updatedScanStep1.actions.size) + assertEquals(listOf(existingAction1, conditionAction1), updatedScanStep1.actions) + + assertEquals(1, unchangedScanStep2.actions.size) + assertEquals(listOf(existingAction2), unchangedScanStep2.actions) + } + + @Test + fun whenEmptyConditionActionsListThenOnlyActionIndexIsIncremented() = runTest { + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(existingAction1, existingAction2), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + ) + val event = ConditionExpectationSucceeded(conditionActions = emptyList()) + + val result = testee.invoke(state, event) + + // Actions should remain unchanged + val updatedScanStep = result.nextState.brokerStepsToExecute[0] as ScanStep + assertEquals(listOf(existingAction1, existingAction2), updatedScanStep.actions) + + // Action index should still increment + assertEquals(testCurrentActionIndex + 1, result.nextState.currentActionIndex) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + } +} diff --git a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandlerTest.kt b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandlerTest.kt new file mode 100644 index 000000000000..214f5ab93f47 --- /dev/null +++ b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/common/actions/JsActionSuccessEventHandlerTest.kt @@ -0,0 +1,606 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * 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 com.duckduckgo.pir.impl.common.actions + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.pir.impl.common.BrokerStepsParser.BrokerStep.EmailConfirmationStep +import com.duckduckgo.pir.impl.common.BrokerStepsParser.BrokerStep.OptOutStep +import com.duckduckgo.pir.impl.common.BrokerStepsParser.BrokerStep.ScanStep +import com.duckduckgo.pir.impl.common.PirJob.RunType +import com.duckduckgo.pir.impl.common.PirRunStateHandler +import com.duckduckgo.pir.impl.common.PirRunStateHandler.PirRunState.BrokerOptOutActionSucceeded +import com.duckduckgo.pir.impl.common.PirRunStateHandler.PirRunState.BrokerScanActionSucceeded +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ExecuteBrokerStepAction +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.JsActionSuccess +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.EvaluateJs +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.GetCaptchaSolution +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.LoadUrl +import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.State +import com.duckduckgo.pir.impl.models.ExtractedProfile +import com.duckduckgo.pir.impl.models.ProfileQuery +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.EmailData +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.JobAttemptData +import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.LinkFetchData +import com.duckduckgo.pir.impl.scripts.models.BrokerAction +import com.duckduckgo.pir.impl.scripts.models.PirScriptRequestData.UserProfile +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ClickResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ConditionResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExpectationResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.ExtractedResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.FillFormResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.GetCaptchaInfoResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.NavigateResponse +import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.SolveCaptchaResponse +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class JsActionSuccessEventHandlerTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + private lateinit var testee: JsActionSuccessEventHandler + + private val mockPirRunStateHandler: PirRunStateHandler = mock() + private val mockCurrentTimeProvider: CurrentTimeProvider = mock() + + // Test data + private val testBrokerName = "test-broker" + private val testProfileQueryId = 123L + private val testCurrentTimeInMillis = 2000L + private val testCurrentActionIndex = 1 + private val testActionRetryCount = 3 + + private val testProfileQuery = + ProfileQuery( + id = testProfileQueryId, + firstName = "John", + lastName = "Doe", + city = "New York", + state = "NY", + addresses = emptyList(), + birthYear = 1990, + fullName = "John Doe", + age = 33, + deprecated = false, + ) + + private val testExtractedProfile = + ExtractedProfile( + profileQueryId = testProfileQueryId, + brokerName = testBrokerName, + name = "John Doe", + ) + + private val testEmailConfirmationJob = + EmailConfirmationJobRecord( + brokerName = testBrokerName, + userProfileId = testProfileQueryId, + extractedProfileId = 456L, + emailData = EmailData( + email = "john@example.com", + attemptId = "test-attempt-id", + ), + linkFetchData = LinkFetchData( + emailConfirmationLink = "https://example.com/confirm", + linkFetchAttemptCount = 0, + lastLinkFetchDateInMillis = 0L, + ), + jobAttemptData = JobAttemptData( + jobAttemptCount = 0, + lastJobAttemptDateInMillis = 0L, + lastJobAttemptActionId = "", + ), + dateCreatedInMillis = 10000000L, + ) + + private val testAction = BrokerAction.Navigate( + id = "action-1", + url = "https://example.com", + ) + + @Before + fun setUp() { + testee = + JsActionSuccessEventHandler( + pirRunStateHandler = mockPirRunStateHandler, + currentTimeProvider = mockCurrentTimeProvider, + ) + + whenever(mockCurrentTimeProvider.currentTimeMillis()).thenReturn(testCurrentTimeInMillis) + } + + @Test + fun whenEventIsJsActionSuccessThenEventTypeIsCorrect() { + assertEquals(JsActionSuccess::class, testee.event) + } + + @Test + fun whenNavigateResponseWithScanStepThenReturnsLoadUrlSideEffect() = runTest { + val navigateResponse = + NavigateResponse( + actionID = "navigate-1", + actionType = "navigate", + response = NavigateResponse.ResponseData(url = "https://example.com/result"), + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = navigateResponse) + + val result = testee.invoke(state, event) + + val expectedState = + state.copy( + pendingUrl = "https://example.com/result", + actionRetryCount = 0, + ) + assertEquals(expectedState, result.nextState) + assertEquals(LoadUrl(url = "https://example.com/result"), result.sideEffect) + assertNull(result.nextEvent) + + val capturedState = argumentCaptor() + verify(mockPirRunStateHandler).handleState(capturedState.capture()) + assertEquals(testBrokerName, capturedState.firstValue.brokerName) + assertEquals(testProfileQueryId, capturedState.firstValue.profileQueryId) + assertEquals(navigateResponse, capturedState.firstValue.pirSuccessResponse) + } + + @Test + fun whenFillFormResponseWithScanStepThenReturnsNextActionEvent() = runTest { + val fillFormResponse = + FillFormResponse( + actionID = "fillform-1", + actionType = "fillForm", + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = fillFormResponse) + + val result = testee.invoke(state, event) + + val expectedState = + state.copy( + currentActionIndex = testCurrentActionIndex + 1, + actionRetryCount = 0, + ) + assertEquals(expectedState, result.nextState) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + assertNull(result.sideEffect) + + verify(mockPirRunStateHandler).handleState(any()) + } + + @Test + fun whenClickResponseWithScanStepThenReturnsNextActionEvent() = runTest { + val clickResponse = + ClickResponse( + actionID = "click-1", + actionType = "click", + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = clickResponse) + + val result = testee.invoke(state, event) + + val expectedState = + state.copy( + currentActionIndex = testCurrentActionIndex + 1, + actionRetryCount = 0, + ) + assertEquals(expectedState, result.nextState) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + assertNull(result.sideEffect) + } + + @Test + fun whenExpectationResponseWithScanStepThenReturnsNextActionEvent() = runTest { + val expectationResponse = + ExpectationResponse( + actionID = "expectation-1", + actionType = "expectation", + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = expectationResponse) + + val result = testee.invoke(state, event) + + val expectedState = + state.copy( + currentActionIndex = testCurrentActionIndex + 1, + actionRetryCount = 0, + ) + assertEquals(expectedState, result.nextState) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + assertNull(result.sideEffect) + } + + @Test + fun whenExtractedResponseWithScanStepThenReturnsNextActionEvent() = runTest { + val extractedResponse = + ExtractedResponse( + actionID = "extract-1", + actionType = "extract", + response = emptyList(), + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = extractedResponse) + + val result = testee.invoke(state, event) + + val expectedState = + state.copy( + currentActionIndex = testCurrentActionIndex + 1, + actionRetryCount = 0, + ) + assertEquals(expectedState, result.nextState) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + assertNull(result.sideEffect) + } + + @Test + fun whenGetCaptchaInfoResponseWithScanStepThenReturnsGetCaptchaSolutionSideEffect() = + runTest { + val captchaResponse = + GetCaptchaInfoResponse( + actionID = "captcha-info-1", + actionType = "getCaptchaInfo", + response = + GetCaptchaInfoResponse.ResponseData( + siteKey = "test-site-key", + url = "https://example.com", + type = "recaptcha", + ), + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = captchaResponse) + + val result = testee.invoke(state, event) + + val expectedState = state.copy(actionRetryCount = 0) + assertEquals(expectedState, result.nextState) + assertEquals( + GetCaptchaSolution( + actionId = "captcha-info-1", + responseData = captchaResponse.response, + isRetry = false, + ), + result.sideEffect, + ) + assertNull(result.nextEvent) + } + + @Test + fun whenSolveCaptchaResponseWithScanStepThenReturnsEvaluateJsSideEffectAndNextEvent() = + runTest { + val solveCaptchaResponse = + SolveCaptchaResponse( + actionID = "solve-captcha-1", + actionType = "solveCaptcha", + response = + SolveCaptchaResponse.ResponseData( + callback = SolveCaptchaResponse.CallbackData(eval = "callback-script"), + ), + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = solveCaptchaResponse) + + val result = testee.invoke(state, event) + + val expectedState = + state.copy( + currentActionIndex = testCurrentActionIndex + 1, + actionRetryCount = 0, + ) + assertEquals(expectedState, result.nextState) + assertEquals(EvaluateJs(callback = "callback-script"), result.sideEffect) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + } + + @Test + fun whenConditionResponseWithActionsThenReturnsConditionExpectationSucceededEvent() = + runTest { + val conditionActions = + listOf( + BrokerAction.Click( + id = "new-action-1", + elements = emptyList(), + selector = null, + ), + ) + val conditionResponse = + ConditionResponse( + actionID = "condition-1", + actionType = "condition", + response = ConditionResponse.ResponseData(actions = conditionActions), + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = conditionResponse) + + val result = testee.invoke(state, event) + + val expectedState = state.copy(actionRetryCount = 0) + assertEquals(expectedState, result.nextState) + assertEquals( + PirActionsRunnerStateEngine.Event.ConditionExpectationSucceeded(conditionActions), + result.nextEvent, + ) + assertNull(result.sideEffect) + } + + @Test + fun whenConditionResponseWithEmptyActionsThenReturnsNextActionEvent() = runTest { + val conditionResponse = + ConditionResponse( + actionID = "condition-1", + actionType = "condition", + response = ConditionResponse.ResponseData(actions = emptyList()), + ) + val scanStep = + ScanStep( + brokerName = testBrokerName, + stepType = "scan", + actions = listOf(testAction), + scanType = "initial", + ) + val state = + State( + runType = RunType.MANUAL, + brokerStepsToExecute = listOf(scanStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = conditionResponse) + + val result = testee.invoke(state, event) + + val expectedState = + state.copy( + currentActionIndex = testCurrentActionIndex + 1, + actionRetryCount = 0, + ) + assertEquals(expectedState, result.nextState) + assertEquals( + ExecuteBrokerStepAction(UserProfile(userProfile = testProfileQuery)), + result.nextEvent, + ) + } + + @Test + fun whenOptOutStepThenHandlesBrokerOptOutActionSucceeded() = runTest { + val navigateResponse = + NavigateResponse( + actionID = "navigate-1", + actionType = "navigate", + response = NavigateResponse.ResponseData(url = "https://example.com/result"), + ) + val optOutStep = + OptOutStep( + brokerName = testBrokerName, + stepType = "optout", + actions = listOf(testAction), + optOutType = "form", + profileToOptOut = testExtractedProfile, + ) + val state = + State( + runType = RunType.OPTOUT, + brokerStepsToExecute = listOf(optOutStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = navigateResponse) + + val result = testee.invoke(state, event) + + val capturedState = argumentCaptor() + verify(mockPirRunStateHandler).handleState(capturedState.capture()) + assertEquals(testBrokerName, capturedState.firstValue.brokerName) + assertEquals(testExtractedProfile, capturedState.firstValue.extractedProfile) + assertEquals(testCurrentTimeInMillis, capturedState.firstValue.completionTimeInMillis) + assertEquals("navigate", capturedState.firstValue.actionType) + assertEquals(navigateResponse, capturedState.firstValue.result) + + assertEquals(LoadUrl(url = "https://example.com/result"), result.sideEffect) + } + + @Test + fun whenEmailConfirmationStepThenHandlesBrokerOptOutActionSucceeded() = runTest { + val navigateResponse = + NavigateResponse( + actionID = "navigate-1", + actionType = "navigate", + response = NavigateResponse.ResponseData(url = "https://example.com/result"), + ) + val emailConfirmationStep = + EmailConfirmationStep( + brokerName = testBrokerName, + stepType = "emailConfirmation", + actions = listOf(testAction), + emailConfirmationJob = testEmailConfirmationJob, + profileToOptOut = testExtractedProfile, + ) + val state = + State( + runType = RunType.EMAIL_CONFIRMATION, + brokerStepsToExecute = listOf(emailConfirmationStep), + profileQuery = testProfileQuery, + currentBrokerStepIndex = 0, + currentActionIndex = testCurrentActionIndex, + actionRetryCount = testActionRetryCount, + ) + val event = JsActionSuccess(pirSuccessResponse = navigateResponse) + + val result = testee.invoke(state, event) + + val capturedState = argumentCaptor() + verify(mockPirRunStateHandler).handleState(capturedState.capture()) + assertEquals(testBrokerName, capturedState.firstValue.brokerName) + assertEquals(testExtractedProfile, capturedState.firstValue.extractedProfile) + assertEquals(testCurrentTimeInMillis, capturedState.firstValue.completionTimeInMillis) + assertEquals("navigate", capturedState.firstValue.actionType) + assertEquals(navigateResponse, capturedState.firstValue.result) + + assertEquals(LoadUrl(url = "https://example.com/result"), result.sideEffect) + } +}