Skip to content

Commit bde007b

Browse files
authored
Switch Plan Wide Event implementation (#7176)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1211542241177650?focus=true ### Description Add wide event for switch flow ### Steps to test this PR _Pre steps_ - [x] Apply patch on Asana task https://app.asana.com/1/137249556945/project/1209991789468715/task/1210448620621729?focus=true _Test 1: Successful Upgrade (Monthly → Yearly)_ - [x] Install from branch - [x] Buy a monthly test subscription and wait for Free Trial to finish (3 minutes) - [x] Navigate to Subscription Settings - [x] Click in Switch to yearly option - [x] Verify switch confirmation dialog appears - [x] Click "Switch to Yearly" button in the dialog - [x] Complete the Play Billing flow - [x] Verify in logcat that all steps are recorded: - `validate_current_subscription.success = true` - `retrieve_target_plan.success = true` - `billing_flow_switch.success = true` - `confirm_switch.success = true` - `ui_refresh` - [x] Verify wide event pixel is sent with: - `context.name = "subscription_settings"` - `from_plan = "ddg.privacy.pro.monthly.renews.[us|row]"` - `to_plan = "ddg.privacy.pro.yearly.renews.[us|row]"` - `switch_type = "upgrade"` - `activation_latency_ms_bucketed` with a value - `feature.status = SUCCESS` _Test 2: Successful Downgrade (Yearly → Monthly)_ - [x] Navigate to Subscription Settings with a yearly subscription - [x] Click "Switch to Monthly" option - [x] Verify switch confirmation dialog appears - [x] Click "Switch to Monthly" button in the dialog - [x] Complete the Play Billing flow - [x] Verify in logcat that all steps are recorded - [x] Verify wide event pixel is sent with: - `from_plan = "ddg.privacy.pro.yearly.renews.[us|row]"` - `to_plan = "ddg.privacy.pro.monthly.renews.[us|row]"` - `switch_type = "downgrade"` - `feature.status = SUCCESS` _Test 3: User Cancels in Play Billing Flow_ - [x] Navigate to Subscription Settings - [x] Click switch plan option and confirm in the dialog - [x] Cancel/back out in the Google Play Billing screen - [x] Verify wide event pixel is sent with: - `feature.status = CANCELLED` _Test 4: Billing Flow Error_ - [x] Start switch flow - [x] Use a test card that always declines (if available in test environment) - [x] Complete the Play Billing flow with the failing card - [x] Verify wide event pixel is sent with: - `feature.status = FAILURE` - `feature.data.ext.error` contains billing error details - `billing_flow_switch.success = false` _Test 5: Switch from Dev Settings (Optional)_ - [x] Go to Settings > Subscriptions dev settings - [x] Use the "Switch Subscription" option - [x] Complete a switch - [x] Verify wide event pixel is sent with: - `context.name = "dev_settings"` - `feature.status = SUCCESS` _Test 6: Feature Flag Disabled_ - [x] Go to Feature Flag inventory - [x] Disable `sendSubscriptionSwitchWideEvent` - [x] Perform a complete switch flow (upgrade or downgrade) - [x] Verify that NO wide event pixels are sent during the switch flow _Test 7: Multiple Switch Attempts_ - [x] Perform multiple switch operations (e.g., upgrade then downgrade) - [x] Verify count pixel (`.c`) is sent on every switch attempt - [x] Verify daily pixel (`.d`) is sent at most once per day - [x] Verify `global.sample_rate = 1` in all events _Test 8: Wide Event Metadata Validation_ For any successful switch, verify all required fields are present: - [x] `context.name` (e.g., "subscription_settings", "dev_settings") - [x] `feature.name = "subscription-switch"` - [x] `feature.status` (SUCCESS/FAILURE/CANCELLED) - [x] `feature.data.ext.from_plan` - [x] `feature.data.ext.to_plan` - [x] `feature.data.ext.switch_type` (upgrade/downgrade) - [x] `feature.data.ext.activation_latency_ms_bucketed` (when billing successful) - [x] All recorded step success/failure indicators - [x] Android common wide event fields (app.name, app.version, app.form_factor, etc.) ### No UI changes
1 parent ced337a commit bde007b

File tree

9 files changed

+765
-28
lines changed

9 files changed

+765
-28
lines changed
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.subscriptions.impl.wideevents
18+
19+
import android.annotation.SuppressLint
20+
import com.duckduckgo.app.statistics.wideevents.CleanupPolicy
21+
import com.duckduckgo.app.statistics.wideevents.FlowStatus
22+
import com.duckduckgo.app.statistics.wideevents.WideEventClient
23+
import com.duckduckgo.common.test.CoroutineTestRule
24+
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
25+
import com.duckduckgo.feature.toggles.api.Toggle
26+
import com.duckduckgo.subscriptions.api.SubscriptionStatus
27+
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
28+
import kotlinx.coroutines.test.runTest
29+
import org.junit.Before
30+
import org.junit.Rule
31+
import org.junit.Test
32+
import org.mockito.kotlin.any
33+
import org.mockito.kotlin.eq
34+
import org.mockito.kotlin.never
35+
import org.mockito.kotlin.verify
36+
import org.mockito.kotlin.verifyNoMoreInteractions
37+
import org.mockito.kotlin.whenever
38+
39+
class SubscriptionSwitchWideEventTest {
40+
41+
@get:Rule
42+
val coroutineRule = CoroutineTestRule()
43+
44+
private val wideEventClient: WideEventClient = org.mockito.kotlin.mock()
45+
46+
@SuppressLint("DenyListedApi")
47+
private val privacyProFeature: PrivacyProFeature =
48+
FakeFeatureToggleFactory
49+
.create(PrivacyProFeature::class.java)
50+
.apply { sendSubscriptionSwitchWideEvent().setRawStoredState(Toggle.State(true)) }
51+
52+
private lateinit var subscriptionSwitchWideEvent: SubscriptionSwitchWideEventImpl
53+
54+
@Before
55+
fun setup() {
56+
subscriptionSwitchWideEvent = SubscriptionSwitchWideEventImpl(
57+
wideEventClient = wideEventClient,
58+
privacyProFeature = { privacyProFeature },
59+
dispatchers = coroutineRule.testDispatcherProvider,
60+
appCoroutineScope = coroutineRule.testScope,
61+
)
62+
}
63+
64+
@Test
65+
fun `onSwitchFlowStarted starts flow with correct metadata`() = runTest {
66+
whenever(wideEventClient.flowStart(any(), any(), any(), any())).thenReturn(Result.success(123L))
67+
68+
subscriptionSwitchWideEvent.onSwitchFlowStarted(
69+
context = "subscription_settings",
70+
fromPlan = "ddg-privacy-pro-monthly-renews-us",
71+
toPlan = "ddg-privacy-pro-yearly-renews-us",
72+
)
73+
74+
// fromPlan is monthly, so switchType should be computed as "upgrade"
75+
verify(wideEventClient).flowStart(
76+
name = "subscription-switch",
77+
flowEntryPoint = "subscription_settings",
78+
metadata = mapOf(
79+
"from_plan" to "ddg-privacy-pro-monthly-renews-us",
80+
"to_plan" to "ddg-privacy-pro-yearly-renews-us",
81+
"switch_type" to "upgrade",
82+
),
83+
cleanupPolicy = CleanupPolicy.OnProcessStart(ignoreIfIntervalTimeoutPresent = true),
84+
)
85+
}
86+
87+
@Test
88+
fun `onCurrentSubscriptionValidated sends successful step`() = runTest {
89+
whenever(wideEventClient.getFlowIds(any()))
90+
.thenReturn(Result.success(listOf(123L)))
91+
92+
subscriptionSwitchWideEvent.onCurrentSubscriptionValidated()
93+
94+
verify(wideEventClient).flowStep(
95+
wideEventId = 123L,
96+
stepName = "validate_current_subscription",
97+
success = true,
98+
)
99+
}
100+
101+
@Test
102+
fun `onValidationFailure sends failure step and finishes flow with error`() = runTest {
103+
whenever(wideEventClient.getFlowIds(any()))
104+
.thenReturn(Result.success(listOf(123L)))
105+
106+
subscriptionSwitchWideEvent.onValidationFailure("User not signed in")
107+
108+
verify(wideEventClient).flowStep(
109+
wideEventId = 123L,
110+
stepName = "validate_current_subscription",
111+
success = false,
112+
)
113+
verify(wideEventClient).flowFinish(
114+
wideEventId = 123L,
115+
status = FlowStatus.Failure("User not signed in"),
116+
metadata = emptyMap(),
117+
)
118+
}
119+
120+
@Test
121+
fun `onTargetPlanRetrieved sends successful step`() = runTest {
122+
whenever(wideEventClient.getFlowIds(any()))
123+
.thenReturn(Result.success(listOf(123L)))
124+
125+
subscriptionSwitchWideEvent.onTargetPlanRetrieved()
126+
127+
verify(wideEventClient).flowStep(
128+
wideEventId = 123L,
129+
stepName = "retrieve_target_plan",
130+
success = true,
131+
)
132+
}
133+
134+
@Test
135+
fun `onTargetPlanRetrievalFailure sends failure step and finishes flow`() = runTest {
136+
whenever(wideEventClient.getFlowIds(any()))
137+
.thenReturn(Result.success(listOf(123L)))
138+
139+
subscriptionSwitchWideEvent.onTargetPlanRetrievalFailure()
140+
141+
verify(wideEventClient).flowStep(
142+
wideEventId = 123L,
143+
stepName = "retrieve_target_plan",
144+
success = false,
145+
)
146+
verify(wideEventClient).flowFinish(
147+
wideEventId = 123L,
148+
status = FlowStatus.Failure("Target plan not found"),
149+
metadata = emptyMap(),
150+
)
151+
}
152+
153+
@Test
154+
fun `onBillingFlowInitSuccess sends successful step`() = runTest {
155+
whenever(wideEventClient.getFlowIds(any()))
156+
.thenReturn(Result.success(listOf(123L)))
157+
158+
subscriptionSwitchWideEvent.onBillingFlowInitSuccess()
159+
160+
verify(wideEventClient).flowStep(
161+
wideEventId = 123L,
162+
stepName = "billing_flow_init",
163+
success = true,
164+
)
165+
}
166+
167+
@Test
168+
fun `onBillingFlowInitFailure sends failure step and finishes flow`() = runTest {
169+
whenever(wideEventClient.getFlowIds(any()))
170+
.thenReturn(Result.success(listOf(123L)))
171+
172+
subscriptionSwitchWideEvent.onBillingFlowInitFailure("Missing product details")
173+
174+
verify(wideEventClient).flowStep(
175+
wideEventId = 123L,
176+
stepName = "billing_flow_init",
177+
success = false,
178+
)
179+
verify(wideEventClient).flowFinish(
180+
wideEventId = 123L,
181+
status = FlowStatus.Failure("Missing product details"),
182+
metadata = emptyMap(),
183+
)
184+
}
185+
186+
@Test
187+
fun `onUserCancelled finishes flow with cancelled status`() = runTest {
188+
whenever(wideEventClient.getFlowIds(any()))
189+
.thenReturn(Result.success(listOf(123L)))
190+
191+
subscriptionSwitchWideEvent.onUserCancelled()
192+
193+
verify(wideEventClient).flowFinish(
194+
wideEventId = 123L,
195+
status = FlowStatus.Cancelled,
196+
metadata = emptyMap(),
197+
)
198+
}
199+
200+
@Test
201+
fun `onPlayBillingSwitchSuccess starts interval and sends flowStep`() = runTest {
202+
whenever(wideEventClient.getFlowIds(any()))
203+
.thenReturn(Result.success(listOf(444L)))
204+
205+
subscriptionSwitchWideEvent.onPlayBillingSwitchSuccess()
206+
207+
verify(wideEventClient).intervalStart(
208+
wideEventId = eq(444L),
209+
key = eq("activation_latency_ms_bucketed"),
210+
timeout = any(),
211+
)
212+
verify(wideEventClient).flowStep(
213+
wideEventId = eq(444L),
214+
stepName = eq("billing_flow_switch"),
215+
success = eq(true),
216+
metadata = eq(emptyMap()),
217+
)
218+
}
219+
220+
@Test
221+
fun `onSwitchConfirmationSuccess ends interval, sends step, and finishes flow with success`() = runTest {
222+
whenever(wideEventClient.getFlowIds(any()))
223+
.thenReturn(Result.success(listOf(123L)))
224+
225+
subscriptionSwitchWideEvent.onSwitchConfirmationSuccess()
226+
227+
verify(wideEventClient).intervalEnd(
228+
wideEventId = 123L,
229+
key = "activation_latency_ms_bucketed",
230+
)
231+
verify(wideEventClient).flowStep(
232+
wideEventId = 123L,
233+
stepName = "confirm_switch",
234+
success = true,
235+
)
236+
verify(wideEventClient).flowFinish(
237+
wideEventId = 123L,
238+
status = FlowStatus.Success,
239+
metadata = emptyMap(),
240+
)
241+
}
242+
243+
@Test
244+
fun `onSubscriptionUpdated from WAITING to ACTIVE finishes flow with success`() = runTest {
245+
whenever(wideEventClient.getFlowIds(any()))
246+
.thenReturn(Result.success(listOf(123L)))
247+
248+
subscriptionSwitchWideEvent.onSubscriptionUpdated(
249+
oldStatus = SubscriptionStatus.WAITING,
250+
newStatus = SubscriptionStatus.AUTO_RENEWABLE,
251+
)
252+
253+
verify(wideEventClient).intervalEnd(
254+
wideEventId = 123L,
255+
key = "activation_latency_ms_bucketed",
256+
)
257+
verify(wideEventClient).flowFinish(
258+
wideEventId = 123L,
259+
status = FlowStatus.Success,
260+
metadata = emptyMap(),
261+
)
262+
}
263+
264+
@Test
265+
fun `onSubscriptionUpdated does not finish flow if not transitioning from WAITING to ACTIVE`() = runTest {
266+
whenever(wideEventClient.getFlowIds(any()))
267+
.thenReturn(Result.success(listOf(123L)))
268+
269+
// Test various status transitions that should NOT finish the flow
270+
subscriptionSwitchWideEvent.onSubscriptionUpdated(
271+
oldStatus = SubscriptionStatus.AUTO_RENEWABLE,
272+
newStatus = SubscriptionStatus.GRACE_PERIOD,
273+
)
274+
275+
subscriptionSwitchWideEvent.onSubscriptionUpdated(
276+
oldStatus = SubscriptionStatus.WAITING,
277+
newStatus = SubscriptionStatus.INACTIVE,
278+
)
279+
280+
subscriptionSwitchWideEvent.onSubscriptionUpdated(
281+
oldStatus = SubscriptionStatus.UNKNOWN,
282+
newStatus = SubscriptionStatus.AUTO_RENEWABLE,
283+
)
284+
285+
verify(wideEventClient, never()).intervalEnd(any(), any())
286+
verify(wideEventClient, never()).flowFinish(any(), any(), any())
287+
}
288+
289+
@Test
290+
fun `onUIRefreshed sends step without success parameter`() = runTest {
291+
whenever(wideEventClient.getFlowIds(any()))
292+
.thenReturn(Result.success(listOf(123L)))
293+
294+
subscriptionSwitchWideEvent.onUIRefreshed()
295+
296+
verify(wideEventClient).flowStep(
297+
wideEventId = 123L,
298+
stepName = "ui_refresh",
299+
)
300+
}
301+
302+
@Test
303+
fun `onSwitchFailed finishes flow with failure status and error`() = runTest {
304+
whenever(wideEventClient.getFlowIds(any()))
305+
.thenReturn(Result.success(listOf(123L)))
306+
307+
subscriptionSwitchWideEvent.onSwitchFailed("Unexpected error")
308+
309+
verify(wideEventClient).flowFinish(
310+
wideEventId = 123L,
311+
status = FlowStatus.Failure("Unexpected error"),
312+
metadata = emptyMap(),
313+
)
314+
}
315+
316+
@SuppressLint("DenyListedApi")
317+
@Test
318+
fun `when feature disabled then no events are sent`() = runTest {
319+
privacyProFeature.sendSubscriptionSwitchWideEvent().setRawStoredState(Toggle.State(false))
320+
321+
// Mock getFlowIds to return empty list when methods try to retrieve flow ID
322+
whenever(wideEventClient.getFlowIds(any()))
323+
.thenReturn(Result.success(emptyList()))
324+
325+
subscriptionSwitchWideEvent.onSwitchFlowStarted(
326+
context = "subscription_settings",
327+
fromPlan = "ddg-privacy-pro-monthly-renews-us",
328+
toPlan = "ddg-privacy-pro-yearly-renews-us",
329+
)
330+
subscriptionSwitchWideEvent.onCurrentSubscriptionValidated()
331+
subscriptionSwitchWideEvent.onTargetPlanRetrieved()
332+
subscriptionSwitchWideEvent.onBillingFlowInitSuccess()
333+
subscriptionSwitchWideEvent.onPlayBillingSwitchSuccess()
334+
subscriptionSwitchWideEvent.onSwitchConfirmationSuccess()
335+
336+
verify(wideEventClient, never()).flowStart(any(), any(), any(), any())
337+
verify(wideEventClient, never()).flowStep(any(), any(), any(), any())
338+
verify(wideEventClient, never()).flowFinish(any(), any(), any())
339+
}
340+
341+
@Test
342+
fun `onSwitchConfirmationSuccess clears cachedFlowId after finishing`() = runTest {
343+
whenever(wideEventClient.getFlowIds(any()))
344+
.thenReturn(Result.success(listOf(100L)))
345+
346+
subscriptionSwitchWideEvent.onSwitchConfirmationSuccess()
347+
348+
verify(wideEventClient).flowFinish(
349+
wideEventId = 100L,
350+
status = FlowStatus.Success,
351+
metadata = emptyMap(),
352+
)
353+
354+
// Reset and verify that cachedFlowId was cleared
355+
org.mockito.kotlin.reset(wideEventClient)
356+
whenever(wideEventClient.getFlowIds(any())).thenReturn(Result.success(emptyList()))
357+
358+
subscriptionSwitchWideEvent.onSwitchConfirmationSuccess()
359+
verify(wideEventClient).getFlowIds("subscription-switch")
360+
verifyNoMoreInteractions(wideEventClient)
361+
}
362+
}

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,9 @@ interface PrivacyProFeature {
255255
@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
256256
fun sendAuthTokenRefreshWideEvent(): Toggle
257257

258+
@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
259+
fun sendSubscriptionSwitchWideEvent(): Toggle
260+
258261
@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
259262
fun useSubscriptionSupport(): Toggle
260263

0 commit comments

Comments
 (0)