mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-05 19:10:08 -06:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user