feat(api): add Readium Progression API

This commit is contained in:
Gauthier Roebroeck 2023-12-07 15:34:37 +08:00
parent fbc103467e
commit 20799ad196
13 changed files with 372 additions and 18 deletions

View file

@ -0,0 +1,6 @@
ALTER TABLE READ_PROGRESS
ADD COLUMN device_id varchar default '';
ALTER TABLE READ_PROGRESS
ADD COLUMN device_name varchar default '';
ALTER TABLE READ_PROGRESS
ADD COLUMN locator blob null;

View file

@ -0,0 +1,6 @@
package org.gotson.komga.domain.model
data class R2Device(
val id: String,
val name: String,
)

View file

@ -0,0 +1,16 @@
package org.gotson.komga.domain.model
import org.gotson.komga.language.toZonedDateTime
import java.time.ZonedDateTime
data class R2Progression(
val modified: ZonedDateTime,
val device: R2Device,
val locator: R2Locator,
)
fun ReadProgress.toR2Progression() = R2Progression(
modified = readDate.toZonedDateTime(),
device = R2Device(deviceId, deviceName),
locator = locator ?: R2Locator("", ""),
)

View file

@ -8,6 +8,9 @@ data class ReadProgress(
val page: Int,
val completed: Boolean,
val readDate: LocalDateTime = LocalDateTime.now(),
val deviceId: String = "",
val deviceName: String = "",
val locator: R2Locator? = null,
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = createdDate,

View file

@ -11,8 +11,10 @@ import org.gotson.komga.domain.model.ImageConversionException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.MarkSelectedPreference
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.R2Progression
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.TypedBytes
@ -33,6 +35,7 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.util.UriUtils
import java.io.File
import java.time.LocalDateTime
import kotlin.io.path.deleteIfExists
@ -41,6 +44,7 @@ import kotlin.io.path.isWritable
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.notExists
import kotlin.io.path.toPath
import kotlin.math.roundToInt
private val logger = KotlinLogging.logger {}
@ -376,6 +380,68 @@ class BookLifecycle(
}
}
fun markProgression(book: Book, user: KomgaUser, newProgression: R2Progression) {
readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user.id)?.let { savedProgress ->
check(newProgression.modified.toLocalDateTime().isAfter(savedProgress.readDate)) { "Progression is older than existing" }
}
val media = mediaRepository.findById(book.id)
requireNotNull(media.profile) { "Media has no profile" }
val progress = when (media.profile!!) {
MediaProfile.DIVINA,
MediaProfile.PDF,
-> {
require(newProgression.locator.locations?.position in 1..media.pageCount) { "Page argument (${newProgression.locator.locations?.position}) must be within 1 and book page count (${media.pageCount})" }
ReadProgress(
book.id,
user.id,
newProgression.locator.locations!!.position!!,
newProgression.locator.locations.position == media.pageCount,
newProgression.modified.toLocalDateTime(),
newProgression.device.id,
newProgression.device.name,
newProgression.locator,
)
}
MediaProfile.EPUB -> {
val href = newProgression.locator.href.replaceBefore("/resource/", "").removePrefix("/resource/").let { UriUtils.decode(it, Charsets.UTF_8) }
require(href in media.files.map { it.fileName }) { "Resource does not exist in book: $href" }
requireNotNull(newProgression.locator.locations?.progression) { "location.progression is required" }
val extension = mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub
?: throw IllegalArgumentException("Epub extension not found")
// match progression with positions
val matchingPositions = extension.positions.filter { it.href == href }
val matchedPosition =
matchingPositions.firstOrNull { it.locations!!.progression == newProgression.locator.locations!!.progression }
?: run {
// no exact match
val before = matchingPositions.filter { it.locations!!.progression!! < newProgression.locator.locations!!.progression!! }.maxByOrNull { it.locations!!.position!! }
val after = matchingPositions.filter { it.locations!!.progression!! > newProgression.locator.locations!!.progression!! }.minByOrNull { it.locations!!.position!! }
if (before == null || after == null || before.locations!!.position!! > after.locations!!.position!!)
throw IllegalArgumentException("Invalid progression")
before
}
val totalProgression = matchedPosition.locations?.totalProgression
ReadProgress(
book.id,
user.id,
totalProgression?.let { (media.pageCount * it).roundToInt() } ?: 0,
totalProgression?.let { it >= 0.99F } ?: false,
newProgression.modified.toLocalDateTime(),
newProgression.device.id,
newProgression.device.name,
newProgression.locator,
)
}
}
readProgressRepository.save(progress)
eventPublisher.publishEvent(DomainEvent.ReadProgressChanged(progress))
}
fun deleteBookFiles(book: Book) {
if (book.path.notExists()) return logger.info { "Cannot delete book file, path does not exist: ${book.path}" }
if (!book.path.isWritable()) return logger.info { "Cannot delete book file, path is not writable: ${book.path}" }

View file

@ -1,5 +1,6 @@
package org.gotson.komga.infrastructure.jooq
import com.fasterxml.jackson.databind.ObjectMapper
import org.gotson.komga.domain.model.AllowExclude
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
@ -10,8 +11,9 @@ import org.jooq.Field
import org.jooq.SortField
import org.jooq.impl.DSL
import org.springframework.data.domain.Sort
import java.time.LocalDateTime
import java.time.ZoneId
import java.io.ByteArrayOutputStream
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
fun Field<String>.noCase() = this.collate("NOCASE")
@ -90,3 +92,26 @@ fun ContentRestrictions.toCondition(dsl: DSLContext): Condition {
return ageAllowed.or(labelAllowed)
.and(ageDenied.and(labelDenied))
}
fun ObjectMapper.serializeJsonGz(obj: Any): ByteArray? =
try {
ByteArrayOutputStream().use { baos ->
GZIPOutputStream(baos).use { gz ->
this.writeValue(gz, obj)
baos.toByteArray()
}
}
} catch (e: Exception) {
null
}
inline fun <reified T> ObjectMapper.deserializeJsonGz(gzJson: ByteArray?): T? {
if (gzJson == null) return null
return try {
GZIPInputStream(gzJson.inputStream()).use { gz ->
this.readValue(gz, T::class.java) as T
}
} catch (e: Exception) {
null
}
}

View file

@ -480,5 +480,7 @@ class BookDtoDao(
readDate = readDate,
created = createdDate,
lastModified = lastModifiedDate,
deviceId = deviceId,
deviceName = deviceName,
)
}

View file

@ -22,11 +22,9 @@ import org.jooq.impl.DSL
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
private val logger = KotlinLogging.logger {}
@ -147,7 +145,7 @@ class MediaDao(
media.comment,
media.pageCount,
media.extension?.let { if (it is ProxyExtension) null else it::class.qualifiedName },
media.extension?.let { if (it is ProxyExtension) null else serializeExtension(it) },
media.extension?.let { if (it is ProxyExtension) null else mapper.serializeJsonGz(it) },
)
}
}.execute()
@ -232,7 +230,7 @@ class MediaDao(
.apply {
if (media.extension != null && media.extension !is ProxyExtension) {
set(m.EXTENSION_CLASS, media.extension::class.qualifiedName)
set(m.EXTENSION_VALUE_BLOB, serializeExtension(media.extension))
set(m.EXTENSION_VALUE_BLOB, mapper.serializeJsonGz(media.extension))
}
}
.set(m.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
@ -282,18 +280,6 @@ class MediaDao(
createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
)
fun serializeExtension(extension: MediaExtension): ByteArray? =
try {
ByteArrayOutputStream().use { baos ->
GZIPOutputStream(baos).use { gz ->
mapper.writeValue(gz, extension)
baos.toByteArray()
}
}
} catch (e: Exception) {
logger.error(e) { "Could not serialize media extension" }
null
}
fun deserializeExtension(extensionClass: String?, extensionBlob: ByteArray?): MediaExtension? {
if (extensionClass == null || extensionBlob == null) return null

View file

@ -1,9 +1,13 @@
package org.gotson.komga.infrastructure.jooq.main
import com.fasterxml.jackson.databind.ObjectMapper
import org.gotson.komga.domain.model.R2Locator
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.jooq.deserializeJsonGz
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.selectTempStrings
import org.gotson.komga.infrastructure.jooq.serializeJsonGz
import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.jooq.main.tables.records.ReadProgressRecord
import org.gotson.komga.language.toCurrentTimeZone
@ -21,6 +25,7 @@ import java.time.ZoneId
class ReadProgressDao(
private val dsl: DSLContext,
@Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int,
private val mapper: ObjectMapper,
) : ReadProgressRepository {
private val r = Tables.READ_PROGRESS
@ -84,6 +89,9 @@ class ReadProgressDao(
r.PAGE,
r.COMPLETED,
r.READ_DATE,
r.DEVICE_ID,
r.DEVICE_NAME,
r.LOCATOR,
)
.values(
bookId,
@ -91,12 +99,18 @@ class ReadProgressDao(
page,
completed,
readDate.toUTC(),
deviceId,
deviceName,
locator?.let { mapper.serializeJsonGz(it) },
)
.onDuplicateKeyUpdate()
.set(r.PAGE, page)
.set(r.COMPLETED, completed)
.set(r.READ_DATE, readDate.toUTC())
.set(r.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
.set(r.DEVICE_ID, deviceId)
.set(r.DEVICE_NAME, deviceName)
.set(r.LOCATOR, locator?.let { mapper.serializeJsonGz(it) })
@Transactional
override fun delete(bookId: String, userId: String) {
@ -179,5 +193,8 @@ class ReadProgressDao(
readDate = readDate.toCurrentTimeZone(),
createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
deviceId = deviceId,
deviceName = deviceName,
locator = mapper.deserializeJsonGz<R2Locator>(locator),
)
}

View file

@ -6,6 +6,7 @@ const val MEDIATYPE_OPDS_JSON_VALUE = "application/opds+json"
const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json"
const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json"
const val MEDIATYPE_POSITION_LIST_JSON_VALUE = "application/vnd.readium.position-list+json"
const val MEDIATYPE_PROGRESSION_JSON_VALUE = "application/vnd.readium.progression+json"
const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina"
const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub"
@ -15,3 +16,4 @@ val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication
val MEDIATYPE_DIVINA_JSON = MediaType("application", "divina+json")
val MEDIATYPE_WEBPUB_JSON = MediaType("application", "webpub+json")
val MEDIATYPE_POSITION_LIST_JSON = MediaType("application", "vnd.readium.position-list+json")
val MEDIATYPE_PROGRESSION_JSON = MediaType("application", "vnd.readium.progression+json")

View file

@ -27,15 +27,18 @@ import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.MediaUnsupportedException
import org.gotson.komga.domain.model.R2Progression
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.toR2Progression
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.ReadListRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.gotson.komga.domain.service.BookAnalyzer
@ -54,6 +57,7 @@ import org.gotson.komga.interfaces.api.checkContentRestriction
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.WPPublicationDto
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository
@ -119,6 +123,7 @@ class BookController(
private val bookAnalyzer: BookAnalyzer,
private val bookLifecycle: BookLifecycle,
private val bookRepository: BookRepository,
private val readProgressRepository: ReadProgressRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val seriesMetadataRepository: SeriesMetadataRepository,
private val mediaRepository: MediaRepository,
@ -741,6 +746,42 @@ class BookController(
.body(R2Positions(extension.positions.size, extension.positions))
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = ["api/v1/books/{bookId}/progression"],
produces = [MEDIATYPE_PROGRESSION_JSON_VALUE],
)
fun getProgression(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<R2Progression> =
bookRepository.findByIdOrNull(bookId)?.let { book ->
principal.user.checkContentRestriction(book)
readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let {
ResponseEntity.ok(it.toR2Progression())
} ?: ResponseEntity.noContent().build()
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PutMapping("api/v1/books/{bookId}/progression")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markProgression(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
@RequestBody progression: R2Progression,
) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
principal.user.checkContentRestriction(book)
try {
bookLifecycle.markProgression(book, principal.user, progression)
} catch (e: IllegalStateException) {
throw ResponseStatusException(HttpStatus.CONFLICT, e.message)
} catch (e: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping(
value = ["api/v1/books/{bookId}/manifest/epub"],
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],

View file

@ -79,4 +79,6 @@ data class ReadProgressDto(
val created: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
val lastModified: LocalDateTime,
val deviceId: String,
val deviceName: String,
)

View file

@ -7,10 +7,17 @@ import com.ninjasquad.springmockk.SpykBean
import io.mockk.every
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatNoException
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.model.R2Device
import org.gotson.komga.domain.model.R2Locator
import org.gotson.komga.domain.model.R2Progression
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeBookPage
@ -26,11 +33,16 @@ import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.nio.file.Files
import java.nio.file.Paths
import java.time.ZonedDateTime
@SpringBootTest
class BookLifecycleTest(
@ -277,4 +289,174 @@ class BookLifecycleTest(
assertThat(Files.notExists(bookPath))
}
}
@Nested
inner class Progression {
@BeforeEach
fun setup() {
makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).let { created ->
val books = listOf(makeBook("1", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
}
private val device = R2Device("abc", "device")
private val epubResources = listOf(
MediaFile("ch1.xhtml", "application/xhtml+xml"),
MediaFile("ch2.xhtml", "application/xhtml+xml"),
MediaFile("ch3.xhtml", "application/xhtml+xml"),
)
private fun makeEpubPositions(): List<R2Locator> {
var startPosition = 1
return epubResources.flatMap { file ->
(1..10).map {
R2Locator(file.fileName, file.mediaType!!, locations = R2Locator.Location(position = startPosition++, progression = it.toFloat()))
}
}
}
@Test
fun `given book when marking progress older than saved then it fails`() {
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/zip", pageCount = 10))
}
val progress = R2Progression(ZonedDateTime.now(), device, R2Locator("", "", locations = R2Locator.Location(position = 5)))
bookLifecycle.markProgression(book, user1, progress)
// when
val thrown = catchThrowable {
bookLifecycle.markProgression(book, user1, progress.copy(modified = progress.modified.minusHours(1)))
}
// then
assertThat(thrown)
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("older than existing")
}
@ParameterizedTest
@ValueSource(strings = ["application/zip", "application/pdf"])
fun `given divina or pdf book when marking progress over the page count then it fails`(mediaType: String) {
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, pageCount = 10, mediaType = mediaType))
}
// when
val thrown = catchThrowable {
bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("", "", locations = R2Locator.Location(position = 15))))
}
// then
assertThat(thrown)
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("must be within 1 and book page count")
}
@Test
fun `given epub book when marking progress with non-existing href then it fails`() {
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources))
}
// when
val thrown = catchThrowable {
bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch5.xhtml", "", locations = R2Locator.Location(position = 15))))
}
// then
assertThat(thrown)
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("Resource does not exist in book")
}
@Test
fun `given epub book when marking progress without location progression then it fails`() {
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources))
}
// when
val thrown = catchThrowable {
bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch1.xhtml", "", locations = R2Locator.Location(position = 15))))
}
// then
assertThat(thrown)
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("location.progression is required")
}
@Test
fun `given epub book without extension when marking progress then it fails`() {
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources))
}
// when
val thrown = catchThrowable {
bookLifecycle.markProgression(book, user1, R2Progression(ZonedDateTime.now(), device, R2Locator("ch1.xhtml", "", locations = R2Locator.Location(progression = 0.3F))))
}
// then
assertThat(thrown)
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("extension not found")
}
@Test
fun `given epub book when marking progress with exact position then it succeeds`() {
val book = bookRepository.findAll().first()
val epubPositions = makeEpubPositions()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources, extension = MediaExtensionEpub(positions = epubPositions)))
}
// when
val newProgression = R2Progression(ZonedDateTime.now(), device, epubPositions.first())
assertThatNoException().isThrownBy {
bookLifecycle.markProgression(book, user1, newProgression)
}
val savedProgression = readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user1.id)
// then
assertThat(savedProgression).isNotNull
assertThat(savedProgression!!.locator).isEqualTo(newProgression.locator)
}
@Test
fun `given epub book when marking progress with skewed position then it succeeds`() {
val book = bookRepository.findAll().first()
val epubPositions = makeEpubPositions()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = "application/epub+zip", files = epubResources, extension = MediaExtensionEpub(positions = epubPositions)))
}
// when
val newProgression = R2Progression(
ZonedDateTime.now(),
device,
with(epubPositions[0]) {
copy(locations = locations!!.copy(progression = locations!!.progression!! + 0.5F))
},
)
assertThatNoException().isThrownBy {
bookLifecycle.markProgression(book, user1, newProgression)
}
val savedProgression = readProgressRepository.findByBookIdAndUserIdOrNull(book.id, user1.id)
// then
assertThat(savedProgression).isNotNull
assertThat(savedProgression!!.locator).isEqualTo(newProgression.locator)
}
}
}