feat(opds): do not show soft deleted books/series

This commit is contained in:
Gauthier Roebroeck 2021-07-08 11:24:19 +08:00
parent d946600a64
commit 5b6b817085
6 changed files with 282 additions and 7 deletions

View File

@ -4,7 +4,8 @@ open class BookSearch(
val libraryIds: Collection<String>? = null,
val seriesIds: Collection<String>? = null,
val searchTerm: String? = null,
val mediaStatus: Collection<Media.Status>? = null
val mediaStatus: Collection<Media.Status>? = null,
val deleted: Boolean? = null,
)
class BookSearchWithReadProgress(

View File

@ -5,7 +5,8 @@ open class SeriesSearch(
val collectionIds: Collection<String>? = null,
val searchTerm: String? = null,
val metadataStatus: Collection<SeriesMetadata.Status>? = null,
val publishers: Collection<String>? = null
val publishers: Collection<String>? = null,
val deleted: Boolean? = null,
)
class SeriesSearchWithReadProgress(

View File

@ -271,6 +271,8 @@ class BookDao(
if (!seriesIds.isNullOrEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds))
searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) }
if (!mediaStatus.isNullOrEmpty()) c = c.and(m.STATUS.`in`(mediaStatus))
if (deleted == true) c = c.and(b.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(b.DELETED_DATE.isNull)
return c
}

View File

@ -129,6 +129,8 @@ class SeriesDao(
searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) }
if (!metadataStatus.isNullOrEmpty()) c = c.and(d.STATUS.`in`(metadataStatus))
if (!publishers.isNullOrEmpty()) c = c.and(DSL.lower(d.PUBLISHER).`in`(publishers.map { it.lowercase() }))
if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(s.DELETED_DATE.isNull)
return c
}

View File

@ -189,7 +189,8 @@ class OpdsController(
val seriesSearch = SeriesSearch(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = searchTerm,
publishers = publishers
publishers = publishers,
deleted = false,
)
val entries = seriesRepository.findAll(seriesSearch)
@ -215,7 +216,8 @@ class OpdsController(
@AuthenticationPrincipal principal: KomgaPrincipal
): OpdsFeed {
val seriesSearch = SeriesSearch(
libraryIds = principal.user.getAuthorizedLibraryIds(null)
libraryIds = principal.user.getAuthorizedLibraryIds(null),
deleted = false,
)
val entries = seriesRepository.findAll(seriesSearch)
@ -243,7 +245,8 @@ class OpdsController(
): OpdsFeed {
val bookSearch = BookSearch(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
mediaStatus = setOf(Media.Status.READY)
mediaStatus = setOf(Media.Status.READY),
deleted = false,
)
val pageRequest = PageRequest.of(0, 50, Sort.by(Sort.Order.desc("createdDate")))
@ -376,7 +379,8 @@ class OpdsController(
val books = bookRepository.findAll(
BookSearch(
seriesIds = listOf(id),
mediaStatus = setOf(Media.Status.READY)
mediaStatus = setOf(Media.Status.READY),
deleted = false,
)
)
val metadata = seriesMetadataRepository.findById(series.id)
@ -407,7 +411,10 @@ class OpdsController(
libraryRepository.findByIdOrNull(id)?.let { library ->
if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
val seriesSearch = SeriesSearch(libraryIds = setOf(library.id))
val seriesSearch = SeriesSearch(
libraryIds = setOf(library.id),
deleted = false,
)
val entries = seriesRepository.findAll(seriesSearch)
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
@ -434,6 +441,7 @@ class OpdsController(
): OpdsFeed {
return collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection ->
val series = collection.seriesIds.mapNotNull { seriesRepository.findByIdOrNull(it) }
.filterNot { it.deletedDate != null }
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
val sorted =
@ -465,6 +473,7 @@ class OpdsController(
): OpdsFeed {
return readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
val books = readList.bookIds.values.mapNotNull { bookRepository.findByIdOrNull(it) }
.filterNot { it.deletedDate != null }
.map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) }
val entries = books.mapIndexed { index, it ->

View File

@ -0,0 +1,260 @@
package org.gotson.komga.interfaces.opds
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle
import org.gotson.komga.interfaces.rest.WithMockCustomUser
import org.hamcrest.Matchers
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class OpdsControllerTest(
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val seriesLifecycle: SeriesLifecycle,
@Autowired private val seriesMetadataRepository: SeriesMetadataRepository,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val bookRepository: BookRepository,
@Autowired private val mediaRepository: MediaRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
@Autowired private val mockMvc: MockMvc
) {
private val library = makeLibrary(id = "1")
private val user = KomgaUser("user@example.org", "", false, id = "1")
private val user2 = KomgaUser("user2@example.org", "", false, id = "2")
@BeforeAll
fun `setup library`() {
libraryRepository.insert(library)
userRepository.insert(user)
userRepository.insert(user2)
}
@AfterAll
fun teardown() {
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
}
@AfterEach
fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Nested
inner class LimitedUser {
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
fun `given user with access to a single library when getting series then only gets series from this library`() {
val createdSeries = makeSeries(name = "series", libraryId = library.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
val books = listOf(makeBook("1", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val otherLibrary = makeLibrary("other")
libraryRepository.insert(otherLibrary)
makeSeries(name = "otherSeries", libraryId = otherLibrary.id).let { series ->
seriesLifecycle.createSeries(series).let { created ->
val otherBooks = listOf(makeBook("2", libraryId = otherLibrary.id))
seriesLifecycle.addBooks(created, otherBooks)
}
}
mockMvc.get("/opds/v1.2/series")
.andExpect {
status { isOk() }
xpath("/feed/entry/id") {
nodeCount(1)
string(createdSeries.id)
}
}
}
}
@Nested
inner class SeriesSort {
@Test
@WithMockCustomUser
fun `given series with titleSort when requesting via opds then series are sorted by titleSort`() {
val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id))
seriesMetadataRepository.findById(alphaC.id).let {
seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The"))
}
seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id))
mockMvc.get("/opds/v1.2/series")
.andExpect {
status { isOk() }
xpath("/feed/entry[1]/title") { string("TheAlpha") }
xpath("/feed/entry[2]/title") { string("Beta") }
}
}
@Test
@WithMockCustomUser
fun `given series when requesting via opds then series are sorted insensitive of case`() {
listOf("a", "b", "B", "C")
.map { name -> makeSeries(name, libraryId = library.id) }
.forEach {
seriesLifecycle.createSeries(it)
}
mockMvc.get("/opds/v1.2/series")
.andExpect {
status { isOk() }
xpath("/feed/entry[1]/title") { string("a") }
xpath("/feed/entry[2]/title") { string(Matchers.equalToIgnoringCase("b")) }
xpath("/feed/entry[3]/title") { string(Matchers.equalToIgnoringCase("b")) }
xpath("/feed/entry[4]/title") { string("C") }
}
}
}
@Nested
inner class SeriesStatus {
@Test
@WithMockCustomUser
fun `given series when requesting via opds then deleted series are not returned`() {
seriesLifecycle.createSeries(makeSeries("Alpha", libraryId = library.id)).also {
seriesRepository.update(it.copy(deletedDate = LocalDateTime.now()))
}
seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id))
mockMvc.get("/opds/v1.2/series")
.andExpect {
status { isOk() }
xpath("/feed/entry") { nodeCount(1) }
xpath("/feed/entry[1]/title") { string("Beta") }
}
}
}
@Nested
inner class BookOrdering {
@Test
@WithMockCustomUser
fun `given books with unordered index when requesting via opds then books are ordered`() {
val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val addedBook = makeBook("2", libraryId = library.id)
seriesLifecycle.addBooks(createdSeries, listOf(addedBook))
seriesLifecycle.sortBooks(createdSeries)
bookRepository.findAll().forEach {
mediaRepository.findById(it.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg"))))
}
}
mockMvc.get("/opds/v1.2/series/${createdSeries.id}")
.andExpect {
status { isOk() }
xpath("/feed/entry[1]/title") { string("1") }
xpath("/feed/entry[2]/title") { string("2") }
xpath("/feed/entry[3]/title") { string("3") }
}
}
}
@Nested
inner class BookStatus {
@Test
@WithMockCustomUser
fun `given books not ready when requesting via opds then no books are returned`() {
val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
bookRepository.findAll().forEach {
mediaRepository.findById(it.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg"))))
}
}
val addedBook = makeBook("2", libraryId = library.id)
seriesLifecycle.addBooks(createdSeries, listOf(addedBook))
seriesLifecycle.sortBooks(createdSeries)
mockMvc.get("/opds/v1.2/series/${createdSeries.id}")
.andExpect {
status { isOk() }
xpath("/feed/entry") { nodeCount(2) }
xpath("/feed/entry[1]/title") { string("1") }
xpath("/feed/entry[2]/title") { string("3") }
}
}
@Test
@WithMockCustomUser
fun `given deleted ready books when requesting via opds then no books are returned`() {
val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val addedBook = makeBook("2", libraryId = library.id)
seriesLifecycle.addBooks(createdSeries, listOf(addedBook))
seriesLifecycle.sortBooks(createdSeries)
bookRepository.findAll().forEach {
mediaRepository.findById(it.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg"))))
}
if (it.id == addedBook.id)
bookRepository.update(it.copy(deletedDate = LocalDateTime.now()))
}
mockMvc.get("/opds/v1.2/series/${createdSeries.id}")
.andExpect {
status { isOk() }
xpath("/feed/entry") { nodeCount(2) }
xpath("/feed/entry[1]/title") { string("1") }
xpath("/feed/entry[2]/title") { string("3") }
}
}
}
}