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("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
id("kotlin-parcelize") id("kotlin-parcelize")
@ -180,4 +181,13 @@ dependencies {
// Background work (cache, TTS sync) // Background work (cache, TTS sync)
implementation("androidx.work:work-runtime-ktx:2.10.0") implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.hilt:hilt-work:1.2.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> <resources>
<!-- Basic Strings --> <!-- 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> <string name="default_string">По умолчанию</string>
<!-- Audio playback --> <!-- 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)
}
}

View file

@ -2,6 +2,7 @@ plugins {
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.0" apply false id("org.jetbrains.kotlin.android") version "2.2.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.0" apply false
id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false
id("com.google.dagger.hilt.android") version "2.57.2" apply false id("com.google.dagger.hilt.android") version "2.57.2" apply false
id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" apply false id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" apply false