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