1 package expo.modules.updates.loader
2 
3 import android.net.Uri
4 import androidx.room.Room
5 import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
6 import androidx.test.platform.app.InstrumentationRegistry
7 import expo.modules.manifests.core.LegacyManifest
8 import expo.modules.updates.UpdatesConfiguration
9 import expo.modules.updates.db.UpdatesDatabase
10 import expo.modules.updates.db.entity.AssetEntity
11 import expo.modules.updates.db.entity.UpdateEntity
12 import expo.modules.updates.db.enums.UpdateStatus
13 import expo.modules.updates.loader.FileDownloader.AssetDownloadCallback
14 import expo.modules.updates.loader.Loader.LoaderCallback
15 import expo.modules.updates.manifest.LegacyUpdateManifest
16 import expo.modules.updates.manifest.UpdateManifest
17 import io.mockk.every
18 import io.mockk.mockk
19 import io.mockk.verify
20 import org.json.JSONException
21 import org.json.JSONObject
22 import org.junit.Assert
23 import org.junit.Before
24 import org.junit.Test
25 import org.junit.runner.RunWith
26 import java.io.File
27 import java.io.IOException
28 import java.util.*
29 
30 @RunWith(AndroidJUnit4ClassRunner::class)
31 class RemoteLoaderTest {
32   private lateinit var db: UpdatesDatabase
33   private lateinit var configuration: UpdatesConfiguration
34   private lateinit var manifest: UpdateManifest
35   private lateinit var loader: RemoteLoader
36   private lateinit var mockLoaderFiles: LoaderFiles
37   private lateinit var mockFileDownloader: FileDownloader
38   private lateinit var mockCallback: LoaderCallback
39 
40   @Before
41   @Throws(JSONException::class)
setupnull42   fun setup() {
43     val configMap = mapOf<String, Any>(
44       "updateUrl" to Uri.parse("https://exp.host/@test/test"),
45       "runtimeVersion" to "1.0"
46     )
47     configuration = UpdatesConfiguration(null, configMap)
48     val context = InstrumentationRegistry.getInstrumentation().targetContext
49     db = Room.inMemoryDatabaseBuilder(context, UpdatesDatabase::class.java).build()
50     mockLoaderFiles = mockk(relaxed = true)
51     mockFileDownloader = mockk()
52     loader = RemoteLoader(
53       context,
54       configuration,
55       db,
56       mockFileDownloader,
57       File("testDirectory"),
58       null,
59       mockLoaderFiles
60     )
61     manifest = LegacyUpdateManifest.fromLegacyManifest(
62       LegacyManifest(JSONObject("{\"name\":\"updates-unit-test-template\",\"slug\":\"updates-unit-test-template\",\"sdkVersion\":\"42.0.0\",\"bundledAssets\":[\"asset_54da1e9816c77e30ebc5920e256736f2.png\"],\"currentFullName\":\"@esamelson/updates-unit-test-template\",\"originalFullName\":\"@esamelson/updates-unit-test-template\",\"id\":\"@esamelson/updates-unit-test-template\",\"scopeKey\":\"@esamelson/updates-unit-test-template\",\"releaseId\":\"2c246487-8879-43ad-a67b-2c22d8a5675e\",\"publishedTime\":\"2021-09-01T00:05:57.701Z\",\"commitTime\":\"2021-09-01T00:05:57.737Z\",\"bundleUrl\":\"https://classic-assets.eascdn.net/%40esamelson%2Fupdates-unit-test-template%2F1.0.0%2Fe5507cbb1760d32bb20d77cefc8cfff5-42.0.0-ios.js\",\"bundleKey\":\"e5507cbb1760d32bb20d77cefc8cfff5\",\"releaseChannel\":\"default\",\"hostUri\":\"exp.host/@esamelson/updates-unit-test-template\"}")),
63       configuration
64     )
65 
66     every { mockFileDownloader.downloadRemoteUpdate(any(), any(), any(), any()) } answers {
67       val callback = arg<FileDownloader.RemoteUpdateDownloadCallback>(3)
68       callback.onSuccess(
69         UpdateResponse(
70           responseHeaderData = null,
71           manifestUpdateResponsePart = UpdateResponsePart.ManifestUpdateResponsePart(manifest),
72           directiveUpdateResponsePart = null
73         )
74       )
75     }
76 
77     every { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) } answers {
78       val asset = firstArg<AssetEntity>()
79       val callback = arg<AssetDownloadCallback>(4)
80       callback.onSuccess(asset, true)
81     }
82 
83     mockCallback = mockk(relaxUnitFun = true)
84     every { mockCallback.onUpdateResponseLoaded(any()) } returns Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
85   }
86 
87   @Test
testRemoteLoader_SimpleCasenull88   fun testRemoteLoader_SimpleCase() {
89     loader.start(mockCallback)
90 
91     verify { mockCallback.onSuccess(any()) }
92     verify(exactly = 0) { mockCallback.onFailure(any()) }
93     verify(exactly = 2) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
94 
95     val updates = db.updateDao().loadAllUpdates()
96     Assert.assertEquals(1, updates.size)
97     Assert.assertEquals(UpdateStatus.READY, updates[0].status)
98     val assets = db.assetDao().loadAllAssets()
99     Assert.assertEquals(2, assets.size)
100   }
101 
102   @Test
testRemoteLoader_FailureToDownloadAssetsnull103   fun testRemoteLoader_FailureToDownloadAssets() {
104     every { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) } answers {
105       val asset = firstArg<AssetEntity>()
106       val callback = arg<AssetDownloadCallback>(4)
107       callback.onFailure(IOException("mock failed to download asset"), asset)
108     }
109 
110     loader.start(mockCallback)
111 
112     verify(exactly = 0) { mockCallback.onSuccess(any()) }
113     verify { mockCallback.onFailure(any()) }
114     verify(exactly = 2) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
115 
116     val updates = db.updateDao().loadAllUpdates()
117     Assert.assertEquals(1, updates.size)
118     Assert.assertEquals(UpdateStatus.PENDING, updates[0].status)
119     val assets = db.assetDao().loadAllAssets()
120     Assert.assertEquals(0, assets.size)
121   }
122 
123   @Test
testRemoteLoader_AssetExists_BothDbAndDisknull124   fun testRemoteLoader_AssetExists_BothDbAndDisk() {
125     // return true when asked if file 54da1e9816c77e30ebc5920e256736f2 exists on disk
126     every { mockLoaderFiles.fileExists(any()) } answers {
127       firstArg<File>().toString().contains("54da1e9816c77e30ebc5920e256736f2")
128     }
129 
130     val existingAsset = AssetEntity("54da1e9816c77e30ebc5920e256736f2", "png")
131     existingAsset.relativePath = "54da1e9816c77e30ebc5920e256736f2.png"
132     db.assetDao()._insertAsset(existingAsset)
133     loader.start(mockCallback)
134 
135     verify { mockCallback.onSuccess(any()) }
136     verify(exactly = 0) { mockCallback.onFailure(any()) }
137 
138     // only 1 asset (bundle) should be downloaded since the other asset already exists
139     verify(exactly = 1) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
140 
141     val updates = db.updateDao().loadAllUpdates()
142     Assert.assertEquals(1, updates.size)
143     Assert.assertEquals(UpdateStatus.READY, updates[0].status)
144     val assets = db.assetDao().loadAllAssets()
145     Assert.assertEquals(2, assets.size)
146 
147     // ensure the asset in the DB was updated with the URL from the manifest
148     assets.forEach { Assert.assertNotNull(it.url) }
149   }
150 
151   @Test
testRemoteLoader_AssetExists_DbOnlynull152   fun testRemoteLoader_AssetExists_DbOnly() {
153     // return false when asked if file 54da1e9816c77e30ebc5920e256736f2 exists on disk
154     every { mockLoaderFiles.fileExists(any()) } returns false
155 
156     val existingAsset = AssetEntity("54da1e9816c77e30ebc5920e256736f2", "png")
157     existingAsset.relativePath = "54da1e9816c77e30ebc5920e256736f2.png"
158     existingAsset.url = Uri.parse("http://example.com")
159     db.assetDao()._insertAsset(existingAsset)
160     loader.start(mockCallback)
161 
162     verify { mockCallback.onSuccess(any()) }
163     verify(exactly = 0) { mockCallback.onFailure(any()) }
164 
165     // both assets should be downloaded regardless of what the database says
166     verify(exactly = 2) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
167 
168     val updates = db.updateDao().loadAllUpdates()
169     Assert.assertEquals(1, updates.size)
170     Assert.assertEquals(UpdateStatus.READY, updates[0].status)
171     val assets = db.assetDao().loadAllAssets()
172     Assert.assertEquals(2, assets.size)
173 
174     // ensure the asset in the DB was updated with the URL from the manifest
175     assets.forEach {
176       Assert.assertNotNull(it.url)
177       Assert.assertEquals(it.url!!.host, "classic-assets.eascdn.net")
178     }
179   }
180 
181   @Test
testRemoteLoader_UpdateExists_Readynull182   fun testRemoteLoader_UpdateExists_Ready() {
183     val update = UpdateEntity(
184       manifest.updateEntity!!.id,
185       manifest.updateEntity!!.commitTime,
186       manifest.updateEntity!!.runtimeVersion,
187       manifest.updateEntity!!.scopeKey,
188       manifest.updateEntity!!.manifest
189     )
190     update.status = UpdateStatus.READY
191     db.updateDao().insertUpdate(update)
192     loader.start(mockCallback)
193 
194     verify { mockCallback.onSuccess(any()) }
195     verify(exactly = 0) { mockCallback.onFailure(any()) }
196     verify(exactly = 0) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
197 
198     val updates = db.updateDao().loadAllUpdates()
199     Assert.assertEquals(1, updates.size)
200     Assert.assertEquals(UpdateStatus.READY, updates[0].status)
201   }
202 
203   @Test
testRemoteLoader_UpdateExists_Pendingnull204   fun testRemoteLoader_UpdateExists_Pending() {
205     val update = UpdateEntity(
206       manifest.updateEntity!!.id,
207       manifest.updateEntity!!.commitTime,
208       manifest.updateEntity!!.runtimeVersion,
209       manifest.updateEntity!!.scopeKey,
210       manifest.updateEntity!!.manifest
211     )
212     update.status = UpdateStatus.PENDING
213     db.updateDao().insertUpdate(update)
214     loader.start(mockCallback)
215 
216     verify { mockCallback.onSuccess(any()) }
217     verify(exactly = 0) { mockCallback.onFailure(any()) }
218 
219     // missing assets should still be downloaded
220     verify(exactly = 2) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
221 
222     val updates = db.updateDao().loadAllUpdates()
223     Assert.assertEquals(1, updates.size)
224     Assert.assertEquals(UpdateStatus.READY, updates[0].status)
225     val assets = db.assetDao().loadAllAssets()
226     Assert.assertEquals(2, assets.size)
227   }
228 
229   @Test
testRemoteLoader_UpdateExists_DifferentScopeKeynull230   fun testRemoteLoader_UpdateExists_DifferentScopeKey() {
231     val update = UpdateEntity(
232       manifest.updateEntity!!.id,
233       manifest.updateEntity!!.commitTime,
234       manifest.updateEntity!!.runtimeVersion,
235       "differentScopeKey",
236       manifest.updateEntity!!.manifest
237     )
238     update.status = UpdateStatus.READY
239     db.updateDao().insertUpdate(update)
240     loader.start(mockCallback)
241 
242     verify { mockCallback.onSuccess(any()) }
243     verify(exactly = 0) { mockCallback.onFailure(any()) }
244     verify(exactly = 0) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
245 
246     val updates = db.updateDao().loadAllUpdates()
247     Assert.assertEquals(1, updates.size)
248     Assert.assertEquals(UpdateStatus.READY, updates[0].status)
249     Assert.assertEquals(manifest.updateEntity!!.scopeKey, updates[0].scopeKey)
250   }
251 
252   @Test
253   @Throws(JSONException::class)
testRemoteLoader_DevelopmentModeManifestnull254   fun testRemoteLoader_DevelopmentModeManifest() {
255     manifest = LegacyUpdateManifest.fromLegacyManifest(
256       LegacyManifest(JSONObject("{\"name\":\"updates-unit-test-template\",\"slug\":\"updates-unit-test-template\",\"sdkVersion\":\"42.0.0\",\"developer\":{\"tool\":\"expo-cli\",\"projectRoot\":\"/Users/eric/expo/updates-unit-test-template\"},\"packagerOpts\":{\"scheme\":null,\"hostType\":\"lan\",\"lanType\":\"ip\",\"dev\":true,\"minify\":false,\"urlRandomness\":null,\"https\":false},\"mainModuleName\":\"index\",\"debuggerHost\":\"127.0.0.1:8081\",\"hostUri\":\"127.0.0.1:8081\",\"bundleUrl\":\"http://127.0.0.1:8081/index.bundle?platform=ios&dev=true&hot=false&minify=false\"}")),
257       configuration
258     )
259 
260     every { mockFileDownloader.downloadRemoteUpdate(any(), any(), any(), any()) } answers {
261       val callback = arg<FileDownloader.RemoteUpdateDownloadCallback>(3)
262       callback.onSuccess(
263         UpdateResponse(
264           responseHeaderData = null,
265           manifestUpdateResponsePart = UpdateResponsePart.ManifestUpdateResponsePart(manifest),
266           directiveUpdateResponsePart = null
267         )
268       )
269     }
270 
271     loader.start(mockCallback)
272 
273     verify { mockCallback.onSuccess(any()) }
274     verify(exactly = 0) { mockCallback.onFailure(any()) }
275     verify(exactly = 0) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
276 
277     val updates = db.updateDao().loadAllUpdates()
278     Assert.assertEquals(1, updates.size)
279     Assert.assertEquals(UpdateStatus.DEVELOPMENT, updates[0].status)
280   }
281 
282   @Test
testRemoteLoader_RollBackDirectivenull283   fun testRemoteLoader_RollBackDirective() {
284     val updateDirective = UpdateDirective.RollBackToEmbeddedUpdateDirective(commitTime = Date(), signingInfo = null)
285     every { mockFileDownloader.downloadRemoteUpdate(any(), any(), any(), any()) } answers {
286       val callback = arg<FileDownloader.RemoteUpdateDownloadCallback>(3)
287       callback.onSuccess(
288         UpdateResponse(
289           responseHeaderData = null,
290           manifestUpdateResponsePart = null,
291           directiveUpdateResponsePart = UpdateResponsePart.DirectiveUpdateResponsePart(updateDirective)
292         )
293       )
294     }
295 
296     loader.start(mockCallback)
297 
298     verify { mockCallback.onSuccess(Loader.LoaderResult(null, updateDirective)) }
299     verify(exactly = 0) { mockCallback.onFailure(any()) }
300     verify(exactly = 0) { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any()) }
301 
302     val updates = db.updateDao().loadAllUpdates()
303     Assert.assertEquals(0, updates.size)
304   }
305 }
306