diff --git a/android/app/src/test/java/com/galo/android/data/image/ObjectCacheKeyTest.kt b/android/app/src/test/java/com/galo/android/data/image/ObjectCacheKeyTest.kt new file mode 100644 index 0000000..be64efb --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/image/ObjectCacheKeyTest.kt @@ -0,0 +1,30 @@ +package com.galo.android.data.image + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ObjectCacheKeyTest { + + @Test + fun `signature is dropped from the key`() { + val a = ObjectCacheKey.keyFor("https://host/api/objects/k/thumb.webp?e=1&s=aaa") + val b = ObjectCacheKey.keyFor("https://host/api/objects/k/thumb.webp?e=999&s=bbb") + assertEquals("https://host/api/objects/k/thumb.webp", a) + assertEquals(a, b) + } + + @Test + fun `different variants keep distinct keys`() { + val thumb = ObjectCacheKey.keyFor("https://host/api/objects/k/thumb.webp?e=1&s=a") + val medium = ObjectCacheKey.keyFor("https://host/api/objects/k/medium.webp?e=1&s=a") + assertNotEquals(thumb, medium) + } + + @Test + fun `blank input yields null`() { + assertNull(ObjectCacheKey.keyFor(null)) + assertNull(ObjectCacheKey.keyFor(" ")) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/AlbumEventParserTest.kt b/android/app/src/test/java/com/galo/android/data/network/AlbumEventParserTest.kt new file mode 100644 index 0000000..a1b9a1b --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/AlbumEventParserTest.kt @@ -0,0 +1,54 @@ +package com.galo.android.data.network + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumEventParserTest { + + @Test + fun `photo created maps to upsert`() { + val event = AlbumEventParser.parse( + "photo.created", + """{"id":"p1","album_id":"a1","original_filename":"x.jpg","kind":"image"}""", + ) + assertTrue(event is AlbumEvent.PhotoUpserted) + assertEquals("p1", (event as AlbumEvent.PhotoUpserted).photo.id) + } + + @Test + fun `photo deleted maps to id`() { + val event = AlbumEventParser.parse("photo.deleted", """{"id":"p9","album_id":"a1"}""") + assertEquals(AlbumEvent.PhotoDeleted("p9"), event) + } + + @Test + fun `social events map to photo id`() { + val like = AlbumEventParser.parse("like.added", """{"photo_id":"p2","user_id":"u1"}""") + assertEquals(AlbumEvent.SocialChanged("p2"), like) + + val comment = AlbumEventParser.parse( + "comment.created", + """{"id":"c1","photo_id":"p3","text":"hi"}""", + ) + assertEquals(AlbumEvent.SocialChanged("p3"), comment) + } + + @Test + fun `album updated maps to album changed`() { + assertEquals(AlbumEvent.AlbumChanged, AlbumEventParser.parse("album.updated", "{}")) + } + + @Test + fun `unknown event types are ignored`() { + assertNull(AlbumEventParser.parse("member.joined", """{"album_id":"a1"}""")) + assertNull(AlbumEventParser.parse("notification", "{}")) + } + + @Test + fun `malformed payload is ignored`() { + assertNull(AlbumEventParser.parse("photo.created", "not json")) + assertNull(AlbumEventParser.parse("photo.deleted", "{}")) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/AlbumSseClientTest.kt b/android/app/src/test/java/com/galo/android/data/network/AlbumSseClientTest.kt new file mode 100644 index 0000000..f6adbcc --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/AlbumSseClientTest.kt @@ -0,0 +1,74 @@ +package com.galo.android.data.network + +import com.galo.android.data.auth.InMemoryTokenStore +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AlbumSseClientTest { + + private lateinit var server: MockWebServer + private lateinit var api: GaloApi + private lateinit var apiClient: ApiClient + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val base = server.url("/").toString().trimEnd('/') + apiClient = ApiClient( + tokenStore = InMemoryTokenStore(), + baseUrlProvider = { base }, + insecureAllowedProvider = { true }, + ) + api = GaloApi(apiClient) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `streams typed events and signals reconnection when the stream drops`() = runBlocking { + val body = buildString { + append("event: photo.created\n") + append("data: {\"id\":\"p1\",\"album_id\":\"a1\",\"original_filename\":\"x.jpg\",\"kind\":\"image\"}\n\n") + } + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody(body), + ) + + val events = withTimeout(10_000) { + AlbumSseClient(api, apiClient.sseClient).events("a1").take(2).toList() + } + + assertTrue(events[0] is AlbumEvent.PhotoUpserted) + assertEquals("p1", (events[0] as AlbumEvent.PhotoUpserted).photo.id) + assertEquals(AlbumEvent.Reconnected, events[1]) + } + + @Test + fun `streaming request targets the album endpoint`() = runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("event: unknown\ndata: {}\n\n"), + ) + withTimeout(10_000) { + AlbumSseClient(api, apiClient.sseClient).events("album-7").take(1).toList() + } + val request = server.takeRequest() + assertEquals("/api/sse/albums/album-7", request.path) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/ErrorMapperTest.kt b/android/app/src/test/java/com/galo/android/data/network/ErrorMapperTest.kt new file mode 100644 index 0000000..b577d1c --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/ErrorMapperTest.kt @@ -0,0 +1,63 @@ +package com.galo.android.data.network + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ErrorMapperTest { + + @Test + fun `parses the error envelope`() { + val error = ErrorMapper.parse( + 403, + """{"error":{"code":"forbidden","message":"insufficient rights"}}""", + ) + assertEquals("forbidden", error.code) + assertEquals("insufficient rights", error.message) + assertEquals(403, error.status) + } + + @Test + fun `falls back when body is empty or malformed`() { + assertEquals("unauthorized", ErrorMapper.parse(401, null).code) + assertEquals("not_found", ErrorMapper.parse(404, "").code) + assertEquals("internal_error", ErrorMapper.parse(500, "not json").code) + assertEquals("conflict", ErrorMapper.parse(409, """{"unexpected":true}""").code) + } + + @Test + fun `fallback derives code from status`() { + assertEquals("file_too_large", ErrorMapper.parse(413, null).code) + } + + @Test + fun `user message localises known codes`() { + val localized = ErrorMapper.userMessage( + ErrorMapper.parse(400, """{"error":{"code":"invalid_credentials","message":"bad"}}"""), + ) + assertEquals("Неверный идентификатор или пароль", localized) + } + + @Test + fun `user message localises profile codes`() { + fun message(code: String) = ErrorMapper.userMessage( + ErrorMapper.parse(400, """{"error":{"code":"$code","message":"raw"}}"""), + ) + assertEquals("Такой телефон уже зарегистрирован", message("phone_taken")) + assertEquals("Укажите email или телефон", message("identifier_required")) + assertEquals("Неверный текущий пароль", message("current_password_incorrect")) + assertEquals("Пароль должен быть не короче 8 символов", message("weak_password")) + assertEquals("Оба идентификатора уже заданы", message("identifiers_complete")) + assertEquals( + "Идентификатор уже задан и не может быть изменён", + message("email_already_set"), + ) + } + + @Test + fun `user message falls back to server text`() { + val message = ErrorMapper.userMessage( + ErrorMapper.parse(400, """{"error":{"code":"weird_code","message":"custom text"}}"""), + ) + assertEquals("custom text", message) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/ObjectUrlResolverTest.kt b/android/app/src/test/java/com/galo/android/data/network/ObjectUrlResolverTest.kt new file mode 100644 index 0000000..a9764fa --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/ObjectUrlResolverTest.kt @@ -0,0 +1,46 @@ +package com.galo.android.data.network + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ObjectUrlResolverTest { + + private fun resolver(base: String?) = ObjectUrlResolver { base } + + @Test + fun `relative object url resolves against base`() { + val resolved = resolver("https://photos.example.com") + .resolve("/api/objects/albums/a/photos/b/thumb.webp?e=1700000000&s=abc") + assertEquals( + "https://photos.example.com/api/objects/albums/a/photos/b/thumb.webp?e=1700000000&s=abc", + resolved, + ) + } + + @Test + fun `resolution preserves query signature`() { + val resolved = resolver("https://photos.example.com") + .resolve("/api/objects/key/medium.webp?e=1&s=deadbeef") + assertEquals("https://photos.example.com/api/objects/key/medium.webp?e=1&s=deadbeef", resolved) + } + + @Test + fun `base with path still resolves relative to origin path`() { + val resolved = resolver("https://host/api") + .resolve("/api/objects/k/thumb.webp") + assertEquals("https://host/api/objects/k/thumb.webp", resolved) + } + + @Test + fun `absolute urls pass through unchanged`() { + val absolute = "https://cdn.example.com/api/objects/k/thumb.webp" + assertEquals(absolute, resolver("https://photos.example.com").resolve(absolute)) + } + + @Test + fun `blank and missing inputs yield null`() { + assertNull(resolver("https://photos.example.com").resolve(null)) + assertNull(resolver("https://photos.example.com").resolve(" ")) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/TokenAuthenticatorTest.kt b/android/app/src/test/java/com/galo/android/data/network/TokenAuthenticatorTest.kt new file mode 100644 index 0000000..40f1a53 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/TokenAuthenticatorTest.kt @@ -0,0 +1,99 @@ +package com.galo.android.data.network + +import com.galo.android.data.auth.InMemoryTokenStore +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class TokenAuthenticatorTest { + + private lateinit var server: MockWebServer + private lateinit var store: InMemoryTokenStore + private lateinit var client: OkHttpClient + + @Before + fun setUp() { + server = MockWebServer() + server.start() + store = InMemoryTokenStore().apply { save("old-access", "refresh-1") } + val refreshClient = OkHttpClient() + val authenticator = TokenAuthenticator(store, refreshClient) { + server.url("/api/auth/refresh").toString() + } + client = refreshClient.newBuilder() + .addInterceptor { chain -> + val token = store.accessToken() + val request = if (token.isNullOrBlank()) { + chain.request() + } else { + chain.request().newBuilder().header("Authorization", "Bearer $token").build() + } + chain.proceed(request) + } + .authenticator(authenticator) + .build() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun makeRequest(): okhttp3.Response = + client.newCall(Request.Builder().url(server.url("/api/albums")).build()).execute() + + @Test + fun `401 triggers refresh and retries with new token`() { + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"tokens":{"access_token":"new-access","refresh_token":"refresh-2","token_type":"Bearer","expires_in":900}}""", + ), + ) + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"albums":[]}""")) + + val response = makeRequest() + + assertEquals(200, response.code) + assertEquals("Bearer old-access", server.takeRequest().getHeader("Authorization")) + val refresh = server.takeRequest() + assertEquals("/api/auth/refresh", refresh.path) + assertEquals("Bearer new-access", server.takeRequest().getHeader("Authorization")) + assertEquals("new-access", store.accessToken()) + assertEquals("refresh-2", store.refreshToken()) + response.close() + } + + @Test + fun `failed refresh clears the session`() { + server.enqueue(MockResponse().setResponseCode(401)) + server.enqueue(MockResponse().setResponseCode(401)) + + val response = makeRequest() + + assertEquals(401, response.code) + assertNull(store.accessToken()) + assertNull(store.refreshToken()) + response.close() + } + + @Test + fun `request without bearer token is not retried`() { + store.clear() + server.enqueue(MockResponse().setResponseCode(401)) + + val response = client.newCall( + Request.Builder().url(server.url("/api/albums")).build(), + ).execute() + + assertEquals(401, response.code) + assertEquals(1, server.requestCount) + response.close() + } +} diff --git a/android/app/src/test/java/com/galo/android/data/repo/AlbumRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/AlbumRepositoryTest.kt new file mode 100644 index 0000000..efb2552 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/repo/AlbumRepositoryTest.kt @@ -0,0 +1,71 @@ +package com.galo.android.data.repo + +import com.galo.android.data.db.entity.AlbumEntity +import com.galo.android.data.network.ObjectUrlResolver +import com.galo.android.data.network.dto.AlbumDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumRepositoryTest { + + private class FakeLocal : AlbumLocalSource { + val state = MutableStateFlow>(emptyList()) + override fun observeEntities(): Flow> = state + override suspend fun replaceAll(albums: List) { + state.value = albums + } + + override suspend fun upsert(albums: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + albums.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + + override suspend fun count(): Int = state.value.size + } + + private class FakeRemote(var result: () -> List) : AlbumRemoteSource { + override suspend fun fetchAlbums(): List = result() + override suspend fun fetchAlbum(albumId: String): AlbumDto = result().first() + } + + private val resolver = ObjectUrlResolver { "https://photos.example.com" } + private val local = FakeLocal() + + @Test + fun `online refresh populates cache and resolves urls`() = runTest { + val remote = FakeRemote { + listOf(AlbumDto(id = "a1", title = "Summer", coverThumbUrl = "/api/objects/a1/thumb.webp?e=1&s=x")) + } + val repo = AlbumRepository(remote, local, resolver) + + assertTrue(repo.refresh()) + + val albums = repo.observeAlbums().first() + assertEquals(1, albums.size) + assertEquals("Summer", albums[0].title) + assertEquals( + "https://photos.example.com/api/objects/a1/thumb.webp?e=1&s=x", + albums[0].coverThumbUrl, + ) + } + + @Test + fun `offline refresh keeps cached albums`() = runTest { + local.state.value = listOf(AlbumEntity(id = "a1", title = "Cached", description = "", ownerId = "u", coverPhotoId = null, coverThumbUrl = null, coverMediumUrl = null, photosCount = 3, membersCount = 1, updatedAt = null)) + val remote = FakeRemote { throw java.io.IOException("offline") } + val repo = AlbumRepository(remote, local, resolver) + + assertFalse(repo.refresh()) + + val albums = repo.observeAlbums().first() + assertEquals(1, albums.size) + assertEquals("Cached", albums[0].title) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/repo/PhotoRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/PhotoRepositoryTest.kt new file mode 100644 index 0000000..beccadc --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/repo/PhotoRepositoryTest.kt @@ -0,0 +1,118 @@ +package com.galo.android.data.repo + +import com.galo.android.data.db.entity.PhotoEntity +import com.galo.android.data.network.ObjectUrlResolver +import com.galo.android.data.network.dto.PhotoDto +import com.galo.android.data.network.dto.PhotoListDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhotoRepositoryTest { + + private class FakeLocal : PhotoLocalSource { + val state = MutableStateFlow>(emptyList()) + override fun observeForAlbum(albumId: String): Flow> = state + + override suspend fun upsertAll(photos: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + photos.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + + override suspend fun deleteById(id: String) { + state.value = state.value.filterNot { it.id == id } + } + + override suspend fun countForAlbum(albumId: String): Int = state.value.size + } + + private class FakeRemote(private val pages: Map) : PhotoRemoteSource { + val requestedCursors = mutableListOf() + override suspend fun fetchPage(albumId: String, cursor: String?): PhotoListDto { + requestedCursors += cursor + return pages[cursor] ?: PhotoListDto() + } + + override suspend fun fetchSince(albumId: String, since: String): PhotoListDto = PhotoListDto() + + override suspend fun fetchPhoto(photoId: String): PhotoDto = + PhotoDto(id = photoId, albumId = "a1", originalFilename = "$photoId.jpg", kind = "image") + } + + private val resolver = ObjectUrlResolver { "https://photos.example.com" } + private val local = FakeLocal() + + private fun photo(id: String, album: String = "a1") = + PhotoDto(id = id, albumId = album, originalFilename = "$id.jpg", kind = "image") + + @Test + fun `cursor pages merge into the local store`() = runTest { + val remote = FakeRemote( + mapOf( + null to PhotoListDto(photos = listOf(photo("p1"), photo("p2")), nextCursor = "c1"), + "c1" to PhotoListDto(photos = listOf(photo("p3")), nextCursor = null), + ), + ) + val repo = PhotoRepository(remote, local, resolver) + + val first = repo.loadNextPage("a1", cursor = null) + assertEquals("c1", first.nextCursor) + assertTrue(first.online) + + val second = repo.loadNextPage("a1", cursor = first.nextCursor) + assertEquals(null, second.nextCursor) + + val photos = repo.observePhotos("a1").first() + assertEquals(listOf("p1", "p2", "p3"), photos.map { it.id }.sorted()) + assertEquals(listOf(null, "c1"), remote.requestedCursors) + } + + @Test + fun `loadSince merges missed changes into the cache`() = runTest { + local.state.value = listOf() + val remote = object : PhotoRemoteSource { + override suspend fun fetchPage(albumId: String, cursor: String?): PhotoListDto = PhotoListDto() + override suspend fun fetchSince(albumId: String, since: String): PhotoListDto = + PhotoListDto(photos = listOf(photo("missed"))) + + override suspend fun fetchPhoto(photoId: String): PhotoDto = photo(photoId) + } + val repo = PhotoRepository(remote, local, resolver) + + assertTrue(repo.loadSince("a1", "2026-01-01T00:00:00Z")) + assertEquals(listOf("missed"), repo.observePhotos("a1").first().map { it.id }) + } + + @Test + fun `offline page load returns online false and keeps cache`() = runTest { + local.state.value = listOf( + PhotoEntity( + id = "p1", albumId = "a1", originalFilename = "p1.jpg", kind = "image", + mimeType = null, width = 0, height = 0, durationMs = 0, size = 0, takenAt = null, + caption = "", isPanorama = false, thumbUrl = null, mediumUrl = null, previewUrl = null, + playbackUrl = null, originalUrl = null, downloadUrl = null, createdAt = null, + ), + ) + val remote = object : PhotoRemoteSource { + override suspend fun fetchPage(albumId: String, cursor: String?): PhotoListDto = + throw java.io.IOException("offline") + + override suspend fun fetchSince(albumId: String, since: String): PhotoListDto = + throw java.io.IOException("offline") + + override suspend fun fetchPhoto(photoId: String): PhotoDto = + throw java.io.IOException("offline") + } + val repo = PhotoRepository(remote, local, resolver) + + val result = repo.loadNextPage("a1", cursor = null) + + assertEquals(false, result.online) + assertEquals(listOf("p1"), repo.observePhotos("a1").first().map { it.id }) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/upload/HeicConverterTest.kt b/android/app/src/test/java/com/galo/android/data/upload/HeicConverterTest.kt new file mode 100644 index 0000000..9ecfcb8 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/upload/HeicConverterTest.kt @@ -0,0 +1,36 @@ +package com.galo.android.data.upload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HeicConverterTest { + + @Test + fun `heic mime types need conversion`() { + assertTrue(HeicConverter.needsConversion("IMG_0001.HEIC", "image/heic")) + assertTrue(HeicConverter.needsConversion("photo.heif", "image/heif")) + assertTrue(HeicConverter.needsConversion("seq.heic", "image/heic-sequence")) + } + + @Test + fun `heic extensions need conversion even without mime`() { + assertTrue(HeicConverter.needsConversion("IMG_0001.HEIC", null)) + assertTrue(HeicConverter.needsConversion("clip.HEIF", "application/octet-stream")) + } + + @Test + fun `jpeg and png do not need conversion`() { + assertFalse(HeicConverter.needsConversion("photo.jpg", "image/jpeg")) + assertFalse(HeicConverter.needsConversion("shot.png", "image/png")) + assertFalse(HeicConverter.needsConversion("clip.mp4", "video/mp4")) + } + + @Test + fun `jpeg name preserves the base name`() { + assertEquals("IMG_0001.jpg", HeicConverter.jpegName("IMG_0001.HEIC")) + assertEquals("trip.2024.jpg", HeicConverter.jpegName("trip.2024.heic")) + assertEquals("photo.jpg", HeicConverter.jpegName("photo")) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/upload/UploadRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/upload/UploadRepositoryTest.kt new file mode 100644 index 0000000..138c379 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/upload/UploadRepositoryTest.kt @@ -0,0 +1,77 @@ +package com.galo.android.data.upload + +import android.content.Context +import android.net.Uri +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.work.testing.WorkManagerTestInitHelper +import com.galo.android.data.db.GaloDatabase +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class UploadRepositoryTest { + + private lateinit var context: Context + private lateinit var database: GaloDatabase + private lateinit var repository: UploadRepository + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + database = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + repository = UploadRepository(context, database.uploadDao()) + } + + @After + fun tearDown() { + database.close() + } + + private fun tempFile(name: String): Uri { + val file = File(context.cacheDir, name).apply { writeBytes(ByteArray(8) { it.toByte() }) } + return Uri.fromFile(file) + } + + @Test + fun `enqueue creates a queued job per file`() = runTest { + val queued = repository.enqueue( + albumId = "album-1", + uris = listOf(tempFile("one.jpg"), tempFile("two.jpg")), + wifiOnly = true, + ) + + assertEquals(2, queued) + val jobs = database.uploadDao().observeAll().first() + assertEquals(2, jobs.size) + assertTrue(jobs.all { it.status == "QUEUED" }) + assertTrue(jobs.all { it.wifiOnly }) + assertTrue(jobs.all { it.albumId == "album-1" }) + } + + @Test + fun `reset requeues interrupted work`() = runTest { + repository.enqueue("album-1", listOf(tempFile("a.jpg")), wifiOnly = false) + val job = database.uploadDao().observeAll().first().first() + repository.updateState(job.id, com.galo.android.domain.model.UploadStatus.RUNNING, 10, null, 0, null) + + repository.requeuePending() + + val pending = database.uploadDao().getPending() + assertEquals(1, pending.size) + assertEquals("QUEUED", pending.first().status) + } +}