|
| 1 | +package org.wordpress.android.ui.posts.editor |
| 2 | + |
| 3 | +import android.content.Context |
| 4 | +import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 5 | +import org.assertj.core.api.Assertions.assertThat |
| 6 | +import org.junit.Before |
| 7 | +import org.junit.Rule |
| 8 | +import org.junit.Test |
| 9 | +import org.junit.rules.TemporaryFolder |
| 10 | +import org.junit.runner.RunWith |
| 11 | +import org.mockito.junit.MockitoJUnitRunner |
| 12 | +import org.mockito.kotlin.any |
| 13 | +import org.mockito.kotlin.anyOrNull |
| 14 | +import org.mockito.kotlin.doReturn |
| 15 | +import org.mockito.kotlin.mock |
| 16 | +import org.mockito.kotlin.never |
| 17 | +import org.mockito.kotlin.verify |
| 18 | +import org.mockito.kotlin.whenever |
| 19 | +import org.wordpress.android.BaseUnitTest |
| 20 | +import org.wordpress.android.R |
| 21 | +import org.wordpress.android.fluxc.model.SiteModel |
| 22 | +import org.wordpress.android.ui.prefs.AppPrefsWrapper |
| 23 | +import org.wordpress.android.util.MediaUtilsWrapper |
| 24 | +import org.wordpress.gutenberg.ProcessedProxyFile |
| 25 | +import java.io.File |
| 26 | + |
| 27 | +@ExperimentalCoroutinesApi |
| 28 | +@RunWith(MockitoJUnitRunner::class) |
| 29 | +class GBKMediaUploadProcessorTest : BaseUnitTest() { |
| 30 | + @get:Rule |
| 31 | + val tempFolder = TemporaryFolder() |
| 32 | + |
| 33 | + private lateinit var appContext: Context |
| 34 | + private lateinit var mediaUtilsWrapper: MediaUtilsWrapper |
| 35 | + private lateinit var appPrefsWrapper: AppPrefsWrapper |
| 36 | + private lateinit var stagedFile: File |
| 37 | + |
| 38 | + @Before |
| 39 | + fun setUp() { |
| 40 | + appContext = mock { |
| 41 | + on { getString(R.string.error_media_file_type_not_allowed) } doReturn FILE_TYPE_ERROR |
| 42 | + on { getString(R.string.error_media_video_duration_exceeds_limit) } doReturn VIDEO_LIMIT_ERROR |
| 43 | + } |
| 44 | + mediaUtilsWrapper = mock { |
| 45 | + on { isMimeTypeSupportedBySitePlan(anyOrNull(), any()) } doReturn true |
| 46 | + } |
| 47 | + appPrefsWrapper = mock() |
| 48 | + stagedFile = tempFolder.newFile("photo.jpg").apply { writeText("staged-bytes") } |
| 49 | + } |
| 50 | + |
| 51 | + private fun createProcessor(site: SiteModel = wpComSite()) = GBKMediaUploadProcessor( |
| 52 | + site = site, |
| 53 | + appContext = appContext, |
| 54 | + mediaUtilsWrapper = mediaUtilsWrapper, |
| 55 | + appPrefsWrapper = appPrefsWrapper, |
| 56 | + ioDispatcher = testDispatcher() |
| 57 | + ) |
| 58 | + |
| 59 | + private fun wpComSite() = SiteModel().apply { setIsWPCom(true) } |
| 60 | + |
| 61 | + private fun selfHostedSite() = SiteModel().apply { setIsWPCom(false) } |
| 62 | + |
| 63 | + @Test |
| 64 | + fun `image is optimized when optimization produces a new file`() = test { |
| 65 | + val optimized = tempFolder.newFile("optimized.jpg") |
| 66 | + val optimizedUri = fileUri(optimized) |
| 67 | + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)) |
| 68 | + .thenReturn(optimizedUri) |
| 69 | + |
| 70 | + val result = createProcessor().processFile(stagedFile, "image/jpeg", "photo.jpg") |
| 71 | + |
| 72 | + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) |
| 73 | + result as ProcessedProxyFile.Processed |
| 74 | + assertThat(result.file.absolutePath).isEqualTo(optimized.absolutePath) |
| 75 | + assertThat(result.mimeType).isEqualTo("image/jpeg") |
| 76 | + assertThat(result.filename).isEqualTo("photo.jpg") |
| 77 | + } |
| 78 | + |
| 79 | + @Test |
| 80 | + fun `image passes through when processing would be a no-op`() = test { |
| 81 | + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(null) |
| 82 | + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(false) |
| 83 | + |
| 84 | + val result = createProcessor(wpComSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") |
| 85 | + |
| 86 | + assertThat(result).isEqualTo(ProcessedProxyFile.Original) |
| 87 | + verify(mediaUtilsWrapper, never()).fixOrientationIssue(any(), any()) |
| 88 | + } |
| 89 | + |
| 90 | + @Test |
| 91 | + fun `gps is stripped onto a copy when strip enabled and optimization off`() = test { |
| 92 | + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(null) |
| 93 | + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) |
| 94 | + |
| 95 | + val result = createProcessor(wpComSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") |
| 96 | + |
| 97 | + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) |
| 98 | + result as ProcessedProxyFile.Processed |
| 99 | + // Stripping must run on a copy, never on the staged file — Original passthrough would |
| 100 | + // re-send the original request body and discard an in-place edit. |
| 101 | + assertThat(result.file.absolutePath).isNotEqualTo(stagedFile.absolutePath) |
| 102 | + assertThat(result.file.readText()).isEqualTo("staged-bytes") |
| 103 | + assertThat(result.mimeType).isEqualTo("image/jpeg") |
| 104 | + assertThat(result.filename).isEqualTo("photo.jpg") |
| 105 | + verify(mediaUtilsWrapper).stripImageLocation(result.file.absolutePath) |
| 106 | + result.file.delete() |
| 107 | + } |
| 108 | + |
| 109 | + @Test |
| 110 | + fun `gps is stripped from the optimized output when strip enabled`() = test { |
| 111 | + val optimized = tempFolder.newFile("optimized.jpg") |
| 112 | + val optimizedUri = fileUri(optimized) |
| 113 | + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)) |
| 114 | + .thenReturn(optimizedUri) |
| 115 | + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(true) |
| 116 | + |
| 117 | + createProcessor().processFile(stagedFile, "image/jpeg", "photo.jpg") |
| 118 | + |
| 119 | + verify(mediaUtilsWrapper).stripImageLocation(optimized.absolutePath) |
| 120 | + } |
| 121 | + |
| 122 | + @Test |
| 123 | + fun `heic reports jpeg mime type and extension after optimization`() = test { |
| 124 | + val heicStaged = tempFolder.newFile("photo.heic") |
| 125 | + val optimized = tempFolder.newFile("optimized.heic") |
| 126 | + val optimizedUri = fileUri(optimized) |
| 127 | + whenever(mediaUtilsWrapper.getOptimizedMedia(heicStaged.absolutePath, false)) |
| 128 | + .thenReturn(optimizedUri) |
| 129 | + |
| 130 | + val result = createProcessor().processFile(heicStaged, "image/heic", "photo.heic") |
| 131 | + |
| 132 | + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) |
| 133 | + result as ProcessedProxyFile.Processed |
| 134 | + assertThat(result.mimeType).isEqualTo("image/jpeg") |
| 135 | + assertThat(result.filename).isEqualTo("photo.jpg") |
| 136 | + } |
| 137 | + |
| 138 | + @Test |
| 139 | + fun `png keeps png mime type and extension after optimization`() = test { |
| 140 | + val pngStaged = tempFolder.newFile("art.png") |
| 141 | + val optimized = tempFolder.newFile("optimized.png") |
| 142 | + val optimizedUri = fileUri(optimized) |
| 143 | + whenever(mediaUtilsWrapper.getOptimizedMedia(pngStaged.absolutePath, false)) |
| 144 | + .thenReturn(optimizedUri) |
| 145 | + |
| 146 | + val result = createProcessor().processFile(pngStaged, "image/png", "art.png") |
| 147 | + |
| 148 | + result as ProcessedProxyFile.Processed |
| 149 | + assertThat(result.mimeType).isEqualTo("image/png") |
| 150 | + assertThat(result.filename).isEqualTo("art.png") |
| 151 | + } |
| 152 | + |
| 153 | + @Test |
| 154 | + fun `gif passes through untouched`() = test { |
| 155 | + val gifStaged = tempFolder.newFile("anim.gif") |
| 156 | + |
| 157 | + val result = createProcessor().processFile(gifStaged, "image/gif", "anim.gif") |
| 158 | + |
| 159 | + assertThat(result).isEqualTo(ProcessedProxyFile.Original) |
| 160 | + verify(mediaUtilsWrapper, never()).getOptimizedMedia(any(), any()) |
| 161 | + } |
| 162 | + |
| 163 | + @Test |
| 164 | + fun `disallowed file type throws with localized message`() = test { |
| 165 | + whenever(mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(anyOrNull(), any())).thenReturn(false) |
| 166 | + val zipStaged = tempFolder.newFile("archive.zip") |
| 167 | + |
| 168 | + val thrown = runCatching { |
| 169 | + createProcessor().processFile(zipStaged, "application/zip", "archive.zip") |
| 170 | + }.exceptionOrNull() |
| 171 | + |
| 172 | + assertThat(thrown) |
| 173 | + .isInstanceOf(GBKMediaUploadException::class.java) |
| 174 | + .hasMessage(FILE_TYPE_ERROR) |
| 175 | + } |
| 176 | + |
| 177 | + @Test |
| 178 | + fun `video exceeding duration limit throws with localized message`() = test { |
| 179 | + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) |
| 180 | + whenever(mediaUtilsWrapper.isProhibitedVideoDuration(any(), any(), any<File>())).thenReturn(true) |
| 181 | + val videoStaged = tempFolder.newFile("movie.mp4") |
| 182 | + |
| 183 | + val thrown = runCatching { |
| 184 | + createProcessor().processFile(videoStaged, "video/mp4", "movie.mp4") |
| 185 | + }.exceptionOrNull() |
| 186 | + |
| 187 | + assertThat(thrown) |
| 188 | + .isInstanceOf(GBKMediaUploadException::class.java) |
| 189 | + .hasMessage(VIDEO_LIMIT_ERROR) |
| 190 | + } |
| 191 | + |
| 192 | + @Test |
| 193 | + fun `video passes through when optimization disabled`() = test { |
| 194 | + whenever(mediaUtilsWrapper.isVideoMimeType("video/mp4")).thenReturn(true) |
| 195 | + whenever(mediaUtilsWrapper.isProhibitedVideoDuration(any(), any(), any<File>())).thenReturn(false) |
| 196 | + whenever(appPrefsWrapper.isVideoOptimize).thenReturn(false) |
| 197 | + val videoStaged = tempFolder.newFile("movie.mp4") |
| 198 | + |
| 199 | + val result = createProcessor().processFile(videoStaged, "video/mp4", "movie.mp4") |
| 200 | + |
| 201 | + assertThat(result).isEqualTo(ProcessedProxyFile.Original) |
| 202 | + } |
| 203 | + |
| 204 | + @Test |
| 205 | + fun `optimization returning the input path is treated as not optimized`() = test { |
| 206 | + // ImageUtils.optimizeImage returns the original path for skips/failures; wrapping it in |
| 207 | + // Processed would mislabel the original file with a corrected mime type. |
| 208 | + val inputPathUri = fileUri(stagedFile) |
| 209 | + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)) |
| 210 | + .thenReturn(inputPathUri) |
| 211 | + whenever(appPrefsWrapper.isStripImageLocation).thenReturn(false) |
| 212 | + |
| 213 | + val result = createProcessor(wpComSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") |
| 214 | + |
| 215 | + assertThat(result).isEqualTo(ProcessedProxyFile.Original) |
| 216 | + } |
| 217 | + |
| 218 | + @Test |
| 219 | + fun `self-hosted image is rotated when optimization is off`() = test { |
| 220 | + whenever(mediaUtilsWrapper.getOptimizedMedia(stagedFile.absolutePath, false)).thenReturn(null) |
| 221 | + val rotated = tempFolder.newFile("rotated.jpg") |
| 222 | + val rotatedUri = fileUri(rotated) |
| 223 | + whenever(mediaUtilsWrapper.fixOrientationIssue(stagedFile.absolutePath, false)) |
| 224 | + .thenReturn(rotatedUri) |
| 225 | + |
| 226 | + val result = createProcessor(selfHostedSite()).processFile(stagedFile, "image/jpeg", "photo.jpg") |
| 227 | + |
| 228 | + assertThat(result).isInstanceOf(ProcessedProxyFile.Processed::class.java) |
| 229 | + result as ProcessedProxyFile.Processed |
| 230 | + assertThat(result.file.absolutePath).isEqualTo(rotated.absolutePath) |
| 231 | + } |
| 232 | + |
| 233 | + @Test |
| 234 | + fun `non-media file allowed by the site plan passes through`() = test { |
| 235 | + val docStaged = tempFolder.newFile("doc.pdf") |
| 236 | + |
| 237 | + val result = createProcessor().processFile(docStaged, "application/pdf", "doc.pdf") |
| 238 | + |
| 239 | + assertThat(result).isEqualTo(ProcessedProxyFile.Original) |
| 240 | + } |
| 241 | + |
| 242 | + private fun fileUri(file: File): android.net.Uri = mock { |
| 243 | + on { path } doReturn file.absolutePath |
| 244 | + } |
| 245 | + |
| 246 | + companion object { |
| 247 | + private const val FILE_TYPE_ERROR = "This file type is not allowed" |
| 248 | + private const val VIDEO_LIMIT_ERROR = "Uploading videos longer than 5 minutes requires a paid plan." |
| 249 | + } |
| 250 | +} |
0 commit comments