From d5facd90df5a184ee6a8f613876dda2df9059e96 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 11:42:34 +0200 Subject: [PATCH] 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 --- .../media/EncryptedMediaServiceTest.kt | 116 +++++++ .../service/media/ServerHealthCheckTest.kt | 49 +++ .../upload/DesktopBlossomClientTest.kt | 286 ++++++++++++++++++ .../upload/DesktopMediaCompressorTest.kt | 124 ++++++++ .../upload/DesktopMediaMetadataTest.kt | 172 +++++++++++ .../upload/DesktopUploadOrchestratorTest.kt | 222 ++++++++++++++ .../upload/DesktopUploadTrackerTest.kt | 127 ++++++++ 7 files changed, 1096 insertions(+) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt new file mode 100644 index 000000000..8921264ec --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaServiceTest.kt @@ -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) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt new file mode 100644 index 000000000..bd0a6a205 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheckTest.kt @@ -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) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt new file mode 100644 index 000000000..00cefee5c --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt @@ -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() + val mockCall = mockk() + val mockClient = mockk() + + 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 { + 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 { + 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() + val mockCall = mockk() + val mockClient = mockk() + + 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() + val mockCall = mockk() + val mockClient = mockk() + + 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() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt new file mode 100644 index 000000000..6cef50f6f --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressorTest.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt new file mode 100644 index 000000000..2bb0f4016 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadataTest.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt new file mode 100644 index 000000000..b014b761d --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestratorTest.kt @@ -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() + val fileSlot = slot() + val contentTypeSlot = slot() + val urlSlot = slot() + + 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(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() + val fileSlot = slot() + + 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(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() + + 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(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() + val authSlot = slot() + + 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(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() + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt new file mode 100644 index 000000000..085f96046 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt @@ -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) + } +}