perf(api): faster readlist matching for cbl

BREAKING-CHANGE: removed api/v1/readlists/import
This commit is contained in:
Gauthier Roebroeck 2023-03-13 18:22:55 +08:00
parent baa39eb00f
commit 2461c835ad
14 changed files with 162 additions and 596 deletions

View File

@ -1,37 +1,37 @@
# Error codes
| Code | Description |
|----------|---------------------------------------------------------|
| ERR_1000 | File could not be accessed during analysis |
| ERR_1001 | Media type is not supported during analysis |
| ERR_1002 | Encrypted RAR archives are not supported |
| ERR_1003 | Solid RAR archives are not supported |
| ERR_1004 | Multi-Volume RAR archives are not supported |
| ERR_1005 | Unknown error while analyzing book |
| ERR_1006 | Book does not contain any page |
| ERR_1007 | Some entries could not be analyzed |
| ERR_1008 | Unknown error while getting book's entries |
| ERR_1009 | A read list with that name already exists |
| ERR_1010 | No books were matched within the read list request |
| ERR_1011 | No unique match for series |
| ERR_1012 | No match for series |
| ERR_1013 | No unique match for book number within series |
| ERR_1014 | No match for book number within series |
| ERR_1015 | Error while deserializing ComicRack ReadingList |
| ERR_1016 | Directory not accessible or not a directory |
| ERR_1017 | Cannot scan folder that is part of an existing library |
| ERR_1018 | File not found |
| ERR_1019 | Cannot import file that is part of an existing library |
| ERR_1020 | Book to upgrade does not belong to provided series |
| ERR_1021 | Destination file already exists |
| ERR_1022 | Newly imported book could not be scanned |
| ERR_1023 | Book already present in ReadingList |
| ERR_1024 | OAuth2 login error: no email attribute |
| ERR_1025 | OAuth2 login error: no local user exist with that email |
| ERR_1026 | OpenIDConnect login error: email not verified |
| ERR_1027 | OpenIDConnect login error: no email_verified attribute |
| ERR_1028 | OpenIDConnect login error: no email attribute |
| ERR_1029 | ComicRack CBL does not contain any Book element |
| ERR_1030 | ComicRack CBL has no Name element |
| ERR_1031 | ComicRack CBL Book is missing series or number |
| Code | Description |
|--------------|---------------------------------------------------------|
| ERR_1000 | File could not be accessed during analysis |
| ERR_1001 | Media type is not supported during analysis |
| ERR_1002 | Encrypted RAR archives are not supported |
| ERR_1003 | Solid RAR archives are not supported |
| ERR_1004 | Multi-Volume RAR archives are not supported |
| ERR_1005 | Unknown error while analyzing book |
| ERR_1006 | Book does not contain any page |
| ERR_1007 | Some entries could not be analyzed |
| ERR_1008 | Unknown error while getting book's entries |
| ~~ERR_1009~~ | ~~A read list with that name already exists~~ |
| ~~ERR_1010~~ | ~~No books were matched within the read list request~~ |
| ~~ERR_1011~~ | ~~No unique match for series~~ |
| ~~ERR_1012~~ | ~~No match for series~~ |
| ~~ERR_1013~~ | ~~No unique match for book number within series~~ |
| ~~ERR_1014~~ | ~~No match for book number within series~~ |
| ERR_1015 | Error while deserializing ComicRack ReadingList |
| ERR_1016 | Directory not accessible or not a directory |
| ERR_1017 | Cannot scan folder that is part of an existing library |
| ERR_1018 | File not found |
| ERR_1019 | Cannot import file that is part of an existing library |
| ERR_1020 | Book to upgrade does not belong to provided series |
| ERR_1021 | Destination file already exists |
| ERR_1022 | Newly imported book could not be scanned |
| ERR_1023 | Book already present in ReadingList |
| ERR_1024 | OAuth2 login error: no email attribute |
| ERR_1025 | OAuth2 login error: no local user exist with that email |
| ERR_1026 | OpenIDConnect login error: email not verified |
| ERR_1027 | OpenIDConnect login error: no email_verified attribute |
| ERR_1028 | OpenIDConnect login error: no email attribute |
| ERR_1029 | ComicRack CBL does not contain any Book element |
| ERR_1030 | ComicRack CBL has no Name element |
| ERR_1031 | ComicRack CBL Book is missing series or number |

View File

@ -0,0 +1,2 @@
create index if not exists idx__series_metadata__title
on SERIES_METADATA (TITLE);

View File

@ -13,20 +13,9 @@ data class ReadListRequestBook(
val number: String,
)
data class ReadListRequestResult(
val readList: ReadList?,
val unmatchedBooks: List<ReadListRequestResultBook> = emptyList(),
val errorCode: String = "",
)
data class ReadListRequestResultBook(
val book: ReadListRequestBook,
val errorCode: String = "",
)
data class ReadListRequestMatch(
val readListMatch: ReadListMatch,
val matches: List<ReadListRequestBookMatches>,
val requests: Collection<ReadListRequestBookMatches>,
val errorCode: String = "",
)
@ -37,5 +26,16 @@ data class ReadListMatch(
data class ReadListRequestBookMatches(
val request: ReadListRequestBook,
val matches: Map<Series, Collection<Book>>,
val matches: Map<ReadListRequestBookMatchSeries, Collection<ReadListRequestBookMatchBook>>,
)
data class ReadListRequestBookMatchSeries(
val id: String,
val title: String,
)
data class ReadListRequestBookMatchBook(
val id: String,
val number: String,
val title: String,
)

View File

@ -0,0 +1,8 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.ReadListRequestBook
import org.gotson.komga.domain.model.ReadListRequestBookMatches
interface ReadListRequestRepository {
fun matchBookRequests(requests: Collection<ReadListRequestBook>): Collection<ReadListRequestBookMatches>
}

View File

@ -6,7 +6,6 @@ import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadListRequestMatch
import org.gotson.komga.domain.model.ReadListRequestResult
import org.gotson.komga.domain.model.ThumbnailReadList
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ThumbnailReadListRepository
@ -122,24 +121,8 @@ class ReadListLifecycle(
return mosaicGenerator.createMosaic(images)
}
fun importReadList(fileContent: ByteArray): ReadListRequestResult {
val request = try {
readListProvider.importFromCbl(fileContent) ?: return ReadListRequestResult(null, emptyList(), "ERR_1015")
} catch (e: Exception) {
return ReadListRequestResult(null, emptyList(), "ERR_1015")
}
val result = readListMatcher.matchAndCreateReadListRequest(request)
return when {
result.readList != null -> {
result.copy(readList = addReadList(result.readList))
}
else -> result
}
}
fun matchComicRackList(fileContent: ByteArray): ReadListRequestMatch {
val request = readListProvider.importFromCblV2(fileContent)
val request = readListProvider.importFromCbl(fileContent)
return readListMatcher.matchReadListRequest(request)
}

View File

@ -1,18 +1,11 @@
package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadListMatch
import org.gotson.komga.domain.model.ReadListRequest
import org.gotson.komga.domain.model.ReadListRequestBookMatches
import org.gotson.komga.domain.model.ReadListRequestMatch
import org.gotson.komga.domain.model.ReadListRequestResult
import org.gotson.komga.domain.model.ReadListRequestResultBook
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.language.toIndexedMap
import org.gotson.komga.domain.persistence.ReadListRequestRepository
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@ -20,55 +13,8 @@ private val logger = KotlinLogging.logger {}
@Service
class ReadListMatcher(
private val readListRepository: ReadListRepository,
private val seriesRepository: SeriesRepository,
private val bookRepository: BookRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val readListRequestRepository: ReadListRequestRepository,
) {
fun matchAndCreateReadListRequest(request: ReadListRequest): ReadListRequestResult {
logger.info { "Trying to match $request" }
if (readListRepository.existsByName(request.name)) {
return ReadListRequestResult(readList = null, unmatchedBooks = request.books.map { ReadListRequestResultBook(it) }, errorCode = "ERR_1009")
}
val bookIds = mutableListOf<String>()
val unmatchedBooks = mutableListOf<ReadListRequestResultBook>()
request.books.forEach { book ->
val seriesMatches = seriesRepository.findAllByTitle(book.series.first())
when {
seriesMatches.size > 1 -> unmatchedBooks += ReadListRequestResultBook(book, "ERR_1011")
seriesMatches.isEmpty() -> unmatchedBooks += ReadListRequestResultBook(book, "ERR_1012")
else -> {
val seriesId = seriesMatches.first().id
val seriesBooks = bookRepository.findAllBySeriesId(seriesId)
val bookMatches = bookMetadataRepository.findAllByIds(seriesBooks.map { it.id })
.filter { (it.number.trimStart('0') == book.number.trimStart('0')) }
.map { it.bookId }
when {
bookMatches.size > 1 -> unmatchedBooks += ReadListRequestResultBook(book, "ERR_1013")
bookMatches.isEmpty() -> unmatchedBooks += ReadListRequestResultBook(book, "ERR_1014")
bookIds.contains(bookMatches.first()) -> unmatchedBooks += ReadListRequestResultBook(book, "ERR_1023")
else -> bookIds.add(bookMatches.first())
}
}
}
}
return if (bookIds.isNotEmpty())
ReadListRequestResult(
readList = ReadList(name = request.name, bookIds = bookIds.toIndexedMap()),
unmatchedBooks = unmatchedBooks,
)
else {
ReadListRequestResult(
readList = null,
unmatchedBooks = unmatchedBooks,
errorCode = "ERR_1010",
)
}
}
fun matchReadListRequest(request: ReadListRequest): ReadListRequestMatch {
logger.info { "Trying to match $request" }
@ -76,13 +22,7 @@ class ReadListMatcher(
if (readListRepository.existsByName(request.name)) ReadListMatch(request.name, "ERR_1009")
else ReadListMatch(request.name)
val matches = request.books.map { book ->
val matches = book.series.flatMap { seriesRepository.findAllByTitle(it) }.associateWith { series ->
bookRepository.findAllBySeriesId(series.id)
.filter { (bookMetadataRepository.findById(it.id).number.trimStart('0') == book.number.trimStart('0')) }
}
ReadListRequestBookMatches(book, matches)
}
val matches = readListRequestRepository.matchBookRequests(request.books)
return ReadListRequestMatch(readListMatch, matches)
}

View File

@ -0,0 +1,61 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.ReadListRequestBook
import org.gotson.komga.domain.model.ReadListRequestBookMatchBook
import org.gotson.komga.domain.model.ReadListRequestBookMatchSeries
import org.gotson.komga.domain.model.ReadListRequestBookMatches
import org.gotson.komga.domain.persistence.ReadListRequestRepository
import org.gotson.komga.jooq.Tables
import org.jooq.DSLContext
import org.jooq.impl.DSL.ltrim
import org.jooq.impl.DSL.row
import org.jooq.impl.DSL.value
import org.jooq.impl.DSL.values
import org.springframework.stereotype.Component
@Component
class ReadListRequestDao(
private val dsl: DSLContext,
) : ReadListRequestRepository {
private val sd = Tables.SERIES_METADATA
private val b = Tables.BOOK
private val bd = Tables.BOOK_METADATA
override fun matchBookRequests(requests: Collection<ReadListRequestBook>): Collection<ReadListRequestBookMatches> {
// use a table expression to join the requests to their potential matches
val requestsAsRows = requests.flatMapIndexed { i, r -> r.series.map { row(i, it, r.number) } }
val seriesField = "series"
val indexField = "index"
val numberField = "number"
val requestsTable = values(*requestsAsRows.toTypedArray()).`as`("request", indexField, seriesField, numberField)
val matchedRequests = dsl.select(
requestsTable.field(indexField, Int::class.java),
sd.SERIES_ID,
sd.TITLE,
bd.BOOK_ID,
bd.NUMBER,
bd.TITLE,
)
.from(requestsTable)
.innerJoin(sd).on(requestsTable.field(seriesField, String::class.java)?.eq(sd.TITLE.noCase()))
.innerJoin(b).on(sd.SERIES_ID.eq(b.SERIES_ID))
.innerJoin(bd).on(
b.ID.eq(bd.BOOK_ID)
.and(ltrim(bd.NUMBER, value("0")).eq(ltrim(requestsTable.field(numberField, String::class.java), value("0")).noCase())),
).fetchGroups(requestsTable.field(indexField, Int::class.java))
.mapValues { (_, records) ->
// use the requests index to match results
records.groupBy(
{ ReadListRequestBookMatchSeries(it.get(1, String::class.java), it.get(2, String::class.java)) },
{ ReadListRequestBookMatchBook(it.get(3, String::class.java), it.get(4, String::class.java), it.get(5, String::class.java)) },
)
}
return requests.mapIndexed { i, request ->
ReadListRequestBookMatches(
request,
matchedRequests.getOrDefault(i, emptyMap()),
)
}
}
}

View File

@ -15,40 +15,8 @@ private val logger = KotlinLogging.logger {}
class ReadListProvider(
@Autowired(required = false) private val mapper: XmlMapper = XmlMapper(),
) {
fun importFromCbl(cbl: ByteArray): ReadListRequest? {
try {
val readingList = mapper.readValue(cbl, ReadingList::class.java)
if (readingList.books.isNotEmpty()) {
logger.debug { "Trying to convert ComicRack ReadingList to ReadListRequest: $readingList" }
if (readingList.name.isNullOrBlank()) {
logger.warn { "ReadingList has no name, skipping" }
return null
}
val books = readingList.books.mapNotNull {
val series = computeSeriesFromSeriesAndVolume(it.series, it.volume)
if (!series.isNullOrBlank() && it.number != null)
ReadListRequestBook(setOf(series), it.number!!.trim())
else {
logger.warn { "Book is missing series or number, skipping: $it" }
null
}
}
if (books.isNotEmpty())
return ReadListRequest(name = readingList.name!!, books = books)
.also { logger.debug { "Converted request: $it" } }
}
} catch (e: Exception) {
logger.error(e) { "Error while trying to parse ComicRack ReadingList" }
}
return null
}
@Throws(ComicRackListException::class)
fun importFromCblV2(cbl: ByteArray): ReadListRequest {
fun importFromCbl(cbl: ByteArray): ReadListRequest {
val readingList = try {
mapper.readValue(cbl, ReadingList::class.java)
} catch (e: Exception) {

View File

@ -39,7 +39,6 @@ import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.gotson.komga.interfaces.api.rest.dto.ReadListCreationDto
import org.gotson.komga.interfaces.api.rest.dto.ReadListDto
import org.gotson.komga.interfaces.api.rest.dto.ReadListRequestMatchDto
import org.gotson.komga.interfaces.api.rest.dto.ReadListRequestResultDto
import org.gotson.komga.interfaces.api.rest.dto.ReadListUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressDto
import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressUpdateDto
@ -242,14 +241,6 @@ class ReadListController(
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
@Deprecated("Deprecated since 0.162.0, use api/v1/readlists/match/comicrack instead")
@PostMapping("/import")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun importFromComicRackList(
@RequestParam("files") files: List<MultipartFile>,
): List<ReadListRequestResultDto> =
files.map { readListLifecycle.importReadList(it.bytes).toDto(it.originalFilename) }
@PostMapping("match/comicrack")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun matchFromComicRackList(

View File

@ -6,21 +6,18 @@ import org.gotson.komga.domain.model.ReadListRequestMatch
data class ReadListRequestMatchDto(
val readListMatch: ReadListMatchDto,
val matches: List<ReadListRequestBookMatchesDto>,
val requests: Collection<ReadListRequestBookMatchesDto>,
val errorCode: String = "",
)
fun ReadListRequestMatch.toDto() =
ReadListRequestMatchDto(
readListMatch.toDto(),
matches.map {
requests.map { request ->
ReadListRequestBookMatchesDto(
it.request.toDtoV2(),
it.matches.entries.map { (series, books) ->
ReadListRequestBookMatchDto(
series.id,
books.map { book -> book.id },
)
request.request.toDto(),
request.matches.entries.map { (series, books) ->
ReadListRequestBookMatchDto(ReadListRequestBookMatchSeriesDto(series.id, series.title), books.map { ReadListRequestBookMatchBookDto(it.id, it.number, it.title) })
},
)
},
@ -31,25 +28,35 @@ data class ReadListMatchDto(
val errorCode: String = "",
)
fun ReadListMatch.toDto() = ReadListMatchDto(name, errorCode)
data class ReadListRequestBookMatchesDto(
val request: ReadListRequestBookV2Dto,
val request: ReadListRequestBookDto,
val matches: List<ReadListRequestBookMatchDto>,
)
data class ReadListRequestBookV2Dto(
data class ReadListRequestBookDto(
val series: Set<String>,
val number: String,
)
fun ReadListRequestBook.toDtoV2() =
ReadListRequestBookV2Dto(
fun ReadListRequestBook.toDto() =
ReadListRequestBookDto(
series = series,
number = number,
)
data class ReadListRequestBookMatchDto(
val seriesId: String,
val bookIds: List<String>,
val series: ReadListRequestBookMatchSeriesDto,
val books: Collection<ReadListRequestBookMatchBookDto>,
)
fun ReadListMatch.toDto() = ReadListMatchDto(name, errorCode)
data class ReadListRequestBookMatchSeriesDto(
val seriesId: String,
val title: String,
)
data class ReadListRequestBookMatchBookDto(
val bookId: String,
val number: String,
val title: String,
)

View File

@ -1,42 +0,0 @@
package org.gotson.komga.interfaces.api.rest.dto
import org.gotson.komga.domain.model.ReadListRequestBook
import org.gotson.komga.domain.model.ReadListRequestResult
import org.gotson.komga.domain.model.ReadListRequestResultBook
data class ReadListRequestBookDto(
val series: String,
val number: String,
)
data class ReadListRequestResultDto(
val readList: ReadListDto?,
val unmatchedBooks: List<ReadListRequestResultBookDto> = emptyList(),
val errorCode: String = "",
val requestName: String,
)
data class ReadListRequestResultBookDto(
val book: ReadListRequestBookDto,
val errorCode: String = "",
)
fun ReadListRequestResult.toDto(requestName: String?) =
ReadListRequestResultDto(
readList = readList?.toDto(),
unmatchedBooks = unmatchedBooks.map { it.toDto() },
errorCode = errorCode,
requestName = requestName ?: "",
)
fun ReadListRequestResultBook.toDto() =
ReadListRequestResultBookDto(
book = book.toDto(),
errorCode = errorCode,
)
fun ReadListRequestBook.toDto() =
ReadListRequestBookDto(
series = series.first(),
number = number,
)

View File

@ -1,5 +1,5 @@
komga:
workspace: localdb
workspace: diesel
database:
file: \${komga.config-dir}/\${komga.workspace}.sqlite
lucene:

View File

@ -68,212 +68,6 @@ class ReadListMatcherTest(
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Nested
inner class MatchAndCreate {
@Test
fun `given request with existing series and books when matching then result contains a read list with all books`() {
// given
val booksSeries1 = listOf(
makeBook("book1", libraryId = library.id),
makeBook("book5", libraryId = library.id),
)
makeSeries(name = "batman", libraryId = library.id).let { s ->
seriesLifecycle.createSeries(s)
seriesLifecycle.addBooks(s, booksSeries1)
seriesLifecycle.sortBooks(s)
seriesMetadataRepository.findById(s.id).let {
seriesMetadataRepository.update(it.copy(title = "Batman: White Knight"))
}
}
val booksSeries2 = listOf(
makeBook("book1", libraryId = library.id),
makeBook("book2", libraryId = library.id),
)
makeSeries(name = "joker", libraryId = library.id).let { s ->
seriesLifecycle.createSeries(s)
seriesLifecycle.addBooks(s, booksSeries2)
seriesLifecycle.sortBooks(s)
bookMetadataRepository.findById(booksSeries2[0].id).let {
bookMetadataRepository.update(it.copy(number = "0025"))
}
}
val request = ReadListRequest(
name = "readlist",
books = listOf(
ReadListRequestBook(series = setOf("Batman: White Knight"), number = "1"),
ReadListRequestBook(series = setOf("joker"), number = "02"),
ReadListRequestBook(series = setOf("Batman: White Knight"), number = "2"),
ReadListRequestBook(series = setOf("joker"), number = "25"),
),
)
// when
val result = readListMatcher.matchAndCreateReadListRequest(request)
// then
with(result) {
assertThat(readList).isNotNull
assertThat(unmatchedBooks).isEmpty()
assertThat(errorCode).isBlank
with(readList!!) {
assertThat(name).isEqualTo(request.name)
assertThat(bookIds).hasSize(4)
assertThat(bookIds).containsExactlyEntriesOf(
mapOf(
0 to booksSeries1[0].id,
1 to booksSeries2[1].id,
2 to booksSeries1[1].id,
3 to booksSeries2[0].id,
),
)
}
}
}
@Test
fun `given request with existing read list when matching then result has no readlist and appropriate error code`() {
// given
readListLifecycle.addReadList(
ReadList(name = "my ReadList"),
)
val request = ReadListRequest(
name = "my readlist",
books = listOf(
ReadListRequestBook(series = setOf("batman: white knight"), number = "1"),
ReadListRequestBook(series = setOf("joker"), number = "2"),
ReadListRequestBook(series = setOf("BATMAN: WHITE KNIGHT"), number = "2"),
ReadListRequestBook(series = setOf("joker"), number = "25"),
),
)
// when
val result = readListMatcher.matchAndCreateReadListRequest(request)
// then
with(result) {
assertThat(readList).isNull()
assertThat(errorCode).isEqualTo("ERR_1009")
assertThat(unmatchedBooks.map { it.book }).containsExactlyElementsOf(request.books)
}
}
@Test
fun `given request and some matching series or books when matching then returns result with appropriate error codes`() {
// given
val booksSeries1 = listOf(
makeBook("book1", libraryId = library.id),
makeBook("book5", libraryId = library.id),
)
makeSeries(name = "batman", libraryId = library.id).let { s ->
seriesLifecycle.createSeries(s)
seriesLifecycle.addBooks(s, booksSeries1)
seriesLifecycle.sortBooks(s)
bookMetadataRepository.findById(booksSeries1[0].id).let {
bookMetadataRepository.update(it.copy(number = "2"))
}
}
val booksSeries2 = listOf(
makeBook("book1", libraryId = library.id),
makeBook("book2", libraryId = library.id),
)
makeSeries(name = "joker", libraryId = library.id).let { s ->
seriesLifecycle.createSeries(s)
seriesLifecycle.addBooks(s, booksSeries2)
seriesLifecycle.sortBooks(s)
}
makeSeries(name = "joker", libraryId = library.id).let { s ->
seriesLifecycle.createSeries(s)
}
val request = ReadListRequest(
name = "readlist",
books = listOf(
ReadListRequestBook(series = setOf("tokyo ghost"), number = "1"),
ReadListRequestBook(series = setOf("batman"), number = "3"),
ReadListRequestBook(series = setOf("joker"), number = "3"),
ReadListRequestBook(series = setOf("batman"), number = "2"),
),
)
// when
val result = readListMatcher.matchAndCreateReadListRequest(request)
// then
with(result) {
assertThat(readList).isNull()
assertThat(errorCode).isEqualTo("ERR_1010")
assertThat(unmatchedBooks).hasSize(4)
assertThat(unmatchedBooks[0].book).isEqualTo(request.books[0])
assertThat(unmatchedBooks[0].errorCode).isEqualTo("ERR_1012")
assertThat(unmatchedBooks[1].book).isEqualTo(request.books[1])
assertThat(unmatchedBooks[1].errorCode).isEqualTo("ERR_1014")
assertThat(unmatchedBooks[2].book).isEqualTo(request.books[2])
assertThat(unmatchedBooks[2].errorCode).isEqualTo("ERR_1011")
assertThat(unmatchedBooks[3].book).isEqualTo(request.books[3])
assertThat(unmatchedBooks[3].errorCode).isEqualTo("ERR_1013")
}
}
@Test
fun `given request with duplicate books when matching then returns result with appropriate error codes`() {
// given
val booksSeries1 = listOf(
makeBook("book1", libraryId = library.id),
makeBook("book2", libraryId = library.id),
)
makeSeries(name = "batman", libraryId = library.id).let { s ->
seriesLifecycle.createSeries(s)
seriesLifecycle.addBooks(s, booksSeries1)
seriesLifecycle.sortBooks(s)
}
val request = ReadListRequest(
name = "readlist",
books = listOf(
ReadListRequestBook(series = setOf("batman"), number = "1"),
ReadListRequestBook(series = setOf("batman"), number = "2"),
ReadListRequestBook(series = setOf("batman"), number = "2"),
),
)
// when
val result = readListMatcher.matchAndCreateReadListRequest(request)
// then
with(result) {
assertThat(readList).isNotNull
with(readList!!) {
assertThat(name).isEqualTo(request.name)
assertThat(bookIds).hasSize(2)
assertThat(bookIds).containsExactlyEntriesOf(
mapOf(
0 to booksSeries1[0].id,
1 to booksSeries1[1].id,
),
)
}
assertThat(errorCode).isBlank
assertThat(unmatchedBooks).hasSize(1)
assertThat(unmatchedBooks[0].book).isEqualTo(request.books[2])
assertThat(unmatchedBooks[0].errorCode).isEqualTo("ERR_1023")
}
}
}
@Nested
inner class Match {
private fun Collection<ReadListRequestBookMatches>.mapIds() = map {
@ -313,7 +107,7 @@ class ReadListMatcherTest(
}
val request = ReadListRequest(
name = "readlist",
name = "readlist request",
books = listOf(
ReadListRequestBook(series = setOf("Batman: White Knight"), number = "1"),
ReadListRequestBook(series = setOf("joker"), number = "02"),
@ -331,9 +125,9 @@ class ReadListMatcherTest(
assertThat(name).isEqualTo(request.name)
assertThat(errorCode).isBlank
}
assertThat(matches).hasSize(4)
assertThat(matches.map { it.request }).containsExactlyElementsOf(request.books)
assertThat(matches.mapIds()).isEqualTo(
assertThat(requests).hasSize(4)
assertThat(requests.map { it.request }).containsExactlyElementsOf(request.books)
assertThat(requests.mapIds()).isEqualTo(
listOf(
mapOf(series1.id to listOf(booksSeries1[0].id)),
mapOf(series2.id to listOf(booksSeries2[1].id)),
@ -397,9 +191,9 @@ class ReadListMatcherTest(
assertThat(name).isEqualTo(request.name)
assertThat(errorCode).isEqualTo("ERR_1009")
}
assertThat(matches).hasSize(4)
assertThat(matches.map { it.request }).containsExactlyElementsOf(request.books)
assertThat(matches.mapIds()).isEqualTo(
assertThat(requests).hasSize(4)
assertThat(requests.map { it.request }).containsExactlyElementsOf(request.books)
assertThat(requests.mapIds()).isEqualTo(
listOf(
mapOf(series1.id to listOf(booksSeries1[0].id)),
mapOf(series2.id to listOf(booksSeries2[1].id)),
@ -459,13 +253,13 @@ class ReadListMatcherTest(
assertThat(name).isEqualTo(request.name)
assertThat(errorCode).isBlank
}
assertThat(matches).hasSize(4)
assertThat(matches.map { it.request }).containsExactlyElementsOf(request.books)
assertThat(matches.mapIds()).isEqualTo(
assertThat(requests).hasSize(4)
assertThat(requests.map { it.request }).containsExactlyElementsOf(request.books)
assertThat(requests.mapIds()).isEqualTo(
listOf(
emptyMap(),
mapOf(series1.id to emptyList()),
mapOf(series2.id to listOf(booksSeries2[1].id), series2dupe.id to emptyList()),
emptyMap(),
mapOf(series2.id to listOf(booksSeries2[1].id)),
mapOf(series1.id to listOf(booksSeries1[0].id, booksSeries1[1].id)),
),
)

View File

@ -41,152 +41,6 @@ class ReadListProviderTest {
// when
val request = readListProvider.importFromCbl(ByteArray(0))
// then
assertThat(request).isNotNull
with(request!!) {
assertThat(name).isEqualTo(cbl.name)
assertThat(books).hasSize(2)
with(books[0]) {
assertThat(series).containsExactlyInAnyOrder("series 1 (2005)")
assertThat(number).isEqualTo("4")
}
with(books[1]) {
assertThat(series).containsExactlyInAnyOrder("series 2")
assertThat(number).isEqualTo("1")
}
}
}
@Test
fun `given CBL list with invalid books when getting ReadListRequest then it is null`() {
// given
val cbl = ReadingList().apply {
name = "my read list"
books = listOf(
Book().apply {
series = " "
number = "4"
volume = 2005
},
Book().apply {
series = null
number = "1"
},
Book().apply {
series = "Series"
number = null
},
)
}
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val request = readListProvider.importFromCbl(ByteArray(0))
// then
assertThat(request).isNull()
}
@Test
fun `given CBL list without books when getting ReadListRequest then it is null`() {
// given
val cbl = ReadingList().apply {
name = "my read list"
books = emptyList()
}
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val request = readListProvider.importFromCbl(ByteArray(0))
// then
assertThat(request).isNull()
}
@Test
fun `given CBL list without name when getting ReadListRequest then it is null`() {
// given
val cbl = ReadingList().apply {
name = null
books = listOf(
Book().apply {
series = "series 1"
number = "4"
volume = 2005
},
Book().apply {
series = "series 2"
number = "1"
},
)
}
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val request = readListProvider.importFromCbl(ByteArray(0))
// then
assertThat(request).isNull()
}
@Test
fun `given CBL list with blank name when getting ReadListRequest then it is null`() {
// given
val cbl = ReadingList().apply {
name = " "
books = listOf(
Book().apply {
series = "series 1"
number = "4"
volume = 2005
},
Book().apply {
series = "series 2"
number = "1"
},
)
}
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val request = readListProvider.importFromCbl(ByteArray(0))
// then
assertThat(request).isNull()
}
}
@Nested
inner class ImportFromCblV2 {
@Test
fun `given CBL list with books when getting ReadListRequest then it is valid`() {
// given
val cbl = ReadingList().apply {
name = "my read list"
books = listOf(
Book().apply {
series = "series 1"
number = " 4 "
volume = 2005
},
Book().apply {
series = "series 2"
number = "1"
},
)
}
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val request = readListProvider.importFromCblV2(ByteArray(0))
// then
with(request) {
assertThat(name).isEqualTo(cbl.name)
@ -229,7 +83,7 @@ class ReadListProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val thrown = catchThrowable { readListProvider.importFromCblV2(ByteArray(0)) }
val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) }
// then
assertThat(thrown).isInstanceOf(ComicRackListException::class.java)
@ -247,7 +101,7 @@ class ReadListProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val thrown = catchThrowable { readListProvider.importFromCblV2(ByteArray(0)) }
val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) }
// then
assertThat(thrown).isInstanceOf(ComicRackListException::class.java)
@ -275,7 +129,7 @@ class ReadListProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val thrown = catchThrowable { readListProvider.importFromCblV2(ByteArray(0)) }
val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) }
// then
assertThat(thrown).isInstanceOf(ComicRackListException::class.java)
@ -303,7 +157,7 @@ class ReadListProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ReadingList::class.java) } returns cbl
// when
val thrown = catchThrowable { readListProvider.importFromCblV2(ByteArray(0)) }
val thrown = catchThrowable { readListProvider.importFromCbl(ByteArray(0)) }
// then
assertThat(thrown).isInstanceOf(ComicRackListException::class.java)