test(android): restore data-layer unit tests hidden by the data/ gitignore rule

These network, repo, upload and image tests existed only in the main checkout because the old unanchored data/ rule ignored them. With the rule anchored to /data/ they now belong to the tracked data layer.
This commit is contained in:
iu
2026-10-13 17:11:50 +03:00
parent 467a42ac4d
commit 15947c0301
10 changed files with 668 additions and 0 deletions
@@ -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(" "))
}
}
@@ -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", "{}"))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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(" "))
}
}
@@ -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()
}
}
@@ -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<List<AlbumEntity>>(emptyList())
override fun observeEntities(): Flow<List<AlbumEntity>> = state
override suspend fun replaceAll(albums: List<AlbumEntity>) {
state.value = albums
}
override suspend fun upsert(albums: List<AlbumEntity>) {
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<AlbumDto>) : AlbumRemoteSource {
override suspend fun fetchAlbums(): List<AlbumDto> = 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)
}
}
@@ -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<List<PhotoEntity>>(emptyList())
override fun observeForAlbum(albumId: String): Flow<List<PhotoEntity>> = state
override suspend fun upsertAll(photos: List<PhotoEntity>) {
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<String?, PhotoListDto>) : PhotoRemoteSource {
val requestedCursors = mutableListOf<String?>()
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 })
}
}
@@ -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"))
}
}
@@ -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)
}
}