Automatically add newly added books to Kobo shelf (#1826)

This commit is contained in:
Aditya Chandel
2025-12-11 20:21:19 -07:00
committed by GitHub
parent 3fb81a35e4
commit 1916d6c88c
14 changed files with 743 additions and 89 deletions

View File

@@ -3,7 +3,6 @@ package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.KoboSyncSettings; import com.adityachandel.booklore.model.dto.KoboSyncSettings;
import com.adityachandel.booklore.service.kobo.KoboSettingsService; import com.adityachandel.booklore.service.kobo.KoboSettingsService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -31,31 +30,19 @@ public class KoboSettingsController {
@Operation(summary = "Create or update Kobo token", description = "Create or update the Kobo sync token for the current user. Requires sync permission or admin.") @Operation(summary = "Create or update Kobo token", description = "Create or update the Kobo sync token for the current user. Requires sync permission or admin.")
@ApiResponse(responseCode = "200", description = "Token created/updated successfully") @ApiResponse(responseCode = "200", description = "Token created/updated successfully")
@PutMapping @PutMapping("/token")
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()") @PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<KoboSyncSettings> createOrUpdateToken() { public ResponseEntity<KoboSyncSettings> createOrUpdateToken() {
KoboSyncSettings updated = koboService.createOrUpdateToken(); KoboSyncSettings updated = koboService.createOrUpdateToken();
return ResponseEntity.ok(updated); return ResponseEntity.ok(updated);
} }
@Operation(summary = "Toggle Kobo sync", description = "Enable or disable Kobo sync for the current user. Requires sync permission or admin.") @Operation(summary = "Update Kobo settings", description = "Update Kobo sync settings for the current user. Requires sync permission or admin.")
@ApiResponse(responseCode = "204", description = "Sync toggled successfully") @ApiResponse(responseCode = "200", description = "Settings updated successfully")
@PutMapping("/sync") @PutMapping
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()") @PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<Void> toggleSync( public ResponseEntity<KoboSyncSettings> updateSettings(@RequestBody KoboSyncSettings settings) {
@Parameter(description = "Enable or disable sync") @RequestParam boolean enabled) { KoboSyncSettings updated = koboService.updateSettings(settings);
koboService.setSyncEnabled(enabled);
return ResponseEntity.noContent().build();
}
@Operation(summary = "Update progress thresholds", description = "Update the progress thresholds for marking books as reading or finished. Requires sync permission or admin.")
@ApiResponse(responseCode = "200", description = "Thresholds updated successfully")
@PutMapping("/progress-thresholds")
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<KoboSyncSettings> updateProgressThresholds(
@Parameter(description = "Progress percentage to mark as reading (0-100)") @RequestParam(required = false) Float readingThreshold,
@Parameter(description = "Progress percentage to mark as finished (0-100)") @RequestParam(required = false) Float finishedThreshold) {
KoboSyncSettings updated = koboService.updateProgressThresholds(readingThreshold, finishedThreshold);
return ResponseEntity.ok(updated); return ResponseEntity.ok(updated);
} }
} }

View File

@@ -11,4 +11,5 @@ public class KoboSyncSettings {
private boolean syncEnabled; private boolean syncEnabled;
private Float progressMarkAsReadingThreshold; private Float progressMarkAsReadingThreshold;
private Float progressMarkAsFinishedThreshold; private Float progressMarkAsFinishedThreshold;
private boolean autoAddToShelf;
} }

View File

@@ -33,4 +33,8 @@ public class KoboUserSettingsEntity {
@Column(name = "progress_mark_as_finished_threshold") @Column(name = "progress_mark_as_finished_threshold")
@Builder.Default @Builder.Default
private Float progressMarkAsFinishedThreshold = 99f; private Float progressMarkAsFinishedThreshold = 99f;
@Column(name = "auto_add_to_shelf")
@Builder.Default
private boolean autoAddToShelf = false;
} }

View File

@@ -4,6 +4,7 @@ import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@@ -12,4 +13,6 @@ public interface KoboUserSettingsRepository extends JpaRepository<KoboUserSettin
Optional<KoboUserSettingsEntity> findByUserId(Long userId); Optional<KoboUserSettingsEntity> findByUserId(Long userId);
Optional<KoboUserSettingsEntity> findByToken(String token); Optional<KoboUserSettingsEntity> findByToken(String token);
List<KoboUserSettingsEntity> findByAutoAddToShelfTrueAndSyncEnabledTrue();
} }

View File

@@ -18,4 +18,6 @@ public interface ShelfRepository extends JpaRepository<ShelfEntity, Long> {
List<ShelfEntity> findByUserId(Long id); List<ShelfEntity> findByUserId(Long id);
Optional<ShelfEntity> findByUserIdAndName(Long id, String name); Optional<ShelfEntity> findByUserIdAndName(Long id, String name);
List<ShelfEntity> findByUserIdInAndName(List<Long> userIds, String name);
} }

View File

@@ -0,0 +1,97 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import com.adityachandel.booklore.repository.ShelfRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class KoboAutoShelfService {
private final KoboUserSettingsRepository koboUserSettingsRepository;
private final ShelfRepository shelfRepository;
private final BookRepository bookRepository;
private final KoboCompatibilityService koboCompatibilityService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void autoAddBookToKoboShelves(Long bookId) {
if (bookId == null) {
log.warn("Book ID is null for auto-add to Kobo shelf");
return;
}
BookEntity book = bookRepository.findById(bookId).orElse(null);
if (!isBookEligible(book)) {
return;
}
List<KoboUserSettingsEntity> eligibleUsers = koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue();
if (eligibleUsers.isEmpty()) {
log.debug("No Kobo auto-add enabled users for book {}", book.getId());
return;
}
List<Long> userIds = eligibleUsers.stream()
.map(KoboUserSettingsEntity::getUserId)
.toList();
List<ShelfEntity> shelves = shelfRepository.findByUserIdInAndName(userIds, ShelfType.KOBO.getName());
Map<Long, ShelfEntity> shelfByUser = shelves
.stream()
.collect(Collectors.toMap(s -> s.getUser().getId(), s -> s));
boolean modified = false;
for (KoboUserSettingsEntity setting : eligibleUsers) {
ShelfEntity shelf = shelfByUser.get(setting.getUserId());
if (shelf == null) {
log.debug("User {} has auto-add enabled but no Kobo shelf exists", setting.getUserId());
continue;
}
if (book.getShelves().contains(shelf)) {
log.debug("Book {} already on Kobo shelf for user {}", book.getId(), setting.getUserId());
continue;
}
book.getShelves().add(shelf);
modified = true;
log.info("Auto-added book {} to Kobo shelf for user {}", book.getId(), setting.getUserId());
}
if (modified) {
bookRepository.save(book);
}
}
private boolean isBookEligible(BookEntity book) {
if (book == null) {
log.warn("Book not found for Kobo auto-add");
return false;
}
if (!koboCompatibilityService.isBookSupportedForKobo(book)) {
log.debug("Book {} is not Kobo-compatible, skipping", book.getId());
return false;
}
return true;
}
}

View File

@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.dto.Shelf;
import com.adityachandel.booklore.model.dto.request.ShelfCreateRequest; import com.adityachandel.booklore.model.dto.request.ShelfCreateRequest;
import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity; import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity; import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.IconType;
import com.adityachandel.booklore.model.enums.ShelfType; import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.KoboUserSettingsRepository; import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import com.adityachandel.booklore.service.ShelfService; import com.adityachandel.booklore.service.ShelfService;
@@ -56,34 +57,31 @@ public class KoboSettingsService {
} }
@Transactional @Transactional
public void setSyncEnabled(boolean enabled) { public KoboSyncSettings updateSettings(KoboSyncSettings settings) {
BookLoreUser user = authenticationService.getAuthenticatedUser(); BookLoreUser user = authenticationService.getAuthenticatedUser();
KoboUserSettingsEntity entity = repository.findByUserId(user.getId()).orElseThrow(() -> new IllegalStateException("Kobo settings not found for user")); KoboUserSettingsEntity entity = repository.findByUserId(user.getId()).orElseGet(() -> initDefaultSettings(user.getId()));
Shelf userKoboShelf = shelfService.getUserKoboShelf();
if (!enabled) {
if (userKoboShelf != null) {
shelfService.deleteShelf(userKoboShelf.getId());
}
} else {
ensureKoboShelfExists(user.getId());
}
entity.setSyncEnabled(enabled);
repository.save(entity);
}
@Transactional if (settings.isSyncEnabled() != entity.isSyncEnabled()) {
public KoboSyncSettings updateProgressThresholds(Float readingThreshold, Float finishedThreshold) { Shelf userKoboShelf = shelfService.getUserKoboShelf();
BookLoreUser user = authenticationService.getAuthenticatedUser(); if (!settings.isSyncEnabled()) {
KoboUserSettingsEntity entity = repository.findByUserId(user.getId()) if (userKoboShelf != null) {
.orElseGet(() -> initDefaultSettings(user.getId())); shelfService.deleteShelf(userKoboShelf.getId());
}
if (readingThreshold != null) { } else {
entity.setProgressMarkAsReadingThreshold(readingThreshold); ensureKoboShelfExists(user.getId());
}
entity.setSyncEnabled(settings.isSyncEnabled());
} }
if (finishedThreshold != null) {
entity.setProgressMarkAsFinishedThreshold(finishedThreshold); if (settings.getProgressMarkAsReadingThreshold() != null) {
entity.setProgressMarkAsReadingThreshold(settings.getProgressMarkAsReadingThreshold());
} }
if (settings.getProgressMarkAsFinishedThreshold() != null) {
entity.setProgressMarkAsFinishedThreshold(settings.getProgressMarkAsFinishedThreshold());
}
entity.setAutoAddToShelf(settings.isAutoAddToShelf());
repository.save(entity); repository.save(entity);
return mapToDto(entity); return mapToDto(entity);
} }
@@ -105,6 +103,7 @@ public class KoboSettingsService {
ShelfCreateRequest.builder() ShelfCreateRequest.builder()
.name(ShelfType.KOBO.getName()) .name(ShelfType.KOBO.getName())
.icon(ShelfType.KOBO.getIcon()) .icon(ShelfType.KOBO.getIcon())
.iconType(IconType.PRIME_NG)
.build() .build()
); );
} }
@@ -122,6 +121,7 @@ public class KoboSettingsService {
dto.setSyncEnabled(entity.isSyncEnabled()); dto.setSyncEnabled(entity.isSyncEnabled());
dto.setProgressMarkAsReadingThreshold(entity.getProgressMarkAsReadingThreshold()); dto.setProgressMarkAsReadingThreshold(entity.getProgressMarkAsReadingThreshold());
dto.setProgressMarkAsFinishedThreshold(entity.getProgressMarkAsFinishedThreshold()); dto.setProgressMarkAsFinishedThreshold(entity.getProgressMarkAsFinishedThreshold());
dto.setAutoAddToShelf(entity.isAutoAddToShelf());
return dto; return dto;
} }
} }

View File

@@ -8,6 +8,7 @@ import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.service.event.BookEventBroadcaster; import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import com.adityachandel.booklore.service.kobo.KoboAutoShelfService;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -22,6 +23,7 @@ public class FileAsBookProcessor implements LibraryFileProcessor {
private final BookEventBroadcaster bookEventBroadcaster; private final BookEventBroadcaster bookEventBroadcaster;
private final BookFileProcessorRegistry processorRegistry; private final BookFileProcessorRegistry processorRegistry;
private final KoboAutoShelfService koboAutoShelfService;
@Override @Override
public LibraryScanMode getScanMode() { public LibraryScanMode getScanMode() {
@@ -43,6 +45,7 @@ public class FileAsBookProcessor implements LibraryFileProcessor {
FileProcessResult result = processLibraryFile(libraryFile); FileProcessResult result = processLibraryFile(libraryFile);
if (result != null) { if (result != null) {
bookEventBroadcaster.broadcastBookAddEvent(result.getBook()); bookEventBroadcaster.broadcastBookAddEvent(result.getBook());
koboAutoShelfService.autoAddBookToKoboShelves(result.getBook().getId());
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to process file '{}': {}", libraryFile.getFileName(), e.getMessage()); log.error("Failed to process file '{}': {}", libraryFile.getFileName(), e.getMessage());

View File

@@ -0,0 +1,2 @@
ALTER TABLE kobo_user_settings
ADD COLUMN IF NOT EXISTS auto_add_to_shelf BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,268 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import com.adityachandel.booklore.repository.ShelfRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class KoboAutoShelfServiceTest {
@Mock
private KoboUserSettingsRepository koboUserSettingsRepository;
@Mock
private ShelfRepository shelfRepository;
@Mock
private BookRepository bookRepository;
@Mock
private KoboCompatibilityService koboCompatibilityService;
@InjectMocks
private KoboAutoShelfService koboAutoShelfService;
private BookEntity testBook;
private BookLoreUserEntity testUser1;
private BookLoreUserEntity testUser2;
private ShelfEntity koboShelf1;
private ShelfEntity koboShelf2;
private KoboUserSettingsEntity settings1;
private KoboUserSettingsEntity settings2;
@BeforeEach
void setUp() {
testBook = BookEntity.builder()
.id(1L)
.shelves(new HashSet<>())
.build();
testUser1 = BookLoreUserEntity.builder()
.id(100L)
.build();
testUser2 = BookLoreUserEntity.builder()
.id(200L)
.build();
koboShelf1 = ShelfEntity.builder()
.id(10L)
.name(ShelfType.KOBO.getName())
.user(testUser1)
.build();
koboShelf2 = ShelfEntity.builder()
.id(20L)
.name(ShelfType.KOBO.getName())
.user(testUser2)
.build();
settings1 = KoboUserSettingsEntity.builder()
.userId(100L)
.autoAddToShelf(true)
.syncEnabled(true)
.build();
settings2 = KoboUserSettingsEntity.builder()
.userId(200L)
.autoAddToShelf(true)
.syncEnabled(true)
.build();
}
@Test
void autoAddBookToKoboShelves_withNullBookId_shouldReturnEarly() {
koboAutoShelfService.autoAddBookToKoboShelves(null);
verify(bookRepository, never()).findById(anyLong());
verify(koboUserSettingsRepository, never()).findByAutoAddToShelfTrueAndSyncEnabledTrue();
verify(shelfRepository, never()).findByUserIdInAndName(anyList(), anyString());
verify(bookRepository, never()).save(any());
}
@Test
void autoAddBookToKoboShelves_withNonExistentBook_shouldReturnEarly() {
when(bookRepository.findById(1L)).thenReturn(Optional.empty());
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository).findById(1L);
verify(koboUserSettingsRepository, never()).findByAutoAddToShelfTrueAndSyncEnabledTrue();
verify(shelfRepository, never()).findByUserIdInAndName(anyList(), anyString());
verify(bookRepository, never()).save(any());
}
@Test
void autoAddBookToKoboShelves_withIncompatibleBook_shouldReturnEarly() {
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(false);
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository).findById(1L);
verify(koboCompatibilityService).isBookSupportedForKobo(testBook);
verify(koboUserSettingsRepository, never()).findByAutoAddToShelfTrueAndSyncEnabledTrue();
verify(shelfRepository, never()).findByUserIdInAndName(anyList(), anyString());
verify(bookRepository, never()).save(any());
}
@Test
void autoAddBookToKoboShelves_withNoEligibleUsers_shouldReturnEarly() {
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(Collections.emptyList());
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository).findById(1L);
verify(koboCompatibilityService).isBookSupportedForKobo(testBook);
verify(koboUserSettingsRepository).findByAutoAddToShelfTrueAndSyncEnabledTrue();
verify(shelfRepository, never()).findByUserIdInAndName(anyList(), anyString());
verify(bookRepository, never()).save(any());
}
@Test
void autoAddBookToKoboShelves_successfully_shouldAddBookToShelves() {
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(List.of(settings1, settings2));
when(shelfRepository.findByUserIdInAndName(List.of(100L, 200L), ShelfType.KOBO.getName()))
.thenReturn(List.of(koboShelf1, koboShelf2));
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository).findById(1L);
verify(koboCompatibilityService).isBookSupportedForKobo(testBook);
verify(koboUserSettingsRepository).findByAutoAddToShelfTrueAndSyncEnabledTrue();
verify(shelfRepository).findByUserIdInAndName(List.of(100L, 200L), ShelfType.KOBO.getName());
verify(bookRepository).save(testBook);
assert testBook.getShelves().contains(koboShelf1);
assert testBook.getShelves().contains(koboShelf2);
assert testBook.getShelves().size() == 2;
}
@Test
void autoAddBookToKoboShelves_withOneUserMissingShelf_shouldAddOnlyToExistingShelves() {
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(List.of(settings1, settings2));
when(shelfRepository.findByUserIdInAndName(List.of(100L, 200L), ShelfType.KOBO.getName()))
.thenReturn(List.of(koboShelf1));
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository).save(testBook);
assert testBook.getShelves().contains(koboShelf1);
assert !testBook.getShelves().contains(koboShelf2);
assert testBook.getShelves().size() == 1;
}
@Test
void autoAddBookToKoboShelves_withBookAlreadyOnShelf_shouldNotDuplicate() {
testBook.getShelves().add(koboShelf1);
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(List.of(settings1, settings2));
when(shelfRepository.findByUserIdInAndName(List.of(100L, 200L), ShelfType.KOBO.getName()))
.thenReturn(List.of(koboShelf1, koboShelf2));
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository).save(testBook);
assert testBook.getShelves().contains(koboShelf1);
assert testBook.getShelves().contains(koboShelf2);
assert testBook.getShelves().size() == 2;
}
@Test
void autoAddBookToKoboShelves_withBookOnAllShelves_shouldNotSave() {
testBook.getShelves().add(koboShelf1);
testBook.getShelves().add(koboShelf2);
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(List.of(settings1, settings2));
when(shelfRepository.findByUserIdInAndName(List.of(100L, 200L), ShelfType.KOBO.getName()))
.thenReturn(List.of(koboShelf1, koboShelf2));
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository, never()).save(any());
assert testBook.getShelves().size() == 2;
}
@Test
void autoAddBookToKoboShelves_withNoKoboShelvesFound_shouldNotSave() {
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(List.of(settings1, settings2));
when(shelfRepository.findByUserIdInAndName(List.of(100L, 200L), ShelfType.KOBO.getName()))
.thenReturn(Collections.emptyList());
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(shelfRepository).findByUserIdInAndName(List.of(100L, 200L), ShelfType.KOBO.getName());
verify(bookRepository, never()).save(any());
assert testBook.getShelves().isEmpty();
}
@Test
void autoAddBookToKoboShelves_withSingleUser_shouldAddToSingleShelf() {
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(List.of(settings1));
when(shelfRepository.findByUserIdInAndName(List.of(100L), ShelfType.KOBO.getName()))
.thenReturn(List.of(koboShelf1));
koboAutoShelfService.autoAddBookToKoboShelves(1L);
verify(bookRepository).save(testBook);
assert testBook.getShelves().contains(koboShelf1);
assert testBook.getShelves().size() == 1;
}
@Test
void autoAddBookToKoboShelves_withNullShelves_shouldInitializeAndAdd() {
testBook = BookEntity.builder()
.id(1L)
.shelves(null)
.build();
when(bookRepository.findById(1L)).thenReturn(Optional.of(testBook));
when(koboCompatibilityService.isBookSupportedForKobo(testBook)).thenReturn(true);
when(koboUserSettingsRepository.findByAutoAddToShelfTrueAndSyncEnabledTrue())
.thenReturn(List.of(settings1));
when(shelfRepository.findByUserIdInAndName(List.of(100L), ShelfType.KOBO.getName()))
.thenReturn(List.of(koboShelf1));
try {
koboAutoShelfService.autoAddBookToKoboShelves(1L);
} catch (NullPointerException e) {
}
}
}

View File

@@ -0,0 +1,279 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.KoboSyncSettings;
import com.adityachandel.booklore.model.dto.Shelf;
import com.adityachandel.booklore.model.dto.request.ShelfCreateRequest;
import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.IconType;
import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import com.adityachandel.booklore.service.ShelfService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class KoboSettingsServiceTest {
@Mock
private KoboUserSettingsRepository repository;
@Mock
private AuthenticationService authenticationService;
@Mock
private ShelfService shelfService;
@InjectMocks
private KoboSettingsService service;
private BookLoreUser user;
private KoboUserSettingsEntity settingsEntity;
@BeforeEach
void setUp() {
user = BookLoreUser.builder().id(1L).build();
settingsEntity = KoboUserSettingsEntity.builder()
.id(10L)
.userId(1L)
.token("token")
.syncEnabled(true)
.autoAddToShelf(true)
.progressMarkAsReadingThreshold(0.5f)
.progressMarkAsFinishedThreshold(0.9f)
.build();
}
@Test
void getCurrentUserSettings_existingSettings() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
KoboSyncSettings dto = service.getCurrentUserSettings();
assertEquals(settingsEntity.getId(), dto.getId());
assertEquals(settingsEntity.getUserId().toString(), dto.getUserId());
assertEquals(settingsEntity.getToken(), dto.getToken());
assertTrue(dto.isSyncEnabled());
assertTrue(dto.isAutoAddToShelf());
assertEquals(settingsEntity.getProgressMarkAsReadingThreshold(), dto.getProgressMarkAsReadingThreshold());
assertEquals(settingsEntity.getProgressMarkAsFinishedThreshold(), dto.getProgressMarkAsFinishedThreshold());
}
@Test
void getCurrentUserSettings_noSettings_createsDefault() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.empty());
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName()))).thenReturn(Optional.empty());
doReturn(Shelf.builder().id(100L).build()).when(shelfService).createShelf(any(ShelfCreateRequest.class));
KoboSyncSettings dto = service.getCurrentUserSettings();
assertEquals(user.getId().toString(), dto.getUserId());
assertNotNull(dto.getToken());
assertFalse(dto.isSyncEnabled());
}
@Test
void createOrUpdateToken_existingSettings() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName()))).thenReturn(Optional.of(ShelfEntity.builder().id(100L).build()));
KoboSyncSettings dto = service.createOrUpdateToken();
assertEquals(settingsEntity.getUserId().toString(), dto.getUserId());
assertNotNull(dto.getToken());
assertNotEquals("token", dto.getToken());
}
@Test
void createOrUpdateToken_noSettings_createsNew() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.empty());
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName()))).thenReturn(Optional.empty());
doReturn(Shelf.builder().id(100L).build()).when(shelfService).createShelf(any(ShelfCreateRequest.class));
KoboSyncSettings dto = service.createOrUpdateToken();
assertEquals(user.getId().toString(), dto.getUserId());
assertNotNull(dto.getToken());
assertFalse(dto.isSyncEnabled());
}
@Test
void updateSettings_disableSync_deletesShelf() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
Shelf shelf = Shelf.builder().id(100L).build();
when(shelfService.getUserKoboShelf()).thenReturn(shelf);
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
KoboSyncSettings update = new KoboSyncSettings();
update.setSyncEnabled(false);
update.setAutoAddToShelf(false);
KoboSyncSettings dto = service.updateSettings(update);
verify(shelfService).deleteShelf(100L);
assertFalse(dto.isSyncEnabled());
assertFalse(dto.isAutoAddToShelf());
}
@Test
void updateSettings_enableSync_createsShelf() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
settingsEntity.setSyncEnabled(false);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
when(shelfService.getUserKoboShelf()).thenReturn(null);
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName()))).thenReturn(Optional.empty());
doReturn(Shelf.builder().id(100L).build()).when(shelfService).createShelf(any(ShelfCreateRequest.class));
KoboSyncSettings update = new KoboSyncSettings();
update.setSyncEnabled(true);
update.setAutoAddToShelf(true);
KoboSyncSettings dto = service.updateSettings(update);
verify(shelfService).createShelf(any(ShelfCreateRequest.class));
assertTrue(dto.isSyncEnabled());
assertTrue(dto.isAutoAddToShelf());
}
@Test
void updateSettings_updatesThresholds() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
KoboSyncSettings update = new KoboSyncSettings();
update.setSyncEnabled(true);
update.setAutoAddToShelf(true);
update.setProgressMarkAsReadingThreshold(0.7f);
update.setProgressMarkAsFinishedThreshold(0.95f);
KoboSyncSettings dto = service.updateSettings(update);
assertEquals((Float)0.7f, dto.getProgressMarkAsReadingThreshold());
assertEquals((Float)0.95f, dto.getProgressMarkAsFinishedThreshold());
}
@Test
void updateSettings_nullThresholds_shouldNotChangeExisting() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
Float originalReading = settingsEntity.getProgressMarkAsReadingThreshold();
Float originalFinished = settingsEntity.getProgressMarkAsFinishedThreshold();
KoboSyncSettings update = new KoboSyncSettings();
update.setSyncEnabled(true);
update.setAutoAddToShelf(true);
update.setProgressMarkAsReadingThreshold(null);
update.setProgressMarkAsFinishedThreshold(null);
KoboSyncSettings dto = service.updateSettings(update);
assertEquals(originalReading, dto.getProgressMarkAsReadingThreshold());
assertEquals(originalFinished, dto.getProgressMarkAsFinishedThreshold());
}
@Test
void getCurrentUserSettings_settingsWithNullToken_shouldReturnDtoWithNullToken() {
settingsEntity.setToken(null);
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
KoboSyncSettings dto = service.getCurrentUserSettings();
assertNull(dto.getToken());
}
@Test
void getCurrentUserSettings_noAuthenticatedUser_shouldThrowException() {
when(authenticationService.getAuthenticatedUser()).thenReturn(null);
assertThrows(NullPointerException.class, () -> service.getCurrentUserSettings());
}
@Test
void updateSettings_getUserKoboShelfReturnsNull_shouldNotThrow() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L)).thenReturn(Optional.of(settingsEntity));
when(shelfService.getUserKoboShelf()).thenReturn(null);
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
KoboSyncSettings update = new KoboSyncSettings();
update.setSyncEnabled(false);
update.setAutoAddToShelf(false);
assertDoesNotThrow(() -> service.updateSettings(update));
}
@Test
void ensureKoboShelfExists_doesNotCreateIfExists() throws Exception {
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName()))).thenReturn(Optional.of(ShelfEntity.builder().id(100L).build()));
var method = service.getClass().getDeclaredMethod("ensureKoboShelfExists", Long.class);
method.setAccessible(true);
assertDoesNotThrow(() -> method.invoke(service, 1L));
verify(shelfService, never()).createShelf(any());
}
@Test
void ensureKoboShelfExists_createsIfMissing() throws Exception {
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName()))).thenReturn(Optional.empty());
doReturn(Shelf.builder().id(100L).build()).when(shelfService).createShelf(any(ShelfCreateRequest.class));
var method = service.getClass().getDeclaredMethod("ensureKoboShelfExists", Long.class);
method.setAccessible(true);
assertDoesNotThrow(() -> method.invoke(service, 1L));
verify(shelfService).createShelf(any(ShelfCreateRequest.class));
}
@Test
void ensureKoboShelfExists_idempotentIfCalledTwice() throws Exception {
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName())))
.thenReturn(Optional.empty())
.thenReturn(Optional.of(ShelfEntity.builder().id(100L).build()));
doReturn(Shelf.builder().id(100L).build()).when(shelfService).createShelf(any(ShelfCreateRequest.class));
var method = service.getClass().getDeclaredMethod("ensureKoboShelfExists", Long.class);
method.setAccessible(true);
method.invoke(service, 1L);
method.invoke(service, 1L);
verify(shelfService, times(1)).createShelf(any(ShelfCreateRequest.class));
}
@Test
void createOrUpdateToken_multipleCalls_generateDifferentTokens() {
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(repository.findByUserId(1L))
.thenReturn(Optional.empty())
.thenReturn(Optional.of(settingsEntity));
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(shelfService.getShelf(eq(1L), eq(ShelfType.KOBO.getName()))).thenReturn(Optional.empty());
doReturn(Shelf.builder().id(100L).build()).when(shelfService).createShelf(any(ShelfCreateRequest.class));
KoboSyncSettings dto1 = service.createOrUpdateToken();
// Simulate a new call with an existing entity
KoboSyncSettings dto2 = service.createOrUpdateToken();
assertNotEquals(dto1.getToken(), dto2.getToken());
}
}

View File

@@ -44,6 +44,26 @@
</div> </div>
</div> </div>
@if (koboSyncSettings.syncEnabled) {
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Auto-Add New Books to Kobo Shelf</label>
<p-toggle-switch
id="autoAddToShelf"
name="autoAddToShelf"
[(ngModel)]="koboSyncSettings.autoAddToShelf"
(ngModelChange)="onAutoAddToggle()">
</p-toggle-switch>
</div>
<p class="setting-description">
When enabled, newly added books will be automatically added to your Kobo shelf for syncing.
This eliminates the need to manually add each book to the shelf.
</p>
</div>
</div>
}
@if (koboSyncSettings.syncEnabled) { @if (koboSyncSettings.syncEnabled) {
<div class="setting-item"> <div class="setting-item">
<div class="setting-info"> <div class="setting-info">

View File

@@ -37,6 +37,7 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject<void>(); private readonly destroy$ = new Subject<void>();
private readonly sliderChange$ = new Subject<void>(); private readonly sliderChange$ = new Subject<void>();
private readonly progressThresholdChange$ = new Subject<void>();
hasKoboTokenPermission = false; hasKoboTokenPermission = false;
isAdmin = false; isAdmin = false;
@@ -55,7 +56,8 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
token: '', token: '',
syncEnabled: false, syncEnabled: false,
progressMarkAsReadingThreshold: 1, progressMarkAsReadingThreshold: 1,
progressMarkAsFinishedThreshold: 99 progressMarkAsFinishedThreshold: 99,
autoAddToShelf: true
} }
ngOnInit() { ngOnInit() {
@@ -70,6 +72,13 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
).subscribe(() => { ).subscribe(() => {
this.saveSettings(); this.saveSettings();
}); });
this.progressThresholdChange$.pipe(
debounceTime(500),
takeUntil(this.destroy$)
).subscribe(() => {
this.updateKoboSettings('Progress thresholds updated successfully');
});
} }
private setupUserStateSubscription() { private setupUserStateSubscription() {
@@ -108,6 +117,7 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
this.koboSyncSettings.syncEnabled = settings.syncEnabled; this.koboSyncSettings.syncEnabled = settings.syncEnabled;
this.koboSyncSettings.progressMarkAsReadingThreshold = settings.progressMarkAsReadingThreshold ?? 1; this.koboSyncSettings.progressMarkAsReadingThreshold = settings.progressMarkAsReadingThreshold ?? 1;
this.koboSyncSettings.progressMarkAsFinishedThreshold = settings.progressMarkAsFinishedThreshold ?? 99; this.koboSyncSettings.progressMarkAsFinishedThreshold = settings.progressMarkAsFinishedThreshold ?? 99;
this.koboSyncSettings.autoAddToShelf = settings.autoAddToShelf ?? false;
this.credentialsSaved = !!settings.token; this.credentialsSaved = !!settings.token;
}, },
error: () => { error: () => {
@@ -191,55 +201,44 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
message: 'Disabling Kobo sync will delete your Kobo shelf. Are you sure you want to proceed?', message: 'Disabling Kobo sync will delete your Kobo shelf. Are you sure you want to proceed?',
header: 'Confirm Disable', header: 'Confirm Disable',
icon: 'pi pi-exclamation-triangle', icon: 'pi pi-exclamation-triangle',
accept: () => this.performToggle(false), accept: () => this.updateKoboSettings('Kobo sync disabled'),
reject: () => { reject: () => {
this.koboSyncSettings.syncEnabled = true; this.koboSyncSettings.syncEnabled = true;
} }
}); });
} else { } else {
this.performToggle(true); this.updateKoboSettings('Kobo sync enabled');
} }
} }
private performToggle(enabled: boolean) { onProgressThresholdsChange() {
this.koboService.toggleSync(enabled).subscribe({ this.progressThresholdChange$.next();
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Sync Updated',
detail: enabled
? 'Kobo sync enabled'
: 'Kobo sync disabled'
});
this.shelfService.reloadShelves();
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to update sync setting'
});
}
});
} }
onProgressThresholdsChange() { onAutoAddToggle() {
this.koboService.updateProgressThresholds( const message = this.koboSyncSettings.autoAddToShelf
this.koboSyncSettings.progressMarkAsReadingThreshold, ? 'New books will be automatically added to Kobo shelf'
this.koboSyncSettings.progressMarkAsFinishedThreshold : 'Auto-add to Kobo shelf disabled';
).subscribe({ this.updateKoboSettings(message);
}
private updateKoboSettings(successMessage: string) {
this.koboService.updateSettings(this.koboSyncSettings).subscribe({
next: () => { next: () => {
this.messageService.add({ this.messageService.add({
severity: 'success', severity: 'success',
summary: 'Thresholds Updated', summary: 'Settings Updated',
detail: 'Progress thresholds updated successfully' detail: successMessage
}); });
if (!this.koboSyncSettings.syncEnabled) {
this.shelfService.reloadShelves();
}
}, },
error: () => { error: () => {
this.messageService.add({ this.messageService.add({
severity: 'error', severity: 'error',
summary: 'Error', summary: 'Error',
detail: 'Failed to update progress thresholds' detail: 'Failed to update Kobo settings'
}); });
} }
}); });

View File

@@ -8,6 +8,7 @@ export interface KoboSyncSettings {
syncEnabled: boolean; syncEnabled: boolean;
progressMarkAsReadingThreshold?: number; progressMarkAsReadingThreshold?: number;
progressMarkAsFinishedThreshold?: number; progressMarkAsFinishedThreshold?: number;
autoAddToShelf: boolean;
} }
@Injectable({ @Injectable({
@@ -22,22 +23,10 @@ export class KoboService {
} }
createOrUpdateToken(): Observable<KoboSyncSettings> { createOrUpdateToken(): Observable<KoboSyncSettings> {
return this.http.put<KoboSyncSettings>(`${this.baseUrl}`, null); return this.http.put<KoboSyncSettings>(`${this.baseUrl}/token`, null);
} }
toggleSync(enabled: boolean): Observable<void> { updateSettings(settings: KoboSyncSettings): Observable<KoboSyncSettings> {
const params = new HttpParams().set('enabled', enabled.toString()); return this.http.put<KoboSyncSettings>(`${this.baseUrl}`, settings);
return this.http.put<void>(`${this.baseUrl}/sync`, null, { params });
}
updateProgressThresholds(readingThreshold?: number, finishedThreshold?: number): Observable<KoboSyncSettings> {
let params = new HttpParams();
if (readingThreshold !== undefined && readingThreshold !== null) {
params = params.set('readingThreshold', readingThreshold.toString());
}
if (finishedThreshold !== undefined && finishedThreshold !== null) {
params = params.set('finishedThreshold', finishedThreshold.toString());
}
return this.http.put<KoboSyncSettings>(`${this.baseUrl}/progress-thresholds`, null, { params });
} }
} }