From 71537406c6090f59f875171719d6b0748b61cda6 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Fri, 19 Jun 2026 22:17:24 +0300 Subject: [PATCH] Fix root cause: add kotlinx-serialization compiler plugin, rename to KRait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/build.gradle.kts | 10 + app/src/main/res/values/strings.xml | 2 +- .../bookshelfapi/BookshelfApiServiceTest.kt | 378 ++++++++++++++++++ build.gradle.kts | 1 + 4 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8433f520..fc04bd39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f1abed5..02db3e07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,7 +6,7 @@ - Book\'s Story + KRait По умолчанию diff --git a/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt b/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt new file mode 100644 index 00000000..b6545ac5 --- /dev/null +++ b/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt @@ -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 = 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> = service.getLibraries() + assertTrue(response.isSuccessful) + val libraries: List = 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> = service.searchLibrary("lib-1", "war") + assertTrue(response.isSuccessful) + val books: List = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = service.downloadYandex(request) + assertTrue(response.isSuccessful) + val yandex: YandexResponse = response.body()!! + assertEquals("123", yandex.bookid) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f076ace1..bf2e129f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { 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.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.dagger.hilt.android") version "2.57.2" apply false id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" apply false