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:
parent
a7a9f65391
commit
71537406c6
4 changed files with 390 additions and 1 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue