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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user