Saving reading position
This commit is contained in:
@@ -152,6 +152,20 @@ public class StoryController {
|
|||||||
return ResponseEntity.ok(convertToDto(story));
|
return ResponseEntity.ok(convertToDto(story));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/reading-progress")
|
||||||
|
public ResponseEntity<StoryDto> updateReadingProgress(@PathVariable UUID id, @RequestBody ReadingProgressRequest request) {
|
||||||
|
logger.info("Updating reading progress for story {} to position {}", id, request.getPosition());
|
||||||
|
Story story = storyService.updateReadingProgress(id, request.getPosition());
|
||||||
|
return ResponseEntity.ok(convertToDto(story));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/reading-status")
|
||||||
|
public ResponseEntity<StoryDto> updateReadingStatus(@PathVariable UUID id, @RequestBody ReadingStatusRequest request) {
|
||||||
|
logger.info("Updating reading status for story {} to {}", id, request.getIsRead() ? "read" : "unread");
|
||||||
|
Story story = storyService.updateReadingStatus(id, request.getIsRead());
|
||||||
|
return ResponseEntity.ok(convertToDto(story));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/reindex")
|
@PostMapping("/reindex")
|
||||||
public ResponseEntity<String> manualReindex() {
|
public ResponseEntity<String> manualReindex() {
|
||||||
if (typesenseService == null) {
|
if (typesenseService == null) {
|
||||||
@@ -402,6 +416,11 @@ public class StoryController {
|
|||||||
dto.setCreatedAt(story.getCreatedAt());
|
dto.setCreatedAt(story.getCreatedAt());
|
||||||
dto.setUpdatedAt(story.getUpdatedAt());
|
dto.setUpdatedAt(story.getUpdatedAt());
|
||||||
|
|
||||||
|
// Reading progress fields
|
||||||
|
dto.setIsRead(story.getIsRead());
|
||||||
|
dto.setReadingPosition(story.getReadingPosition());
|
||||||
|
dto.setLastReadAt(story.getLastReadAt());
|
||||||
|
|
||||||
if (story.getAuthor() != null) {
|
if (story.getAuthor() != null) {
|
||||||
dto.setAuthorId(story.getAuthor().getId());
|
dto.setAuthorId(story.getAuthor().getId());
|
||||||
dto.setAuthorName(story.getAuthor().getName());
|
dto.setAuthorName(story.getAuthor().getName());
|
||||||
@@ -434,6 +453,11 @@ public class StoryController {
|
|||||||
dto.setUpdatedAt(story.getUpdatedAt());
|
dto.setUpdatedAt(story.getUpdatedAt());
|
||||||
dto.setPartOfSeries(story.isPartOfSeries());
|
dto.setPartOfSeries(story.isPartOfSeries());
|
||||||
|
|
||||||
|
// Reading progress fields
|
||||||
|
dto.setIsRead(story.getIsRead());
|
||||||
|
dto.setReadingPosition(story.getReadingPosition());
|
||||||
|
dto.setLastReadAt(story.getLastReadAt());
|
||||||
|
|
||||||
if (story.getAuthor() != null) {
|
if (story.getAuthor() != null) {
|
||||||
dto.setAuthorId(story.getAuthor().getId());
|
dto.setAuthorId(story.getAuthor().getId());
|
||||||
dto.setAuthorName(story.getAuthor().getName());
|
dto.setAuthorName(story.getAuthor().getName());
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
|
||||||
|
public class ReadingProgressRequest {
|
||||||
|
|
||||||
|
@Min(value = 0, message = "Reading position must be non-negative")
|
||||||
|
private Integer position;
|
||||||
|
|
||||||
|
public ReadingProgressRequest() {}
|
||||||
|
|
||||||
|
public ReadingProgressRequest(Integer position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPosition(Integer position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public class ReadingStatusRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "Reading status is required")
|
||||||
|
private Boolean isRead;
|
||||||
|
|
||||||
|
public ReadingStatusRequest() {}
|
||||||
|
|
||||||
|
public ReadingStatusRequest(Boolean isRead) {
|
||||||
|
this.isRead = isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRead() {
|
||||||
|
return isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRead(Boolean isRead) {
|
||||||
|
this.isRead = isRead;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ public class StoryDto {
|
|||||||
private Integer rating;
|
private Integer rating;
|
||||||
private Integer volume;
|
private Integer volume;
|
||||||
|
|
||||||
|
// Reading progress fields
|
||||||
|
private Boolean isRead;
|
||||||
|
private Integer readingPosition;
|
||||||
|
private LocalDateTime lastReadAt;
|
||||||
|
|
||||||
// Related entities as simple references
|
// Related entities as simple references
|
||||||
private UUID authorId;
|
private UUID authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
@@ -133,6 +138,30 @@ public class StoryDto {
|
|||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRead() {
|
||||||
|
return isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRead(Boolean isRead) {
|
||||||
|
this.isRead = isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getReadingPosition() {
|
||||||
|
return readingPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadingPosition(Integer readingPosition) {
|
||||||
|
this.readingPosition = readingPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastReadAt() {
|
||||||
|
return lastReadAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastReadAt(LocalDateTime lastReadAt) {
|
||||||
|
this.lastReadAt = lastReadAt;
|
||||||
|
}
|
||||||
|
|
||||||
public UUID getAuthorId() {
|
public UUID getAuthorId() {
|
||||||
return authorId;
|
return authorId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public class StorySearchDto {
|
|||||||
private Integer rating;
|
private Integer rating;
|
||||||
private Integer volume;
|
private Integer volume;
|
||||||
|
|
||||||
|
// Reading status
|
||||||
|
private Boolean isRead;
|
||||||
|
|
||||||
// Author info
|
// Author info
|
||||||
private UUID authorId;
|
private UUID authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
@@ -109,6 +112,14 @@ public class StorySearchDto {
|
|||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRead() {
|
||||||
|
return isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRead(Boolean isRead) {
|
||||||
|
this.isRead = isRead;
|
||||||
|
}
|
||||||
|
|
||||||
public UUID getAuthorId() {
|
public UUID getAuthorId() {
|
||||||
return authorId;
|
return authorId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ public class StorySummaryDto {
|
|||||||
private Integer rating;
|
private Integer rating;
|
||||||
private Integer volume;
|
private Integer volume;
|
||||||
|
|
||||||
|
// Reading progress fields
|
||||||
|
private Boolean isRead;
|
||||||
|
private Integer readingPosition;
|
||||||
|
private LocalDateTime lastReadAt;
|
||||||
|
|
||||||
// Related entities as simple references
|
// Related entities as simple references
|
||||||
private UUID authorId;
|
private UUID authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
@@ -106,6 +111,30 @@ public class StorySummaryDto {
|
|||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRead() {
|
||||||
|
return isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRead(Boolean isRead) {
|
||||||
|
this.isRead = isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getReadingPosition() {
|
||||||
|
return readingPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadingPosition(Integer readingPosition) {
|
||||||
|
this.readingPosition = readingPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastReadAt() {
|
||||||
|
return lastReadAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastReadAt(LocalDateTime lastReadAt) {
|
||||||
|
this.lastReadAt = lastReadAt;
|
||||||
|
}
|
||||||
|
|
||||||
public UUID getAuthorId() {
|
public UUID getAuthorId() {
|
||||||
return authorId;
|
return authorId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ public class Story {
|
|||||||
@Column(name = "volume")
|
@Column(name = "volume")
|
||||||
private Integer volume;
|
private Integer volume;
|
||||||
|
|
||||||
|
@Column(name = "is_read")
|
||||||
|
private Boolean isRead = false;
|
||||||
|
|
||||||
|
@Column(name = "reading_position")
|
||||||
|
private Integer readingPosition = 0;
|
||||||
|
|
||||||
|
@Column(name = "last_read_at")
|
||||||
|
private LocalDateTime lastReadAt;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "author_id")
|
@JoinColumn(name = "author_id")
|
||||||
@JsonBackReference("author-stories")
|
@JsonBackReference("author-stories")
|
||||||
@@ -212,6 +221,30 @@ public class Story {
|
|||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRead() {
|
||||||
|
return isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRead(Boolean isRead) {
|
||||||
|
this.isRead = isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getReadingPosition() {
|
||||||
|
return readingPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadingPosition(Integer readingPosition) {
|
||||||
|
this.readingPosition = readingPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastReadAt() {
|
||||||
|
return lastReadAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastReadAt(LocalDateTime lastReadAt) {
|
||||||
|
this.lastReadAt = lastReadAt;
|
||||||
|
}
|
||||||
|
|
||||||
public Author getAuthor() {
|
public Author getAuthor() {
|
||||||
return author;
|
return author;
|
||||||
}
|
}
|
||||||
@@ -252,6 +285,37 @@ public class Story {
|
|||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the reading progress and timestamp
|
||||||
|
*/
|
||||||
|
public void updateReadingProgress(Integer position) {
|
||||||
|
this.readingPosition = position;
|
||||||
|
this.lastReadAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the story as read and updates the reading position to the end
|
||||||
|
*/
|
||||||
|
public void markAsRead() {
|
||||||
|
this.isRead = true;
|
||||||
|
this.lastReadAt = LocalDateTime.now();
|
||||||
|
// Set reading position to the end of content if available
|
||||||
|
if (contentPlain != null) {
|
||||||
|
this.readingPosition = contentPlain.length();
|
||||||
|
} else if (contentHtml != null) {
|
||||||
|
this.readingPosition = contentHtml.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the story as unread and resets reading position
|
||||||
|
*/
|
||||||
|
public void markAsUnread() {
|
||||||
|
this.isRead = false;
|
||||||
|
this.readingPosition = 0;
|
||||||
|
this.lastReadAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean equals(Object o) {
|
public boolean equals(Object o) {
|
||||||
if (this == o) return true;
|
if (this == o) return true;
|
||||||
@@ -272,6 +336,8 @@ public class Story {
|
|||||||
", title='" + title + '\'' +
|
", title='" + title + '\'' +
|
||||||
", wordCount=" + wordCount +
|
", wordCount=" + wordCount +
|
||||||
", rating=" + rating +
|
", rating=" + rating +
|
||||||
|
", isRead=" + isRead +
|
||||||
|
", readingPosition=" + readingPosition +
|
||||||
'}';
|
'}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,6 +271,45 @@ public class StoryService {
|
|||||||
return savedStory;
|
return savedStory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Story updateReadingProgress(UUID id, Integer position) {
|
||||||
|
if (position != null && position < 0) {
|
||||||
|
throw new IllegalArgumentException("Reading position must be non-negative");
|
||||||
|
}
|
||||||
|
|
||||||
|
Story story = findById(id);
|
||||||
|
story.updateReadingProgress(position);
|
||||||
|
Story savedStory = storyRepository.save(story);
|
||||||
|
|
||||||
|
// Update Typesense index with new reading progress
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.updateStory(savedStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Story updateReadingStatus(UUID id, Boolean isRead) {
|
||||||
|
Story story = findById(id);
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(isRead)) {
|
||||||
|
story.markAsRead();
|
||||||
|
} else {
|
||||||
|
story.setIsRead(false);
|
||||||
|
story.setLastReadAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
Story savedStory = storyRepository.save(story);
|
||||||
|
|
||||||
|
// Update Typesense index with new reading status
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.updateStory(savedStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedStory;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<Story> findBySeriesOrderByVolume(UUID seriesId) {
|
public List<Story> findBySeriesOrderByVolume(UUID seriesId) {
|
||||||
return storyRepository.findBySeriesOrderByVolume(seriesId);
|
return storyRepository.findBySeriesOrderByVolume(seriesId);
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.entity.Story;
|
||||||
|
import com.storycove.repository.StoryRepository;
|
||||||
|
import com.storycove.repository.TagRepository;
|
||||||
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("Story Service Unit Tests - Reading Progress")
|
||||||
|
class StoryServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private StoryRepository storyRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TagRepository tagRepository;
|
||||||
|
|
||||||
|
private StoryService storyService;
|
||||||
|
private Story testStory;
|
||||||
|
private UUID testId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
testId = UUID.randomUUID();
|
||||||
|
testStory = new Story("Test Story");
|
||||||
|
testStory.setId(testId);
|
||||||
|
testStory.setContentHtml("<p>Test content for reading progress tracking</p>");
|
||||||
|
|
||||||
|
// Create StoryService with only required repositories, all services can be null for these tests
|
||||||
|
storyService = new StoryService(
|
||||||
|
storyRepository,
|
||||||
|
tagRepository,
|
||||||
|
null, // authorService - not needed for reading progress tests
|
||||||
|
null, // tagService - not needed for reading progress tests
|
||||||
|
null, // seriesService - not needed for reading progress tests
|
||||||
|
null, // sanitizationService - not needed for reading progress tests
|
||||||
|
null // typesenseService - will test both with and without
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should update reading progress successfully")
|
||||||
|
void shouldUpdateReadingProgress() {
|
||||||
|
Integer position = 150;
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingProgress(testId, position);
|
||||||
|
|
||||||
|
assertEquals(position, result.getReadingPosition());
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
verify(storyRepository).findById(testId);
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should update reading progress with zero position")
|
||||||
|
void shouldUpdateReadingProgressWithZeroPosition() {
|
||||||
|
Integer position = 0;
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingProgress(testId, position);
|
||||||
|
|
||||||
|
assertEquals(0, result.getReadingPosition());
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should throw exception for negative reading position")
|
||||||
|
void shouldThrowExceptionForNegativeReadingPosition() {
|
||||||
|
Integer position = -1;
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> storyService.updateReadingProgress(testId, position));
|
||||||
|
|
||||||
|
verify(storyRepository, never()).findById(any());
|
||||||
|
verify(storyRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should handle null reading position")
|
||||||
|
void shouldHandleNullReadingPosition() {
|
||||||
|
Integer position = null;
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingProgress(testId, position);
|
||||||
|
|
||||||
|
assertNull(result.getReadingPosition());
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should throw exception when story not found for reading progress update")
|
||||||
|
void shouldThrowExceptionWhenStoryNotFoundForReadingProgress() {
|
||||||
|
Integer position = 100;
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(ResourceNotFoundException.class,
|
||||||
|
() -> storyService.updateReadingProgress(testId, position));
|
||||||
|
|
||||||
|
verify(storyRepository).findById(testId);
|
||||||
|
verify(storyRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should mark story as read")
|
||||||
|
void shouldMarkStoryAsRead() {
|
||||||
|
Boolean isRead = true;
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingStatus(testId, isRead);
|
||||||
|
|
||||||
|
assertTrue(result.getIsRead());
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
// When marked as read, position should be set to content length
|
||||||
|
assertTrue(result.getReadingPosition() > 0);
|
||||||
|
verify(storyRepository).findById(testId);
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should mark story as unread")
|
||||||
|
void shouldMarkStoryAsUnread() {
|
||||||
|
Boolean isRead = false;
|
||||||
|
// First mark story as read to test transition
|
||||||
|
testStory.markAsRead();
|
||||||
|
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingStatus(testId, isRead);
|
||||||
|
|
||||||
|
assertFalse(result.getIsRead());
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should handle null reading status")
|
||||||
|
void shouldHandleNullReadingStatus() {
|
||||||
|
Boolean isRead = null;
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingStatus(testId, isRead);
|
||||||
|
|
||||||
|
assertFalse(result.getIsRead());
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should throw exception when story not found for reading status update")
|
||||||
|
void shouldThrowExceptionWhenStoryNotFoundForReadingStatus() {
|
||||||
|
Boolean isRead = true;
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(ResourceNotFoundException.class,
|
||||||
|
() -> storyService.updateReadingStatus(testId, isRead));
|
||||||
|
|
||||||
|
verify(storyRepository).findById(testId);
|
||||||
|
verify(storyRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should update lastReadAt timestamp when updating progress")
|
||||||
|
void shouldUpdateLastReadAtWhenUpdatingProgress() {
|
||||||
|
Integer position = 50;
|
||||||
|
LocalDateTime beforeUpdate = LocalDateTime.now().minusMinutes(1);
|
||||||
|
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingProgress(testId, position);
|
||||||
|
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
assertTrue(result.getLastReadAt().isAfter(beforeUpdate));
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should update lastReadAt timestamp when updating status")
|
||||||
|
void shouldUpdateLastReadAtWhenUpdatingStatus() {
|
||||||
|
Boolean isRead = true;
|
||||||
|
LocalDateTime beforeUpdate = LocalDateTime.now().minusMinutes(1);
|
||||||
|
|
||||||
|
when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory));
|
||||||
|
when(storyRepository.save(any(Story.class))).thenReturn(testStory);
|
||||||
|
|
||||||
|
Story result = storyService.updateReadingStatus(testId, isRead);
|
||||||
|
|
||||||
|
assertNotNull(result.getLastReadAt());
|
||||||
|
assertTrue(result.getLastReadAt().isAfter(beforeUpdate));
|
||||||
|
verify(storyRepository).save(testStory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as cheerio from 'cheerio';
|
||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
// Dynamic cheerio import used to avoid client-side bundling issues
|
// Dynamic cheerio import used to avoid client-side bundling issues
|
||||||
@@ -36,7 +37,7 @@ export function extractByTextPattern(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractTextBlocks(
|
export function extractTextBlocks(
|
||||||
$: any,
|
$: cheerio.CheerioAPI,
|
||||||
config: TextBlockStrategy
|
config: TextBlockStrategy
|
||||||
): string {
|
): string {
|
||||||
const blocks: Array<{element: any, text: string}> = [];
|
const blocks: Array<{element: any, text: string}> = [];
|
||||||
@@ -48,7 +49,7 @@ export function extractTextBlocks(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$('*').each((_: any, elem: any) => {
|
$('*').each((_, elem) => {
|
||||||
const $elem = $(elem);
|
const $elem = $(elem);
|
||||||
const text = $elem.clone().children().remove().end().text().trim();
|
const text = $elem.clone().children().remove().end().text().trim();
|
||||||
|
|
||||||
@@ -101,10 +102,16 @@ export function extractHtmlBetween(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractLinkText(
|
export function extractLinkText(
|
||||||
$: any,
|
$: cheerio.CheerioAPI,
|
||||||
config: LinkTextStrategy
|
config: LinkTextStrategy
|
||||||
): string {
|
): string {
|
||||||
let searchScope = config.searchWithin ? $(config.searchWithin) : $('body');
|
let searchScope: cheerio.Cheerio<cheerio.AnyNode>;
|
||||||
|
|
||||||
|
if (config.searchWithin) {
|
||||||
|
searchScope = $(config.searchWithin);
|
||||||
|
} else {
|
||||||
|
searchScope = $('body').length ? $('body') : $('*');
|
||||||
|
}
|
||||||
|
|
||||||
// Look for links near the specified text patterns
|
// Look for links near the specified text patterns
|
||||||
let foundText = '';
|
let foundText = '';
|
||||||
@@ -112,7 +119,7 @@ export function extractLinkText(
|
|||||||
config.nearText.forEach(text => {
|
config.nearText.forEach(text => {
|
||||||
if (foundText) return; // Already found
|
if (foundText) return; // Already found
|
||||||
|
|
||||||
searchScope.find('*').each((_: any, elem: any) => {
|
searchScope.find('*').each((_, elem) => {
|
||||||
const $elem = $(elem);
|
const $elem = $(elem);
|
||||||
const elemText = $elem.text().toLowerCase();
|
const elemText = $elem.text().toLowerCase();
|
||||||
|
|
||||||
@@ -132,7 +139,7 @@ export function extractLinkText(
|
|||||||
|
|
||||||
// Look for links in the next few siblings
|
// Look for links in the next few siblings
|
||||||
const $siblings = $elem.nextAll().slice(0, 3);
|
const $siblings = $elem.nextAll().slice(0, 3);
|
||||||
$siblings.find('a').first().each((_: any, link: any) => {
|
$siblings.find('a').first().each((_, link) => {
|
||||||
foundText = $(link).text().trim();
|
foundText = $(link).text().trim();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user