test(media): add 49 unit tests for media upload, encryption, and server services
Coverage for DesktopMediaMetadata, DesktopUploadTracker, DesktopMediaCompressor, DesktopBlossomClient (MockK), DesktopUploadOrchestrator (MockK + mockkObject), ServerHealthCheck, and AESGCM encrypt/decrypt round-trip. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for the AESGCM encryption used by EncryptedMediaService.
|
||||
* These test the crypto primitives without requiring network access.
|
||||
*/
|
||||
class EncryptedMediaServiceTest {
|
||||
@Test
|
||||
fun aesgcmEncryptDecryptRoundTrip() {
|
||||
val plaintext = "Hello, encrypted media!".toByteArray()
|
||||
val cipher = AESGCM()
|
||||
|
||||
val encrypted = cipher.encrypt(plaintext)
|
||||
val decrypted = cipher.decrypt(encrypted)
|
||||
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aesgcmEncryptedDataDiffersFromPlaintext() {
|
||||
val plaintext = "Secret data".toByteArray()
|
||||
val cipher = AESGCM()
|
||||
|
||||
val encrypted = cipher.encrypt(plaintext)
|
||||
|
||||
assertFalse(plaintext.contentEquals(encrypted))
|
||||
assertTrue(encrypted.size > plaintext.size) // Includes auth tag
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aesgcmDecryptWithExplicitKeyAndNonce() {
|
||||
val cipher1 = AESGCM()
|
||||
val plaintext = "Roundtrip test data".toByteArray()
|
||||
|
||||
val encrypted = cipher1.encrypt(plaintext)
|
||||
|
||||
// Reconstruct cipher with same key and nonce
|
||||
val cipher2 = AESGCM(cipher1.keyBytes, cipher1.nonce)
|
||||
val decrypted = cipher2.decrypt(encrypted)
|
||||
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aesgcmKeyAndNonceAreGenerated() {
|
||||
val cipher = AESGCM()
|
||||
|
||||
assertNotNull(cipher.keyBytes)
|
||||
assertNotNull(cipher.nonce)
|
||||
assertTrue(cipher.keyBytes.size == 32) // AES-256
|
||||
assertTrue(cipher.nonce.size == 16)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aesgcmDifferentCiphersProduceDifferentOutput() {
|
||||
val plaintext = "Same message".toByteArray()
|
||||
val cipher1 = AESGCM()
|
||||
val cipher2 = AESGCM()
|
||||
|
||||
val encrypted1 = cipher1.encrypt(plaintext)
|
||||
val encrypted2 = cipher2.encrypt(plaintext)
|
||||
|
||||
// Different keys should produce different ciphertext
|
||||
assertFalse(encrypted1.contentEquals(encrypted2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aesgcmHandlesEmptyData() {
|
||||
val cipher = AESGCM()
|
||||
val plaintext = byteArrayOf()
|
||||
|
||||
val encrypted = cipher.encrypt(plaintext)
|
||||
val decrypted = cipher.decrypt(encrypted)
|
||||
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aesgcmHandlesLargeData() {
|
||||
val cipher = AESGCM()
|
||||
// Simulate a small "file" - 64KB
|
||||
val plaintext = ByteArray(65536) { (it % 256).toByte() }
|
||||
|
||||
val encrypted = cipher.encrypt(plaintext)
|
||||
val decrypted = cipher.decrypt(encrypted)
|
||||
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ServerHealthCheckTest {
|
||||
@Test
|
||||
fun checkOfflineForInvalidUrl() =
|
||||
runTest {
|
||||
// Localhost on a random high port should be unreachable
|
||||
val status = ServerHealthCheck.check("http://127.0.0.1:19999")
|
||||
assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOfflineForMalformedUrl() =
|
||||
runTest {
|
||||
val status = ServerHealthCheck.check("not-a-url")
|
||||
assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOfflineForNonexistentHost() =
|
||||
runTest {
|
||||
val status = ServerHealthCheck.check("https://this-host-definitely-does-not-exist-12345.example.com")
|
||||
assertEquals(ServerHealthCheck.ServerStatus.OFFLINE, status)
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Call
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopBlossomClientTest {
|
||||
private fun mockOkHttp(
|
||||
responseCode: Int,
|
||||
body: String = "",
|
||||
headers: Headers = Headers.headersOf(),
|
||||
): OkHttpClient {
|
||||
val requestSlot = slot<Request>()
|
||||
val mockCall = mockk<Call>()
|
||||
val mockClient = mockk<OkHttpClient>()
|
||||
|
||||
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
|
||||
every { mockCall.execute() } returns
|
||||
Response
|
||||
.Builder()
|
||||
.request(Request.Builder().url("https://example.com").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(responseCode)
|
||||
.message(if (responseCode == 200) "OK" else "Error")
|
||||
.headers(headers)
|
||||
.body(body.toResponseBody())
|
||||
.build()
|
||||
|
||||
return mockClient
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadSuccessReturnsResult() =
|
||||
runTest {
|
||||
val json =
|
||||
"""{"url":"https://blossom.example.com/abc123.png","sha256":"abc123","size":1024}"""
|
||||
val client = DesktopBlossomClient(mockOkHttp(200, json))
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1, 2, 3))
|
||||
|
||||
try {
|
||||
val result =
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = "Nostr abc",
|
||||
)
|
||||
|
||||
assertEquals("https://blossom.example.com/abc123.png", result.url)
|
||||
assertEquals("abc123", result.sha256)
|
||||
assertEquals(1024L, result.size)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadFailureThrowsException() =
|
||||
runTest {
|
||||
val headers = Headers.headersOf("X-Reason", "File too large")
|
||||
val client = DesktopBlossomClient(mockOkHttp(413, "", headers))
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1, 2, 3))
|
||||
|
||||
try {
|
||||
val ex =
|
||||
assertFailsWith<RuntimeException> {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = null,
|
||||
)
|
||||
}
|
||||
assertTrue(ex.message!!.contains("File too large"))
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadFailureUsesStatusCodeWhenNoXReason() =
|
||||
runTest {
|
||||
val client = DesktopBlossomClient(mockOkHttp(500))
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1))
|
||||
|
||||
try {
|
||||
val ex =
|
||||
assertFailsWith<RuntimeException> {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = null,
|
||||
)
|
||||
}
|
||||
assertTrue(ex.message!!.contains("500"))
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSuccessReturnsTrue() =
|
||||
runTest {
|
||||
val client = DesktopBlossomClient(mockOkHttp(200))
|
||||
|
||||
val result =
|
||||
client.delete(
|
||||
hash = "abc123",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = "Nostr xyz",
|
||||
)
|
||||
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFailureReturnsFalse() =
|
||||
runTest {
|
||||
val client = DesktopBlossomClient(mockOkHttp(404))
|
||||
|
||||
val result =
|
||||
client.delete(
|
||||
hash = "abc123",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = null,
|
||||
)
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headUploadSuccessReturnsTrue() =
|
||||
runTest {
|
||||
val client = DesktopBlossomClient(mockOkHttp(200))
|
||||
|
||||
val result =
|
||||
client.headUpload(
|
||||
contentType = "image/png",
|
||||
contentLength = 1024,
|
||||
sha256 = "abc123",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = null,
|
||||
)
|
||||
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headUploadFailureReturnsFalse() =
|
||||
runTest {
|
||||
val client = DesktopBlossomClient(mockOkHttp(403))
|
||||
|
||||
val result =
|
||||
client.headUpload(
|
||||
contentType = "image/png",
|
||||
contentLength = 1024,
|
||||
sha256 = "abc123",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = null,
|
||||
)
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadSendsAuthorizationHeader() =
|
||||
runTest {
|
||||
val requestSlot = slot<Request>()
|
||||
val mockCall = mockk<Call>()
|
||||
val mockClient = mockk<OkHttpClient>()
|
||||
|
||||
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
|
||||
every { mockCall.execute() } returns
|
||||
Response
|
||||
.Builder()
|
||||
.request(Request.Builder().url("https://example.com").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
|
||||
.build()
|
||||
|
||||
val client = DesktopBlossomClient(mockClient)
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1))
|
||||
|
||||
try {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
authHeader = "Nostr base64token",
|
||||
)
|
||||
|
||||
val sentRequest = requestSlot.captured
|
||||
assertEquals("Nostr base64token", sentRequest.header("Authorization"))
|
||||
assertEquals("https://blossom.example.com/upload", sentRequest.url.toString())
|
||||
assertEquals("PUT", sentRequest.method)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadUrlStripsTrailingSlash() =
|
||||
runTest {
|
||||
val requestSlot = slot<Request>()
|
||||
val mockCall = mockk<Call>()
|
||||
val mockClient = mockk<OkHttpClient>()
|
||||
|
||||
every { mockClient.newCall(capture(requestSlot)) } returns mockCall
|
||||
every { mockCall.execute() } returns
|
||||
Response
|
||||
.Builder()
|
||||
.request(Request.Builder().url("https://example.com").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
|
||||
.build()
|
||||
|
||||
val client = DesktopBlossomClient(mockClient)
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1))
|
||||
|
||||
try {
|
||||
client.upload(
|
||||
file = file,
|
||||
contentType = "image/png",
|
||||
serverBaseUrl = "https://blossom.example.com/",
|
||||
authHeader = null,
|
||||
)
|
||||
|
||||
// Should not have double slash
|
||||
assertEquals("https://blossom.example.com/upload", requestSlot.captured.url.toString())
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopMediaCompressorTest {
|
||||
@Test
|
||||
fun stripExifReturnsSameFileForPng() {
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB)
|
||||
ImageIO.write(img, "png", file)
|
||||
|
||||
val result = DesktopMediaCompressor.stripExif(file)
|
||||
|
||||
// Should return the same file object since it's not JPEG
|
||||
assertEquals(file, result)
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripExifReturnsSameFileForTextFile() {
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("not a jpeg")
|
||||
|
||||
val result = DesktopMediaCompressor.stripExif(file)
|
||||
|
||||
assertEquals(file, result)
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripExifReturnsSameFileForMp4() {
|
||||
val file = File.createTempFile("test_", ".mp4")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(0, 0, 0))
|
||||
|
||||
val result = DesktopMediaCompressor.stripExif(file)
|
||||
|
||||
assertEquals(file, result)
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripExifHandlesJpegWithoutExif() {
|
||||
// Create a minimal JPEG without EXIF
|
||||
val file = createMinimalJpeg()
|
||||
try {
|
||||
val result = DesktopMediaCompressor.stripExif(file)
|
||||
// Should return the same file since there's no EXIF to strip
|
||||
assertEquals(file, result)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripExifProcessesJpegFile() {
|
||||
// Create a JPEG (may or may not have metadata depending on ImageIO)
|
||||
val file = File.createTempFile("test_", ".jpg")
|
||||
file.deleteOnExit()
|
||||
val img = BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB)
|
||||
ImageIO.write(img, "jpg", file)
|
||||
|
||||
val result = DesktopMediaCompressor.stripExif(file)
|
||||
|
||||
// Result should be a valid file regardless
|
||||
assertTrue(result.exists())
|
||||
assertTrue(result.length() > 0)
|
||||
|
||||
// Clean up temp file if different from original
|
||||
if (result != file) {
|
||||
result.delete()
|
||||
}
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripExifHandlesUppercaseJpeg() {
|
||||
val file = File.createTempFile("test_", ".JPEG")
|
||||
file.deleteOnExit()
|
||||
val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB)
|
||||
ImageIO.write(img, "jpg", file)
|
||||
|
||||
val result = DesktopMediaCompressor.stripExif(file)
|
||||
|
||||
assertTrue(result.exists())
|
||||
if (result != file) result.delete()
|
||||
file.delete()
|
||||
}
|
||||
|
||||
private fun createMinimalJpeg(): File {
|
||||
val file = File.createTempFile("minimal_", ".jpg")
|
||||
file.deleteOnExit()
|
||||
val img = BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB)
|
||||
ImageIO.write(img, "jpg", file)
|
||||
return file
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopMediaMetadataTest {
|
||||
// --- guessMimeType ---
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForJpeg() {
|
||||
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpg")))
|
||||
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpeg")))
|
||||
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.JPEG")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForPng() {
|
||||
assertEquals("image/png", DesktopMediaMetadata.guessMimeType(File("image.png")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForGif() {
|
||||
assertEquals("image/gif", DesktopMediaMetadata.guessMimeType(File("anim.gif")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForWebp() {
|
||||
assertEquals("image/webp", DesktopMediaMetadata.guessMimeType(File("image.webp")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForSvg() {
|
||||
assertEquals("image/svg+xml", DesktopMediaMetadata.guessMimeType(File("icon.svg")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForAvif() {
|
||||
assertEquals("image/avif", DesktopMediaMetadata.guessMimeType(File("photo.avif")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForVideoFormats() {
|
||||
assertEquals("video/mp4", DesktopMediaMetadata.guessMimeType(File("clip.mp4")))
|
||||
assertEquals("video/webm", DesktopMediaMetadata.guessMimeType(File("clip.webm")))
|
||||
assertEquals("video/quicktime", DesktopMediaMetadata.guessMimeType(File("clip.mov")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForAudioFormats() {
|
||||
assertEquals("audio/mpeg", DesktopMediaMetadata.guessMimeType(File("song.mp3")))
|
||||
assertEquals("audio/ogg", DesktopMediaMetadata.guessMimeType(File("track.ogg")))
|
||||
assertEquals("audio/wav", DesktopMediaMetadata.guessMimeType(File("sound.wav")))
|
||||
assertEquals("audio/flac", DesktopMediaMetadata.guessMimeType(File("lossless.flac")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guessMimeTypeForUnknownExtension() {
|
||||
assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("data.xyz")))
|
||||
assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("noext")))
|
||||
}
|
||||
|
||||
// --- compute ---
|
||||
|
||||
@Test
|
||||
fun computeForPngImage() {
|
||||
val file = createTempPng(width = 10, height = 5)
|
||||
try {
|
||||
val meta = DesktopMediaMetadata.compute(file)
|
||||
|
||||
assertEquals("image/png", meta.mimeType)
|
||||
assertTrue(meta.size > 0)
|
||||
assertTrue(meta.sha256.length == 64) // hex-encoded SHA-256
|
||||
assertEquals(10, meta.width)
|
||||
assertEquals(5, meta.height)
|
||||
assertNotNull(meta.blurhash)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeForTextFile() {
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("hello world")
|
||||
try {
|
||||
val meta = DesktopMediaMetadata.compute(file)
|
||||
|
||||
assertEquals("application/octet-stream", meta.mimeType)
|
||||
assertEquals(11L, meta.size)
|
||||
assertTrue(meta.sha256.isNotEmpty())
|
||||
assertNull(meta.width)
|
||||
assertNull(meta.height)
|
||||
assertNull(meta.blurhash)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeProducesConsistentHash() {
|
||||
val file = File.createTempFile("hash_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(1, 2, 3, 4, 5))
|
||||
try {
|
||||
val meta1 = DesktopMediaMetadata.compute(file)
|
||||
val meta2 = DesktopMediaMetadata.compute(file)
|
||||
assertEquals(meta1.sha256, meta2.sha256)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeForMp4GivesNoDimensions() {
|
||||
val file = File.createTempFile("video_", ".mp4")
|
||||
file.deleteOnExit()
|
||||
file.writeBytes(byteArrayOf(0, 0, 0))
|
||||
try {
|
||||
val meta = DesktopMediaMetadata.compute(file)
|
||||
assertEquals("video/mp4", meta.mimeType)
|
||||
assertNull(meta.width)
|
||||
assertNull(meta.height)
|
||||
assertNull(meta.blurhash)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTempPng(
|
||||
width: Int,
|
||||
height: Int,
|
||||
): File {
|
||||
val img = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||
// Draw something so blurhash has data
|
||||
val g = img.createGraphics()
|
||||
g.color = java.awt.Color.BLUE
|
||||
g.fillRect(0, 0, width, height)
|
||||
g.dispose()
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
ImageIO.write(img, "png", file)
|
||||
return file
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopUploadOrchestratorTest {
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
mockkObject(DesktopBlossomAuth)
|
||||
coEvery {
|
||||
DesktopBlossomAuth.createUploadAuth(any(), any(), any(), any())
|
||||
} returns "Nostr fakeAuthToken"
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
unmockkObject(DesktopBlossomAuth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadCallsClientWithCorrectParameters() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
val fileSlot = slot<File>()
|
||||
val contentTypeSlot = slot<String>()
|
||||
val urlSlot = slot<String>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(
|
||||
file = capture(fileSlot),
|
||||
contentType = capture(contentTypeSlot),
|
||||
serverBaseUrl = capture(urlSlot),
|
||||
authHeader = any(),
|
||||
)
|
||||
} returns
|
||||
BlossomUploadResult(
|
||||
url = "https://blossom.example.com/abc123.png",
|
||||
sha256 = "abc123",
|
||||
size = 100,
|
||||
)
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".png")
|
||||
file.deleteOnExit()
|
||||
val img = java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB)
|
||||
javax.imageio.ImageIO.write(img, "png", file)
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = "test upload",
|
||||
serverBaseUrl = "https://blossom.example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
mockClient.upload(
|
||||
file = any(),
|
||||
contentType = any(),
|
||||
serverBaseUrl = any(),
|
||||
authHeader = any(),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("https://blossom.example.com", urlSlot.captured)
|
||||
assertEquals("image/png", contentTypeSlot.captured)
|
||||
assertNotNull(result.metadata)
|
||||
assertEquals("image/png", result.metadata.mimeType)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadPassesSameFileWhenNoStripExif() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
val fileSlot = slot<File>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(
|
||||
file = capture(fileSlot),
|
||||
contentType = any(),
|
||||
serverBaseUrl = any(),
|
||||
authHeader = any(),
|
||||
)
|
||||
} returns BlossomUploadResult(url = "https://example.com/hash")
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("content")
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = "https://example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
assertEquals(file.absolutePath, fileSlot.captured.absolutePath)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadComputesMetadata() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(any(), any(), any(), any())
|
||||
} returns BlossomUploadResult(url = "https://example.com/hash")
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("hello world")
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = "https://example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
assertEquals(11L, result.metadata.size)
|
||||
assertTrue(result.metadata.sha256.length == 64)
|
||||
assertEquals("application/octet-stream", result.metadata.mimeType)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadPassesAuthHeaderToClient() =
|
||||
runTest {
|
||||
val mockClient = mockk<DesktopBlossomClient>()
|
||||
val authSlot = slot<String?>()
|
||||
|
||||
coEvery {
|
||||
mockClient.upload(
|
||||
file = any(),
|
||||
contentType = any(),
|
||||
serverBaseUrl = any(),
|
||||
authHeader = captureNullable(authSlot),
|
||||
)
|
||||
} returns BlossomUploadResult(url = "https://example.com/hash")
|
||||
|
||||
val orchestrator = DesktopUploadOrchestrator(mockClient)
|
||||
|
||||
val file = File.createTempFile("test_", ".txt")
|
||||
file.deleteOnExit()
|
||||
file.writeText("data")
|
||||
|
||||
val mockSigner = mockk<com.vitorpamplona.quartz.nip01Core.signers.NostrSigner>(relaxed = true)
|
||||
|
||||
try {
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = "https://example.com",
|
||||
signer = mockSigner,
|
||||
stripExif = false,
|
||||
)
|
||||
|
||||
assertEquals("Nostr fakeAuthToken", authSlot.captured)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.upload
|
||||
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopUploadTrackerTest {
|
||||
@Test
|
||||
fun initialStateIsIdle() {
|
||||
val tracker = DesktopUploadTracker()
|
||||
val state = tracker.state.value
|
||||
|
||||
assertFalse(state.isUploading)
|
||||
assertNull(state.fileName)
|
||||
assertNull(state.error)
|
||||
assertNull(state.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startUploadSetsUploadingState() {
|
||||
val tracker = DesktopUploadTracker()
|
||||
|
||||
tracker.startUpload("photo.jpg")
|
||||
|
||||
val state = tracker.state.value
|
||||
assertTrue(state.isUploading)
|
||||
assertEquals("photo.jpg", state.fileName)
|
||||
assertNull(state.error)
|
||||
assertNull(state.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onSuccessStoresResult() {
|
||||
val tracker = DesktopUploadTracker()
|
||||
val metadata = MediaMetadata(sha256 = "abc123", size = 1024, mimeType = "image/png")
|
||||
val blossom = BlossomUploadResult(url = "https://blossom.example.com/abc123.png")
|
||||
val result = UploadResult(blossom = blossom, metadata = metadata)
|
||||
|
||||
tracker.startUpload("test.png")
|
||||
tracker.onSuccess(result)
|
||||
|
||||
val state = tracker.state.value
|
||||
assertFalse(state.isUploading)
|
||||
assertNotNull(state.result)
|
||||
assertEquals("https://blossom.example.com/abc123.png", state.result!!.blossom.url)
|
||||
assertNull(state.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onErrorStoresErrorMessage() {
|
||||
val tracker = DesktopUploadTracker()
|
||||
|
||||
tracker.startUpload("test.png")
|
||||
tracker.onError("Connection refused")
|
||||
|
||||
val state = tracker.state.value
|
||||
assertFalse(state.isUploading)
|
||||
assertEquals("Connection refused", state.error)
|
||||
assertNull(state.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetReturnsToInitialState() {
|
||||
val tracker = DesktopUploadTracker()
|
||||
|
||||
tracker.startUpload("test.png")
|
||||
tracker.onError("failed")
|
||||
tracker.reset()
|
||||
|
||||
val state = tracker.state.value
|
||||
assertFalse(state.isUploading)
|
||||
assertNull(state.fileName)
|
||||
assertNull(state.error)
|
||||
assertNull(state.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateFlowEmitsLatestValue() =
|
||||
runTest {
|
||||
val tracker = DesktopUploadTracker()
|
||||
|
||||
// Initial emission
|
||||
val initial = tracker.state.first()
|
||||
assertFalse(initial.isUploading)
|
||||
|
||||
tracker.startUpload("file.mp4")
|
||||
val uploading = tracker.state.first()
|
||||
assertTrue(uploading.isUploading)
|
||||
assertEquals("file.mp4", uploading.fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleUploadsOverwriteState() {
|
||||
val tracker = DesktopUploadTracker()
|
||||
|
||||
tracker.startUpload("first.jpg")
|
||||
tracker.startUpload("second.png")
|
||||
|
||||
assertEquals("second.png", tracker.state.value.fileName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user