Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
plugins {
alias(libs.plugins.architecture.android.library)
alias(libs.plugins.architecture.koin)
alias(libs.plugins.architecture.kotlinx.serialization)
alias(libs.plugins.architecture.android.unit.test)
}
android {
namespace = "com.example.architecture.feature.characters.data"
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:data"))
implementation(project(":feature:characters:domain"))
// Swap a Ktor MockEngine into HttpClientFactory.create(...) for the repository test.
testImplementation(libs.ktor.client.mock)
testImplementation(libs.ktor.client.content.negotiation)
testImplementation(libs.ktor.serialization.kotlinx.json)
}

View File

@@ -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() }
}

View File

@@ -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")
}

View File

@@ -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>() }
}

View File

@@ -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,
)

View File

@@ -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>,
)

View File

@@ -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?,
)

View File

@@ -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()

View File

@@ -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()
}
}