feat(api): manage book read progress per user

ability to mark a book as read, unread, or in progress by storing the last page read

related to #25
This commit is contained in:
Gauthier Roebroeck 2020-06-02 17:25:51 +08:00
parent 59dd3682ef
commit 17c80cd1a1
15 changed files with 568 additions and 28 deletions

View file

@ -0,0 +1,15 @@
create table read_progress
(
book_id bigint not null,
user_id bigint not null,
created_date timestamp not null default now(),
last_modified_date timestamp not null default now(),
page integer not null,
completed boolean not null
);
alter table read_progress
add constraint fk_read_progress_book_book_id foreign key (book_id) references book (id);
alter table read_progress
add constraint fk_read_progress_user_user_id foreign key (user_id) references user (id);

View file

@ -0,0 +1,13 @@
package org.gotson.komga.domain.model
import java.time.LocalDateTime
data class ReadProgress(
val bookId: Long,
val userId: Long,
val page: Int,
val completed: Boolean,
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable()

View file

@ -0,0 +1,18 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.ReadProgress
interface ReadProgressRepository {
fun findAll(): Collection<ReadProgress>
fun findByBookIdAndUserId(bookId: Long, userId: Long): ReadProgress?
fun findByUserId(userId: Long): Collection<ReadProgress>
fun save(readProgress: ReadProgress)
fun delete(bookId: Long, userId: Long)
fun deleteByUserId(userId: Long)
fun deleteByBookId(bookId: Long)
fun deleteByBookIds(bookIds: Collection<Long>)
fun deleteAll()
}

View file

@ -4,11 +4,14 @@ import mu.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookPageContent
import org.gotson.komga.domain.model.ImageConversionException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.springframework.stereotype.Service
@ -20,6 +23,7 @@ class BookLifecycle(
private val bookRepository: BookRepository,
private val mediaRepository: MediaRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val readProgressRepository: ReadProgressRepository,
private val bookAnalyzer: BookAnalyzer,
private val imageConverter: ImageConverter
) {
@ -96,9 +100,27 @@ class BookLifecycle(
fun delete(bookId: Long) {
logger.info { "Delete book id: $bookId" }
readProgressRepository.deleteByBookId(bookId)
mediaRepository.delete(bookId)
bookMetadataRepository.delete(bookId)
bookRepository.delete(bookId)
}
fun markReadProgress(book: Book, user: KomgaUser, page: Int) {
val media = mediaRepository.findById(book.id)
require(page >= 1 && page <= media.pages.size) { "Page argument ($page) must be within 1 and book page count (${media.pages.size})" }
readProgressRepository.save(ReadProgress(book.id, user.id, page, page == media.pages.size))
}
fun markReadProgressCompleted(book: Book, user: KomgaUser) {
val media = mediaRepository.findById(book.id)
readProgressRepository.save(ReadProgress(book.id, user.id, media.pages.size, true))
}
fun deleteReadProgress(book: Book, user: KomgaUser) {
readProgressRepository.delete(book.id, user.id)
}
}

View file

@ -4,6 +4,7 @@ import mu.KotlinLogging
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.userdetails.UserDetails
@ -17,6 +18,7 @@ private val logger = KotlinLogging.logger {}
@Service
class KomgaUserLifecycle(
private val userRepository: KomgaUserRepository,
private val readProgressRepository: ReadProgressRepository,
private val passwordEncoder: PasswordEncoder,
private val sessionRegistry: SessionRegistry
@ -52,6 +54,7 @@ class KomgaUserLifecycle(
fun deleteUser(user: KomgaUser) {
logger.info { "Deleting user: $user" }
readProgressRepository.deleteByUserId(user.id)
userRepository.delete(user)
expireSessions(user)
}

View file

@ -5,12 +5,14 @@ import org.gotson.komga.interfaces.rest.dto.AuthorDto
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.BookMetadataDto
import org.gotson.komga.interfaces.rest.dto.MediaDto
import org.gotson.komga.interfaces.rest.dto.ReadProgressDto
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord
import org.gotson.komga.jooq.tables.records.BookMetadataRecord
import org.gotson.komga.jooq.tables.records.BookRecord
import org.gotson.komga.jooq.tables.records.MediaRecord
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Record
@ -31,6 +33,7 @@ class BookDtoDao(
private val b = Tables.BOOK
private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS
private val a = Tables.BOOK_METADATA_AUTHOR
private val mediaFields = m.fields().filterNot { it.name == m.THUMBNAIL.name }.toTypedArray()
@ -42,7 +45,7 @@ class BookDtoDao(
"fileSize" to b.FILE_SIZE
)
override fun findAll(search: BookSearch, pageable: Pageable): Page<BookDto> {
override fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page<BookDto> {
val conditions = search.toCondition()
val count = dsl.selectCount()
@ -56,6 +59,7 @@ class BookDtoDao(
val dtos = selectBase()
.where(conditions)
.and(readProgressCondition(userId))
.orderBy(orderBy)
.limit(pageable.pageSize)
.offset(pageable.offset)
@ -68,18 +72,20 @@ class BookDtoDao(
)
}
override fun findByIdOrNull(bookId: Long): BookDto? =
override fun findByIdOrNull(bookId: Long, userId: Long): BookDto? =
selectBase()
.where(b.ID.eq(bookId))
.and(readProgressCondition(userId))
.fetchAndMap()
.firstOrNull()
override fun findPreviousInSeries(bookId: Long): BookDto? = findSibling(bookId, next = false)
override fun findPreviousInSeries(bookId: Long, userId: Long): BookDto? = findSibling(bookId, userId, next = false)
override fun findNextInSeries(bookId: Long): BookDto? = findSibling(bookId, next = true)
override fun findNextInSeries(bookId: Long, userId: Long): BookDto? = findSibling(bookId, userId, next = true)
private fun readProgressCondition(userId: Long): Condition = r.USER_ID.eq(userId).or(r.USER_ID.isNull)
private fun findSibling(bookId: Long, next: Boolean): BookDto? {
private fun findSibling(bookId: Long, userId: Long, next: Boolean): BookDto? {
val record = dsl.select(b.SERIES_ID, d.NUMBER_SORT)
.from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
@ -90,6 +96,7 @@ class BookDtoDao(
return selectBase()
.where(b.SERIES_ID.eq(seriesId))
.and(readProgressCondition(userId))
.orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() })
.seek(numberSort)
.limit(1)
@ -102,20 +109,23 @@ class BookDtoDao(
*b.fields(),
*mediaFields,
*d.fields(),
*a.fields()
*a.fields(),
*r.fields()
).from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(a).on(d.BOOK_ID.eq(a.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
private fun ResultQuery<Record>.fetchAndMap() =
fetchGroups(
{ it.into(*b.fields(), *mediaFields, *d.fields()) }, { it.into(a) }
).map { (r, ar) ->
val br = r.into(b)
val mr = r.into(m)
val dr = r.into(d)
br.toDto(mr.toDto(), dr.toDto(ar))
{ it.into(*b.fields(), *mediaFields, *d.fields(), *r.fields()) }, { it.into(a) }
).map { (rec, ar) ->
val br = rec.into(b)
val mr = rec.into(m)
val dr = rec.into(d)
val rr = rec.into(r)
br.toDto(mr.toDto(), dr.toDto(ar), if (rr.userId != null) rr.toDto() else null)
}
private fun BookSearch.toCondition(): Condition {
@ -129,7 +139,7 @@ class BookDtoDao(
return c
}
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto) =
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto, readProgress: ReadProgressDto?) =
BookDto(
id = id,
seriesId = seriesId,
@ -142,7 +152,8 @@ class BookDtoDao(
fileLastModified = fileLastModified.toUTC(),
sizeBytes = fileSize,
media = media,
metadata = metadata
metadata = metadata,
readProgress = readProgress
)
private fun MediaRecord.toDto() =
@ -174,4 +185,12 @@ class BookDtoDao(
authors = ar.filter { it.name != null }.map { AuthorDto(it.name, it.role) },
authorsLock = authorsLock
)
private fun ReadProgressRecord.toDto() =
ReadProgressDto(
page = page,
completed = completed,
created = createdDate.toUTC(),
lastModified = lastModifiedDate.toUTC()
)
}

View file

@ -0,0 +1,91 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
import org.jooq.DSLContext
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class ReadProgressDao(
private val dsl: DSLContext
) : ReadProgressRepository {
private val r = Tables.READ_PROGRESS
override fun findAll(): Collection<ReadProgress> =
dsl.selectFrom(r)
.fetchInto(r)
.map { it.toDomain() }
override fun findByBookIdAndUserId(bookId: Long, userId: Long): ReadProgress? =
dsl.selectFrom(r)
.where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId)))
.fetchOneInto(r)
?.toDomain()
override fun findByUserId(userId: Long): Collection<ReadProgress> =
dsl.selectFrom(r)
.where(r.USER_ID.eq(userId))
.fetchInto(r)
.map { it.toDomain() }
override fun save(readProgress: ReadProgress) {
dsl.mergeInto(r)
.using(dsl.selectOne())
.on(r.BOOK_ID.eq(readProgress.bookId).and(r.USER_ID.eq(readProgress.userId)))
.whenMatchedThenUpdate()
.set(r.PAGE, readProgress.page)
.set(r.COMPLETED, readProgress.completed)
.set(r.LAST_MODIFIED_DATE, LocalDateTime.now())
.whenNotMatchedThenInsert()
.set(r.BOOK_ID, readProgress.bookId)
.set(r.USER_ID, readProgress.userId)
.set(r.PAGE, readProgress.page)
.set(r.COMPLETED, readProgress.completed)
.execute()
}
override fun delete(bookId: Long, userId: Long) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId)))
.execute()
}
override fun deleteByUserId(userId: Long) {
dsl.deleteFrom(r)
.where(r.USER_ID.eq(userId))
.execute()
}
override fun deleteByBookId(bookId: Long) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.eq(bookId))
.execute()
}
override fun deleteByBookIds(bookIds: Collection<Long>) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.`in`(bookIds))
.execute()
}
override fun deleteAll() {
dsl.deleteFrom(r).execute()
}
private fun ReadProgressRecord.toDomain() =
ReadProgress(
bookId = bookId,
userId = userId,
page = page,
completed = completed,
createdDate = createdDate,
lastModifiedDate = lastModifiedDate
)
}

View file

@ -23,6 +23,7 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.PageDto
import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto
import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.springframework.core.io.FileSystemResource
@ -38,6 +39,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
@ -90,7 +92,7 @@ class BookController(
mediaStatus = mediaStatus ?: emptyList()
)
return bookDtoRepository.findAll(bookSearch, pageRequest)
return bookDtoRepository.findAll(bookSearch, principal.user.id, pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) }
}
@ -114,6 +116,7 @@ class BookController(
BookSearch(
libraryIds = libraryIds
),
principal.user.id,
pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@ -124,7 +127,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long
): BookDto =
bookDtoRepository.findByIdOrNull(bookId)?.let {
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let {
if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
it.restrictUrl(!principal.user.roleAdmin)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -138,7 +141,7 @@ class BookController(
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookDtoRepository.findPreviousInSeries(bookId)
return bookDtoRepository.findPreviousInSeries(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@ -152,7 +155,7 @@ class BookController(
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookDtoRepository.findNextInSeries(bookId)
return bookDtoRepository.findNextInSeries(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@ -342,7 +345,8 @@ class BookController(
fun updateMetadata(
@PathVariable bookId: Long,
@Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.")
@Valid @RequestBody newMetadata: BookMetadataUpdateDto
@Valid @RequestBody newMetadata: BookMetadataUpdateDto,
@AuthenticationPrincipal principal: KomgaPrincipal
): BookDto =
bookMetadataRepository.findByIdOrNull(bookId)?.let { existing ->
val updated = with(newMetadata) {
@ -370,9 +374,45 @@ class BookController(
)
}
bookMetadataRepository.update(updated)
bookDtoRepository.findByIdOrNull(bookId)
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PatchMapping("api/v1/books/{bookId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markReadProgress(
@PathVariable bookId: Long,
@Parameter(description = "page can be omitted if completed is set to true. completed can be omitted, and will be set accordingly depending on the page passed and the total number of pages in the book.")
@Valid @RequestBody readProgress: ReadProgressUpdateDto,
@AuthenticationPrincipal principal: KomgaPrincipal
) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try {
if (readProgress.completed != null && readProgress.completed)
bookLifecycle.markReadProgressCompleted(book, principal.user)
else
bookLifecycle.markReadProgress(book, principal.user, readProgress.page!!)
} catch (e: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@DeleteMapping("api/v1/books/{bookId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteReadProgress(
@PathVariable bookId: Long,
@AuthenticationPrincipal principal: KomgaPrincipal
) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
bookLifecycle.deleteReadProgress(book, principal.user)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
private fun ResponseEntity.BodyBuilder.setCachePrivate() =
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
.cachePrivate()

View file

@ -196,6 +196,7 @@ class SeriesController(
seriesIds = listOf(seriesId),
mediaStatus = mediaStatus ?: emptyList()
),
principal.user.id,
pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
}

View file

@ -22,7 +22,8 @@ data class BookDto(
val sizeBytes: Long,
val size: String = BinaryByteUnit.format(sizeBytes),
val media: MediaDto,
val metadata: BookMetadataDto
val metadata: BookMetadataDto,
val readProgress: ReadProgressDto? = null
)
fun BookDto.restrictUrl(restrict: Boolean) =
@ -62,3 +63,12 @@ data class AuthorDto(
val role: String
)
data class ReadProgressDto(
val page: Int,
val completed: Boolean,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val created: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModified: LocalDateTime
)

View file

@ -0,0 +1,29 @@
package org.gotson.komga.interfaces.rest.dto
import javax.validation.Constraint
import javax.validation.ConstraintValidator
import javax.validation.ConstraintValidatorContext
import javax.validation.constraints.Positive
import kotlin.reflect.KClass
@ReadProgressUpdateDtoConstraint
data class ReadProgressUpdateDto(
@get:Positive val page: Int?,
val completed: Boolean?
)
@Constraint(validatedBy = [ReadProgressUpdateDtoValidator::class])
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ReadProgressUpdateDtoConstraint(
val message: String = "page must be specified if completed is false or null",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Any>> = []
)
class ReadProgressUpdateDtoValidator : ConstraintValidator<ReadProgressUpdateDtoConstraint, ReadProgressUpdateDto> {
override fun isValid(value: ReadProgressUpdateDto?, context: ConstraintValidatorContext?): Boolean =
value != null && (
value.page != null || (value.completed != null && value.completed)
)
}

View file

@ -6,8 +6,8 @@ import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface BookDtoRepository {
fun findAll(search: BookSearch, pageable: Pageable): Page<BookDto>
fun findByIdOrNull(bookId: Long): BookDto?
fun findPreviousInSeries(bookId: Long): BookDto?
fun findNextInSeries(bookId: Long): BookDto?
fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page<BookDto>
fun findByIdOrNull(bookId: Long, userId: Long): BookDto?
fun findPreviousInSeries(bookId: Long, userId: Long): BookDto?
fun findNextInSeries(bookId: Long, userId: Long): BookDto?
}

View file

@ -0,0 +1,122 @@
package org.gotson.komga.infrastructure.jooq
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ReadProgress
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.SeriesRepository
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
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.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureTestDatabase
class ReadProgressDaoTest(
@Autowired private val readProgressDao: ReadProgressDao,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val libraryRepository: LibraryRepository
) {
private var library = makeLibrary()
private var series = makeSeries("Series")
private var user1 = KomgaUser("user1@example.org", "", false)
private var user2 = KomgaUser("user2@example.org", "", false)
private var book1 = makeBook("Book1")
private var book2 = makeBook("Book2")
@BeforeAll
fun setup() {
library = libraryRepository.insert(library)
series = seriesRepository.insert(series.copy(libraryId = library.id))
user1 = userRepository.save(user1)
user2 = userRepository.save(user2)
book1 = bookRepository.insert(book1.copy(libraryId = library.id, seriesId = series.id))
book2 = bookRepository.insert(book2.copy(libraryId = library.id, seriesId = series.id))
}
@AfterEach
fun deleteReadProgress() {
readProgressDao.deleteAll()
}
@AfterAll
fun tearDown() {
userRepository.deleteAll()
bookRepository.deleteAll()
seriesRepository.deleteAll()
libraryRepository.deleteAll()
}
@Test
fun `given book without user progress when saving progress then progress is saved`() {
val now = LocalDateTime.now()
readProgressDao.save(ReadProgress(
book1.id,
user1.id,
5,
false
))
val readProgressList = readProgressDao.findByUserId(user1.id)
assertThat(readProgressList).hasSize(1)
with(readProgressList.first()) {
assertThat(page).isEqualTo(5)
assertThat(completed).isEqualTo(false)
assertThat(bookId).isEqualTo(book1.id)
assertThat(createdDate)
.isAfterOrEqualTo(now)
.isEqualTo(lastModifiedDate)
}
}
@Test
fun `given book with user progress when saving progress then progress is updated`() {
readProgressDao.save(ReadProgress(
book1.id,
user1.id,
5,
false
))
Thread.sleep(5)
val modificationDate = LocalDateTime.now()
readProgressDao.save(ReadProgress(
book1.id,
user1.id,
10,
true
))
val readProgressList = readProgressDao.findByUserId(user1.id)
assertThat(readProgressList).hasSize(1)
with(readProgressList.first()) {
assertThat(page).isEqualTo(10)
assertThat(completed).isEqualTo(true)
assertThat(bookId).isEqualTo(book1.id)
assertThat(createdDate)
.isBefore(modificationDate)
.isNotEqualTo(lastModifiedDate)
assertThat(lastModifiedDate).isAfterOrEqualTo(modificationDate)
}
}
}

View file

@ -6,17 +6,21 @@ import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.BookSearch
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.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.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.core.IsNull
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
@ -36,6 +40,7 @@ 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 java.time.LocalDate
@ -54,6 +59,8 @@ class BookControllerTest(
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val bookRepository: BookRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
@Autowired private val mockMvc: MockMvc
) {
@ -70,11 +77,15 @@ class BookControllerTest(
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`() {
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
@ -737,4 +748,148 @@ class BookControllerTest(
}
}
}
@Nested
inner class ReadProgress {
@ParameterizedTest
@ValueSource(strings = [
"""{"completed": false}""",
"""{}""",
"""{"page":0}"""
])
@WithMockCustomUser
fun `given invalid payload when marking book in progress then validation error is returned`(jsonString: String) {
mockMvc.patch("/api/v1/books/1/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(id = 2)
fun `given user when marking book in progress with page read then progress is marked accordingly`() {
makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
val jsonString = """
{
"page": 5
}
""".trimIndent()
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/books/${book.id}")
.andExpect {
status { isOk }
jsonPath("$.readProgress.page") { value(5) }
jsonPath("$.readProgress.completed") { value(false) }
}
}
@Test
@WithMockCustomUser(id = 2)
fun `given user when marking book completed then progress is marked accordingly`() {
makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
val jsonString = """
{
"completed": true
}
""".trimIndent()
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/books/${book.id}")
.andExpect {
status { isOk }
jsonPath("$.readProgress.page") { value(10) }
jsonPath("$.readProgress.completed") { value(true) }
}
}
@Test
@WithMockCustomUser(id = 2)
fun `given user when deleting read progress then progress is removed`() {
makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
val jsonString = """
{
"page": 5,
"completed": false
}
""".trimIndent()
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.delete("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/books/${book.id}")
.andExpect {
status { isOk }
jsonPath("$.readProgress") { value(IsNull.nullValue()) }
}
}
}
}

View file

@ -15,7 +15,8 @@ annotation class WithMockCustomUser(
val email: String = "user@example.org",
val roles: Array<String> = [],
val sharedAllLibraries: Boolean = true,
val sharedLibraries: LongArray = []
val sharedLibraries: LongArray = [],
val id: Long = 0
)
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
@ -28,7 +29,8 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
password = "",
roleAdmin = customUser.roles.contains("ADMIN"),
sharedAllLibraries = customUser.sharedAllLibraries,
sharedLibrariesIds = customUser.sharedLibraries.toSet()
sharedLibrariesIds = customUser.sharedLibraries.toSet(),
id = customUser.id
)
)
val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities)