diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/ChangedReadingState.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/ChangedReadingState.java new file mode 100644 index 000000000..047066488 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/ChangedReadingState.java @@ -0,0 +1,27 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +public class ChangedReadingState implements Entitlement { + + private WrappedReadingState changedReadingState; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + public static class WrappedReadingState { + private KoboReadingState readingState; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java index fc4c2bb8a..018464ef8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java @@ -82,6 +82,15 @@ public class UserBookProgressEntity { @Column(name = "koreader_last_sync_time") private Instant koreaderLastSyncTime; - @Column(name = "kobo_last_sync_time") - private Instant koboLastSyncTime; + @Column(name = "kobo_progress_received_time") + private Instant koboProgressReceivedTime; + + @Column(name = "kobo_status_sent_time") + private Instant koboStatusSentTime; + + @Column(name = "kobo_progress_sent_time") + private Instant koboProgressSentTime; + + @Column(name = "read_status_modified_time") + private Instant readStatusModifiedTime; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java index 9a44e6c57..312f1b716 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/UserBookProgressRepository.java @@ -2,6 +2,8 @@ package com.adityachandel.booklore.repository; import com.adityachandel.booklore.model.entity.UserBookProgressEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -14,4 +16,28 @@ public interface UserBookProgressRepository extends JpaRepository findByUserIdAndBookId(Long userId, Long bookId); List findByUserIdAndBookIdIn(Long userId, Set bookIds); + + @Query(""" + SELECT ubp FROM UserBookProgressEntity ubp + WHERE ubp.user.id = :userId + AND ubp.book.id IN ( + SELECT ksb.bookId FROM KoboSnapshotBookEntity ksb + WHERE ksb.snapshot.id = :snapshotId + ) + AND ( + (ubp.readStatusModifiedTime IS NOT NULL AND ( + ubp.koboStatusSentTime IS NULL + OR ubp.readStatusModifiedTime > ubp.koboStatusSentTime + )) + OR + (ubp.koboProgressReceivedTime IS NOT NULL AND ( + ubp.koboProgressSentTime IS NULL + OR ubp.koboProgressReceivedTime > ubp.koboProgressSentTime + )) + ) + """) + List findAllBooksNeedingKoboSync( + @Param("userId") Long userId, + @Param("snapshotId") String snapshotId + ); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java index 1ec9bde06..f05c66ba5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java @@ -16,6 +16,7 @@ import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.ReadStatus; import com.adityachandel.booklore.model.enums.ResetProgressType; import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.service.kobo.KoboReadingStateService; import com.adityachandel.booklore.service.user.UserProgressService; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.adityachandel.booklore.util.FileService; @@ -62,6 +63,7 @@ public class BookService { private final UserProgressService userProgressService; private final BookDownloadService bookDownloadService; private final MonitoringRegistrationService monitoringRegistrationService; + private final KoboReadingStateService koboReadingStateService; private void setBookProgress(Book book, UserBookProgressEntity progress) { @@ -407,6 +409,7 @@ public class BookService { progress.setUser(userEntity); progress.setBook(book); progress.setReadStatus(readStatus); + progress.setReadStatusModifiedTime(Instant.now()); if (readStatus == ReadStatus.READ) { progress.setDateFinished(Instant.now()); @@ -452,6 +455,11 @@ public class BookService { progress.setBook(bookEntity); progress.setUser(userEntity.orElseThrow()); + + if (progress.getReadStatus() != null) { + progress.setReadStatusModifiedTime(Instant.now()); + } + progress.setReadStatus(null); progress.setLastReadTime(null); progress.setDateFinished(null); @@ -473,7 +481,8 @@ public class BookService { progress.setKoboLocation(null); progress.setKoboLocationType(null); progress.setKoboLocationSource(null); - progress.setKoboLastSyncTime(null); + progress.setKoboProgressReceivedTime(null); + koboReadingStateService.deleteReadingState(bookId); } userBookProgressRepository.save(progress); updatedBooks.add(bookMapper.toBook(bookEntity)); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java index b0210fdda..faf63b4c3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java @@ -19,6 +19,7 @@ import com.adityachandel.booklore.service.book.BookQueryService; import com.adityachandel.booklore.service.appsettings.AppSettingService; import com.adityachandel.booklore.util.kobo.KoboUrlBuilder; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.OffsetDateTime; @@ -32,6 +33,7 @@ import java.util.stream.Collectors; @AllArgsConstructor @Service +@Slf4j public class KoboEntitlementService { private static final Pattern NON_ALPHANUMERIC_LOWERCASE_PATTERN = Pattern.compile("[^a-z0-9]"); @@ -87,39 +89,72 @@ public class KoboEntitlementService { .collect(Collectors.toList()); } + public List generateChangedReadingStates(List progressEntries) { + OffsetDateTime now = getCurrentUtc(); + String timestamp = now.toString(); + + return progressEntries.stream() + .map(progress -> buildChangedReadingState(progress, timestamp, now)) + .toList(); + } + + private ChangedReadingState buildChangedReadingState(UserBookProgressEntity progress, String timestamp, OffsetDateTime now) { + String entitlementId = String.valueOf(progress.getBook().getId()); + + KoboReadingState.CurrentBookmark bookmark = progress.getKoboProgressPercent() != null + ? readingStateBuilder.buildBookmarkFromProgress(progress, now) + : readingStateBuilder.buildEmptyBookmark(now); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId(entitlementId) + .created(timestamp) + .lastModified(timestamp) + .priorityTimestamp(timestamp) + .statusInfo(readingStateBuilder.buildStatusInfoFromProgress(progress, timestamp)) + .currentBookmark(bookmark) + .statistics(KoboReadingState.Statistics.builder().lastModified(timestamp).build()) + .build(); + + return ChangedReadingState.builder() + .changedReadingState(ChangedReadingState.WrappedReadingState.builder() + .readingState(readingState) + .build()) + .build(); + } + private KoboReadingState createInitialReadingState(BookEntity book) { OffsetDateTime now = getCurrentUtc(); - OffsetDateTime createdOn = getCreatedOn(book); String entitlementId = String.valueOf(book.getId()); KoboReadingState existingState = readingStateRepository.findByEntitlementId(entitlementId) .map(readingStateMapper::toDto) .orElse(null); - KoboReadingState.CurrentBookmark bookmark; - if (existingState != null && existingState.getCurrentBookmark() != null) { - bookmark = existingState.getCurrentBookmark(); - } else { - bookmark = progressRepository - .findByUserIdAndBookId(authenticationService.getAuthenticatedUser().getId(), book.getId()) - .filter(progress -> progress.getKoboProgressPercent() != null) - .map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, now)) - .orElseGet(() -> readingStateBuilder.buildEmptyBookmark(now)); - } - - return KoboReadingState.builder() - .entitlementId(entitlementId) - .created(createdOn.toString()) - .lastModified(now.toString()) - .statusInfo(KoboReadingState.StatusInfo.builder() + Optional userProgress = progressRepository + .findByUserIdAndBookId(authenticationService.getAuthenticatedUser().getId(), book.getId()); + + KoboReadingState.CurrentBookmark bookmark = existingState != null && existingState.getCurrentBookmark() != null + ? existingState.getCurrentBookmark() + : userProgress + .filter(progress -> progress.getKoboProgressPercent() != null) + .map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, now)) + .orElseGet(() -> readingStateBuilder.buildEmptyBookmark(now)); + + KoboReadingState.StatusInfo statusInfo = userProgress + .map(progress -> readingStateBuilder.buildStatusInfoFromProgress(progress, now.toString())) + .orElseGet(() -> KoboReadingState.StatusInfo.builder() .lastModified(now.toString()) .status(KoboReadStatus.READY_TO_READ) .timesStartedReading(0) - .build()) + .build()); + + return KoboReadingState.builder() + .entitlementId(entitlementId) + .created(getCreatedOn(book).toString()) + .lastModified(now.toString()) + .statusInfo(statusInfo) .currentBookmark(bookmark) - .statistics(KoboReadingState.Statistics.builder() - .lastModified(now.toString()) - .build()) + .statistics(KoboReadingState.Statistics.builder().lastModified(now.toString()).build()) .priorityTimestamp(now.toString()) .build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java index 8b6c83bd7..47e796bec 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java @@ -6,7 +6,9 @@ import com.adityachandel.booklore.model.dto.BookloreSyncToken; import com.adityachandel.booklore.model.dto.kobo.*; import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity; import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity; +import com.adityachandel.booklore.model.entity.UserBookProgressEntity; import com.adityachandel.booklore.repository.KoboDeletedBookProgressRepository; +import com.adityachandel.booklore.repository.UserBookProgressRepository; import com.adityachandel.booklore.util.RequestUtils; import com.adityachandel.booklore.util.kobo.BookloreSyncTokenGenerator; import com.fasterxml.jackson.databind.JsonNode; @@ -17,9 +19,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; import java.util.*; import java.util.stream.Collectors; @@ -32,9 +36,11 @@ public class KoboLibrarySyncService { private final KoboLibrarySnapshotService koboLibrarySnapshotService; private final KoboEntitlementService entitlementService; private final KoboDeletedBookProgressRepository koboDeletedBookProgressRepository; + private final UserBookProgressRepository userBookProgressRepository; private final KoboServerProxy koboServerProxy; private final ObjectMapper objectMapper; + @Transactional public ResponseEntity syncLibrary(BookLoreUser user, String token) { HttpServletRequest request = RequestUtils.getCurrentRequest(); BookloreSyncToken syncToken = Optional.ofNullable(tokenGenerator.fromRequestHeaders(request)).orElse(new BookloreSyncToken()); @@ -68,6 +74,10 @@ public class KoboLibrarySyncService { entitlements.addAll(entitlementService.generateNewEntitlements(addedIds, token, false)); entitlements.addAll(entitlementService.generateChangedEntitlements(removedIds, token, true)); + + if (!shouldContinueSync) { + entitlements.addAll(syncReadingStatesToKobo(user.getId(), currSnapshot.getId())); + } } else { int maxRemaining = 5; List all = new ArrayList<>(); @@ -80,6 +90,10 @@ public class KoboLibrarySyncService { } Set ids = all.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet()); entitlements.addAll(entitlementService.generateNewEntitlements(ids, token, false)); + + if (!shouldContinueSync) { + entitlements.addAll(syncReadingStatesToKobo(user.getId(), currSnapshot.getId())); + } } if (!shouldContinueSync) { @@ -131,4 +145,47 @@ public class KoboLibrarySyncService { .header(KoboHeaders.X_KOBO_SYNCTOKEN, tokenGenerator.toBase64(syncToken)) .body(entitlements); } -} \ No newline at end of file + + private List syncReadingStatesToKobo(Long userId, String snapshotId) { + List booksNeedingSync = + userBookProgressRepository.findAllBooksNeedingKoboSync(userId, snapshotId); + + if (booksNeedingSync.isEmpty()) { + return Collections.emptyList(); + } + + List changedStates = entitlementService.generateChangedReadingStates(booksNeedingSync); + + Instant sentTime = Instant.now(); + for (UserBookProgressEntity progress : booksNeedingSync) { + if (needsStatusSync(progress)) { + progress.setKoboStatusSentTime(sentTime); + } + if (needsProgressSync(progress)) { + progress.setKoboProgressSentTime(sentTime); + } + } + userBookProgressRepository.saveAll(booksNeedingSync); + + log.info("Synced {} reading states to Kobo", changedStates.size()); + return changedStates; + } + + private boolean needsStatusSync(UserBookProgressEntity progress) { + Instant modifiedTime = progress.getReadStatusModifiedTime(); + if (modifiedTime == null) { + return false; + } + Instant sentTime = progress.getKoboStatusSentTime(); + return sentTime == null || modifiedTime.isAfter(sentTime); + } + + private boolean needsProgressSync(UserBookProgressEntity progress) { + Instant receivedTime = progress.getKoboProgressReceivedTime(); + if (receivedTime == null) { + return false; + } + Instant sentTime = progress.getKoboProgressSentTime(); + return sentTime == null || receivedTime.isAfter(sentTime); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateBuilder.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateBuilder.java index 5a9f1fbb9..51f38baec 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateBuilder.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateBuilder.java @@ -2,6 +2,8 @@ package com.adityachandel.booklore.service.kobo; import com.adityachandel.booklore.model.dto.kobo.KoboReadingState; import com.adityachandel.booklore.model.entity.UserBookProgressEntity; +import com.adityachandel.booklore.model.enums.KoboReadStatus; +import com.adityachandel.booklore.model.enums.ReadStatus; import org.springframework.stereotype.Component; import java.time.Instant; @@ -24,14 +26,14 @@ public class KoboReadingStateBuilder { public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress, OffsetDateTime defaultTime) { KoboReadingState.CurrentBookmark.Location location = Optional.ofNullable(progress.getKoboLocation()) - .map(loc -> KoboReadingState.CurrentBookmark.Location.builder() - .value(loc) + .map(koboLocation -> KoboReadingState.CurrentBookmark.Location.builder() + .value(koboLocation) .type(progress.getKoboLocationType()) .source(progress.getKoboLocationSource()) .build()) .orElse(null); - String lastModified = Optional.ofNullable(progress.getKoboLastSyncTime()) + String lastModified = Optional.ofNullable(progress.getKoboProgressReceivedTime()) .map(this::formatTimestamp) .or(() -> Optional.ofNullable(defaultTime).map(OffsetDateTime::toString)) .orElse(null); @@ -48,15 +50,45 @@ public class KoboReadingStateBuilder { public KoboReadingState buildReadingStateFromProgress(String entitlementId, UserBookProgressEntity progress) { KoboReadingState.CurrentBookmark bookmark = buildBookmarkFromProgress(progress); String lastModified = bookmark.getLastModified(); + KoboReadingState.StatusInfo statusInfo = buildStatusInfoFromProgress(progress, lastModified); return KoboReadingState.builder() .entitlementId(entitlementId) .currentBookmark(bookmark) + .statusInfo(statusInfo) .created(lastModified) .lastModified(lastModified) .build(); } + public KoboReadingState.StatusInfo buildStatusInfoFromProgress(UserBookProgressEntity progress, String lastModified) { + KoboReadStatus koboStatus = mapReadStatusToKoboStatus(progress.getReadStatus()); + int timesStartedReading = koboStatus == KoboReadStatus.READY_TO_READ ? 0 : 1; + + KoboReadingState.StatusInfo.StatusInfoBuilder builder = KoboReadingState.StatusInfo.builder() + .lastModified(lastModified) + .status(koboStatus) + .timesStartedReading(timesStartedReading); + + if (koboStatus == KoboReadStatus.FINISHED && progress.getDateFinished() != null) { + builder.lastTimeFinished(formatTimestamp(progress.getDateFinished())); + } + + return builder.build(); + } + + public KoboReadStatus mapReadStatusToKoboStatus(ReadStatus readStatus) { + if (readStatus == null) { + return KoboReadStatus.READY_TO_READ; + } + + return switch (readStatus) { + case READ -> KoboReadStatus.FINISHED; + case PARTIALLY_READ, READING, RE_READING, PAUSED -> KoboReadStatus.READING; + case UNREAD, WONT_READ, ABANDONED -> KoboReadStatus.READY_TO_READ; + }; + } + private String formatTimestamp(Instant instant) { return instant.atOffset(ZoneOffset.UTC).toString(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java index 246fb9ec3..7edef110a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java @@ -31,6 +31,8 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class KoboReadingStateService { + private static final int STATUS_SYNC_BUFFER_SECONDS = 10; + private final KoboReadingStateRepository repository; private final KoboReadingStateMapper mapper; private final UserBookProgressRepository progressRepository; @@ -88,6 +90,11 @@ public class KoboReadingStateService { .collect(Collectors.toList()); } + @Transactional + public void deleteReadingState(Long bookId) { + repository.findByEntitlementId(String.valueOf(bookId)).ifPresent(repository::delete); + } + public KoboReadingStateWrapper getReadingState(String entitlementId) { Optional readingState = repository.findByEntitlementId(entitlementId) .map(mapper::toDto) @@ -105,11 +112,7 @@ public class KoboReadingStateService { return progressRepository.findByUserIdAndBookId(user.getId(), bookId) .filter(progress -> progress.getKoboProgressPercent() != null || progress.getKoboLocation() != null) - .map(progress -> { - log.info("Constructed reading state from UserBookProgress for book: {}, progress: {}%", - entitlementId, progress.getKoboProgressPercent()); - return readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress); - }); + .map(progress -> readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress)); } catch (NumberFormatException e) { log.warn("Invalid entitlement ID format when constructing reading state: {}", entitlementId); return Optional.empty(); @@ -155,43 +158,63 @@ public class KoboReadingStateService { } } - progress.setKoboLastSyncTime(Instant.now()); - progress.setLastReadTime(Instant.now()); + Instant now = Instant.now(); + progress.setKoboProgressReceivedTime(now); + progress.setLastReadTime(now); if (progress.getKoboProgressPercent() != null) { - updateKoboReadStatus(progress, progress.getKoboProgressPercent() / 100.0); + updateReadStatusFromKoboProgress(progress, now); } progressRepository.save(progress); - - log.info("Synced Kobo progress to BookLore: userId={}, bookId={}, progress={}%", - userId, bookId, progress.getKoboProgressPercent()); - + log.debug("Synced Kobo progress: bookId={}, progress={}%", bookId, progress.getKoboProgressPercent()); } catch (NumberFormatException e) { log.warn("Invalid entitlement ID format: {}", readingState.getEntitlementId()); } } - private void updateKoboReadStatus(UserBookProgressEntity userProgress, double progressFraction) { - KoboSyncSettings settings = koboSettingsService.getCurrentUserSettings(); - - double progressPercent = progressFraction * 100.0; - float finishedThreshold = settings.getProgressMarkAsFinishedThreshold() != null - ? settings.getProgressMarkAsFinishedThreshold() - : 99f; - float readingThreshold = settings.getProgressMarkAsReadingThreshold() != null - ? settings.getProgressMarkAsReadingThreshold() - : 1f; - - if (progressPercent >= finishedThreshold) { - userProgress.setReadStatus(ReadStatus.READ); - if (userProgress.getDateFinished() == null) { - userProgress.setDateFinished(Instant.now()); - } - } else if (progressPercent >= readingThreshold) { - userProgress.setReadStatus(ReadStatus.READING); - } else { - userProgress.setReadStatus(ReadStatus.UNREAD); + private void updateReadStatusFromKoboProgress(UserBookProgressEntity userProgress, Instant now) { + if (shouldPreserveCurrentStatus(userProgress, now)) { + return; + } + + double koboProgressPercent = userProgress.getKoboProgressPercent(); + + ReadStatus derivedStatus = deriveStatusFromProgress(koboProgressPercent); + userProgress.setReadStatus(derivedStatus); + + if (derivedStatus == ReadStatus.READ && userProgress.getDateFinished() == null) { + userProgress.setDateFinished(Instant.now()); } } + + private boolean shouldPreserveCurrentStatus(UserBookProgressEntity progress, Instant now) { + Instant statusModifiedTime = progress.getReadStatusModifiedTime(); + if (statusModifiedTime == null) { + return false; + } + + Instant statusSentTime = progress.getKoboStatusSentTime(); + + boolean hasPendingStatusUpdate = statusSentTime == null || statusModifiedTime.isAfter(statusSentTime); + if (hasPendingStatusUpdate) { + return true; + } + + boolean withinSyncBufferWindow = now.isBefore(statusSentTime.plusSeconds(STATUS_SYNC_BUFFER_SECONDS)); + return withinSyncBufferWindow; + } + + private ReadStatus deriveStatusFromProgress(double progressPercent) { + KoboSyncSettings settings = koboSettingsService.getCurrentUserSettings(); + + float finishedThreshold = settings.getProgressMarkAsFinishedThreshold() != null + ? settings.getProgressMarkAsFinishedThreshold() : 99f; + float readingThreshold = settings.getProgressMarkAsReadingThreshold() != null + ? settings.getProgressMarkAsReadingThreshold() : 1f; + + if (progressPercent >= finishedThreshold) return ReadStatus.READ; + if (progressPercent >= readingThreshold) return ReadStatus.READING; + return ReadStatus.UNREAD; + } } diff --git a/booklore-api/src/main/resources/db/migration/V64__Add_kobo_reading_status_sync_tracking.sql b/booklore-api/src/main/resources/db/migration/V64__Add_kobo_reading_status_sync_tracking.sql new file mode 100644 index 000000000..f0ddc74ca --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V64__Add_kobo_reading_status_sync_tracking.sql @@ -0,0 +1,11 @@ +ALTER TABLE user_book_progress + ADD COLUMN IF NOT EXISTS kobo_status_sent_time TIMESTAMP; + +ALTER TABLE user_book_progress + ADD COLUMN IF NOT EXISTS read_status_modified_time TIMESTAMP; + +ALTER TABLE user_book_progress + ADD COLUMN IF NOT EXISTS kobo_progress_sent_time TIMESTAMP; + +ALTER TABLE user_book_progress + CHANGE COLUMN kobo_last_sync_time kobo_progress_received_time TIMESTAMP; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java index 71551432d..64495e5ee 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java @@ -8,6 +8,7 @@ import com.adityachandel.booklore.service.book.BookQueryService; import com.adityachandel.booklore.service.book.BookService; import com.adityachandel.booklore.service.user.UserProgressService; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import com.adityachandel.booklore.service.kobo.KoboReadingStateService; import com.adityachandel.booklore.util.FileService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -50,6 +51,7 @@ class BookServiceDeleteTests { UserProgressService userProgressService = Mockito.mock(UserProgressService.class); BookDownloadService bookDownloadService = Mockito.mock(BookDownloadService.class); MonitoringRegistrationService monitoringRegistrationService = Mockito.mock(MonitoringRegistrationService.class); + KoboReadingStateService koboReadingStateService = Mockito.mock(KoboReadingStateService.class); bookService = new BookService( bookRepository, @@ -66,7 +68,8 @@ class BookServiceDeleteTests { bookQueryService, userProgressService, bookDownloadService, - monitoringRegistrationService + monitoringRegistrationService, + koboReadingStateService ); } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboProgressSyncTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboProgressSyncTest.java new file mode 100644 index 000000000..76bfadf31 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboProgressSyncTest.java @@ -0,0 +1,157 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.UserBookProgressEntity; +import com.adityachandel.booklore.model.enums.ReadStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Kobo Progress Sync Behavior") +class KoboProgressSyncTest { + + @Nested + @DisplayName("When Kobo receives progress from a device") + class WhenKoboReceivesProgress { + + @Test + @DisplayName("New progress should be marked for sync to other devices") + void newProgress_shouldBeMarkedForSync() { + UserBookProgressEntity progress = createProgressForBook(1L); + progress.setKoboProgressPercent(50f); + progress.setKoboProgressReceivedTime(Instant.now()); + progress.setKoboProgressSentTime(null); + + assertTrue(hasUnsyncedProgress(progress)); + } + + @Test + @DisplayName("Already synced progress should not sync again") + void alreadySyncedProgress_shouldNotSyncAgain() { + UserBookProgressEntity progress = createProgressForBook(1L); + Instant receivedTime = Instant.now().minusSeconds(60); + Instant sentTime = Instant.now().minusSeconds(30); + + progress.setKoboProgressReceivedTime(receivedTime); + progress.setKoboProgressSentTime(sentTime); + + assertFalse(hasUnsyncedProgress(progress)); + } + + @Test + @DisplayName("Updated progress after sync should be marked for re-sync") + void updatedProgressAfterSync_shouldBeMarkedForReSync() { + UserBookProgressEntity progress = createProgressForBook(1L); + Instant oldSentTime = Instant.now().minusSeconds(60); + Instant newReceivedTime = Instant.now().minusSeconds(30); + + progress.setKoboProgressSentTime(oldSentTime); + progress.setKoboProgressReceivedTime(newReceivedTime); + + assertTrue(hasUnsyncedProgress(progress)); + } + } + + @Nested + @DisplayName("When user changes read status in BookLore") + class WhenUserChangesStatus { + + @Test + @DisplayName("New status change should be marked for sync") + void newStatusChange_shouldBeMarkedForSync() { + UserBookProgressEntity progress = createProgressForBook(1L); + progress.setReadStatus(ReadStatus.READ); + progress.setReadStatusModifiedTime(Instant.now()); + progress.setKoboStatusSentTime(null); + + assertTrue(hasUnsyncedStatus(progress)); + } + + @Test + @DisplayName("Already synced status should not sync again") + void alreadySyncedStatus_shouldNotSyncAgain() { + UserBookProgressEntity progress = createProgressForBook(1L); + Instant modifiedTime = Instant.now().minusSeconds(60); + Instant sentTime = Instant.now().minusSeconds(30); + + progress.setReadStatusModifiedTime(modifiedTime); + progress.setKoboStatusSentTime(sentTime); + + assertFalse(hasUnsyncedStatus(progress)); + } + } + + @Nested + @DisplayName("Kobo-to-Kobo sync scenarios") + class KoboToKoboSync { + + @Test + @DisplayName("Progress from Device A should sync to Device B") + void progressFromDeviceA_shouldSyncToDeviceB() { + UserBookProgressEntity progress = createProgressForBook(1L); + + // Device A sends progress + progress.setKoboProgressPercent(50f); + progress.setKoboLocation("epubcfi(/6/10)"); + progress.setKoboProgressReceivedTime(Instant.now()); + + // Should need sync (to Device B) + assertTrue(hasUnsyncedProgress(progress)); + + // After sync completes + progress.setKoboProgressSentTime(Instant.now()); + + // Should no longer need sync + assertFalse(hasUnsyncedProgress(progress)); + } + + @Test + @DisplayName("Both status and progress changes should sync together") + void statusAndProgressChanges_shouldSyncTogether() { + UserBookProgressEntity progress = createProgressForBook(1L); + + // User marks as read in BookLore + progress.setReadStatus(ReadStatus.READ); + progress.setReadStatusModifiedTime(Instant.now().minusSeconds(30)); + + // Device also sends progress + progress.setKoboProgressPercent(100f); + progress.setKoboProgressReceivedTime(Instant.now().minusSeconds(20)); + + // Both should need sync + assertTrue(hasUnsyncedStatus(progress)); + assertTrue(hasUnsyncedProgress(progress)); + } + } + + private UserBookProgressEntity createProgressForBook(Long bookId) { + BookEntity book = new BookEntity(); + book.setId(bookId); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setBook(book); + return progress; + } + + private boolean hasUnsyncedProgress(UserBookProgressEntity progress) { + Instant receivedTime = progress.getKoboProgressReceivedTime(); + if (receivedTime == null) { + return false; + } + Instant sentTime = progress.getKoboProgressSentTime(); + return sentTime == null || receivedTime.isAfter(sentTime); + } + + private boolean hasUnsyncedStatus(UserBookProgressEntity progress) { + Instant modifiedTime = progress.getReadStatusModifiedTime(); + if (modifiedTime == null) { + return false; + } + Instant sentTime = progress.getKoboStatusSentTime(); + return sentTime == null || modifiedTime.isAfter(sentTime); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateBuilderTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateBuilderTest.java new file mode 100644 index 000000000..9af6e4dec --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateBuilderTest.java @@ -0,0 +1,258 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.model.dto.kobo.KoboReadingState; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.UserBookProgressEntity; +import com.adityachandel.booklore.model.enums.KoboReadStatus; +import com.adityachandel.booklore.model.enums.ReadStatus; +import com.adityachandel.booklore.service.kobo.KoboReadingStateBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("KoboReadingStateBuilder Tests") +class KoboReadingStateBuilderTest { + + private KoboReadingStateBuilder builder; + + @BeforeEach + void setUp() { + builder = new KoboReadingStateBuilder(); + } + + @Nested + @DisplayName("Status Mapping - ReadStatus to KoboReadStatus") + class StatusMapping { + + @Test + @DisplayName("Should map null ReadStatus to READY_TO_READ") + void mapNullStatus() { + assertEquals(KoboReadStatus.READY_TO_READ, builder.mapReadStatusToKoboStatus(null)); + } + + @ParameterizedTest + @MethodSource("finishedStatusProvider") + @DisplayName("Should map completion statuses to FINISHED") + void mapToFinished(ReadStatus input) { + assertEquals(KoboReadStatus.FINISHED, builder.mapReadStatusToKoboStatus(input)); + } + + static Stream finishedStatusProvider() { + return Stream.of( + Arguments.of(ReadStatus.READ) + ); + } + + @ParameterizedTest + @MethodSource("readingStatusProvider") + @DisplayName("Should map in-progress statuses to READING") + void mapToReading(ReadStatus input) { + assertEquals(KoboReadStatus.READING, builder.mapReadStatusToKoboStatus(input)); + } + + static Stream readingStatusProvider() { + return Stream.of( + Arguments.of(ReadStatus.PARTIALLY_READ), + Arguments.of(ReadStatus.READING), + Arguments.of(ReadStatus.RE_READING), + Arguments.of(ReadStatus.PAUSED) + ); + } + + @ParameterizedTest + @MethodSource("readyToReadStatusProvider") + @DisplayName("Should map non-started statuses to READY_TO_READ") + void mapToReadyToRead(ReadStatus input) { + assertEquals(KoboReadStatus.READY_TO_READ, builder.mapReadStatusToKoboStatus(input)); + } + + static Stream readyToReadStatusProvider() { + return Stream.of( + Arguments.of(ReadStatus.UNREAD), + Arguments.of(ReadStatus.WONT_READ), + Arguments.of(ReadStatus.ABANDONED) + ); + } + } + + @Nested + @DisplayName("StatusInfo Building") + class StatusInfoBuilding { + + @Test + @DisplayName("Should build StatusInfo with FINISHED status and finishedDate") + void buildStatusInfo_WithFinishedDate() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setReadStatus(ReadStatus.READ); + progress.setDateFinished(Instant.parse("2025-11-15T10:30:00Z")); + + KoboReadingState.StatusInfo statusInfo = builder.buildStatusInfoFromProgress( + progress, "2025-11-26T12:00:00Z"); + + assertEquals(KoboReadStatus.FINISHED, statusInfo.getStatus()); + assertNotNull(statusInfo.getLastTimeFinished()); + assertEquals(1, statusInfo.getTimesStartedReading()); + } + + @Test + @DisplayName("Should build StatusInfo with READING status") + void buildStatusInfo_Reading() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setReadStatus(ReadStatus.READING); + + KoboReadingState.StatusInfo statusInfo = builder.buildStatusInfoFromProgress( + progress, "2025-11-26T12:00:00Z"); + + assertEquals(KoboReadStatus.READING, statusInfo.getStatus()); + assertNull(statusInfo.getLastTimeFinished()); + assertEquals(1, statusInfo.getTimesStartedReading()); + } + + @Test + @DisplayName("Should build StatusInfo with READY_TO_READ and zero times started") + void buildStatusInfo_ReadyToRead() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setReadStatus(ReadStatus.UNREAD); + + KoboReadingState.StatusInfo statusInfo = builder.buildStatusInfoFromProgress( + progress, "2025-11-26T12:00:00Z"); + + assertEquals(KoboReadStatus.READY_TO_READ, statusInfo.getStatus()); + assertNull(statusInfo.getLastTimeFinished()); + assertEquals(0, statusInfo.getTimesStartedReading()); + } + + @Test + @DisplayName("Should handle FINISHED without dateFinished") + void buildStatusInfo_FinishedWithoutDate() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setReadStatus(ReadStatus.READ); + progress.setDateFinished(null); + + KoboReadingState.StatusInfo statusInfo = builder.buildStatusInfoFromProgress( + progress, "2025-11-26T12:00:00Z"); + + assertEquals(KoboReadStatus.FINISHED, statusInfo.getStatus()); + assertNull(statusInfo.getLastTimeFinished()); + assertEquals(1, statusInfo.getTimesStartedReading()); + } + } + + @Nested + @DisplayName("Bookmark Building") + class BookmarkBuilding { + + @Test + @DisplayName("Should build empty bookmark with timestamp") + void buildEmptyBookmark() { + OffsetDateTime timestamp = OffsetDateTime.now(ZoneOffset.UTC); + KoboReadingState.CurrentBookmark bookmark = builder.buildEmptyBookmark(timestamp); + + assertNotNull(bookmark); + assertEquals(timestamp.toString(), bookmark.getLastModified()); + assertNull(bookmark.getProgressPercent()); + assertNull(bookmark.getLocation()); + } + + @Test + @DisplayName("Should build bookmark from progress with location") + void buildBookmarkFromProgress_WithLocation() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setKoboProgressPercent(75.5f); + progress.setKoboLocation("epubcfi(/6/4[chap01ref]!/4/2/1:3)"); + progress.setKoboLocationType("EpubCfi"); + progress.setKoboLocationSource("Kobo"); + progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T10:00:00Z")); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress); + + assertNotNull(bookmark); + assertEquals(76, bookmark.getProgressPercent()); // Rounded + assertNotNull(bookmark.getLocation()); + assertEquals("epubcfi(/6/4[chap01ref]!/4/2/1:3)", bookmark.getLocation().getValue()); + assertEquals("EpubCfi", bookmark.getLocation().getType()); + assertEquals("Kobo", bookmark.getLocation().getSource()); + } + + @Test + @DisplayName("Should build bookmark without location when null") + void buildBookmarkFromProgress_NoLocation() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setKoboProgressPercent(50f); + progress.setKoboLocation(null); + progress.setKoboProgressReceivedTime(Instant.now()); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress); + + assertNotNull(bookmark); + assertEquals(50, bookmark.getProgressPercent()); + assertNull(bookmark.getLocation()); + } + + @Test + @DisplayName("Should use default time when progress received time is null") + void buildBookmarkFromProgress_UseDefaultTime() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setKoboProgressPercent(25f); + progress.setKoboProgressReceivedTime(null); + + OffsetDateTime defaultTime = OffsetDateTime.parse("2025-11-26T12:00:00Z"); + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, defaultTime); + + assertNotNull(bookmark); + assertEquals(defaultTime.toString(), bookmark.getLastModified()); + } + + @Test + @DisplayName("Should round progress percentage correctly") + void buildBookmarkFromProgress_RoundProgress() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setKoboProgressPercent(33.7f); + progress.setKoboProgressReceivedTime(Instant.now()); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress); + + assertEquals(34, bookmark.getProgressPercent()); // Rounded up + } + } + + @Nested + @DisplayName("Full ReadingState Building") + class FullReadingStateBuilding { + + @Test + @DisplayName("Should build complete reading state from progress") + void buildReadingStateFromProgress() { + BookEntity book = new BookEntity(); + book.setId(100L); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setBook(book); + progress.setKoboProgressPercent(75f); + progress.setKoboLocation("epubcfi(/6/4!)"); + progress.setKoboLocationType("EpubCfi"); + progress.setKoboLocationSource("Kobo"); + progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T10:00:00Z")); + progress.setReadStatus(ReadStatus.READING); + + KoboReadingState state = builder.buildReadingStateFromProgress("100", progress); + + assertNotNull(state); + assertEquals("100", state.getEntitlementId()); + assertNotNull(state.getCurrentBookmark()); + assertNotNull(state.getStatusInfo()); + assertEquals(KoboReadStatus.READING, state.getStatusInfo().getStatus()); + } + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java index 32f8905e9..751176f7c 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java @@ -6,7 +6,6 @@ import com.adityachandel.booklore.model.dto.BookLoreUser; import com.adityachandel.booklore.model.dto.KoboSyncSettings; import com.adityachandel.booklore.model.dto.kobo.KoboReadingState; import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper; -import com.adityachandel.booklore.model.dto.response.kobo.KoboReadingStateResponse; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.KoboReadingStateEntity; @@ -95,127 +94,6 @@ class KoboReadingStateServiceTest { when(koboSettingsService.getCurrentUserSettings()).thenReturn(testSettings); } - @Test - @DisplayName("Should sync Kobo progress to UserBookProgress with valid data") - void testSyncKoboProgressToUserBookProgress_Success() { - String entitlementId = "100"; - KoboReadingState.CurrentBookmark.Location location = KoboReadingState.CurrentBookmark.Location.builder() - .value("epubcfi(/6/4[chap01ref]!/4/2/1:3)") - .type("EpubCfi") - .source("Kobo") - .build(); - - KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() - .progressPercent(25) - .location(location) - .build(); - - KoboReadingState readingState = KoboReadingState.builder() - .entitlementId(entitlementId) - .currentBookmark(bookmark) - .build(); - - KoboReadingStateEntity entity = new KoboReadingStateEntity(); - when(mapper.toEntity(any())).thenReturn(entity); - when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); - when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty()); - when(repository.save(any())).thenReturn(entity); - when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); - when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); - when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty()); - - ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class); - when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity()); - - KoboReadingStateResponse response = service.saveReadingState(List.of(readingState)); - - assertNotNull(response); - assertEquals("Success", response.getRequestResult()); - assertEquals(1, response.getUpdateResults().size()); - - UserBookProgressEntity savedProgress = progressCaptor.getValue(); - assertNotNull(savedProgress); - assertEquals(25.0f, savedProgress.getKoboProgressPercent()); - assertEquals("epubcfi(/6/4[chap01ref]!/4/2/1:3)", savedProgress.getKoboLocation()); - assertEquals("EpubCfi", savedProgress.getKoboLocationType()); - assertEquals("Kobo", savedProgress.getKoboLocationSource()); - assertNotNull(savedProgress.getKoboLastSyncTime()); - assertNotNull(savedProgress.getLastReadTime()); - assertEquals(ReadStatus.READING, savedProgress.getReadStatus()); - } - - @Test - @DisplayName("Should update existing progress when syncing Kobo data") - void testSyncKoboProgressToUserBookProgress_UpdateExisting() { - String entitlementId = "100"; - UserBookProgressEntity existingProgress = new UserBookProgressEntity(); - existingProgress.setUser(testUserEntity); - existingProgress.setBook(testBook); - existingProgress.setKoboProgressPercent(10.0f); - - KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() - .progressPercent(50) - .build(); - - KoboReadingState readingState = KoboReadingState.builder() - .entitlementId(entitlementId) - .currentBookmark(bookmark) - .build(); - - KoboReadingStateEntity entity = new KoboReadingStateEntity(); - when(mapper.toEntity(any())).thenReturn(entity); - when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); - when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty()); - when(repository.save(any())).thenReturn(entity); - when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); - when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); - when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); - - ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class); - when(progressRepository.save(progressCaptor.capture())).thenReturn(existingProgress); - - service.saveReadingState(List.of(readingState)); - - UserBookProgressEntity savedProgress = progressCaptor.getValue(); - assertEquals(50.0f, savedProgress.getKoboProgressPercent()); - assertEquals(ReadStatus.READING, savedProgress.getReadStatus()); - } - - @Test - @DisplayName("Should mark book as READ when progress reaches finished threshold") - void testSyncKoboProgressToUserBookProgress_MarkAsRead() { - String entitlementId = "100"; - testSettings.setProgressMarkAsFinishedThreshold(99f); - - KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() - .progressPercent(100) - .build(); - - KoboReadingState readingState = KoboReadingState.builder() - .entitlementId(entitlementId) - .currentBookmark(bookmark) - .build(); - - KoboReadingStateEntity entity = new KoboReadingStateEntity(); - when(mapper.toEntity(any())).thenReturn(entity); - when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); - when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty()); - when(repository.save(any())).thenReturn(entity); - when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); - when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); - when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty()); - - ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class); - when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity()); - - service.saveReadingState(List.of(readingState)); - - UserBookProgressEntity savedProgress = progressCaptor.getValue(); - assertEquals(100.0f, savedProgress.getKoboProgressPercent()); - assertEquals(ReadStatus.READ, savedProgress.getReadStatus()); - assertNotNull(savedProgress.getDateFinished()); - } - @Test @DisplayName("Should not overwrite existing finished date when syncing completed book") void testSyncKoboProgressToUserBookProgress_PreserveExistingFinishedDate() { @@ -260,75 +138,6 @@ class KoboReadingStateServiceTest { "Existing finished date should not be overwritten during sync"); } - @Test - @DisplayName("Should mark book as READING when progress exceeds reading threshold") - void testSyncKoboProgressToUserBookProgress_MarkAsReading() { - String entitlementId = "100"; - testSettings.setProgressMarkAsReadingThreshold(1f); - - KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() - .progressPercent(1) - .build(); - - KoboReadingState readingState = KoboReadingState.builder() - .entitlementId(entitlementId) - .currentBookmark(bookmark) - .build(); - - KoboReadingStateEntity entity = new KoboReadingStateEntity(); - when(mapper.toEntity(any())).thenReturn(entity); - when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); - when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty()); - when(repository.save(any())).thenReturn(entity); - when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); - when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); - when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty()); - - ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class); - when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity()); - - service.saveReadingState(List.of(readingState)); - - UserBookProgressEntity savedProgress = progressCaptor.getValue(); - assertEquals(1.0f, savedProgress.getKoboProgressPercent()); - assertEquals(ReadStatus.READING, savedProgress.getReadStatus()); - assertNull(savedProgress.getDateFinished()); - } - - @Test - @DisplayName("Should use custom thresholds from settings") - void testSyncKoboProgressToUserBookProgress_CustomThresholds() { - String entitlementId = "100"; - testSettings.setProgressMarkAsReadingThreshold(5.0f); - testSettings.setProgressMarkAsFinishedThreshold(95.0f); - - KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() - .progressPercent(96) - .build(); - - KoboReadingState readingState = KoboReadingState.builder() - .entitlementId(entitlementId) - .currentBookmark(bookmark) - .build(); - - KoboReadingStateEntity entity = new KoboReadingStateEntity(); - when(mapper.toEntity(any())).thenReturn(entity); - when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); - when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty()); - when(repository.save(any())).thenReturn(entity); - when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); - when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); - when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty()); - - ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class); - when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity()); - - service.saveReadingState(List.of(readingState)); - - UserBookProgressEntity savedProgress = progressCaptor.getValue(); - assertEquals(ReadStatus.READ, savedProgress.getReadStatus()); - } - @Test @DisplayName("Should handle invalid entitlement ID gracefully") void testSyncKoboProgressToUserBookProgress_InvalidEntitlementId() { @@ -379,7 +188,7 @@ class KoboReadingStateServiceTest { progress.setKoboLocation("epubcfi(/6/4[chap01ref]!/4/2/1:3)"); progress.setKoboLocationType("EpubCfi"); progress.setKoboLocationSource("Kobo"); - progress.setKoboLastSyncTime(Instant.now()); + progress.setKoboProgressReceivedTime(Instant.now()); KoboReadingState expectedState = KoboReadingState.builder() .entitlementId(entitlementId) @@ -496,7 +305,7 @@ class KoboReadingStateServiceTest { UserBookProgressEntity savedProgress = progressCaptor.getValue(); assertNull(savedProgress.getKoboProgressPercent()); - assertNotNull(savedProgress.getKoboLastSyncTime()); + assertNotNull(savedProgress.getKoboProgressReceivedTime()); } @Test diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboStatusSyncProtectionTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboStatusSyncProtectionTest.java new file mode 100644 index 000000000..69909355a --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboStatusSyncProtectionTest.java @@ -0,0 +1,459 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.mapper.KoboReadingStateMapper; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.KoboSyncSettings; +import com.adityachandel.booklore.model.dto.kobo.KoboReadingState; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; +import com.adityachandel.booklore.model.entity.KoboReadingStateEntity; +import com.adityachandel.booklore.model.entity.UserBookProgressEntity; +import com.adityachandel.booklore.model.enums.ReadStatus; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.KoboReadingStateRepository; +import com.adityachandel.booklore.repository.UserBookProgressRepository; +import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.service.kobo.KoboReadingStateBuilder; +import com.adityachandel.booklore.service.kobo.KoboReadingStateService; +import com.adityachandel.booklore.service.kobo.KoboSettingsService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("Kobo Status Sync Protection Tests") +class KoboStatusSyncProtectionTest { + + @Mock + private KoboReadingStateRepository repository; + @Mock + private KoboReadingStateMapper mapper; + @Mock + private UserBookProgressRepository progressRepository; + @Mock + private BookRepository bookRepository; + @Mock + private UserRepository userRepository; + @Mock + private AuthenticationService authenticationService; + @Mock + private KoboSettingsService koboSettingsService; + @Mock + private KoboReadingStateBuilder readingStateBuilder; + + @InjectMocks + private KoboReadingStateService service; + + private BookLoreUser testUser; + private BookEntity testBook; + private BookLoreUserEntity testUserEntity; + private KoboSyncSettings testSettings; + + @BeforeEach + void setUp() { + testUser = BookLoreUser.builder().id(1L).username("testuser").build(); + testUserEntity = new BookLoreUserEntity(); + testUserEntity.setId(1L); + testBook = new BookEntity(); + testBook.setId(100L); + + testSettings = new KoboSyncSettings(); + testSettings.setProgressMarkAsReadingThreshold(1f); + testSettings.setProgressMarkAsFinishedThreshold(99f); + + when(authenticationService.getAuthenticatedUser()).thenReturn(testUser); + when(koboSettingsService.getCurrentUserSettings()).thenReturn(testSettings); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); + } + + private void setupMocksForSave(String entitlementId, KoboReadingState readingState) { + KoboReadingStateEntity entity = new KoboReadingStateEntity(); + when(mapper.toEntity(any())).thenReturn(entity); + when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); + when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty()); + when(repository.save(any())).thenReturn(entity); + } + + @Nested + @DisplayName("Pending Sync Protection - User changed status in BookLore, waiting to sync to Kobo") + class PendingSyncProtection { + + @Test + @DisplayName("User marks book READ in BookLore -> Kobo sends 50% progress before sync completes -> READ status preserved until synced") + void preserveManualReadStatus_WhenKoboSendsProgress() { + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.READ); + existingProgress.setReadStatusModifiedTime(Instant.now().minusSeconds(60)); + existingProgress.setKoboStatusSentTime(null); // Not yet sent to Kobo + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(50) // Would normally set to READING + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + UserBookProgressEntity saved = captor.getValue(); + assertEquals(ReadStatus.READ, saved.getReadStatus(), + "READ status must be preserved until it has been synced to Kobo"); + } + + @Test + @DisplayName("User marks book UNREAD in BookLore -> Kobo sends 100% progress before sync -> UNREAD preserved until synced") + void preserveManualUnreadStatus_WhenKoboReports100Percent() { + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.UNREAD); + existingProgress.setReadStatusModifiedTime(Instant.now().minusSeconds(30)); + existingProgress.setKoboStatusSentTime(null); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(100) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + UserBookProgressEntity saved = captor.getValue(); + assertEquals(ReadStatus.UNREAD, saved.getReadStatus(), + "UNREAD status must be preserved until it has been synced to Kobo"); + } + + @Test + @DisplayName("User changes status AFTER last sync -> new status preserved until next sync completes") + void preserveStatus_WhenModifiedAfterSent() { + Instant sentTime = Instant.now().minusSeconds(120); + Instant modifiedTime = Instant.now().minusSeconds(60); // Modified AFTER sent + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.ABANDONED); + existingProgress.setReadStatusModifiedTime(modifiedTime); + existingProgress.setKoboStatusSentTime(sentTime); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(75) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + assertEquals(ReadStatus.ABANDONED, captor.getValue().getReadStatus(), + "Status modified after last sync should be preserved"); + } + } + + @Nested + @DisplayName("Buffer Window - Kobo sends multiple requests during single sync cycle") + class BufferWindowProtection { + + @Test + @DisplayName("Status synced 5s ago -> Kobo sends progress -> preserve status (handles rapid Kobo requests)") + void preserveStatus_WithinBufferWindow() { + Instant sentTime = Instant.now().minusSeconds(5); + Instant modifiedTime = sentTime.minusSeconds(10); + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.READ); + existingProgress.setReadStatusModifiedTime(modifiedTime); + existingProgress.setKoboStatusSentTime(sentTime); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(50) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + assertEquals(ReadStatus.READ, captor.getValue().getReadStatus(), + "Status should be preserved within 10-second buffer after sync"); + } + + @Test + @DisplayName("Status synced 15s ago -> buffer expired -> Kobo progress can now update status (e.g., re-reading)") + void allowStatusUpdate_AfterBufferExpires() { + Instant sentTime = Instant.now().minusSeconds(15); + Instant modifiedTime = sentTime.minusSeconds(10); + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.READ); + existingProgress.setReadStatusModifiedTime(modifiedTime); + existingProgress.setKoboStatusSentTime(sentTime); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(50) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + assertEquals(ReadStatus.READING, captor.getValue().getReadStatus(), + "Status should update after buffer window expires"); + } + + @Test + @DisplayName("Status synced 11s ago -> just past buffer -> allow status update") + void handleBufferBoundary_AtExactly10Seconds() { + Instant sentTime = Instant.now().minusSeconds(11); + Instant modifiedTime = sentTime.minusSeconds(5); + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.WONT_READ); + existingProgress.setReadStatusModifiedTime(modifiedTime); + existingProgress.setKoboStatusSentTime(sentTime); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(100) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + assertEquals(ReadStatus.READ, captor.getValue().getReadStatus(), + "Status should update when past 10-second boundary"); + } + } + + @Nested + @DisplayName("Normal Updates - Status set purely from Kobo progress (no manual status)") + class NormalStatusUpdates { + + @Test + @DisplayName("No manual status set -> Kobo progress determines status") + void setStatusFromProgress_WhenNoManualStatus() { + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(null); + existingProgress.setReadStatusModifiedTime(null); + existingProgress.setKoboStatusSentTime(null); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(50) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + assertEquals(ReadStatus.READING, captor.getValue().getReadStatus()); + } + + @Test + @DisplayName("First sync from Kobo -> creates progress with status from progress%") + void setStatusForNewProgress() { + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(100) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(new UserBookProgressEntity()); + + service.saveReadingState(List.of(readingState)); + + UserBookProgressEntity saved = captor.getValue(); + assertEquals(ReadStatus.READ, saved.getReadStatus()); + assertNotNull(saved.getDateFinished()); + } + + @Test + @DisplayName("Progress below threshold -> UNREAD (e.g., just opened book)") + void setUnread_ForLowProgress() { + testSettings.setProgressMarkAsReadingThreshold(5f); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(1) + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(new UserBookProgressEntity()); + + service.saveReadingState(List.of(readingState)); + + assertEquals(ReadStatus.UNREAD, captor.getValue().getReadStatus()); + } + } + + @Nested + @DisplayName("Re-reading - User re-reads after status was synced") + class ReReadingScenario { + + @Test + @DisplayName("Book marked READ, synced 5min ago, now progress at 50% -> becomes READING") + void allowReReading_AfterSyncAndBuffer() { + Instant sentTime = Instant.now().minusSeconds(300); // 5 minutes ago + Instant modifiedTime = sentTime.minusSeconds(60); + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.READ); + existingProgress.setReadStatusModifiedTime(modifiedTime); + existingProgress.setKoboStatusSentTime(sentTime); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(10) // Starting to re-read + .build()) + .build(); + + setupMocksForSave("100", readingState); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(readingState)); + + assertEquals(ReadStatus.READING, captor.getValue().getReadStatus(), + "Re-reading should update status after sync completed and buffer expired"); + } + } + + @Nested + @DisplayName("Rapid Requests - Kobo sends multiple syncs within seconds") + class MultipleRapidRequests { + + @Test + @DisplayName("Two requests 2s after sync -> both preserve READ status") + void handleMultipleRapidRequests() { + Instant sentTime = Instant.now().minusSeconds(2); + Instant modifiedTime = sentTime.minusSeconds(5); + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setReadStatus(ReadStatus.READ); + existingProgress.setReadStatusModifiedTime(modifiedTime); + existingProgress.setKoboStatusSentTime(sentTime); + existingProgress.setKoboProgressPercent(50f); + + // First request + KoboReadingState firstRequest = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(52) + .build()) + .build(); + + setupMocksForSave("100", firstRequest); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + + service.saveReadingState(List.of(firstRequest)); + assertEquals(ReadStatus.READ, captor.getValue().getReadStatus(), + "First rapid request should preserve status"); + + // Second request immediately after + KoboReadingState secondRequest = KoboReadingState.builder() + .entitlementId("100") + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(55) + .build()) + .build(); + + setupMocksForSave("100", secondRequest); + service.saveReadingState(List.of(secondRequest)); + assertEquals(ReadStatus.READ, captor.getValue().getReadStatus(), + "Second rapid request should also preserve status"); + } + } +}