From d2609c0560a28afd39629590eac11513d1ab3277 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 15 Jul 2021 19:54:22 +0300 Subject: [PATCH] Improve remote repository tests --- app/build.gradle | 2 + .../core/parser/site/MangareadRepository.kt | 5 +- .../network/TestCookieJar.kt} | 4 +- .../core/parser/RemoteMangaRepositoryTest.kt | 126 +++++++++++++++++ .../parser}/RepositoryTestModule.kt | 6 +- .../parser/SourceSettingsStub.kt} | 4 +- .../kotatsu/parsers/RemoteRepositoryTest.kt | 132 ------------------ .../org/koitharu/kotatsu/utils/AssertX.kt | 42 ------ .../kotatsu/utils/CoroutineTestRule.kt | 32 +++++ .../koitharu/kotatsu/utils/TestResponse.kt | 32 +++++ .../org/koitharu/kotatsu/utils/TruthExt.kt | 11 ++ build.gradle | 2 +- 12 files changed, 213 insertions(+), 185 deletions(-) rename app/src/test/java/org/koitharu/kotatsu/{parsers/TemporaryCookieJar.kt => core/network/TestCookieJar.kt} (87%) create mode 100644 app/src/test/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepositoryTest.kt rename app/src/test/java/org/koitharu/kotatsu/{parsers => core/parser}/RepositoryTestModule.kt (85%) rename app/src/test/java/org/koitharu/kotatsu/{parsers/SourceSettingsMock.kt => core/parser/SourceSettingsStub.kt} (67%) delete mode 100644 app/src/test/java/org/koitharu/kotatsu/parsers/RemoteRepositoryTest.kt delete mode 100644 app/src/test/java/org/koitharu/kotatsu/utils/AssertX.kt create mode 100644 app/src/test/java/org/koitharu/kotatsu/utils/CoroutineTestRule.kt create mode 100644 app/src/test/java/org/koitharu/kotatsu/utils/TestResponse.kt create mode 100644 app/src/test/java/org/koitharu/kotatsu/utils/TruthExt.kt diff --git a/app/build.gradle b/app/build.gradle index ce37ed57b..b4f852eb3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -102,6 +102,8 @@ dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' testImplementation 'junit:junit:4.13.2' + testImplementation 'com.google.truth:truth:1.1.3' testImplementation 'org.json:json:20210307' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1' testImplementation 'io.insert-koin:koin-test-junit4:3.1.2' } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt index ed390f61a..06fe7459e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt @@ -26,11 +26,8 @@ class MangareadRepository( sortOrder: SortOrder?, tag: MangaTag? ): List { - if (offset % PAGE_SIZE != 0) { - return emptyList() - } val payload = createRequestTemplate() - payload["page"] = (offset / PAGE_SIZE).toString() + payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString() payload["vars[meta_key]"] = when (sortOrder) { SortOrder.POPULARITY -> "_wp_manga_views" SortOrder.UPDATED -> "_latest_update" diff --git a/app/src/test/java/org/koitharu/kotatsu/parsers/TemporaryCookieJar.kt b/app/src/test/java/org/koitharu/kotatsu/core/network/TestCookieJar.kt similarity index 87% rename from app/src/test/java/org/koitharu/kotatsu/parsers/TemporaryCookieJar.kt rename to app/src/test/java/org/koitharu/kotatsu/core/network/TestCookieJar.kt index 09bdf00ed..45fee5711 100644 --- a/app/src/test/java/org/koitharu/kotatsu/parsers/TemporaryCookieJar.kt +++ b/app/src/test/java/org/koitharu/kotatsu/core/network/TestCookieJar.kt @@ -1,10 +1,10 @@ -package org.koitharu.kotatsu.parsers +package org.koitharu.kotatsu.core.network import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl -class TemporaryCookieJar : CookieJar { +class TestCookieJar : CookieJar { private val cache = HashMap() diff --git a/app/src/test/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepositoryTest.kt b/app/src/test/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepositoryTest.kt new file mode 100644 index 000000000..50977a1bd --- /dev/null +++ b/app/src/test/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepositoryTest.kt @@ -0,0 +1,126 @@ +package org.koitharu.kotatsu.core.parser + +import com.google.common.truth.Truth +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.koin.core.component.inject +import org.koin.core.parameter.parametersOf +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.model.SortOrder +import org.koitharu.kotatsu.parsers.repositoryTestModule +import org.koitharu.kotatsu.utils.CoroutineTestRule +import org.koitharu.kotatsu.utils.TestResponse +import org.koitharu.kotatsu.utils.ext.mapToSet +import org.koitharu.kotatsu.utils.ext.medianOrNull +import org.koitharu.kotatsu.utils.isAbsoluteUrl +import org.koitharu.kotatsu.utils.isRelativeUrl + +@RunWith(Parameterized::class) +class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest { + + private val repo by inject { + parametersOf(source) + } + + @get:Rule + val koinTestRule = KoinTestRule.create { + printLogger() + modules(repositoryTestModule) + } + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + @Test + fun list() = coroutineTestRule.runBlockingTest { + val list = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null) + checkMangaList(list) + } + + @Test + fun search() = coroutineTestRule.runBlockingTest { + val subject = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null) + .first() + val list = repo.getList(offset = 0, query = subject.title, sortOrder = null, tag = null) + checkMangaList(list) + Truth.assertThat(list.map { it.url }).contains(subject.url) + } + + @Test + fun tags() = coroutineTestRule.runBlockingTest { + val tags = repo.getTags() + Truth.assertThat(tags).isNotEmpty() + val keys = tags.map { it.key } + Truth.assertThat(keys).containsNoDuplicates() + Truth.assertThat(keys).doesNotContain("") + val titles = tags.map { it.title } + Truth.assertThat(titles).containsNoDuplicates() + Truth.assertThat(titles).doesNotContain("") + Truth.assertThat(tags.mapToSet { it.source }).containsExactly(source) + + val list = repo.getList(offset = 0, tag = tags.last(), query = null, sortOrder = null) + checkMangaList(list) + } + + @Test + fun details() = coroutineTestRule.runBlockingTest { + val list = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null) + val item = list.first() + val details = repo.getDetails(item) + + Truth.assertThat(details.chapters).isNotEmpty() + Truth.assertThat(details.publicUrl).isAbsoluteUrl() + Truth.assertThat(details.description).isNotNull() + Truth.assertThat(details.title).startsWith(item.title) + Truth.assertThat(details.source).isEqualTo(source) + + Truth.assertThat(details.chapters?.map { it.id }).containsNoDuplicates() + Truth.assertThat(details.chapters?.map { it.number }).containsNoDuplicates() + Truth.assertThat(details.chapters?.map { it.name }).doesNotContain("") + Truth.assertThat(details.chapters?.mapToSet { it.source }).containsExactly(source) + } + + @Test + fun pages() = coroutineTestRule.runBlockingTest { + val list = repo.getList(20, query = null, sortOrder = SortOrder.POPULARITY, tag = null) + val chapter = + repo.getDetails(list.first()).chapters?.firstOrNull() ?: error("Chapter is null") + val pages = repo.getPages(chapter) + + Truth.assertThat(pages).isNotEmpty() + Truth.assertThat(pages.map { it.id }).containsNoDuplicates() + Truth.assertThat(pages.mapToSet { it.source }).containsExactly(source) + + val page = pages.medianOrNull() ?: error("No page") + val pageUrl = repo.getPageUrl(page) + Truth.assertThat(pageUrl).isNotEmpty() + Truth.assertThat(pageUrl).isAbsoluteUrl() + val pageResponse = TestResponse.testRequest(pageUrl) + Truth.assertThat(pageResponse.code).isIn(200..299) + Truth.assertThat(pageResponse.type).isEqualTo("image") + } + + private fun checkMangaList(list: List) { + Truth.assertThat(list).isNotEmpty() + Truth.assertThat(list.map { it.id }).containsNoDuplicates() + for (item in list) { + Truth.assertThat(item.url).isNotEmpty() + Truth.assertThat(item.url).isRelativeUrl() + Truth.assertThat(item.coverUrl).isAbsoluteUrl() + Truth.assertThat(item.title).isNotEmpty() + Truth.assertThat(item.publicUrl).isAbsoluteUrl() + } + } + + companion object { + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun getProviders() = (MangaSource.values().toList() - MangaSource.LOCAL).toTypedArray() + } +} \ No newline at end of file diff --git a/app/src/test/java/org/koitharu/kotatsu/parsers/RepositoryTestModule.kt b/app/src/test/java/org/koitharu/kotatsu/core/parser/RepositoryTestModule.kt similarity index 85% rename from app/src/test/java/org/koitharu/kotatsu/parsers/RepositoryTestModule.kt rename to app/src/test/java/org/koitharu/kotatsu/core/parser/RepositoryTestModule.kt index bb31ee2bc..c28ccdec8 100644 --- a/app/src/test/java/org/koitharu/kotatsu/parsers/RepositoryTestModule.kt +++ b/app/src/test/java/org/koitharu/kotatsu/core/parser/RepositoryTestModule.kt @@ -5,14 +5,16 @@ import okhttp3.OkHttpClient import org.koin.dsl.module import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.network.TestCookieJar import org.koitharu.kotatsu.core.network.UserAgentInterceptor import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.core.parser.SourceSettingsStub import org.koitharu.kotatsu.core.prefs.SourceSettings import java.util.concurrent.TimeUnit val repositoryTestModule get() = module { - single { TemporaryCookieJar() } + single { TestCookieJar() } factory { OkHttpClient.Builder() .cookieJar(get()) @@ -25,7 +27,7 @@ val repositoryTestModule single { object : MangaLoaderContext(get(), get()) { override fun getSettings(source: MangaSource): SourceSettings { - return SourceSettingsMock() + return SourceSettingsStub() } } } diff --git a/app/src/test/java/org/koitharu/kotatsu/parsers/SourceSettingsMock.kt b/app/src/test/java/org/koitharu/kotatsu/core/parser/SourceSettingsStub.kt similarity index 67% rename from app/src/test/java/org/koitharu/kotatsu/parsers/SourceSettingsMock.kt rename to app/src/test/java/org/koitharu/kotatsu/core/parser/SourceSettingsStub.kt index e5bb09bf2..2c2f27461 100644 --- a/app/src/test/java/org/koitharu/kotatsu/parsers/SourceSettingsMock.kt +++ b/app/src/test/java/org/koitharu/kotatsu/core/parser/SourceSettingsStub.kt @@ -1,8 +1,8 @@ -package org.koitharu.kotatsu.parsers +package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.core.prefs.SourceSettings -class SourceSettingsMock : SourceSettings { +class SourceSettingsStub : SourceSettings { override fun getDomain(defaultValue: String) = defaultValue diff --git a/app/src/test/java/org/koitharu/kotatsu/parsers/RemoteRepositoryTest.kt b/app/src/test/java/org/koitharu/kotatsu/parsers/RemoteRepositoryTest.kt deleted file mode 100644 index 8f5020ddd..000000000 --- a/app/src/test/java/org/koitharu/kotatsu/parsers/RemoteRepositoryTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.koitharu.kotatsu.parsers - -import kotlinx.coroutines.runBlocking -import org.junit.Assert -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import org.koin.core.component.inject -import org.koin.core.parameter.parametersOf -import org.koin.test.KoinTest -import org.koin.test.KoinTestRule -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.utils.AssertX -import org.koitharu.kotatsu.utils.ext.isDistinctBy - -@RunWith(Parameterized::class) -class RemoteRepositoryTest(source: MangaSource) : KoinTest { - - private val repo by inject { - parametersOf(source) - } - - @get:Rule - val koinTestRule = KoinTestRule.create { - printLogger() - modules(repositoryTestModule) - } - - @Test - fun list() { - val list = runBlocking { repo.getList(60) } - Assert.assertFalse("List is empty", list.isEmpty()) - Assert.assertTrue("Mangas are not distinct", list.isDistinctBy { it.id }) - val item = list.random() - AssertX.assertUrlRelative("Url is not relative", item.url) - AssertX.assertUrlAbsolute("Url is not absolute", item.coverUrl) - AssertX.assertContentType("Bad cover at ${item.url}", item.coverUrl, "image/*") - AssertX.assertContentType( - "invalid public url ${item.publicUrl}", - item.publicUrl, - "text/html" - ) - Assert.assertFalse("Title is blank at ${item.url}", item.title.isBlank()) - } - - @Test - fun search() { - val list = runBlocking { repo.getList(0, query = "tail") } - Assert.assertFalse("List is empty", list.isEmpty()) - Assert.assertTrue("Mangas are not distinct", list.isDistinctBy { it.id }) - val nextList = runBlocking { repo.getList(list.size, query = "tail") } - Assert.assertNotEquals("Search pagination is broken", list, nextList) - val item = list.random() - AssertX.assertUrlRelative("Url is not relative", item.url) - AssertX.assertContentType("Bad cover at ${item.url}", item.coverUrl, "image/*") - AssertX.assertContentType( - "invalid public url ${item.publicUrl}", - item.publicUrl, - "text/html" - ) - Assert.assertFalse("Title is blank at ${item.url}", item.title.isBlank()) - } - - @Test - fun tags() { - val tags = runBlocking { repo.getTags() } - Assert.assertFalse("No tags found", tags.isEmpty()) - val tag = tags.random() - Assert.assertFalse("Tag title is blank for $tag", tag.key.isBlank()) - Assert.assertFalse("Tag title is blank for $tag", tag.title.isBlank()) - val list = runBlocking { repo.getList(0, tag = tag) } - Assert.assertFalse("List is empty", list.isEmpty()) - val item = list.random() - AssertX.assertUrlRelative("Url is not relative", item.url) - AssertX.assertContentType("Bad cover at ${item.coverUrl}", item.coverUrl, "image/*") - AssertX.assertContentType( - "invalid public url ${item.publicUrl}", - item.publicUrl, - "text/html" - ) - Assert.assertFalse("Title is blank at ${item.url}", item.title.isBlank()) - } - - @Test - fun details() { - val manga = runBlocking { repo.getList(0) }.random() - val details = runBlocking { repo.getDetails(manga) } - Assert.assertFalse("No chapters at ${details.url}", details.chapters.isNullOrEmpty()) - AssertX.assertContentType( - "invalid public url ${details.publicUrl}", - details.publicUrl, - "text/html" - ) - Assert.assertFalse( - "Description is empty at ${details.url}", - details.description.isNullOrEmpty() - ) - Assert.assertTrue( - "Chapters are not distinct", - details.chapters.orEmpty().isDistinctBy { it.id }) - val chapter = details.chapters?.randomOrNull() ?: return - AssertX.assertUrlRelative("Url is not relative", chapter.url) - Assert.assertFalse( - "Chapter name missing at ${details.url}:${chapter.number}", - chapter.name.isBlank() - ) - } - - @Test - fun pages() { - val manga = runBlocking { repo.getList(0) }.random() - val details = runBlocking { repo.getDetails(manga) } - val chapter = checkNotNull(details.chapters?.randomOrNull()) { - "No chapters at ${details.url}" - } - val pages = runBlocking { repo.getPages(chapter) } - Assert.assertFalse("Cannot find any page at ${chapter.url}", pages.isEmpty()) - Assert.assertTrue("Pages are not distinct", pages.isDistinctBy { it.id }) - val page = pages.randomOrNull() ?: return - val fullUrl = runBlocking { repo.getPageUrl(page) } - AssertX.assertContentType("Wrong page response from $fullUrl", fullUrl, "image/*") - } - - companion object { - - @JvmStatic - @Parameterized.Parameters(name = "{0}") - fun getProviders() = (MangaSource.values().toList() - MangaSource.LOCAL).toTypedArray() - } -} \ No newline at end of file diff --git a/app/src/test/java/org/koitharu/kotatsu/utils/AssertX.kt b/app/src/test/java/org/koitharu/kotatsu/utils/AssertX.kt deleted file mode 100644 index e7f9e22cb..000000000 --- a/app/src/test/java/org/koitharu/kotatsu/utils/AssertX.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.utils - -import okhttp3.OkHttpClient -import okhttp3.Request -import org.junit.Assert -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.net.HttpURLConnection -import java.net.URI - -object AssertX : KoinComponent { - - private val okHttp by inject() - - fun assertContentType(message: String, url: String, vararg types: String) { - Assert.assertFalse("URL is empty: $message", url.isEmpty()) - val request = Request.Builder() - .url(url) - .head() - .build() - val response = okHttp.newCall(request).execute() - when (val code = response.code) { - HttpURLConnection.HTTP_OK -> { - val type = response.body!!.contentType() - Assert.assertTrue(types.any { - val x = it.split('/') - type?.type == x[0] && (x[1] == "*" || type.subtype == x[1]) - }) - } - else -> Assert.fail("Invalid response code $code at $url: $message") - } - } - - fun assertUrlRelative(message: String, url: String) { - Assert.assertFalse(message, URI(url).isAbsolute) - } - - fun assertUrlAbsolute(message: String, url: String) { - Assert.assertTrue(message, URI(url).isAbsolute) - } - -} \ No newline at end of file diff --git a/app/src/test/java/org/koitharu/kotatsu/utils/CoroutineTestRule.kt b/app/src/test/java/org/koitharu/kotatsu/utils/CoroutineTestRule.kt new file mode 100644 index 000000000..4558ef197 --- /dev/null +++ b/app/src/test/java/org/koitharu/kotatsu/utils/CoroutineTestRule.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CoroutineTestRule( + private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(), +) : TestWatcher() { + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } + + fun runBlockingTest(block: suspend CoroutineScope.() -> Unit) { + runBlocking(testDispatcher) { + block() + } + } +} \ No newline at end of file diff --git a/app/src/test/java/org/koitharu/kotatsu/utils/TestResponse.kt b/app/src/test/java/org/koitharu/kotatsu/utils/TestResponse.kt new file mode 100644 index 000000000..2fcc3e18f --- /dev/null +++ b/app/src/test/java/org/koitharu/kotatsu/utils/TestResponse.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.utils + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +data class TestResponse( + val code: Int, + val type: String?, + val subtype: String?, +) { + + companion object : KoinComponent { + + private val okHttp by inject() + + fun testRequest(url: String): TestResponse { + val request = Request.Builder() + .url(url) + .head() + .build() + val response = okHttp.newCall(request).execute() + val type = response.body?.contentType() + return TestResponse( + code = response.code, + type = type?.type, + subtype = type?.subtype, + ) + } + } +} diff --git a/app/src/test/java/org/koitharu/kotatsu/utils/TruthExt.kt b/app/src/test/java/org/koitharu/kotatsu/utils/TruthExt.kt new file mode 100644 index 000000000..14f62bfc2 --- /dev/null +++ b/app/src/test/java/org/koitharu/kotatsu/utils/TruthExt.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.utils + +import com.google.common.truth.StringSubject +import java.util.regex.Pattern + +private val PATTERN_URL_ABSOLUTE = Pattern.compile("https?://[^\\s]+", Pattern.CASE_INSENSITIVE) +private val PATTERN_URL_RELATIVE = Pattern.compile("^/[^\\s]+", Pattern.CASE_INSENSITIVE) + +fun StringSubject.isRelativeUrl() = matches(PATTERN_URL_RELATIVE) + +fun StringSubject.isAbsoluteUrl() = matches(PATTERN_URL_ABSOLUTE) \ No newline at end of file diff --git a/build.gradle b/build.gradle index cab96dae5..de81afc1e 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:4.2.2' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files