@@ -0,0 +1,25 @@
|
||||
package com.example.architecture.feature.characters.data
|
||||
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.core.domain.map
|
||||
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||
import com.example.architecture.feature.characters.data.mappers.toCharacterDetails
|
||||
import com.example.architecture.feature.characters.data.mappers.toDomain
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
/**
|
||||
* Network-backed [CharacterRepository]. Maps DTOs to domain via the mappers; the `Result`'s
|
||||
* `DataError.Network` widens to the `DataError` supertype through `Result`'s covariance.
|
||||
*/
|
||||
internal class NetworkCharacterRepository(
|
||||
private val dataSource: KtorCharacterDataSource,
|
||||
) : CharacterRepository {
|
||||
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> =
|
||||
dataSource.getCharacters(page).map { it.toDomain() }
|
||||
|
||||
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
|
||||
dataSource.getCharacter(id).map { it.toCharacterDetails() }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.architecture.feature.characters.data.datasource
|
||||
|
||||
import com.example.architecture.core.data.network.get
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
/**
|
||||
* Remote data source for characters. Returns raw DTOs (no mapping here - the repository maps via
|
||||
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
|
||||
*/
|
||||
internal class KtorCharacterDataSource(
|
||||
private val httpClient: HttpClient,
|
||||
) {
|
||||
suspend fun getCharacters(page: Int): Result<CharactersResponseDto, DataError.Network> =
|
||||
httpClient.get(route = "/character", queryParameters = mapOf("page" to page))
|
||||
|
||||
suspend fun getCharacter(id: Int): Result<CharacterDto, DataError.Network> =
|
||||
httpClient.get(route = "/character/$id")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.architecture.feature.characters.data.di
|
||||
|
||||
import com.example.architecture.feature.characters.data.NetworkCharacterRepository
|
||||
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val charactersDataModule = module {
|
||||
singleOf(::KtorCharacterDataSource)
|
||||
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CharacterDto(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val species: String,
|
||||
val type: String,
|
||||
val gender: String,
|
||||
val origin: LocationRefDto,
|
||||
val location: LocationRefDto,
|
||||
val image: String,
|
||||
val episode: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocationRefDto(
|
||||
val name: String,
|
||||
val url: String,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CharactersResponseDto(
|
||||
val info: PageInfoDto,
|
||||
val results: List<CharacterDto>,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.architecture.feature.characters.data.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PageInfoDto(
|
||||
val count: Int,
|
||||
val pages: Int,
|
||||
val next: String?,
|
||||
val prev: String?,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.architecture.feature.characters.data.mappers
|
||||
|
||||
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||
import com.example.architecture.feature.characters.domain.model.Character
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
internal fun CharactersResponseDto.toDomain(): CharactersPage = CharactersPage(
|
||||
characters = results.map { it.toCharacter() },
|
||||
nextPage = info.next?.toPageNumber(),
|
||||
)
|
||||
|
||||
internal fun CharacterDto.toCharacter(): Character = Character(
|
||||
id = id,
|
||||
name = name,
|
||||
status = status.toCharacterStatus(),
|
||||
species = species,
|
||||
imageUrl = image,
|
||||
)
|
||||
|
||||
internal fun CharacterDto.toCharacterDetails(): CharacterDetails = CharacterDetails(
|
||||
id = id,
|
||||
name = name,
|
||||
status = status.toCharacterStatus(),
|
||||
species = species,
|
||||
type = type,
|
||||
gender = gender,
|
||||
origin = origin.name,
|
||||
location = location.name,
|
||||
imageUrl = image,
|
||||
episodeCount = episode.size,
|
||||
)
|
||||
|
||||
private fun String.toCharacterStatus(): CharacterStatus = when (lowercase()) {
|
||||
"alive" -> CharacterStatus.ALIVE
|
||||
"dead" -> CharacterStatus.DEAD
|
||||
else -> CharacterStatus.UNKNOWN
|
||||
}
|
||||
|
||||
/** The API's `next` is a full URL like `.../character?page=2`; pull the page number out of it. */
|
||||
private fun String.toPageNumber(): Int? =
|
||||
Regex("[?&]page=(\\d+)").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.example.architecture.feature.characters.data
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.endsWith
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import com.example.architecture.core.data.network.HttpClientFactory
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.MockRequestHandleScope
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.client.request.HttpResponseData
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.headersOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real
|
||||
* [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) - so the
|
||||
* full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping →
|
||||
* domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a
|
||||
* malformed-body → SERIALIZATION case.
|
||||
*/
|
||||
class NetworkCharacterRepositoryTest {
|
||||
|
||||
private fun repository(
|
||||
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
|
||||
): NetworkCharacterRepository {
|
||||
val engine = MockEngine { request -> handler(request) }
|
||||
val httpClient = HttpClientFactory.create(engine)
|
||||
return NetworkCharacterRepository(KtorCharacterDataSource(httpClient))
|
||||
}
|
||||
|
||||
private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json")
|
||||
|
||||
@Test
|
||||
fun `getCharacters maps a successful response to a domain page`() = runTest {
|
||||
var requestedPath: String? = null
|
||||
var requestedPage: String? = null
|
||||
val repository = repository { request ->
|
||||
requestedPath = request.url.encodedPath
|
||||
requestedPage = request.url.parameters["page"]
|
||||
respond(content = CHARACTERS_PAGE_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
|
||||
}
|
||||
|
||||
val result = repository.getCharacters(page = 3)
|
||||
|
||||
// Request construction: correct endpoint and the page forwarded as a query param.
|
||||
assertThat(requestedPath).isNotNull().endsWith("/character")
|
||||
assertThat(requestedPage).isEqualTo("3")
|
||||
|
||||
assertThat(result).isInstanceOf(Result.Success::class)
|
||||
val page = (result as Result.Success).data
|
||||
assertThat(page.characters.size).isEqualTo(2)
|
||||
assertThat(page.characters.first().name).isEqualTo("Rick Sanchez")
|
||||
// `next` URL ".../character?page=2" is parsed to a page number.
|
||||
assertThat(page.nextPage).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCharacters maps 404 to NOT_FOUND`() = runTest {
|
||||
val repository = repository {
|
||||
respond(content = "", status = HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
val result = repository.getCharacters(page = 1)
|
||||
|
||||
assertThat(result).isEqualTo(Result.Error(DataError.Network.NOT_FOUND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCharacters maps 500 to SERVER_ERROR`() = runTest {
|
||||
val repository = repository {
|
||||
respond(content = "", status = HttpStatusCode.InternalServerError)
|
||||
}
|
||||
|
||||
val result = repository.getCharacters(page = 1)
|
||||
|
||||
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERVER_ERROR))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCharacters maps a malformed body to SERIALIZATION`() = runTest {
|
||||
val repository = repository {
|
||||
respond(content = "{ this is not valid json", status = HttpStatusCode.OK, headers = jsonHeaders())
|
||||
}
|
||||
|
||||
val result = repository.getCharacters(page = 1)
|
||||
|
||||
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERIALIZATION))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCharacterDetails maps a successful response to domain details`() = runTest {
|
||||
var requestedPath: String? = null
|
||||
val repository = repository { request ->
|
||||
requestedPath = request.url.encodedPath
|
||||
respond(content = CHARACTER_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
|
||||
}
|
||||
|
||||
val result = repository.getCharacterDetails(id = 1)
|
||||
|
||||
// Request construction: the id is placed in the path.
|
||||
assertThat(requestedPath).isNotNull().endsWith("/character/1")
|
||||
|
||||
assertThat(result).isInstanceOf(Result.Success::class)
|
||||
val details = (result as Result.Success).data
|
||||
assertThat(details.name).isEqualTo("Rick Sanchez")
|
||||
assertThat(details.origin).isEqualTo("Earth (C-137)")
|
||||
assertThat(details.episodeCount).isEqualTo(3)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CHARACTER_JSON = """
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Rick Sanchez",
|
||||
"status": "Alive",
|
||||
"species": "Human",
|
||||
"type": "",
|
||||
"gender": "Male",
|
||||
"origin": { "name": "Earth (C-137)", "url": "" },
|
||||
"location": { "name": "Citadel of Ricks", "url": "" },
|
||||
"image": "https://example.com/1.png",
|
||||
"episode": ["e1", "e2", "e3"]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val CHARACTERS_PAGE_JSON = """
|
||||
{
|
||||
"info": {
|
||||
"count": 2,
|
||||
"pages": 1,
|
||||
"next": "https://rickandmortyapi.com/api/character?page=2",
|
||||
"prev": null
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": 1, "name": "Rick Sanchez", "status": "Alive", "species": "Human",
|
||||
"type": "", "gender": "Male",
|
||||
"origin": { "name": "Earth (C-137)", "url": "" },
|
||||
"location": { "name": "Citadel of Ricks", "url": "" },
|
||||
"image": "https://example.com/1.png", "episode": ["e1", "e2"]
|
||||
},
|
||||
{
|
||||
"id": 2, "name": "Morty Smith", "status": "Alive", "species": "Human",
|
||||
"type": "", "gender": "Male",
|
||||
"origin": { "name": "Earth (C-137)", "url": "" },
|
||||
"location": { "name": "Citadel of Ricks", "url": "" },
|
||||
"image": "https://example.com/2.png", "episode": ["e1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user