feat(api): mark all books in series as read or unread

related to #25
This commit is contained in:
Gauthier Roebroeck 2020-06-02 18:05:56 +08:00
parent 1aab9b0714
commit 75b72164fe
5 changed files with 130 additions and 10 deletions

View file

@ -114,13 +114,13 @@ class BookLifecycle(
readProgressRepository.save(ReadProgress(book.id, user.id, page, page == media.pages.size)) readProgressRepository.save(ReadProgress(book.id, user.id, page, page == media.pages.size))
} }
fun markReadProgressCompleted(book: Book, user: KomgaUser) { fun markReadProgressCompleted(bookId: Long, user: KomgaUser) {
val media = mediaRepository.findById(book.id) val media = mediaRepository.findById(bookId)
readProgressRepository.save(ReadProgress(book.id, user.id, media.pages.size, true)) readProgressRepository.save(ReadProgress(bookId, user.id, media.pages.size, true))
} }
fun deleteReadProgress(book: Book, user: KomgaUser) { fun deleteReadProgress(bookId: Long, user: KomgaUser) {
readProgressRepository.delete(book.id, user.id) readProgressRepository.delete(bookId, user.id)
} }
} }

View file

@ -390,7 +390,7 @@ class BookController(
try { try {
if (readProgress.completed != null && readProgress.completed) if (readProgress.completed != null && readProgress.completed)
bookLifecycle.markReadProgressCompleted(book, principal.user) bookLifecycle.markReadProgressCompleted(book.id, principal.user)
else else
bookLifecycle.markReadProgress(book, principal.user, readProgress.page!!) bookLifecycle.markReadProgress(book, principal.user, readProgress.page!!)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
@ -408,7 +408,7 @@ class BookController(
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
bookLifecycle.deleteReadProgress(book, principal.user) bookLifecycle.deleteReadProgress(book.id, principal.user)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }

View file

@ -14,6 +14,7 @@ import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
@ -32,6 +33,7 @@ import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@ -53,6 +55,7 @@ class SeriesController(
private val seriesRepository: SeriesRepository, private val seriesRepository: SeriesRepository,
private val seriesMetadataRepository: SeriesMetadataRepository, private val seriesMetadataRepository: SeriesMetadataRepository,
private val seriesDtoRepository: SeriesDtoRepository, private val seriesDtoRepository: SeriesDtoRepository,
private val bookLifecycle: BookLifecycle,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val bookDtoRepository: BookDtoRepository, private val bookDtoRepository: BookDtoRepository,
private val bookController: BookController private val bookController: BookController
@ -241,4 +244,33 @@ class SeriesController(
seriesDtoRepository.findByIdOrNull(seriesId)!! seriesDtoRepository.findByIdOrNull(seriesId)!!
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PostMapping("{seriesId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markAsRead(
@PathVariable seriesId: Long,
@AuthenticationPrincipal principal: KomgaPrincipal
) {
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
bookRepository.findAllIdBySeriesId(seriesId).forEach {
bookLifecycle.markReadProgressCompleted(it, principal.user)
}
}
@DeleteMapping("{seriesId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markAsUnread(
@PathVariable seriesId: Long,
@AuthenticationPrincipal principal: KomgaPrincipal
) {
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
bookRepository.findAllIdBySeriesId(seriesId).forEach {
bookLifecycle.deleteReadProgress(it, principal.user)
}
}
} }

View file

@ -82,7 +82,7 @@ class BookControllerTest(
} }
@AfterAll @AfterAll
fun `teardown library`() { fun `teardown`() {
userRepository.findAll().forEach { userRepository.findAll().forEach {
userLifecycle.deleteUser(it) userLifecycle.deleteUser(it)
} }

View file

@ -1,19 +1,25 @@
package org.gotson.komga.interfaces.rest package org.gotson.komga.interfaces.rest
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
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.SeriesMetadata import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository 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.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository 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.LibraryLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle import org.gotson.komga.domain.service.SeriesLifecycle
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.hamcrest.core.IsNull
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
@ -32,8 +38,10 @@ import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MockMvcResultMatchersDsl import org.springframework.test.web.servlet.MockMvcResultMatchersDsl
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post
import javax.sql.DataSource import javax.sql.DataSource
import kotlin.random.Random import kotlin.random.Random
@ -50,6 +58,8 @@ class SeriesControllerTest(
@Autowired private val bookRepository: BookRepository, @Autowired private val bookRepository: BookRepository,
@Autowired private val mediaRepository: MediaRepository, @Autowired private val mediaRepository: MediaRepository,
@Autowired private val bookMetadataRepository: BookMetadataRepository, @Autowired private val bookMetadataRepository: BookMetadataRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
@Autowired private val mockMvc: MockMvc @Autowired private val mockMvc: MockMvc
) { ) {
@ -66,11 +76,15 @@ class SeriesControllerTest(
fun `setup library`() { fun `setup library`() {
jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1") jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1")
library = libraryRepository.insert(library) library = libraryRepository.insert(library) // id = 1
userRepository.save(KomgaUser("user@example.org", "", false)) // id = 2
} }
@AfterAll @AfterAll
fun `teardown library`() { fun `teardown`() {
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach { libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it) libraryLifecycle.deleteLibrary(it)
} }
@ -469,4 +483,78 @@ class SeriesControllerTest(
} }
} }
} }
@Nested
inner class ReadProgress {
@Test
@WithMockCustomUser(id = 2)
fun `given user when marking series as read then progress is marked for all books`() {
val series = makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id), makeBook("2.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
bookRepository.findAll().forEach { book ->
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
}
mockMvc.post("/api/v1/series/${series.id}/read-progress")
.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/series/${series.id}/books")
.andExpect {
status { isOk }
jsonPath("$.content[0].readProgress.completed") { value(true) }
jsonPath("$.content[1].readProgress.completed") { value(true) }
jsonPath("$.numberOfElements") { value(2) }
}
}
@Test
@WithMockCustomUser(id = 2)
fun `given user when marking series as unread then progress is removed for all books`() {
val series = makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id), makeBook("2.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
bookRepository.findAll().forEach { book ->
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
}
mockMvc.post("/api/v1/series/${series.id}/read-progress")
.andExpect {
status { isNoContent }
}
mockMvc.delete("/api/v1/series/${series.id}/read-progress")
.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/series/${series.id}/books")
.andExpect {
status { isOk }
jsonPath("$.content[0].readProgress") { value(IsNull.nullValue()) }
jsonPath("$.content[1].readProgress") { value(IsNull.nullValue()) }
jsonPath("$.numberOfElements") { value(2) }
}
}
}
} }