Feat: Improve kobo reading state sync logic for better bidirectional support (#1644)

* feat: kobo sync improvements (push changes from booklore, and properly delete kobo data on reset)

* fix: issues with sync race conditions and improve readability

* fix: update function names to be clearer and more explicit

* fix: move partially_read to reading status mapping

* fix: make the bufferwindow more explicit in the unsynced progress check

* chore: add unit tests for additional sync logic

* fix: improve bi-directional logic to account for changes in progress and status

* chore: add additional tests to check the kobo sync logic and remove redundant tests
This commit is contained in:
CounterClops
2025-12-01 00:44:11 +08:00
committed by GitHub
parent 2336d04a46
commit 3ef5b60ed6
14 changed files with 1169 additions and 254 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<UserBookProgre
Optional<UserBookProgressEntity> findByUserIdAndBookId(Long userId, Long bookId);
List<UserBookProgressEntity> findByUserIdAndBookIdIn(Long userId, Set<Long> 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<UserBookProgressEntity> findAllBooksNeedingKoboSync(
@Param("userId") Long userId,
@Param("snapshotId") String snapshotId
);
}

View File

@@ -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));

View File

@@ -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<ChangedReadingState> generateChangedReadingStates(List<UserBookProgressEntity> 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<UserBookProgressEntity> 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();
}

View File

@@ -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<KoboSnapshotBookEntity> all = new ArrayList<>();
@@ -80,6 +90,10 @@ public class KoboLibrarySyncService {
}
Set<Long> 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);
}
}
private List<ChangedReadingState> syncReadingStatesToKobo(Long userId, String snapshotId) {
List<UserBookProgressEntity> booksNeedingSync =
userBookProgressRepository.findAllBooksNeedingKoboSync(userId, snapshotId);
if (booksNeedingSync.isEmpty()) {
return Collections.emptyList();
}
List<ChangedReadingState> 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);
}
}

View File

@@ -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();
}

View File

@@ -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<KoboReadingState> 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;
}
}

View File

@@ -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;

View File

@@ -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
);
}

View File

@@ -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);
}
}

View File

@@ -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<Arguments> 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<Arguments> 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<Arguments> 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());
}
}
}

View File

@@ -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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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

View File

@@ -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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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<UserBookProgressEntity> 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");
}
}
}