Saving reading position

This commit is contained in:
Stefan Hardegger
2025-07-28 14:09:19 +02:00
parent fcad028959
commit a501b27169
10 changed files with 473 additions and 6 deletions

View File

@@ -152,6 +152,20 @@ public class StoryController {
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")
public ResponseEntity<String> manualReindex() {
if (typesenseService == null) {
@@ -402,6 +416,11 @@ public class StoryController {
dto.setCreatedAt(story.getCreatedAt());
dto.setUpdatedAt(story.getUpdatedAt());
// Reading progress fields
dto.setIsRead(story.getIsRead());
dto.setReadingPosition(story.getReadingPosition());
dto.setLastReadAt(story.getLastReadAt());
if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId());
dto.setAuthorName(story.getAuthor().getName());
@@ -434,6 +453,11 @@ public class StoryController {
dto.setUpdatedAt(story.getUpdatedAt());
dto.setPartOfSeries(story.isPartOfSeries());
// Reading progress fields
dto.setIsRead(story.getIsRead());
dto.setReadingPosition(story.getReadingPosition());
dto.setLastReadAt(story.getLastReadAt());
if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId());
dto.setAuthorName(story.getAuthor().getName());

View File

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

View File

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

View File

@@ -28,6 +28,11 @@ public class StoryDto {
private Integer rating;
private Integer volume;
// Reading progress fields
private Boolean isRead;
private Integer readingPosition;
private LocalDateTime lastReadAt;
// Related entities as simple references
private UUID authorId;
private String authorName;
@@ -133,6 +138,30 @@ public class StoryDto {
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() {
return authorId;
}

View File

@@ -16,6 +16,9 @@ public class StorySearchDto {
private Integer rating;
private Integer volume;
// Reading status
private Boolean isRead;
// Author info
private UUID authorId;
private String authorName;
@@ -109,6 +112,14 @@ public class StorySearchDto {
this.volume = volume;
}
public Boolean getIsRead() {
return isRead;
}
public void setIsRead(Boolean isRead) {
this.isRead = isRead;
}
public UUID getAuthorId() {
return authorId;
}

View File

@@ -20,6 +20,11 @@ public class StorySummaryDto {
private Integer rating;
private Integer volume;
// Reading progress fields
private Boolean isRead;
private Integer readingPosition;
private LocalDateTime lastReadAt;
// Related entities as simple references
private UUID authorId;
private String authorName;
@@ -106,6 +111,30 @@ public class StorySummaryDto {
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() {
return authorId;
}

View File

@@ -55,6 +55,15 @@ public class Story {
@Column(name = "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)
@JoinColumn(name = "author_id")
@JsonBackReference("author-stories")
@@ -212,6 +221,30 @@ public class Story {
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() {
return author;
}
@@ -252,6 +285,37 @@ public class Story {
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
public boolean equals(Object o) {
if (this == o) return true;
@@ -272,6 +336,8 @@ public class Story {
", title='" + title + '\'' +
", wordCount=" + wordCount +
", rating=" + rating +
", isRead=" + isRead +
", readingPosition=" + readingPosition +
'}';
}
}

View File

@@ -271,6 +271,45 @@ public class StoryService {
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)
public List<Story> findBySeriesOrderByVolume(UUID seriesId) {
return storyRepository.findBySeriesOrderByVolume(seriesId);

View File

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