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:
nrobi144
2026-03-16 11:42:34 +02:00
parent dfaca39528
commit d5facd90df
7 changed files with 1096 additions and 0 deletions
@@ -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)
}
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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()
}
}
}
@@ -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)
}
}