ShareControllerTest.kt 15.7 KB
Newer Older
1
2
3
4
5
6
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.fenix.share

7
import android.app.Activity
8
import android.content.ActivityNotFoundException
9
import android.content.Context
10
11
import android.content.Intent
import androidx.navigation.NavController
12
import com.google.android.material.snackbar.Snackbar
13
14
15
16
17
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
18
import io.mockk.spyk
19
20
import io.mockk.verify
import io.mockk.verifyOrder
21
22
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
23
import kotlinx.coroutines.test.TestCoroutineDispatcher
24
import kotlinx.coroutines.test.TestCoroutineScope
25
import mozilla.components.concept.engine.prompt.ShareData
26
27
import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.DeviceType
28
import mozilla.components.concept.sync.TabData
29
import mozilla.components.feature.accounts.push.SendTabUseCases
30
import mozilla.components.feature.share.RecentAppsStorage
31
import mozilla.components.support.test.robolectric.testContext
32
import mozilla.components.support.test.rule.MainCoroutineRule
33
import org.junit.After
34
35
36
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
37
import org.junit.Before
38
import org.junit.Rule
39
40
41
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
42
import org.mozilla.fenix.components.FenixSnackbar
43
44
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
45
import org.mozilla.fenix.ext.metrics
46
import org.mozilla.fenix.ext.nav
47
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
48
import org.mozilla.fenix.share.listadapters.AppShareOption
49

50
@RunWith(FenixRobolectricTestRunner::class)
51
@ExperimentalCoroutinesApi
52
class ShareControllerTest {
53
54
55
    // Need a valid context to retrieve Strings for example, but we also need it to return our "metrics"
    private val context: Context = spyk(testContext)
    private val metrics: MetricController = mockk(relaxed = true)
56
    private val shareSubject = "shareSubject"
57
58
59
    private val shareData = listOf(
        ShareData(url = "url0", title = "title0"),
        ShareData(url = "url1", title = "title1")
60
    )
61

62
63
64
65
66
    // Navigation between app fragments uses ShareTab as arguments. SendTabUseCases uses TabData.
    private val tabsData = listOf(
        TabData("title0", "url0"),
        TabData("title1", "url1")
    )
67
    private val textToShare = "${shareData[0].url}\n\n${shareData[1].url}"
68
    private val testDispatcher = TestCoroutineDispatcher()
69
    private val testCoroutineScope = TestCoroutineScope()
70
71
72
73
74
75
76
    private val sendTabUseCases = mockk<SendTabUseCases>(relaxed = true)
    private val snackbar = mockk<FenixSnackbar>(relaxed = true)
    private val navController = mockk<NavController>(relaxed = true)
    private val dismiss = mockk<(ShareController.Result) -> Unit>(relaxed = true)
    private val recentAppStorage = mockk<RecentAppsStorage>(relaxed = true)
    private val controller = DefaultShareController(
        context, shareSubject, shareData, sendTabUseCases, snackbar, navController,
77
        recentAppStorage, testCoroutineScope, testDispatcher, dismiss
78
    )
79

80
81
82
    @get:Rule
    val coroutinesTestRule = MainCoroutineRule(testDispatcher)

83
84
    @Before
    fun setUp() {
85
        every { context.metrics } returns metrics
86
    }
87

88
89
90
91
92
    @After
    fun cleanUp() {
        testCoroutineScope.cleanupTestCoroutines()
    }

93
94
95
96
    @Test
    fun `handleShareClosed should call a passed in delegate to close this`() {
        controller.handleShareClosed()

97
        verify { dismiss(ShareController.Result.DISMISSED) }
98
99
100
    }

    @Test
101
    fun `handleShareToApp should start a new sharing activity and close this`() = runBlocking {
102
103
104
        val appPackageName = "package"
        val appClassName = "activity"
        val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
105
        val shareIntent = slot<Intent>()
106
107
108
        // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
        // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
        // need to use an Activity Context.
109
        val activityContext: Context = mockk<Activity>()
110
111
        val testController = DefaultShareController(
            activityContext, shareSubject, shareData, mockk(),
112
            mockk(), mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss
113
        )
114
        every { activityContext.startActivity(capture(shareIntent)) } just Runs
115
        every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
116

117
        testController.handleShareToApp(appShareOption)
118
        testDispatcher.advanceUntilIdle()
119

120
        // Check that the Intent used for querying apps has the expected structure
121
122
        assertTrue(shareIntent.isCaptured)
        assertEquals(Intent.ACTION_SEND, shareIntent.captured.action)
123
        assertEquals(shareSubject, shareIntent.captured.extras!![Intent.EXTRA_SUBJECT])
124
125
        assertEquals(textToShare, shareIntent.captured.extras!![Intent.EXTRA_TEXT])
        assertEquals("text/plain", shareIntent.captured.type)
126
        assertEquals(Intent.FLAG_ACTIVITY_NEW_DOCUMENT + Intent.FLAG_ACTIVITY_MULTIPLE_TASK, shareIntent.captured.flags)
127
128
        assertEquals(appPackageName, shareIntent.captured.component!!.packageName)
        assertEquals(appClassName, shareIntent.captured.component!!.className)
129

130
        verify { recentAppStorage.updateRecentApp(appShareOption.activityName) }
131
        verifyOrder {
132
            activityContext.startActivity(shareIntent.captured)
133
134
135
136
137
138
            dismiss(ShareController.Result.SUCCESS)
        }
    }

    @Test
    fun `handleShareToApp should dismiss with an error start when a security exception occurs`() {
139
140
141
        val appPackageName = "package"
        val appClassName = "activity"
        val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
142
143
144
145
        val shareIntent = slot<Intent>()
        // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
        // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
        // need to use an Activity Context.
146
        val activityContext: Context = mockk<Activity>()
147
148
        val testController = DefaultShareController(
            activityContext, shareSubject, shareData, mockk(),
149
            snackbar, mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss
150
151
        )
        every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
152
153
        every { activityContext.startActivity(capture(shareIntent)) } throws SecurityException()
        every { activityContext.getString(R.string.share_error_snackbar) } returns "Cannot share to this app"
154
155

        testController.handleShareToApp(appShareOption)
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174

        verifyOrder {
            activityContext.startActivity(shareIntent.captured)
            snackbar.setText("Cannot share to this app")
            snackbar.show()
            dismiss(ShareController.Result.SHARE_ERROR)
        }
    }

    @Test
    fun `handleShareToApp should dismiss with an error start when a ActivityNotFoundException occurs`() {
        val appPackageName = "package"
        val appClassName = "activity"
        val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
        val shareIntent = slot<Intent>()
        // Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
        // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
        // need to use an Activity Context.
        val activityContext: Context = mockk<Activity>()
175
176
        val testController = DefaultShareController(
            activityContext, shareSubject, shareData, mockk(),
177
            snackbar, mockk(), recentAppStorage, testCoroutineScope, testDispatcher, dismiss
178
179
        )
        every { recentAppStorage.updateRecentApp(appShareOption.activityName) } just Runs
180
181
182
183
        every { activityContext.startActivity(capture(shareIntent)) } throws ActivityNotFoundException()
        every { activityContext.getString(R.string.share_error_snackbar) } returns "Cannot share to this app"

        testController.handleShareToApp(appShareOption)
184
185
186

        verifyOrder {
            activityContext.startActivity(shareIntent.captured)
187
188
            snackbar.setText("Cannot share to this app")
            snackbar.show()
189
            dismiss(ShareController.Result.SHARE_ERROR)
190
191
192
193
194
195
        }
    }

    @Test
    @Suppress("DeferredResultUnused")
    fun `handleShareToDevice should share to account device, inform callbacks and dismiss`() {
196
        val deviceToShareTo = Device(
197
198
            "deviceId", "deviceName", DeviceType.UNKNOWN, false, 0L, emptyList(), false, null
        )
199
        val deviceId = slot<String>()
200
        val tabsShared = slot<List<TabData>>()
201
202
203
204

        controller.handleShareToDevice(deviceToShareTo)

        // Verify all the needed methods are called.
205
        verifyOrder {
206
            metrics.track(Event.SendTab)
207
            sendTabUseCases.sendToDeviceAsync(capture(deviceId), capture(tabsShared))
208
            // dismiss() is also to be called, but at the moment cannot test it in a coroutine.
209
        }
210
211
212
213
214

        assertTrue(deviceId.isCaptured)
        assertEquals(deviceToShareTo.id, deviceId.captured)
        assertTrue(tabsShared.isCaptured)
        assertEquals(tabsData, tabsShared.captured)
215
216
217
    }

    @Test
218
    @Suppress("DeferredResultUnused")
219
220
    fun `handleShareToAllDevices calls handleShareToDevice multiple times`() {
        val devicesToShareTo = listOf(
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
            Device(
                "deviceId0",
                "deviceName0",
                DeviceType.UNKNOWN,
                false,
                0L,
                emptyList(),
                false,
                null
            ),
            Device(
                "deviceId1",
                "deviceName1",
                DeviceType.UNKNOWN,
                true,
                1L,
                emptyList(),
                false,
                null
            )
241
        )
242
        val tabsShared = slot<List<TabData>>()
243
244
245

        controller.handleShareToAllDevices(devicesToShareTo)

246
247
        verifyOrder {
            sendTabUseCases.sendToAllAsync(capture(tabsShared))
248
            // dismiss() is also to be called, but at the moment cannot test it in a coroutine.
249
        }
250
251
252
253

        // SendTabUseCases should send a the `shareTabs` mapped to tabData
        assertTrue(tabsShared.isCaptured)
        assertEquals(tabsData, tabsShared.captured)
254
255
256
257
258
259
260
    }

    @Test
    fun `handleSignIn should navigate to the Sync Fragment and dismiss this one`() {
        controller.handleSignIn()

        verifyOrder {
261
            metrics.track(Event.SignInToSendTab)
262
263
            navController.nav(
                R.id.shareFragment,
Jeff Boek's avatar
Jeff Boek committed
264
                ShareFragmentDirections.actionGlobalTurnOnSync()
265
            )
266
            dismiss(ShareController.Result.DISMISSED)
267
268
269
        }
    }

270
271
272
273
274
275
276
    @Test
    fun `handleReauth should navigate to the Account Problem Fragment and dismiss this one`() {
        controller.handleReauth()

        verifyOrder {
            navController.nav(
                R.id.shareFragment,
Jeff Boek's avatar
Jeff Boek committed
277
                ShareFragmentDirections.actionGlobalAccountProblemFragment()
278
            )
279
            dismiss(ShareController.Result.DISMISSED)
280
281
282
        }
    }

283
284
285
286
287
288
289
    @Test
    fun `showSuccess should show a snackbar with a success message`() {
        val expectedMessage = controller.getSuccessMessage()
        val expectedTimeout = Snackbar.LENGTH_SHORT

        controller.showSuccess()

290
291
292
        verify {
            snackbar.setText(expectedMessage)
            snackbar.setLength(expectedTimeout)
293
294
295
296
297
298
299
300
301
302
303
        }
    }

    @Test
    fun `showFailureWithRetryOption should show a snackbar with a retry action`() {
        val expectedMessage = context.getString(R.string.sync_sent_tab_error_snackbar)
        val expectedTimeout = Snackbar.LENGTH_LONG
        val operation: () -> Unit = { println("Hello World") }
        val expectedRetryMessage =
            context.getString(R.string.sync_sent_tab_error_snackbar_action)

304
        controller.showFailureWithRetryOption(operation)
305
306

        verify {
307
308
309
310
311
312
            snackbar.apply {
                setText(expectedMessage)
                setLength(expectedTimeout)
                setAction(expectedRetryMessage, operation)
                setAppropriateBackground(true)
            }
313
314
315
316
317
        }
    }

    @Test
    fun `getSuccessMessage should return different strings depending on the number of shared tabs`() {
318
319
320
321
322
323
324
325
326
        val controllerWithOneSharedTab = DefaultShareController(
            context,
            shareSubject,
            listOf(ShareData(url = "url0", title = "title0")),
            mockk(),
            mockk(),
            mockk(),
            mockk(),
            mockk(),
327
            mockk(),
328
            mockk()
329
        )
330
        val controllerWithMoreSharedTabs = controller
331
332
333
334
335
336
        val expectedTabSharedMessage = context.getString(R.string.sync_sent_tab_snackbar)
        val expectedTabsSharedMessage = context.getString(R.string.sync_sent_tabs_snackbar)

        val tabSharedMessage = controllerWithOneSharedTab.getSuccessMessage()
        val tabsSharedMessage = controllerWithMoreSharedTabs.getSuccessMessage()

337
338
339
        assertNotEquals(tabsSharedMessage, tabSharedMessage)
        assertEquals(expectedTabSharedMessage, tabSharedMessage)
        assertEquals(expectedTabsSharedMessage, tabsSharedMessage)
340
341
    }

342
    @Test
343
    fun `getShareText should respect concatenate shared tabs urls`() {
344
        assertEquals(textToShare, controller.getShareText())
345
    }
346

347
348
349
350
351
352
353
    @Test
    fun `getShareText attempts to use original URL for reader pages`() {
        val shareData = listOf(
            ShareData(url = "moz-extension://eb8df45a-895b-4f3a-896a-c0c71ae4/page.html"),
            ShareData(url = "moz-extension://eb8df45a-895b-4f3a-896a-c0c71ae5/page.html?url=url0"),
            ShareData(url = "url1")
        )
354
355
        val controller = DefaultShareController(
            context, shareSubject, shareData, sendTabUseCases, snackbar, navController,
356
            recentAppStorage, testCoroutineScope, testDispatcher, dismiss
357
        )
358
359
360
361
362

        val expectedShareText = "${shareData[0].url}\n\nurl0\n\n${shareData[2].url}"
        assertEquals(expectedShareText, controller.getShareText())
    }

363
364
    @Test
    fun `getShareSubject will return "shareSubject" if that is non null`() {
365
        assertEquals(shareSubject, controller.getShareSubject())
366
367
368
369
    }

    @Test
    fun `getShareSubject will return a concatenation of tab titles if "shareSubject" is null`() {
370
371
        val controller = DefaultShareController(
            context, null, shareData, sendTabUseCases, snackbar, navController,
372
            recentAppStorage, testCoroutineScope, testDispatcher, dismiss
373
        )
374
375
376
377

        assertEquals("title0, title1", controller.getShareSubject())
    }

378
    @Test
379
    fun `ShareTab#toTabData maps a list of ShareTab to a TabData list`() {
380
381
382
383
        var tabData: List<TabData>

        with(controller) {
            tabData = shareData.toTabData()
384
385
        }

386
        assertEquals(tabsData, tabData)
387
    }
388
389
390

    @Test
    fun `ShareTab#toTabData creates a data url from text if no url is specified`() {
391
        var tabData: List<TabData>
392
393
394
395
396
        val expected = listOf(
            TabData(title = "title0", url = ""),
            TabData(title = "title1", url = "data:,Hello%2C%20World!")
        )

397
398
        with(controller) {
            tabData = listOf(
399
400
401
402
403
                ShareData(title = "title0"),
                ShareData(title = "title1", text = "Hello, World!")
            ).toTabData()
        }

404
        assertEquals(expected, tabData)
405
    }
406
}