Fix root cause: add kotlinx-serialization compiler plugin, rename to KRait

- Added org.jetbrains.kotlin.plugin.serialization to app/build.gradle.kts
- Added plugin version to root build.gradle.kts
- Added test dependencies (serialization, retrofit, okhttp, coroutines)
- 16 unit tests for BookshelfApiService — all pass
- Renamed app_name to KRait
- Fixed search test: search_time_ms Int, total field name
This commit is contained in:
Atte149 2026-06-19 22:17:24 +03:00
parent a7a9f65391
commit 71537406c6
4 changed files with 390 additions and 1 deletions

View file

@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
id("kotlin-parcelize")
@ -180,4 +181,13 @@ dependencies {
// Background work (cache, TTS sync)
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.hilt:hilt-work:1.2.0")
// Test
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
testImplementation("com.squareup.retrofit2:retrofit:2.11.0")
testImplementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}

View file

@ -6,7 +6,7 @@
<resources>
<!-- Basic Strings -->
<string name="app_name" translatable="false">Book\'s Story</string>
<string name="app_name" translatable="false">KRait</string>
<string name="default_string">По умолчанию</string>
<!-- Audio playback -->

View file

@ -0,0 +1,378 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
class BookshelfApiServiceTest {
private lateinit var server: MockWebServer
private lateinit var service: BookshelfApiService
@Before
fun setUp() {
server = MockWebServer()
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
service = retrofit.create(BookshelfApiService::class.java)
}
@After
fun tearDown() {
server.shutdown()
}
private fun jsonResponse(body: String) = MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body)
@Test
fun `all endpoints can be created without converter errors`() {
assertNotNull(service)
}
@Test
fun `health returns success`() = runBlocking {
server.enqueue(jsonResponse("""{"status":"ok"}"""))
val response: Response<Unit> = service.health()
assertTrue(response.isSuccessful)
}
@Test
fun `getLibraries deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
[
{"id":"lib-1","name":"Audiobooks","media_type":"book","item_count":42},
{"id":"lib-2","name":"Ebooks","media_type":"book","item_count":15}
]
""".trimIndent()))
val response: Response<List<LibraryResponse>> = service.getLibraries()
assertTrue(response.isSuccessful)
val libraries: List<LibraryResponse> = response.body()!!
assertEquals(2, libraries.size)
assertEquals("lib-1", libraries[0].id)
assertEquals("Audiobooks", libraries[0].name)
assertEquals("book", libraries[0].mediaType)
assertEquals(42, libraries[0].itemCount)
}
@Test
fun `searchLibrary deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
[
{
"id":"item-1",
"title":"War and Peace",
"author":"Leo Tolstoy",
"media_type":"book",
"library_id":"lib-1",
"cover_url":"https://example.com/cover.jpg",
"duration":0.0,
"size":2048000
}
]
""".trimIndent()))
val response: Response<List<BookItemResponse>> = service.searchLibrary("lib-1", "war")
assertTrue(response.isSuccessful)
val books: List<BookItemResponse> = response.body()!!
assertEquals(1, books.size)
assertEquals("item-1", books[0].id)
assertEquals("War and Peace", books[0].title)
assertEquals("Leo Tolstoy", books[0].author)
assertEquals("book", books[0].mediaType)
assertEquals("lib-1", books[0].libraryId)
assertEquals("https://example.com/cover.jpg", books[0].coverUrl)
assertEquals(2048000L, books[0].size)
}
@Test
fun `getBook returns raw response body`() = runBlocking {
server.enqueue(jsonResponse("""{"id":"item-1","media":{"metadata":{"title":"Test"}}}"""))
val response: Response<okhttp3.ResponseBody> = service.getBook("item-1")
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
val raw = body!!.string()
assertTrue(raw.contains("item-1"))
}
@Test
fun `updateReadingProgress succeeds`() = runBlocking {
server.enqueue(jsonResponse("""{"itemId":"item-1","libraryId":"lib-1","scrollIndex":5,"scrollOffset":100,"progress":0.5,"last_chapter":"Ch1","updatedAt":1718611200000}"""))
val request = ProgressUpdateRequest(
itemId = "item-1",
libraryId = "lib-1",
scrollIndex = 5,
scrollOffset = 100,
progress = 0.5f,
lastChapter = "Ch1",
updatedAt = 1718611200000L,
)
val response: Response<okhttp3.ResponseBody> = service.updateReadingProgress("item-1", request)
assertTrue(response.isSuccessful)
}
@Test
fun `updatePlaybackProgress succeeds`() = runBlocking {
server.enqueue(jsonResponse("""{"itemId":"item-2","libraryId":"lib-2","current_file":"track-1.m4b","current_position":12345,"duration":360000,"updatedAt":1718611200001}"""))
val request = PlaybackProgressUpdateRequest(
itemId = "item-2",
libraryId = "lib-2",
currentFile = "track-1.m4b",
currentPosition = 12345L,
duration = 360000L,
updatedAt = 1718611200001L,
)
val response: Response<okhttp3.ResponseBody> = service.updatePlaybackProgress("item-2", request)
assertTrue(response.isSuccessful)
}
@Test
fun `getTtsEngines deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
{
"engines": [
{
"id":"silero",
"name":"Silero",
"capabilities": {
"supports_streaming":false,
"supports_cloning":false,
"max_text_length":5000,
"needs_network":false
}
}
]
}
""".trimIndent()))
val response: Response<TtsEnginesResponse> = service.getTtsEngines()
assertTrue(response.isSuccessful)
val engines: TtsEnginesResponse = response.body()!!
assertEquals(1, engines.engines.size)
assertEquals("silero", engines.engines[0].id)
}
@Test
fun `getTtsVoices deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
{
"voices": [
{
"id":"baya",
"name":"Baya",
"language":"ru-RU",
"engine":"silero",
"gender":"female",
"quality":"high",
"requires_reference":false
}
]
}
""".trimIndent()))
val response: Response<TtsVoicesResponse> = service.getTtsVoices("silero")
assertTrue(response.isSuccessful)
val voices: TtsVoicesResponse = response.body()!!
assertEquals(1, voices.voices.size)
assertEquals("baya", voices.voices[0].id)
assertEquals("ru-RU", voices.voices[0].language)
}
@Test
fun `createTtsJob deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
{
"job_id":"job-1",
"book_id":"item-1",
"title":"",
"author":"",
"engine":"silero",
"voice_id":"baya",
"speed":1.0,
"status":"queued",
"progress":0.0,
"current_chapter":"",
"total_chapters":0,
"completed_chapters":0,
"output_path":"",
"error":"",
"created_at":1718611200.0,
"started_at":0.0,
"completed_at":0.0
}
""".trimIndent()))
val request = TtsCreateRequest(bookId = "item-1", engine = "silero", voiceId = "baya", speed = 1.0)
val response: Response<TtsJobResponse> = service.createTtsJob(request)
assertTrue(response.isSuccessful)
val job: TtsJobResponse = response.body()!!
assertEquals("job-1", job.jobId)
assertEquals("queued", job.status)
}
@Test
fun `listTtsJobs deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
{
"jobs": [
{
"job_id":"job-1",
"book_id":"item-1",
"title":"Test Book",
"author":"Author",
"engine":"silero",
"voice_id":"baya",
"speed":1.0,
"status":"completed",
"progress":1.0,
"current_chapter":"Chapter 10",
"total_chapters":10,
"completed_chapters":10,
"output_path":"/audiobooks/Test.m4b",
"error":"",
"created_at":1718611200.0,
"started_at":1718611300.0,
"completed_at":1718611900.0
}
]
}
""".trimIndent()))
val response: Response<TtsJobsResponse> = service.listTtsJobs()
assertTrue(response.isSuccessful)
val jobs: TtsJobsResponse = response.body()!!
assertEquals(1, jobs.jobs.size)
assertEquals("completed", jobs.jobs[0].status)
}
@Test
fun `search deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
{
"results": [
{
"title":"Test Book",
"author":"Author",
"size":"10 MB",
"size_human":"10 MB",
"seeders":5,
"media_type":"book",
"magnet_url":"magnet:?xt=urn:btih:abc",
"download_url":"https://example.com/dl",
"cover_url":"https://example.com/cover.jpg",
"info_hash":"abc123",
"source":"flibusta",
"download_protocol":"torrent",
"language":"ru"
}
],
"total":1,
"search_time_ms":150
}
""".trimIndent()))
val request = SearchRequest(query = "test")
val response: Response<SearchResponse> = service.search(request)
assertTrue(response.isSuccessful)
val search: SearchResponse = response.body()!!
assertEquals(1, search.total)
assertEquals("Test Book", search.results[0].title)
}
@Test
fun `startDownload deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""{"success":true,"error":null,"download_id":"dl-1"}"""))
val request = DownloadRequest(url = "magnet:?xt=urn:btih:abc", title = "Test")
val response: Response<DownloadResponse> = service.startDownload(request)
assertTrue(response.isSuccessful)
val download: DownloadResponse = response.body()!!
assertTrue(download.success)
assertEquals("dl-1", download.downloadId)
}
@Test
fun `listDownloads deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""
{
"downloads": [
{
"id":"dl-1",
"title":"Test Book",
"status":"downloading",
"progress":0.5,
"error":null,
"created_at":1718611200.0
}
]
}
""".trimIndent()))
val response: Response<DownloadsListResponse> = service.listDownloads()
assertTrue(response.isSuccessful)
val downloads: DownloadsListResponse = response.body()!!
assertEquals(1, downloads.downloads.size)
assertEquals("downloading", downloads.downloads[0].status)
}
@Test
fun `downloadPodcast deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""{"url":"https://youtube.com/watch?v=abc","title":"Podcast","tracks":3,"files":["f1.m4a","f2.m4a"],"abs_scan_triggered":true}"""))
val request = PodcastRequest(url = "https://youtube.com/watch?v=abc")
val response: Response<PodcastResponse> = service.downloadPodcast(request)
assertTrue(response.isSuccessful)
val podcast: PodcastResponse = response.body()!!
assertEquals("Podcast", podcast.title)
assertTrue(podcast.absScanTriggered)
}
@Test
fun `downloadYandex deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""{"bookid":"123","files":["book.epub"],"abs_scan_triggered":false}"""))
val request = YandexRequest(bookid = "123")
val response: Response<YandexResponse> = service.downloadYandex(request)
assertTrue(response.isSuccessful)
val yandex: YandexResponse = response.body()!!
assertEquals("123", yandex.bookid)
}
}