Skip to content

Commit 77ea280

Browse files
dcalhounclaude
andcommitted
test: cover GBKMediaUploadProcessor decision table
Adds JVM unit tests for the processor's decision table: optimized image output with corrected mime/extension (incl. HEIC to JPEG and PNG passthrough), no-op short-circuit, GPS stripping onto the optimized output and onto a copy when optimization is off, GIF passthrough, self-hosted orientation fix, plan-disallowed type and over-limit video rejections with localized messages, video passthrough when optimization is disabled, and same-path optimization results treated as unprocessed. Also adds a File-based isProhibitedVideoDuration overload to MediaUtilsWrapper so the processor avoids Uri.fromFile, keeping it testable on the JVM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ce33121 commit 77ea280

3 files changed

Lines changed: 255 additions & 2 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GBKMediaUploadProcessor.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package org.wordpress.android.ui.posts.editor
22

33
import android.content.Context
4-
import android.net.Uri
54
import android.webkit.MimeTypeMap
65
import kotlinx.coroutines.CoroutineDispatcher
76
import kotlinx.coroutines.Dispatchers
@@ -79,7 +78,7 @@ class GBKMediaUploadProcessor(
7978
}
8079

8180
private suspend fun processVideo(file: File, filename: String): ProcessedProxyFile {
82-
if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, Uri.fromFile(file))) {
81+
if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, file)) {
8382
throw GBKMediaUploadException(
8483
appContext.getString(R.string.error_media_video_duration_exceeds_limit)
8584
)

WordPress/src/main/java/org/wordpress/android/util/MediaUtilsWrapper.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import org.wordpress.android.editor.EditorMediaUtils
88
import org.wordpress.android.fluxc.model.SiteModel
99
import org.wordpress.android.fluxc.utils.MimeTypes.Plan
1010
import org.wordpress.android.util.AppLog.T
11+
import java.io.File
1112
import java.util.concurrent.TimeUnit
1213
import javax.inject.Inject
1314

@@ -63,6 +64,9 @@ class MediaUtilsWrapper @Inject constructor(private val appContext: Context) {
6364
fun isVideoFile(mediaUri: Uri): Boolean =
6465
isVideo(mediaUri) || isVideoMimeType(getMimeType(mediaUri))
6566

67+
fun isProhibitedVideoDuration(context: Context, site: SiteModel, file: File): Boolean =
68+
isProhibitedVideoDuration(context, site, Uri.fromFile(file))
69+
6670
fun isProhibitedVideoDuration(context: Context, site: SiteModel, uri: Uri): Boolean {
6771
if (isVideoFile(uri) && site.hasFreePlan && !site.isActiveModuleEnabled("videopress")) {
6872
val retriever = MediaMetadataRetriever()
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)