diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index c47a7037b..bd1dec488 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -114,13 +114,13 @@ class BookLifecycle( readProgressRepository.save(ReadProgress(book.id, user.id, page, page == media.pages.size)) } - fun markReadProgressCompleted(book: Book, user: KomgaUser) { - val media = mediaRepository.findById(book.id) + fun markReadProgressCompleted(bookId: Long, user: KomgaUser) { + 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) { - readProgressRepository.delete(book.id, user.id) + fun deleteReadProgress(bookId: Long, user: KomgaUser) { + readProgressRepository.delete(bookId, user.id) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 8a990a097..a8f6ef52f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -390,7 +390,7 @@ class BookController( try { if (readProgress.completed != null && readProgress.completed) - bookLifecycle.markReadProgressCompleted(book, principal.user) + bookLifecycle.markReadProgressCompleted(book.id, principal.user) else bookLifecycle.markReadProgress(book, principal.user, readProgress.page!!) } catch (e: IllegalArgumentException) { @@ -408,7 +408,7 @@ class BookController( bookRepository.findByIdOrNull(bookId)?.let { book -> 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) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index d7c8150f9..069d749dd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -14,6 +14,7 @@ import org.gotson.komga.domain.model.SeriesSearch import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository 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.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam @@ -32,6 +33,7 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize 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.PatchMapping import org.springframework.web.bind.annotation.PathVariable @@ -53,6 +55,7 @@ class SeriesController( private val seriesRepository: SeriesRepository, private val seriesMetadataRepository: SeriesMetadataRepository, private val seriesDtoRepository: SeriesDtoRepository, + private val bookLifecycle: BookLifecycle, private val bookRepository: BookRepository, private val bookDtoRepository: BookDtoRepository, private val bookController: BookController @@ -241,4 +244,33 @@ class SeriesController( seriesDtoRepository.findByIdOrNull(seriesId)!! } ?: 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) + } + } } diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt index 55be6bf75..0ce0e027d 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt @@ -82,7 +82,7 @@ class BookControllerTest( } @AfterAll - fun `teardown library`() { + fun `teardown`() { userRepository.findAll().forEach { userLifecycle.deleteUser(it) } diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt index b121f4ace..f5af7a4ad 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt @@ -1,19 +1,25 @@ package org.gotson.komga.interfaces.rest 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.makeBook import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeSeries import org.gotson.komga.domain.persistence.BookMetadataRepository 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.hamcrest.Matchers +import org.hamcrest.core.IsNull import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach 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.web.servlet.MockMvc 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.patch +import org.springframework.test.web.servlet.post import javax.sql.DataSource import kotlin.random.Random @@ -50,6 +58,8 @@ class SeriesControllerTest( @Autowired private val bookRepository: BookRepository, @Autowired private val mediaRepository: MediaRepository, @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, @Autowired private val mockMvc: MockMvc ) { @@ -66,11 +76,15 @@ class SeriesControllerTest( fun `setup library`() { 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 - fun `teardown library`() { + fun `teardown`() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } libraryRepository.findAll().forEach { 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) } + } + } + } }