package org.koitharu.kotatsu.parsers import kotlinx.coroutines.test.runTest import okhttp3.HttpUrl import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Disabled import org.junit.jupiter.params.ParameterizedTest import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery import org.koitharu.kotatsu.parsers.model.search.QueryCriteria import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include import org.koitharu.kotatsu.parsers.model.search.SearchableField.* import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.medianOrNull import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.test_util.* import kotlin.time.Duration.Companion.minutes //@ExtendWith(AuthCheckExtension::class) internal class MangaParserTest { private val context = MangaLoaderContextMock private val timeout = 2.minutes @ParameterizedTest(name = "{index}|list|{0}") @MangaSources fun list(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val list = parser.getList(0, parser.defaultSortOrder, MangaListFilter.EMPTY) checkMangaList(list, "list") assert(list.all { it.source == source }) } @ParameterizedTest(name = "{index}|pagination|{0}") @MangaSources fun pagination(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) if (parser is SinglePageMangaParser) { return@runTest } val page1 = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()) val page2 = parser.queryManga(MangaSearchQuery.Builder().offset(page1.size).order(parser.defaultSortOrder).build()) if (parser is PagedMangaParser) { assert(parser.pageSize >= page1.size) { "Page size is ${page1.size} but ${parser.pageSize} expected" } } assert(page1.isNotEmpty()) { "Page 1 is empty" } assert(page2.isNotEmpty()) { "Page 2 is empty" } assert(page1 != page2) { "Pages are equal" } val intersection = page1.intersect(page2.toSet()) assert(intersection.isEmpty()) { "Pages are intersected by " + intersection.size } } @ParameterizedTest(name = "{index}|search|{0}") @MangaSources fun searchByTitleName(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val subject = parser.queryManga( MangaSearchQuery.Builder() .offset(0) .order(parser.defaultSortOrder) .build(), ).minByOrNull { it.title.length } ?: error("No manga found") val query = subject.title check(query.isNotBlank()) { "Manga title '$query' is blank" } val list = parser.queryManga( MangaSearchQuery.Builder() .order(SortOrder.RELEVANCE) .criterion(QueryCriteria.Match(TITLE_NAME, query)) .build(), ) assert(list.isNotEmpty()) { "Empty search results by \"$query\"" } assert(list.singleOrNull { it.url == subject.url && it.id == subject.id } != null) { "Single subject '${subject.title} (${subject.publicUrl})' not found in search results" } checkMangaList(list, "search('$query')") assert(list.all { it.source == source }) } @ParameterizedTest(name = "{index}|tags|{0}") @MangaSources fun tags(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val tags = parser.getFilterOptions().availableTags assert(tags.isNotEmpty()) { "No tags found" } val keys = tags.map { it.key } assert(keys.isDistinct()) assert("" !in keys) val titles = tags.map { it.title } assert(titles.isDistinct()) assert("" !in titles) assert(titles.all { it.isCapitalized() }) { val badTags = titles.filterNot { it.isCapitalized() }.joinToString() "Not all tags are capitalized: $badTags" } assert(tags.all { it.source == source }) val tag = tags.last() val list = parser.queryManga( MangaSearchQuery.Builder() .offset(0) .order(parser.defaultSortOrder) .criterion(Include(TAG, setOf(tag))) .build(), ) checkMangaList(list, "${tag.title} (${tag.key})") assert(list.all { it.source == source }) } @ParameterizedTest(name = "{index}|tags_multiple|{0}") @MangaSources fun tagsMultiple(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) if (!parser.filterCapabilities.isMultipleTagsSupported) return@runTest val tags = parser.getFilterOptions().availableTags.shuffled().take(2).toSet() val list = parser.queryManga( MangaSearchQuery.Builder() .offset(0) .order(parser.defaultSortOrder) .criterion(Include(TAG, tags)) .build(), ) checkMangaList(list, "${tags.joinToString { it.title }} (${tags.joinToString { it.key }})") assert(list.all { it.source == source }) } @ParameterizedTest(name = "{index}|locale|{0}") @MangaSources fun locale(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val locales = parser.getFilterOptions().availableLocales if (locales.isEmpty()) { return@runTest } val locale = locales.random() val list = parser.queryManga( MangaSearchQuery.Builder() .offset(0) .order(parser.defaultSortOrder) .criterion(Include(LANGUAGE, setOf(locale))) .criterion(Include(LANGUAGE, setOf(locale))) .criterion(Include(ORIGINAL_LANGUAGE, setOf(locales.random()))) .build(), ) checkMangaList(list, locale.toString()) assert(list.all { it.source == source }) } @ParameterizedTest(name = "{index}|details|{0}") @MangaSources fun details(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val list = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()) val manga = list[0] parser.getDetails(manga).apply { assert(!chapters.isNullOrEmpty()) { "Chapters are null or empty" } assert(publicUrl.isUrlAbsolute()) { "Manga public url is not absolute: '$publicUrl'" } assert(description != null) { "Detailed description is null: '$publicUrl'" } assert(title.startsWith(manga.title)) { "Titles are mismatch: '$title' and '${manga.title}' for $publicUrl" } assert(this.source == source) val c = checkNotNull(chapters) assert(c.isDistinctBy { it.id }) { "Chapters are not distinct by id: ${c.maxDuplicates { it.id }} for $publicUrl" } assert(c.isDistinctByNotNull { it.key() }) { val dup = c.mapNotNull { it.key() }.maxDuplicates { it } "Chapters are not distinct by branch/volume/number: $dup for $publicUrl" } assert(c.all { it.source == source }) checkImageRequest(coverUrl, source) largeCoverUrl?.let { checkImageRequest(it, source) } } } @ParameterizedTest(name = "{index}|pages|{0}") @MangaSources fun pages(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val list = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()) val manga = list.first() val chapter = parser.getDetails(manga).chapters?.firstOrNull() ?: error("Chapter is null at ${manga.publicUrl}") val pages = parser.getPages(chapter) assert(pages.isNotEmpty()) assert(pages.isDistinctBy { it.id }) assert(pages.all { it.source == source }) arrayOf( pages.first(), pages.medianOrNull() ?: error("No page"), ).forEach { page -> val pageUrl = parser.getPageUrl(page) assert(pageUrl.isNotEmpty()) assert(pageUrl.isUrlAbsolute()) checkImageRequest(pageUrl, page.source) } } @ParameterizedTest(name = "{index}|favicon|{0}") @MangaSources fun favicon(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val favicons = parser.getFavicons() val types = setOf("png", "svg", "ico", "gif", "jpg", "jpeg", "webp", "avif") assert(favicons.isNotEmpty()) favicons.forEach { assert(it.url.isUrlAbsolute()) { "Favicon url is not absolute: ${it.url}" } assert(it.type in types) { "Unknown icon type: ${it.type}" } } val favicon = favicons.find(24) checkNotNull(favicon) checkImageRequest(favicon.url, source) } @ParameterizedTest(name = "{index}|domain|{0}") @MangaSources fun domain(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val defaultDomain = parser.domain val url = HttpUrl.Builder().host(defaultDomain).scheme("https").toString() val response = context.doRequest(url, source) val realUrl = response.request.url val realDomain = realUrl.topPrivateDomain() val realHost = realUrl.host assert(defaultDomain == realHost || defaultDomain == realDomain) { "Domain mismatch:\nRequired:\t\t\t$defaultDomain\nActual:\t\t\t$realDomain\nHost:\t\t\t$realHost" } } @ParameterizedTest(name = "{index}|link|{0}") @MangaSources fun link(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) val manga = parser.queryManga(MangaSearchQuery.Builder().offset(0).order(parser.defaultSortOrder).build()).first() val resolved = context.newLinkResolver(manga.publicUrl).getManga() Assertions.assertNotNull(resolved) resolved ?: return@runTest Assertions.assertEquals(manga.id, resolved.id) Assertions.assertEquals(manga.publicUrl, resolved.publicUrl) Assertions.assertEquals(manga.url, resolved.url) Assertions.assertEquals(manga.title, resolved.title) } @ParameterizedTest(name = "{index}|authorization|{0}") @MangaSources @Disabled fun authorization(source: MangaParserSource) = runTest(timeout = timeout) { val parser = context.newParserInstance(source) if (parser is MangaParserAuthProvider) { val username = parser.getUsername() assert(username.isNotBlank()) { "Username is blank" } println("Signed in to ${source.name} as $username") } } private suspend fun checkMangaList(list: List, cause: String) { assert(list.isNotEmpty()) { "Manga list for '$cause' is empty" } assert(list.isDistinctBy { it.id }) { "Manga list for '$cause' contains duplicated ids" } for (item in list) { assert(item.url.isNotEmpty()) { "Url is empty" } assert(!item.url.isUrlAbsolute()) { "Url looks like absolute: ${item.url}" } item.coverUrl?.let { assert(it.isUrlAbsolute()) { "Cover url is not absolute: ${item.coverUrl}" } } assert(item.title.isNotEmpty()) { "Title for ${item.publicUrl} is empty" } assert(item.publicUrl.isUrlAbsolute()) } val testItem = list.random() checkImageRequest(testItem.coverUrl, testItem.source) } private suspend fun checkImageRequest(url: String?, source: MangaSource) { if (url == null) { return } context.doRequest(url, source).use { assert(it.isSuccessful) { "Request failed: ${it.code}(${it.message}): $url" } assert(it.mimeType?.startsWith("image/") == true) { "Wrong response mime type: ${it.mimeType}" } } } private fun String.isCapitalized(): Boolean { return !first().isLowerCase() } private fun MangaChapter.key(): Any? = when { number > 0f && volume > 0 -> Triple(branch, volume, number) number > 0f -> Pair(branch, number) else -> null } }