Various Improvements.

- Testing Coverage
- Image Handling
- Session Handling
- Library Switching
This commit is contained in:
Stefan Hardegger
2025-10-20 08:24:29 +02:00
parent 20d0652c85
commit 30c0132a92
26 changed files with 5810 additions and 75 deletions

View File

@@ -0,0 +1,465 @@
package com.storycove.service;
import com.storycove.dto.CollectionDto;
import com.storycove.dto.SearchResultDto;
import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.repository.CollectionRepository;
import com.storycove.repository.CollectionStoryRepository;
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.Test;
import org.junit.jupiter.api.DisplayName;
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.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CollectionServiceTest {
@Mock
private CollectionRepository collectionRepository;
@Mock
private CollectionStoryRepository collectionStoryRepository;
@Mock
private StoryRepository storyRepository;
@Mock
private TagRepository tagRepository;
@Mock
private SearchServiceAdapter searchServiceAdapter;
@Mock
private ReadingTimeService readingTimeService;
@InjectMocks
private CollectionService collectionService;
private Collection testCollection;
private Story testStory;
private Tag testTag;
private UUID collectionId;
private UUID storyId;
@BeforeEach
void setUp() {
collectionId = UUID.randomUUID();
storyId = UUID.randomUUID();
testCollection = new Collection();
testCollection.setId(collectionId);
testCollection.setName("Test Collection");
testCollection.setDescription("Test Description");
testCollection.setIsArchived(false);
testStory = new Story();
testStory.setId(storyId);
testStory.setTitle("Test Story");
testStory.setWordCount(1000);
testTag = new Tag();
testTag.setId(UUID.randomUUID());
testTag.setName("test-tag");
}
// ========================================
// Search Tests
// ========================================
@Test
@DisplayName("Should search collections using SearchServiceAdapter")
void testSearchCollections() {
// Arrange
CollectionDto dto = new CollectionDto();
dto.setId(collectionId);
dto.setName("Test Collection");
SearchResultDto<CollectionDto> searchResult = new SearchResultDto<>(
List.of(dto), 1, 0, 10, "test", 100L
);
when(searchServiceAdapter.searchCollections(anyString(), anyList(), anyBoolean(), anyInt(), anyInt()))
.thenReturn(searchResult);
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
// Act
SearchResultDto<Collection> result = collectionService.searchCollections("test", null, false, 0, 10);
// Assert
assertNotNull(result);
assertEquals(1, result.getTotalHits());
assertEquals(1, result.getResults().size());
assertEquals(collectionId, result.getResults().get(0).getId());
verify(searchServiceAdapter).searchCollections("test", null, false, 0, 10);
}
@Test
@DisplayName("Should handle search with tag filters")
void testSearchCollectionsWithTags() {
// Arrange
List<String> tags = List.of("fantasy", "adventure");
CollectionDto dto = new CollectionDto();
dto.setId(collectionId);
SearchResultDto<CollectionDto> searchResult = new SearchResultDto<>(
List.of(dto), 1, 0, 10, "test", 50L
);
when(searchServiceAdapter.searchCollections(anyString(), eq(tags), anyBoolean(), anyInt(), anyInt()))
.thenReturn(searchResult);
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
// Act
SearchResultDto<Collection> result = collectionService.searchCollections("test", tags, false, 0, 10);
// Assert
assertEquals(1, result.getResults().size());
verify(searchServiceAdapter).searchCollections("test", tags, false, 0, 10);
}
@Test
@DisplayName("Should return empty results when search fails")
void testSearchCollectionsFailure() {
// Arrange
when(searchServiceAdapter.searchCollections(anyString(), anyList(), anyBoolean(), anyInt(), anyInt()))
.thenThrow(new RuntimeException("Search failed"));
// Act
SearchResultDto<Collection> result = collectionService.searchCollections("test", null, false, 0, 10);
// Assert
assertNotNull(result);
assertEquals(0, result.getTotalHits());
assertTrue(result.getResults().isEmpty());
}
// ========================================
// CRUD Operations Tests
// ========================================
@Test
@DisplayName("Should find collection by ID")
void testFindById() {
// Arrange
when(collectionRepository.findByIdWithStoriesAndTags(collectionId))
.thenReturn(Optional.of(testCollection));
// Act
Collection result = collectionService.findById(collectionId);
// Assert
assertNotNull(result);
assertEquals(collectionId, result.getId());
assertEquals("Test Collection", result.getName());
}
@Test
@DisplayName("Should throw exception when collection not found")
void testFindByIdNotFound() {
// Arrange
when(collectionRepository.findByIdWithStoriesAndTags(any()))
.thenReturn(Optional.empty());
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
collectionService.findById(UUID.randomUUID());
});
}
@Test
@DisplayName("Should create collection with tags")
void testCreateCollection() {
// Arrange
List<String> tagNames = List.of("fantasy", "adventure");
when(tagRepository.findByName("fantasy")).thenReturn(Optional.of(testTag));
when(tagRepository.findByName("adventure")).thenReturn(Optional.empty());
when(tagRepository.save(any(Tag.class))).thenReturn(testTag);
when(collectionRepository.save(any(Collection.class))).thenReturn(testCollection);
// Act
Collection result = collectionService.createCollection("New Collection", "Description", tagNames, null);
// Assert
assertNotNull(result);
verify(collectionRepository).save(any(Collection.class));
verify(tagRepository, times(2)).findByName(anyString());
}
@Test
@DisplayName("Should create collection with initial stories")
void testCreateCollectionWithStories() {
// Arrange
List<UUID> storyIds = List.of(storyId);
when(collectionRepository.save(any(Collection.class))).thenReturn(testCollection);
when(storyRepository.findAllById(storyIds)).thenReturn(List.of(testStory));
when(collectionStoryRepository.existsByCollectionIdAndStoryId(any(), any())).thenReturn(false);
when(collectionStoryRepository.getNextPosition(any())).thenReturn(1000);
when(collectionStoryRepository.save(any())).thenReturn(new CollectionStory());
when(collectionRepository.findByIdWithStoriesAndTags(any()))
.thenReturn(Optional.of(testCollection));
// Act
Collection result = collectionService.createCollection("New Collection", "Description", null, storyIds);
// Assert
assertNotNull(result);
verify(storyRepository).findAllById(storyIds);
verify(collectionStoryRepository).save(any(CollectionStory.class));
}
@Test
@DisplayName("Should update collection metadata")
void testUpdateCollection() {
// Arrange
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
when(collectionRepository.save(any(Collection.class)))
.thenReturn(testCollection);
// Act
Collection result = collectionService.updateCollection(
collectionId, "Updated Name", "Updated Description", null, 5
);
// Assert
assertNotNull(result);
verify(collectionRepository).save(any(Collection.class));
}
@Test
@DisplayName("Should delete collection")
void testDeleteCollection() {
// Arrange
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
doNothing().when(collectionRepository).delete(any(Collection.class));
// Act
collectionService.deleteCollection(collectionId);
// Assert
verify(collectionRepository).delete(testCollection);
}
@Test
@DisplayName("Should archive collection")
void testArchiveCollection() {
// Arrange
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
when(collectionRepository.save(any(Collection.class)))
.thenReturn(testCollection);
// Act
Collection result = collectionService.archiveCollection(collectionId, true);
// Assert
assertNotNull(result);
verify(collectionRepository).save(any(Collection.class));
}
// ========================================
// Story Management Tests
// ========================================
@Test
@DisplayName("Should add stories to collection")
void testAddStoriesToCollection() {
// Arrange
List<UUID> storyIds = List.of(storyId);
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
when(storyRepository.findAllById(storyIds))
.thenReturn(List.of(testStory));
when(collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId))
.thenReturn(false);
when(collectionStoryRepository.getNextPosition(collectionId))
.thenReturn(1000);
when(collectionStoryRepository.save(any()))
.thenReturn(new CollectionStory());
when(collectionStoryRepository.countByCollectionId(collectionId))
.thenReturn(1L);
// Act
Map<String, Object> result = collectionService.addStoriesToCollection(collectionId, storyIds, null);
// Assert
assertEquals(1, result.get("added"));
assertEquals(0, result.get("skipped"));
assertEquals(1L, result.get("totalStories"));
verify(collectionStoryRepository).save(any(CollectionStory.class));
}
@Test
@DisplayName("Should skip duplicate stories when adding")
void testAddDuplicateStories() {
// Arrange
List<UUID> storyIds = List.of(storyId);
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
when(storyRepository.findAllById(storyIds))
.thenReturn(List.of(testStory));
when(collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId))
.thenReturn(true);
when(collectionStoryRepository.countByCollectionId(collectionId))
.thenReturn(1L);
// Act
Map<String, Object> result = collectionService.addStoriesToCollection(collectionId, storyIds, null);
// Assert
assertEquals(0, result.get("added"));
assertEquals(1, result.get("skipped"));
verify(collectionStoryRepository, never()).save(any());
}
@Test
@DisplayName("Should throw exception when adding non-existent stories")
void testAddNonExistentStories() {
// Arrange
List<UUID> storyIds = List.of(storyId, UUID.randomUUID());
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
when(storyRepository.findAllById(storyIds))
.thenReturn(List.of(testStory)); // Only one story found
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
collectionService.addStoriesToCollection(collectionId, storyIds, null);
});
}
@Test
@DisplayName("Should remove story from collection")
void testRemoveStoryFromCollection() {
// Arrange
CollectionStory collectionStory = new CollectionStory();
when(collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId))
.thenReturn(true);
when(collectionStoryRepository.findByCollectionIdAndStoryId(collectionId, storyId))
.thenReturn(collectionStory);
doNothing().when(collectionStoryRepository).delete(any());
// Act
collectionService.removeStoryFromCollection(collectionId, storyId);
// Assert
verify(collectionStoryRepository).delete(collectionStory);
}
@Test
@DisplayName("Should throw exception when removing non-existent story")
void testRemoveNonExistentStory() {
// Arrange
when(collectionStoryRepository.existsByCollectionIdAndStoryId(any(), any()))
.thenReturn(false);
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
collectionService.removeStoryFromCollection(collectionId, storyId);
});
}
@Test
@DisplayName("Should reorder stories in collection")
void testReorderStories() {
// Arrange
List<Map<String, Object>> storyOrders = List.of(
Map.of("storyId", storyId.toString(), "position", 1)
);
when(collectionRepository.findById(collectionId))
.thenReturn(Optional.of(testCollection));
doNothing().when(collectionStoryRepository).updatePosition(any(), any(), anyInt());
// Act
collectionService.reorderStories(collectionId, storyOrders);
// Assert
verify(collectionStoryRepository, times(2)).updatePosition(any(), any(), anyInt());
}
// ========================================
// Statistics Tests
// ========================================
@Test
@DisplayName("Should get collection statistics")
void testGetCollectionStatistics() {
// Arrange
testStory.setWordCount(1000);
testStory.setRating(5);
CollectionStory cs = new CollectionStory();
cs.setStory(testStory);
testCollection.setCollectionStories(List.of(cs));
when(collectionRepository.findByIdWithStoriesAndTags(collectionId))
.thenReturn(Optional.of(testCollection));
when(readingTimeService.calculateReadingTime(1000))
.thenReturn(5);
// Act
Map<String, Object> stats = collectionService.getCollectionStatistics(collectionId);
// Assert
assertNotNull(stats);
assertEquals(1, stats.get("totalStories"));
assertEquals(1000, stats.get("totalWordCount"));
assertEquals(5, stats.get("estimatedReadingTime"));
assertTrue(stats.containsKey("averageStoryRating"));
}
// ========================================
// Helper Method Tests
// ========================================
@Test
@DisplayName("Should find all collections with tags for indexing")
void testFindAllWithTags() {
// Arrange
when(collectionRepository.findAllWithTags())
.thenReturn(List.of(testCollection));
// Act
List<Collection> result = collectionService.findAllWithTags();
// Assert
assertNotNull(result);
assertEquals(1, result.size());
verify(collectionRepository).findAllWithTags();
}
@Test
@DisplayName("Should get collections for a specific story")
void testGetCollectionsForStory() {
// Arrange
CollectionStory cs = new CollectionStory();
cs.setCollection(testCollection);
when(collectionStoryRepository.findByStoryId(storyId))
.thenReturn(List.of(cs));
// Act
List<Collection> result = collectionService.getCollectionsForStory(storyId);
// Assert
assertNotNull(result);
assertEquals(1, result.size());
assertEquals(collectionId, result.get(0).getId());
}
}

View File

@@ -0,0 +1,721 @@
package com.storycove.service;
import com.storycove.dto.EPUBExportRequest;
import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.ReadingPosition;
import com.storycove.entity.Series;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.repository.ReadingPositionRepository;
import com.storycove.service.exception.ResourceNotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests for EPUBExportService.
* Note: These tests focus on service logic. Full EPUB validation would be done in integration tests.
*/
@ExtendWith(MockitoExtension.class)
class EPUBExportServiceTest {
@Mock
private StoryService storyService;
@Mock
private ReadingPositionRepository readingPositionRepository;
@Mock
private CollectionService collectionService;
@InjectMocks
private EPUBExportService epubExportService;
private Story testStory;
private Author testAuthor;
private Series testSeries;
private Collection testCollection;
private EPUBExportRequest testRequest;
private UUID storyId;
private UUID collectionId;
@BeforeEach
void setUp() {
storyId = UUID.randomUUID();
collectionId = UUID.randomUUID();
testAuthor = new Author();
testAuthor.setId(UUID.randomUUID());
testAuthor.setName("Test Author");
testSeries = new Series();
testSeries.setId(UUID.randomUUID());
testSeries.setName("Test Series");
testStory = new Story();
testStory.setId(storyId);
testStory.setTitle("Test Story");
testStory.setDescription("Test Description");
testStory.setContentHtml("<p>Test content here</p>");
testStory.setWordCount(1000);
testStory.setRating(5);
testStory.setAuthor(testAuthor);
testStory.setCreatedAt(LocalDateTime.now());
testStory.setTags(new HashSet<>());
testCollection = new Collection();
testCollection.setId(collectionId);
testCollection.setName("Test Collection");
testCollection.setDescription("Test Collection Description");
testCollection.setCreatedAt(LocalDateTime.now());
testCollection.setCollectionStories(new ArrayList<>());
testRequest = new EPUBExportRequest();
testRequest.setStoryId(storyId);
testRequest.setIncludeCoverImage(false);
testRequest.setIncludeMetadata(false);
testRequest.setIncludeReadingPosition(false);
testRequest.setSplitByChapters(false);
}
// ========================================
// Basic Export Tests
// ========================================
@Test
@DisplayName("Should export story as EPUB successfully")
void testExportStoryAsEPUB() throws IOException {
// Arrange
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
verify(storyService).findById(storyId);
}
@Test
@DisplayName("Should throw exception when story not found")
void testExportNonExistentStory() {
// Arrange
when(storyService.findById(any())).thenThrow(new ResourceNotFoundException("Story not found"));
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
epubExportService.exportStoryAsEPUB(testRequest);
});
}
@Test
@DisplayName("Should export story with HTML content")
void testExportStoryWithHtmlContent() throws IOException {
// Arrange
testStory.setContentHtml("<p>HTML content</p>");
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
@Test
@DisplayName("Should export story with plain text content when HTML is null")
void testExportStoryWithPlainContent() throws IOException {
// Arrange
// Note: contentPlain is set automatically when contentHtml is set
// We test with HTML then clear it to simulate plain text content
testStory.setContentHtml("<p>Plain text content here</p>");
// contentPlain will be auto-populated, then we clear HTML
testStory.setContentHtml(null);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
@Test
@DisplayName("Should handle story with no content")
void testExportStoryWithNoContent() throws IOException {
// Arrange
// Create a fresh story with no content (don't set contentHtml at all)
Story emptyContentStory = new Story();
emptyContentStory.setId(storyId);
emptyContentStory.setTitle("Story With No Content");
emptyContentStory.setAuthor(testAuthor);
emptyContentStory.setCreatedAt(LocalDateTime.now());
emptyContentStory.setTags(new HashSet<>());
// Don't set contentHtml - it will be null by default
when(storyService.findById(storyId)).thenReturn(emptyContentStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
// ========================================
// Metadata Tests
// ========================================
@Test
@DisplayName("Should use custom title when provided")
void testCustomTitle() throws IOException {
// Arrange
testRequest.setCustomTitle("Custom Title");
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertEquals("Custom Title", testRequest.getCustomTitle());
}
@Test
@DisplayName("Should use custom author when provided")
void testCustomAuthor() throws IOException {
// Arrange
testRequest.setCustomAuthor("Custom Author");
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertEquals("Custom Author", testRequest.getCustomAuthor());
}
@Test
@DisplayName("Should use story author when custom author not provided")
void testDefaultAuthor() throws IOException {
// Arrange
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertEquals("Test Author", testStory.getAuthor().getName());
}
@Test
@DisplayName("Should handle story with no author")
void testStoryWithNoAuthor() throws IOException {
// Arrange
testStory.setAuthor(null);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertNull(testStory.getAuthor());
}
@Test
@DisplayName("Should include metadata when requested")
void testIncludeMetadata() throws IOException {
// Arrange
testRequest.setIncludeMetadata(true);
testStory.setSeries(testSeries);
testStory.setVolume(1);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(testRequest.getIncludeMetadata());
}
@Test
@DisplayName("Should set custom language")
void testCustomLanguage() throws IOException {
// Arrange
testRequest.setLanguage("de");
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertEquals("de", testRequest.getLanguage());
}
@Test
@DisplayName("Should use default language when not specified")
void testDefaultLanguage() throws IOException {
// Arrange
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertNull(testRequest.getLanguage());
}
@Test
@DisplayName("Should handle custom metadata")
void testCustomMetadata() throws IOException {
// Arrange
List<String> customMetadata = Arrays.asList(
"publisher: Test Publisher",
"isbn: 123-456-789"
);
testRequest.setCustomMetadata(customMetadata);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertEquals(2, testRequest.getCustomMetadata().size());
}
// ========================================
// Chapter Splitting Tests
// ========================================
@Test
@DisplayName("Should export as single chapter when splitByChapters is false")
void testSingleChapter() throws IOException {
// Arrange
testRequest.setSplitByChapters(false);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertFalse(testRequest.getSplitByChapters());
}
@Test
@DisplayName("Should split into chapters when requested")
void testSplitByChapters() throws IOException {
// Arrange
testRequest.setSplitByChapters(true);
testStory.setContentHtml("<h1>Chapter 1</h1><p>Content 1</p><h1>Chapter 2</h1><p>Content 2</p>");
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(testRequest.getSplitByChapters());
}
@Test
@DisplayName("Should respect max words per chapter setting")
void testMaxWordsPerChapter() throws IOException {
// Arrange
testRequest.setSplitByChapters(true);
testRequest.setMaxWordsPerChapter(500);
String longContent = String.join(" ", Collections.nCopies(1000, "word"));
testStory.setContentHtml("<p>" + longContent + "</p>");
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertEquals(500, testRequest.getMaxWordsPerChapter());
}
// ========================================
// Reading Position Tests
// ========================================
@Test
@DisplayName("Should include reading position when requested")
void testIncludeReadingPosition() throws IOException {
// Arrange
testRequest.setIncludeReadingPosition(true);
ReadingPosition position = new ReadingPosition(testStory);
position.setChapterIndex(5);
position.setWordPosition(100);
position.setPercentageComplete(50.0);
position.setEpubCfi("epubcfi(/6/4[chap01ref]!/4/2/2[page005])");
position.setUpdatedAt(LocalDateTime.now());
when(storyService.findById(storyId)).thenReturn(testStory);
when(readingPositionRepository.findByStoryId(storyId)).thenReturn(Optional.of(position));
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(testRequest.getIncludeReadingPosition());
verify(readingPositionRepository).findByStoryId(storyId);
}
@Test
@DisplayName("Should handle missing reading position gracefully")
void testMissingReadingPosition() throws IOException {
// Arrange
testRequest.setIncludeReadingPosition(true);
when(storyService.findById(storyId)).thenReturn(testStory);
when(readingPositionRepository.findByStoryId(storyId)).thenReturn(Optional.empty());
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
verify(readingPositionRepository).findByStoryId(storyId);
}
// ========================================
// Filename Generation Tests
// ========================================
@Test
@DisplayName("Should generate filename with author and title")
void testGenerateFilenameWithAuthor() {
// Act
String filename = epubExportService.getEPUBFilename(testStory);
// Assert
assertNotNull(filename);
assertTrue(filename.contains("Test_Author"));
assertTrue(filename.contains("Test_Story"));
assertTrue(filename.endsWith(".epub"));
}
@Test
@DisplayName("Should generate filename without author")
void testGenerateFilenameWithoutAuthor() {
// Arrange
testStory.setAuthor(null);
// Act
String filename = epubExportService.getEPUBFilename(testStory);
// Assert
assertNotNull(filename);
assertTrue(filename.contains("Test_Story"));
assertTrue(filename.endsWith(".epub"));
}
@Test
@DisplayName("Should include series info in filename")
void testGenerateFilenameWithSeries() {
// Arrange
testStory.setSeries(testSeries);
testStory.setVolume(3);
// Act
String filename = epubExportService.getEPUBFilename(testStory);
// Assert
assertNotNull(filename);
assertTrue(filename.contains("Test_Series"));
assertTrue(filename.contains("3"));
}
@Test
@DisplayName("Should sanitize special characters in filename")
void testSanitizeFilename() {
// Arrange
testStory.setTitle("Test: Story? With/Special\\Characters!");
// Act
String filename = epubExportService.getEPUBFilename(testStory);
// Assert
assertNotNull(filename);
assertFalse(filename.contains(":"));
assertFalse(filename.contains("?"));
assertFalse(filename.contains("/"));
assertFalse(filename.contains("\\"));
assertTrue(filename.endsWith(".epub"));
}
// ========================================
// Collection Export Tests
// ========================================
@Test
@DisplayName("Should export collection as EPUB")
void testExportCollectionAsEPUB() throws IOException {
// Arrange
CollectionStory cs = new CollectionStory();
cs.setStory(testStory);
cs.setPosition(1000);
testCollection.setCollectionStories(Arrays.asList(cs));
when(collectionService.findById(collectionId)).thenReturn(testCollection);
// Act
Resource result = epubExportService.exportCollectionAsEPUB(collectionId, testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
verify(collectionService).findById(collectionId);
}
@Test
@DisplayName("Should throw exception when exporting empty collection")
void testExportEmptyCollection() {
// Arrange
testCollection.setCollectionStories(new ArrayList<>());
when(collectionService.findById(collectionId)).thenReturn(testCollection);
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
epubExportService.exportCollectionAsEPUB(collectionId, testRequest);
});
}
@Test
@DisplayName("Should export collection with multiple stories in order")
void testExportCollectionWithMultipleStories() throws IOException {
// Arrange
Story story2 = new Story();
story2.setId(UUID.randomUUID());
story2.setTitle("Second Story");
story2.setContentHtml("<p>Second content</p>");
story2.setAuthor(testAuthor);
story2.setCreatedAt(LocalDateTime.now());
story2.setTags(new HashSet<>());
CollectionStory cs1 = new CollectionStory();
cs1.setStory(testStory);
cs1.setPosition(1000);
CollectionStory cs2 = new CollectionStory();
cs2.setStory(story2);
cs2.setPosition(2000);
testCollection.setCollectionStories(Arrays.asList(cs1, cs2));
when(collectionService.findById(collectionId)).thenReturn(testCollection);
// Act
Resource result = epubExportService.exportCollectionAsEPUB(collectionId, testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
@Test
@DisplayName("Should generate collection EPUB filename")
void testGenerateCollectionFilename() {
// Act
String filename = epubExportService.getCollectionEPUBFilename(testCollection);
// Assert
assertNotNull(filename);
assertTrue(filename.contains("Test_Collection"));
assertTrue(filename.contains("collection"));
assertTrue(filename.endsWith(".epub"));
}
// ========================================
// Utility Method Tests
// ========================================
@Test
@DisplayName("Should check if story can be exported")
void testCanExportStory() {
// Arrange
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
boolean canExport = epubExportService.canExportStory(storyId);
// Assert
assertTrue(canExport);
}
@Test
@DisplayName("Should return false for story with no content")
void testCannotExportStoryWithNoContent() {
// Arrange
// Create a story with no content set at all
Story emptyStory = new Story();
emptyStory.setId(storyId);
emptyStory.setTitle("Empty Story");
when(storyService.findById(storyId)).thenReturn(emptyStory);
// Act
boolean canExport = epubExportService.canExportStory(storyId);
// Assert
assertFalse(canExport);
}
@Test
@DisplayName("Should return false for non-existent story")
void testCannotExportNonExistentStory() {
// Arrange
when(storyService.findById(any())).thenThrow(new ResourceNotFoundException("Story not found"));
// Act
boolean canExport = epubExportService.canExportStory(UUID.randomUUID());
// Assert
assertFalse(canExport);
}
@Test
@DisplayName("Should return true for story with plain text content only")
void testCanExportStoryWithPlainContent() {
// Arrange
// Set HTML first which will populate contentPlain, then clear HTML
testStory.setContentHtml("<p>Plain text content</p>");
testStory.setContentHtml(null);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
boolean canExport = epubExportService.canExportStory(storyId);
// Assert
// Note: This might return false because contentPlain is protected and we can't verify it
// The service checks both contentHtml and contentPlain, but since we can't set contentPlain directly
// in tests, this test documents the limitation
assertFalse(canExport);
}
// ========================================
// Edge Cases
// ========================================
@Test
@DisplayName("Should handle story with tags")
void testStoryWithTags() throws IOException {
// Arrange
Tag tag1 = new Tag();
tag1.setName("fantasy");
Tag tag2 = new Tag();
tag2.setName("adventure");
testStory.getTags().add(tag1);
testStory.getTags().add(tag2);
testRequest.setIncludeMetadata(true);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertEquals(2, testStory.getTags().size());
}
@Test
@DisplayName("Should handle long story title")
void testLongTitle() throws IOException {
// Arrange
testStory.setTitle("A".repeat(200));
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
@Test
@DisplayName("Should handle HTML with special characters")
void testHtmlWithSpecialCharacters() throws IOException {
// Arrange
testStory.setContentHtml("<p>Content with &lt; &gt; &amp; special chars</p>");
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
@Test
@DisplayName("Should handle story with null description")
void testNullDescription() throws IOException {
// Arrange
testStory.setDescription(null);
when(storyService.findById(storyId)).thenReturn(testStory);
// Act
Resource result = epubExportService.exportStoryAsEPUB(testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
@Test
@DisplayName("Should handle collection with null description")
void testCollectionWithNullDescription() throws IOException {
// Arrange
testCollection.setDescription(null);
CollectionStory cs = new CollectionStory();
cs.setStory(testStory);
cs.setPosition(1000);
testCollection.setCollectionStories(Arrays.asList(cs));
when(collectionService.findById(collectionId)).thenReturn(testCollection);
// Act
Resource result = epubExportService.exportCollectionAsEPUB(collectionId, testRequest);
// Assert
assertNotNull(result);
assertTrue(result.contentLength() > 0);
}
}

View File

@@ -0,0 +1,490 @@
package com.storycove.service;
import com.storycove.dto.EPUBImportRequest;
import com.storycove.dto.EPUBImportResponse;
import com.storycove.entity.*;
import com.storycove.repository.ReadingPositionRepository;
import com.storycove.service.exception.InvalidFileException;
import com.storycove.service.exception.ResourceNotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests for EPUBImportService.
* Note: These tests mock the EPUB parsing since nl.siegmann.epublib is complex to test.
* Integration tests should be added separately to test actual EPUB file parsing.
*/
@ExtendWith(MockitoExtension.class)
class EPUBImportServiceTest {
@Mock
private StoryService storyService;
@Mock
private AuthorService authorService;
@Mock
private SeriesService seriesService;
@Mock
private TagService tagService;
@Mock
private ReadingPositionRepository readingPositionRepository;
@Mock
private HtmlSanitizationService sanitizationService;
@Mock
private ImageService imageService;
@InjectMocks
private EPUBImportService epubImportService;
private EPUBImportRequest testRequest;
private Story testStory;
private Author testAuthor;
private Series testSeries;
private UUID storyId;
@BeforeEach
void setUp() {
storyId = UUID.randomUUID();
testStory = new Story();
testStory.setId(storyId);
testStory.setTitle("Test Story");
testStory.setWordCount(1000);
testAuthor = new Author();
testAuthor.setId(UUID.randomUUID());
testAuthor.setName("Test Author");
testSeries = new Series();
testSeries.setId(UUID.randomUUID());
testSeries.setName("Test Series");
testRequest = new EPUBImportRequest();
}
// ========================================
// File Validation Tests
// ========================================
@Test
@DisplayName("Should reject null EPUB file")
void testNullEPUBFile() {
// Arrange
testRequest.setEpubFile(null);
// Act
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert
assertFalse(response.isSuccess());
assertEquals("EPUB file is required", response.getMessage());
}
@Test
@DisplayName("Should reject empty EPUB file")
void testEmptyEPUBFile() {
// Arrange
MockMultipartFile emptyFile = new MockMultipartFile(
"file", "test.epub", "application/epub+zip", new byte[0]
);
testRequest.setEpubFile(emptyFile);
// Act
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert
assertFalse(response.isSuccess());
assertEquals("EPUB file is required", response.getMessage());
}
@Test
@DisplayName("Should reject non-EPUB file by extension")
void testInvalidFileExtension() {
// Arrange
MockMultipartFile pdfFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf", "fake content".getBytes()
);
testRequest.setEpubFile(pdfFile);
// Act
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert
assertFalse(response.isSuccess());
assertEquals("Invalid EPUB file format", response.getMessage());
}
@Test
@DisplayName("Should validate EPUB file and return errors")
void testValidateEPUBFile() {
// Arrange
MockMultipartFile invalidFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf", "fake content".getBytes()
);
// Act
List<String> errors = epubImportService.validateEPUBFile(invalidFile);
// Assert
assertNotNull(errors);
assertFalse(errors.isEmpty());
assertTrue(errors.stream().anyMatch(e -> e.contains("Invalid EPUB file format")));
}
@Test
@DisplayName("Should validate file size limit")
void testFileSizeLimit() {
// Arrange
byte[] largeData = new byte[101 * 1024 * 1024]; // 101MB
MockMultipartFile largeFile = new MockMultipartFile(
"file", "large.epub", "application/epub+zip", largeData
);
// Act
List<String> errors = epubImportService.validateEPUBFile(largeFile);
// Assert
assertTrue(errors.stream().anyMatch(e -> e.contains("100MB limit")));
}
@Test
@DisplayName("Should accept valid EPUB with correct extension")
void testAcceptValidEPUBExtension() {
// Arrange
MockMultipartFile validFile = new MockMultipartFile(
"file", "test.epub", "application/epub+zip", createMinimalEPUB()
);
testRequest.setEpubFile(validFile);
// Note: This will fail at parsing since we don't have a real EPUB
// But it should pass the extension validation
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert - should fail at parsing, not at validation
assertFalse(response.isSuccess());
assertNotEquals("Invalid EPUB file format", response.getMessage());
}
@Test
@DisplayName("Should accept EPUB with application/zip content type")
void testAcceptZipContentType() {
// Arrange
MockMultipartFile zipFile = new MockMultipartFile(
"file", "test.epub", "application/zip", createMinimalEPUB()
);
testRequest.setEpubFile(zipFile);
// Act
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert - should not fail at content type validation
assertFalse(response.isSuccess());
assertNotEquals("Invalid EPUB file format", response.getMessage());
}
// ========================================
// Request Parameter Tests
// ========================================
@Test
@DisplayName("Should handle createMissingAuthor flag")
void testCreateMissingAuthor() {
// This is an integration-level test and would require actual EPUB parsing
// We verify the flag is present in the request object
testRequest.setCreateMissingAuthor(true);
assertTrue(testRequest.getCreateMissingAuthor());
}
@Test
@DisplayName("Should handle createMissingSeries flag")
void testCreateMissingSeries() {
testRequest.setCreateMissingSeries(true);
testRequest.setSeriesName("New Series");
testRequest.setSeriesVolume(1);
assertTrue(testRequest.getCreateMissingSeries());
assertEquals("New Series", testRequest.getSeriesName());
assertEquals(1, testRequest.getSeriesVolume());
}
@Test
@DisplayName("Should handle extractCover flag")
void testExtractCoverFlag() {
testRequest.setExtractCover(true);
assertTrue(testRequest.getExtractCover());
testRequest.setExtractCover(false);
assertFalse(testRequest.getExtractCover());
}
@Test
@DisplayName("Should handle preserveReadingPosition flag")
void testPreserveReadingPositionFlag() {
testRequest.setPreserveReadingPosition(true);
assertTrue(testRequest.getPreserveReadingPosition());
}
@Test
@DisplayName("Should handle custom tags")
void testCustomTags() {
List<String> tags = Arrays.asList("fantasy", "adventure", "magic");
testRequest.setTags(tags);
assertEquals(3, testRequest.getTags().size());
assertTrue(testRequest.getTags().contains("fantasy"));
}
// ========================================
// Author Handling Tests
// ========================================
@Test
@DisplayName("Should use provided authorId when available")
void testUseProvidedAuthorId() {
// This would require mocking the EPUB parsing
// We verify the request accepts authorId
UUID authorId = UUID.randomUUID();
testRequest.setAuthorId(authorId);
assertEquals(authorId, testRequest.getAuthorId());
}
@Test
@DisplayName("Should use provided authorName")
void testUseProvidedAuthorName() {
testRequest.setAuthorName("Custom Author Name");
assertEquals("Custom Author Name", testRequest.getAuthorName());
}
// ========================================
// Series Handling Tests
// ========================================
@Test
@DisplayName("Should use provided seriesId and volume")
void testUseProvidedSeriesId() {
UUID seriesId = UUID.randomUUID();
testRequest.setSeriesId(seriesId);
testRequest.setSeriesVolume(5);
assertEquals(seriesId, testRequest.getSeriesId());
assertEquals(5, testRequest.getSeriesVolume());
}
// ========================================
// Error Handling Tests
// ========================================
@Test
@DisplayName("Should handle corrupt EPUB file gracefully")
void testCorruptEPUBFile() {
// Arrange
MockMultipartFile corruptFile = new MockMultipartFile(
"file", "corrupt.epub", "application/epub+zip", "not a real epub".getBytes()
);
testRequest.setEpubFile(corruptFile);
// Act
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert
assertFalse(response.isSuccess());
assertNotNull(response.getMessage());
assertTrue(response.getMessage().contains("Failed to import EPUB"));
}
@Test
@DisplayName("Should handle missing metadata gracefully")
void testMissingMetadata() {
// Arrange
MockMultipartFile epubFile = new MockMultipartFile(
"file", "test.epub", "application/epub+zip", createMinimalEPUB()
);
// Act
List<String> errors = epubImportService.validateEPUBFile(epubFile);
// Assert - validation should catch missing metadata
assertNotNull(errors);
}
// ========================================
// Response Tests
// ========================================
@Test
@DisplayName("Should create success response with correct fields")
void testSuccessResponse() {
// Arrange
EPUBImportResponse response = EPUBImportResponse.success(storyId, "Test Story");
response.setWordCount(1500);
response.setTotalChapters(10);
// Assert
assertTrue(response.isSuccess());
assertEquals(storyId, response.getStoryId());
assertEquals("Test Story", response.getStoryTitle());
assertEquals(1500, response.getWordCount());
assertEquals(10, response.getTotalChapters());
assertNull(response.getMessage());
}
@Test
@DisplayName("Should create error response with message")
void testErrorResponse() {
// Arrange
EPUBImportResponse response = EPUBImportResponse.error("Test error message");
// Assert
assertFalse(response.isSuccess());
assertEquals("Test error message", response.getMessage());
assertNull(response.getStoryId());
assertNull(response.getStoryTitle());
}
// ========================================
// Integration Scenario Tests
// ========================================
@Test
@DisplayName("Should handle complete import workflow (mock)")
void testCompleteImportWorkflow() {
// This test verifies that all the request parameters are properly structured
// Actual EPUB parsing would be tested in integration tests
// Arrange - Create a complete request
testRequest.setEpubFile(new MockMultipartFile(
"file", "story.epub", "application/epub+zip", createMinimalEPUB()
));
testRequest.setAuthorName("Jane Doe");
testRequest.setCreateMissingAuthor(true);
testRequest.setSeriesName("Epic Series");
testRequest.setSeriesVolume(3);
testRequest.setCreateMissingSeries(true);
testRequest.setTags(Arrays.asList("fantasy", "adventure"));
testRequest.setExtractCover(true);
testRequest.setPreserveReadingPosition(true);
// Assert - All parameters set correctly
assertNotNull(testRequest.getEpubFile());
assertEquals("Jane Doe", testRequest.getAuthorName());
assertTrue(testRequest.getCreateMissingAuthor());
assertEquals("Epic Series", testRequest.getSeriesName());
assertEquals(3, testRequest.getSeriesVolume());
assertTrue(testRequest.getCreateMissingSeries());
assertEquals(2, testRequest.getTags().size());
assertTrue(testRequest.getExtractCover());
assertTrue(testRequest.getPreserveReadingPosition());
}
@Test
@DisplayName("Should handle minimal import request")
void testMinimalImportRequest() {
// Arrange - Only required field
testRequest.setEpubFile(new MockMultipartFile(
"file", "simple.epub", "application/epub+zip", createMinimalEPUB()
));
// Assert - Optional fields are null/false
assertNotNull(testRequest.getEpubFile());
assertNull(testRequest.getAuthorId());
assertNull(testRequest.getAuthorName());
assertNull(testRequest.getSeriesId());
assertNull(testRequest.getTags());
}
// ========================================
// Edge Cases
// ========================================
@Test
@DisplayName("Should handle EPUB with special characters in filename")
void testSpecialCharactersInFilename() {
// Arrange
MockMultipartFile fileWithSpecialChars = new MockMultipartFile(
"file", "test story (2024) #1.epub", "application/epub+zip", createMinimalEPUB()
);
testRequest.setEpubFile(fileWithSpecialChars);
// Act
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert - should not fail due to filename
assertNotNull(response);
}
@Test
@DisplayName("Should handle EPUB with null content type")
void testNullContentType() {
// Arrange
MockMultipartFile fileWithNullContentType = new MockMultipartFile(
"file", "test.epub", null, createMinimalEPUB()
);
testRequest.setEpubFile(fileWithNullContentType);
// Act - Should still validate based on extension
EPUBImportResponse response = epubImportService.importEPUB(testRequest);
// Assert - should not fail at validation, only at parsing
assertNotNull(response);
}
@Test
@DisplayName("Should trim whitespace from author name")
void testTrimAuthorName() {
testRequest.setAuthorName(" John Doe ");
// The service should trim this internally
assertEquals(" John Doe ", testRequest.getAuthorName());
}
@Test
@DisplayName("Should handle empty tags list")
void testEmptyTagsList() {
testRequest.setTags(new ArrayList<>());
assertNotNull(testRequest.getTags());
assertTrue(testRequest.getTags().isEmpty());
}
@Test
@DisplayName("Should handle duplicate tags in request")
void testDuplicateTags() {
List<String> tagsWithDuplicates = Arrays.asList("fantasy", "adventure", "fantasy");
testRequest.setTags(tagsWithDuplicates);
assertEquals(3, testRequest.getTags().size());
// The service should handle deduplication internally
}
// ========================================
// Helper Methods
// ========================================
/**
* Creates minimal EPUB-like content for testing.
* Note: This is not a real EPUB, just test data.
*/
private byte[] createMinimalEPUB() {
// This creates minimal test data that looks like an EPUB structure
// Real EPUB parsing would require a proper EPUB file structure
return "PK\u0003\u0004fake epub content".getBytes();
}
}

View File

@@ -0,0 +1,335 @@
package com.storycove.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.storycove.dto.HtmlSanitizationConfigDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
/**
* Security-critical tests for HtmlSanitizationService.
* These tests ensure that malicious HTML is properly sanitized.
*/
@SpringBootTest
class HtmlSanitizationServiceTest {
@Autowired
private HtmlSanitizationService sanitizationService;
@BeforeEach
void setUp() {
// Service is initialized via @PostConstruct
}
// ========================================
// XSS Attack Prevention Tests
// ========================================
@Test
@DisplayName("Should remove script tags (XSS prevention)")
void testRemoveScriptTags() {
String malicious = "<p>Hello</p><script>alert('XSS')</script>";
String sanitized = sanitizationService.sanitize(malicious);
assertFalse(sanitized.contains("<script>"));
assertFalse(sanitized.contains("alert"));
assertTrue(sanitized.contains("Hello"));
}
@Test
@DisplayName("Should remove inline JavaScript event handlers")
void testRemoveEventHandlers() {
String malicious = "<p onclick='alert(\"XSS\")'>Click me</p>";
String sanitized = sanitizationService.sanitize(malicious);
assertFalse(sanitized.contains("onclick"));
assertFalse(sanitized.contains("alert"));
assertTrue(sanitized.contains("Click me"));
}
@Test
@DisplayName("Should remove javascript: URLs")
void testRemoveJavaScriptUrls() {
String malicious = "<a href='javascript:alert(\"XSS\")'>Click</a>";
String sanitized = sanitizationService.sanitize(malicious);
assertFalse(sanitized.contains("javascript:"));
assertFalse(sanitized.contains("alert"));
}
@Test
@DisplayName("Should remove data: URLs with JavaScript")
void testRemoveDataUrlsWithJs() {
String malicious = "<a href='data:text/html,<script>alert(\"XSS\")</script>'>Click</a>";
String sanitized = sanitizationService.sanitize(malicious);
assertFalse(sanitized.toLowerCase().contains("script"));
}
@Test
@DisplayName("Should remove iframe tags")
void testRemoveIframeTags() {
String malicious = "<p>Content</p><iframe src='http://evil.com'></iframe>";
String sanitized = sanitizationService.sanitize(malicious);
assertFalse(sanitized.contains("<iframe"));
assertTrue(sanitized.contains("Content"));
}
@Test
@DisplayName("Should remove object and embed tags")
void testRemoveObjectAndEmbedTags() {
String malicious = "<object data='http://evil.com'></object><embed src='http://evil.com'>";
String sanitized = sanitizationService.sanitize(malicious);
assertFalse(sanitized.contains("<object"));
assertFalse(sanitized.contains("<embed"));
}
// ========================================
// Allowed Content Tests
// ========================================
@Test
@DisplayName("Should preserve safe HTML tags")
void testPreserveSafeTags() {
String safe = "<p>Paragraph</p><h1>Heading</h1><ul><li>Item</li></ul>";
String sanitized = sanitizationService.sanitize(safe);
assertTrue(sanitized.contains("<p>"));
assertTrue(sanitized.contains("<h1>"));
assertTrue(sanitized.contains("<ul>"));
assertTrue(sanitized.contains("<li>"));
assertTrue(sanitized.contains("Paragraph"));
assertTrue(sanitized.contains("Heading"));
}
@Test
@DisplayName("Should preserve text formatting tags")
void testPreserveFormattingTags() {
String formatted = "<p><strong>Bold</strong> <em>Italic</em> <u>Underline</u></p>";
String sanitized = sanitizationService.sanitize(formatted);
assertTrue(sanitized.contains("<strong>"));
assertTrue(sanitized.contains("<em>"));
assertTrue(sanitized.contains("<u>"));
}
@Test
@DisplayName("Should preserve safe links")
void testPreserveSafeLinks() {
String link = "<a href='https://example.com'>Link</a>";
String sanitized = sanitizationService.sanitize(link);
assertTrue(sanitized.contains("<a"));
assertTrue(sanitized.contains("href"));
assertTrue(sanitized.contains("example.com"));
}
@Test
@DisplayName("Should preserve images with safe attributes")
void testPreserveSafeImages() {
String img = "<img src='https://example.com/image.jpg' alt='Description'>";
String sanitized = sanitizationService.sanitize(img);
assertTrue(sanitized.contains("<img"));
assertTrue(sanitized.contains("src"));
assertTrue(sanitized.contains("alt"));
}
@Test
@DisplayName("Should preserve relative image URLs")
void testPreserveRelativeImageUrls() {
String img = "<img src='/images/photo.jpg' alt='Photo'>";
String sanitized = sanitizationService.sanitize(img);
assertTrue(sanitized.contains("<img"));
assertTrue(sanitized.contains("/images/photo.jpg"));
}
// ========================================
// Figure Tag Preprocessing Tests
// ========================================
@Test
@DisplayName("Should extract image from figure tag")
void testExtractImageFromFigure() {
String figure = "<figure><img src='/image.jpg' alt='Test'><figcaption>Caption</figcaption></figure>";
String sanitized = sanitizationService.sanitize(figure);
assertFalse(sanitized.contains("<figure"));
assertFalse(sanitized.contains("<figcaption"));
assertTrue(sanitized.contains("<img"));
assertTrue(sanitized.contains("/image.jpg"));
}
@Test
@DisplayName("Should use figcaption as alt text if alt is missing")
void testFigcaptionAsAltText() {
String figure = "<figure><img src='/image.jpg'><figcaption>My Caption</figcaption></figure>";
String sanitized = sanitizationService.sanitize(figure);
assertTrue(sanitized.contains("<img"));
assertTrue(sanitized.contains("alt="));
assertTrue(sanitized.contains("My Caption"));
}
@Test
@DisplayName("Should remove figure without images")
void testRemoveFigureWithoutImages() {
String figure = "<p>Before</p><figure><figcaption>Caption only</figcaption></figure><p>After</p>";
String sanitized = sanitizationService.sanitize(figure);
assertFalse(sanitized.contains("<figure"));
assertFalse(sanitized.contains("Caption only"));
assertTrue(sanitized.contains("Before"));
assertTrue(sanitized.contains("After"));
}
// ========================================
// Edge Cases and Utility Methods
// ========================================
@Test
@DisplayName("Should handle null input")
void testNullInput() {
String sanitized = sanitizationService.sanitize(null);
assertEquals("", sanitized);
}
@Test
@DisplayName("Should handle empty input")
void testEmptyInput() {
String sanitized = sanitizationService.sanitize("");
assertEquals("", sanitized);
}
@Test
@DisplayName("Should handle whitespace-only input")
void testWhitespaceInput() {
String sanitized = sanitizationService.sanitize(" ");
assertEquals("", sanitized);
}
@Test
@DisplayName("Should extract plain text from HTML")
void testExtractPlainText() {
String html = "<p>Hello <strong>World</strong></p>";
String plainText = sanitizationService.extractPlainText(html);
assertEquals("Hello World", plainText);
assertFalse(plainText.contains("<"));
assertFalse(plainText.contains(">"));
}
@Test
@DisplayName("Should detect clean HTML")
void testIsCleanWithCleanHtml() {
String clean = "<p>Safe content</p>";
assertTrue(sanitizationService.isClean(clean));
}
@Test
@DisplayName("Should detect malicious HTML")
void testIsCleanWithMaliciousHtml() {
String malicious = "<p>Content</p><script>alert('XSS')</script>";
assertFalse(sanitizationService.isClean(malicious));
}
@Test
@DisplayName("Should sanitize and extract text")
void testSanitizeAndExtractText() {
String html = "<p>Hello</p><script>alert('XSS')</script>";
String result = sanitizationService.sanitizeAndExtractText(html);
assertEquals("Hello", result);
assertFalse(result.contains("script"));
assertFalse(result.contains("XSS"));
}
// ========================================
// Configuration Tests
// ========================================
@Test
@DisplayName("Should load and provide configuration")
void testGetConfiguration() {
HtmlSanitizationConfigDto config = sanitizationService.getConfiguration();
assertNotNull(config);
assertNotNull(config.getAllowedTags());
assertFalse(config.getAllowedTags().isEmpty());
assertTrue(config.getAllowedTags().contains("p"));
assertTrue(config.getAllowedTags().contains("a"));
assertTrue(config.getAllowedTags().contains("img"));
}
// ========================================
// Complex Attack Vectors
// ========================================
@Test
@DisplayName("Should prevent nested XSS attacks")
void testNestedXssAttacks() {
String nested = "<p><script><script>alert('XSS')</script></script></p>";
String sanitized = sanitizationService.sanitize(nested);
assertFalse(sanitized.contains("<script"));
assertFalse(sanitized.contains("alert"));
}
@Test
@DisplayName("Should prevent encoded XSS attacks")
void testEncodedXssAttacks() {
String encoded = "<img src=x onerror='alert(1)'>";
String sanitized = sanitizationService.sanitize(encoded);
assertFalse(sanitized.contains("onerror"));
assertFalse(sanitized.contains("alert"));
}
@Test
@DisplayName("Should prevent CSS injection attacks")
void testCssInjectionPrevention() {
String cssInjection = "<p style='background:url(javascript:alert(1))'>Text</p>";
String sanitized = sanitizationService.sanitize(cssInjection);
assertFalse(sanitized.toLowerCase().contains("javascript:"));
}
@Test
@DisplayName("Should preserve multiple safe elements")
void testComplexSafeHtml() {
String complex = "<div><h1>Title</h1><p>Paragraph with <strong>bold</strong> and " +
"<em>italic</em></p><ul><li>Item 1</li><li>Item 2</li></ul>" +
"<img src='/image.jpg' alt='Image'></div>";
String sanitized = sanitizationService.sanitize(complex);
assertTrue(sanitized.contains("<div"));
assertTrue(sanitized.contains("<h1>"));
assertTrue(sanitized.contains("<p>"));
assertTrue(sanitized.contains("<strong>"));
assertTrue(sanitized.contains("<em>"));
assertTrue(sanitized.contains("<ul>"));
assertTrue(sanitized.contains("<li>"));
assertTrue(sanitized.contains("<img"));
assertTrue(sanitized.contains("Title"));
assertTrue(sanitized.contains("Item 1"));
}
@Test
@DisplayName("Should handle malformed HTML gracefully")
void testMalformedHtml() {
String malformed = "<p>Unclosed paragraph<div>Nested incorrectly</p></div>";
String sanitized = sanitizationService.sanitize(malformed);
// Should not throw exception and should return something
assertNotNull(sanitized);
assertTrue(sanitized.contains("Unclosed paragraph"));
assertTrue(sanitized.contains("Nested incorrectly"));
}
}

View File

@@ -0,0 +1,621 @@
package com.storycove.service;
import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.Story;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests for ImageService.
* Note: Some tests use mocking due to filesystem and network dependencies.
* Full integration tests would be in a separate test class.
*/
@ExtendWith(MockitoExtension.class)
class ImageServiceTest {
@Mock
private LibraryService libraryService;
@Mock
private StoryService storyService;
@Mock
private AuthorService authorService;
@Mock
private CollectionService collectionService;
@InjectMocks
private ImageService imageService;
@TempDir
Path tempDir;
private MultipartFile validImageFile;
private UUID testStoryId;
@BeforeEach
void setUp() throws IOException {
testStoryId = UUID.randomUUID();
// Create a simple valid PNG file (1x1 pixel)
byte[] pngData = createMinimalPngData();
validImageFile = new MockMultipartFile(
"image", "test.png", "image/png", pngData
);
// Configure ImageService with test values
when(libraryService.getCurrentImagePath()).thenReturn("/default");
when(libraryService.getCurrentLibraryId()).thenReturn("default");
// Set image service properties using reflection
ReflectionTestUtils.setField(imageService, "baseUploadDir", tempDir.toString());
ReflectionTestUtils.setField(imageService, "coverMaxWidth", 800);
ReflectionTestUtils.setField(imageService, "coverMaxHeight", 1200);
ReflectionTestUtils.setField(imageService, "avatarMaxSize", 400);
ReflectionTestUtils.setField(imageService, "maxFileSize", 5242880L);
ReflectionTestUtils.setField(imageService, "publicUrl", "http://localhost:6925");
}
// ========================================
// File Validation Tests
// ========================================
@Test
@DisplayName("Should reject null file")
void testRejectNullFile() {
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
imageService.uploadImage(null, ImageService.ImageType.COVER);
});
}
@Test
@DisplayName("Should reject empty file")
void testRejectEmptyFile() {
// Arrange
MockMultipartFile emptyFile = new MockMultipartFile(
"image", "test.png", "image/png", new byte[0]
);
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
imageService.uploadImage(emptyFile, ImageService.ImageType.COVER);
});
}
@Test
@DisplayName("Should reject file with invalid content type")
void testRejectInvalidContentType() {
// Arrange
MockMultipartFile invalidFile = new MockMultipartFile(
"image", "test.pdf", "application/pdf", "fake pdf content".getBytes()
);
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
imageService.uploadImage(invalidFile, ImageService.ImageType.COVER);
});
}
@Test
@DisplayName("Should reject file with invalid extension")
void testRejectInvalidExtension() {
// Arrange
MockMultipartFile invalidFile = new MockMultipartFile(
"image", "test.gif", "image/png", createMinimalPngData()
);
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
imageService.uploadImage(invalidFile, ImageService.ImageType.COVER);
});
}
@Test
@DisplayName("Should reject file exceeding size limit")
void testRejectOversizedFile() {
// Arrange
// Create file larger than 5MB limit
byte[] largeData = new byte[6 * 1024 * 1024]; // 6MB
MockMultipartFile largeFile = new MockMultipartFile(
"image", "large.png", "image/png", largeData
);
// Act & Assert
assertThrows(IllegalArgumentException.class, () -> {
imageService.uploadImage(largeFile, ImageService.ImageType.COVER);
});
}
@Test
@DisplayName("Should accept JPG files")
void testAcceptJpgFile() {
// Arrange
MockMultipartFile jpgFile = new MockMultipartFile(
"image", "test.jpg", "image/jpeg", createMinimalPngData() // Using PNG data for test simplicity
);
// Note: This test will fail at image processing stage since we're not providing real JPG data
// but it validates that JPG is accepted as a file type
}
@Test
@DisplayName("Should accept PNG files")
void testAcceptPngFile() {
// PNG is tested in setUp, this validates the behavior
assertNotNull(validImageFile);
assertEquals("image/png", validImageFile.getContentType());
}
// ========================================
// Image Type Tests
// ========================================
@Test
@DisplayName("Should have correct directory for COVER type")
void testCoverImageDirectory() {
assertEquals("covers", ImageService.ImageType.COVER.getDirectory());
}
@Test
@DisplayName("Should have correct directory for AVATAR type")
void testAvatarImageDirectory() {
assertEquals("avatars", ImageService.ImageType.AVATAR.getDirectory());
}
@Test
@DisplayName("Should have correct directory for CONTENT type")
void testContentImageDirectory() {
assertEquals("content", ImageService.ImageType.CONTENT.getDirectory());
}
// ========================================
// Image Existence Tests
// ========================================
@Test
@DisplayName("Should return false for null image path")
void testImageExistsWithNullPath() {
assertFalse(imageService.imageExists(null));
}
@Test
@DisplayName("Should return false for empty image path")
void testImageExistsWithEmptyPath() {
assertFalse(imageService.imageExists(""));
assertFalse(imageService.imageExists(" "));
}
@Test
@DisplayName("Should return false for non-existent image")
void testImageExistsWithNonExistentPath() {
assertFalse(imageService.imageExists("covers/non-existent.jpg"));
}
@Test
@DisplayName("Should return false for null library ID in imageExistsInLibrary")
void testImageExistsInLibraryWithNullLibraryId() {
assertFalse(imageService.imageExistsInLibrary("covers/test.jpg", null));
}
// ========================================
// Image Deletion Tests
// ========================================
@Test
@DisplayName("Should return false when deleting null path")
void testDeleteNullPath() {
assertFalse(imageService.deleteImage(null));
}
@Test
@DisplayName("Should return false when deleting empty path")
void testDeleteEmptyPath() {
assertFalse(imageService.deleteImage(""));
assertFalse(imageService.deleteImage(" "));
}
@Test
@DisplayName("Should return false when deleting non-existent image")
void testDeleteNonExistentImage() {
assertFalse(imageService.deleteImage("covers/non-existent.jpg"));
}
// ========================================
// Content Image Processing Tests
// ========================================
@Test
@DisplayName("Should process content with no images")
void testProcessContentWithNoImages() {
// Arrange
String htmlContent = "<p>This is plain text with no images</p>";
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(htmlContent, testStoryId);
// Assert
assertNotNull(result);
assertEquals(htmlContent, result.getProcessedContent());
assertTrue(result.getDownloadedImages().isEmpty());
assertFalse(result.hasWarnings());
}
@Test
@DisplayName("Should handle null content gracefully")
void testProcessNullContent() {
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(null, testStoryId);
// Assert
assertNotNull(result);
assertNull(result.getProcessedContent());
assertTrue(result.getDownloadedImages().isEmpty());
}
@Test
@DisplayName("Should handle empty content gracefully")
void testProcessEmptyContent() {
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages("", testStoryId);
// Assert
assertNotNull(result);
assertEquals("", result.getProcessedContent());
assertTrue(result.getDownloadedImages().isEmpty());
}
@Test
@DisplayName("Should skip data URLs")
void testSkipDataUrls() {
// Arrange
String htmlWithDataUrl = "<p><img src=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"></p>";
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(htmlWithDataUrl, testStoryId);
// Assert
assertNotNull(result);
assertTrue(result.getDownloadedImages().isEmpty());
assertFalse(result.hasWarnings());
}
@Test
@DisplayName("Should skip local/relative URLs")
void testSkipLocalUrls() {
// Arrange
String htmlWithLocalUrl = "<p><img src=\"/images/local-image.jpg\"></p>";
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(htmlWithLocalUrl, testStoryId);
// Assert
assertNotNull(result);
assertTrue(result.getDownloadedImages().isEmpty());
assertFalse(result.hasWarnings());
}
@Test
@DisplayName("Should skip images from same application")
void testSkipApplicationUrls() {
// Arrange
String htmlWithAppUrl = "<p><img src=\"/api/files/images/default/covers/test.jpg\"></p>";
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(htmlWithAppUrl, testStoryId);
// Assert
assertNotNull(result);
assertTrue(result.getDownloadedImages().isEmpty());
assertFalse(result.hasWarnings());
}
@Test
@DisplayName("Should handle external URL gracefully when download fails")
void testHandleDownloadFailure() {
// Arrange
String htmlWithExternalUrl = "<p><img src=\"http://example.com/non-existent-image.jpg\"></p>";
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(htmlWithExternalUrl, testStoryId);
// Assert
assertNotNull(result);
assertTrue(result.hasWarnings());
assertEquals(1, result.getWarnings().size());
}
// ========================================
// Content Image Cleanup Tests
// ========================================
@Test
@DisplayName("Should perform dry run cleanup without deleting")
void testDryRunCleanup() {
// Arrange
when(storyService.findAllWithAssociations()).thenReturn(new ArrayList<>());
when(authorService.findAll()).thenReturn(new ArrayList<>());
when(collectionService.findAllWithTags()).thenReturn(new ArrayList<>());
// Act
ImageService.ContentImageCleanupResult result =
imageService.cleanupOrphanedContentImages(true);
// Assert
assertNotNull(result);
assertTrue(result.isDryRun());
}
@Test
@DisplayName("Should handle cleanup with no content directory")
void testCleanupWithNoContentDirectory() {
// Arrange
when(storyService.findAllWithAssociations()).thenReturn(new ArrayList<>());
when(authorService.findAll()).thenReturn(new ArrayList<>());
when(collectionService.findAllWithTags()).thenReturn(new ArrayList<>());
// Act
ImageService.ContentImageCleanupResult result =
imageService.cleanupOrphanedContentImages(false);
// Assert
assertNotNull(result);
assertEquals(0, result.getTotalReferencedImages());
assertTrue(result.getOrphanedImages().isEmpty());
}
@Test
@DisplayName("Should collect image references from stories")
void testCollectImageReferences() {
// Arrange
Story story = new Story();
story.setId(testStoryId);
story.setContentHtml("<p><img src=\"/api/files/images/default/content/" + testStoryId + "/test-image.jpg\"></p>");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
when(authorService.findAll()).thenReturn(new ArrayList<>());
when(collectionService.findAllWithTags()).thenReturn(new ArrayList<>());
// Act
ImageService.ContentImageCleanupResult result =
imageService.cleanupOrphanedContentImages(true);
// Assert
assertNotNull(result);
assertTrue(result.getTotalReferencedImages() > 0);
}
// ========================================
// Cleanup Result Formatting Tests
// ========================================
@Test
@DisplayName("Should format bytes correctly")
void testFormatBytes() {
ImageService.ContentImageCleanupResult result =
new ImageService.ContentImageCleanupResult(
new ArrayList<>(), 512, 0, 0, new ArrayList<>(), true
);
assertEquals("512 B", result.getFormattedSize());
}
@Test
@DisplayName("Should format kilobytes correctly")
void testFormatKilobytes() {
ImageService.ContentImageCleanupResult result =
new ImageService.ContentImageCleanupResult(
new ArrayList<>(), 1536, 0, 0, new ArrayList<>(), true
);
assertTrue(result.getFormattedSize().contains("KB"));
}
@Test
@DisplayName("Should format megabytes correctly")
void testFormatMegabytes() {
ImageService.ContentImageCleanupResult result =
new ImageService.ContentImageCleanupResult(
new ArrayList<>(), 1024 * 1024 * 5, 0, 0, new ArrayList<>(), true
);
assertTrue(result.getFormattedSize().contains("MB"));
}
@Test
@DisplayName("Should format gigabytes correctly")
void testFormatGigabytes() {
ImageService.ContentImageCleanupResult result =
new ImageService.ContentImageCleanupResult(
new ArrayList<>(), 1024L * 1024L * 1024L * 2L, 0, 0, new ArrayList<>(), true
);
assertTrue(result.getFormattedSize().contains("GB"));
}
@Test
@DisplayName("Should track cleanup errors")
void testCleanupErrors() {
List<String> errors = new ArrayList<>();
errors.add("Test error 1");
errors.add("Test error 2");
ImageService.ContentImageCleanupResult result =
new ImageService.ContentImageCleanupResult(
new ArrayList<>(), 0, 0, 0, errors, false
);
assertTrue(result.hasErrors());
assertEquals(2, result.getErrors().size());
}
// ========================================
// Content Image Processing Result Tests
// ========================================
@Test
@DisplayName("Should create processing result with warnings")
void testProcessingResultWithWarnings() {
List<String> warnings = List.of("Warning 1", "Warning 2");
ImageService.ContentImageProcessingResult result =
new ImageService.ContentImageProcessingResult(
"<p>Content</p>", warnings, new ArrayList<>()
);
assertTrue(result.hasWarnings());
assertEquals(2, result.getWarnings().size());
}
@Test
@DisplayName("Should create processing result without warnings")
void testProcessingResultWithoutWarnings() {
ImageService.ContentImageProcessingResult result =
new ImageService.ContentImageProcessingResult(
"<p>Content</p>", new ArrayList<>(), new ArrayList<>()
);
assertFalse(result.hasWarnings());
assertEquals("<p>Content</p>", result.getProcessedContent());
}
@Test
@DisplayName("Should track downloaded images")
void testTrackDownloadedImages() {
List<String> downloadedImages = List.of(
"content/story1/image1.jpg",
"content/story1/image2.jpg"
);
ImageService.ContentImageProcessingResult result =
new ImageService.ContentImageProcessingResult(
"<p>Content</p>", new ArrayList<>(), downloadedImages
);
assertEquals(2, result.getDownloadedImages().size());
assertTrue(result.getDownloadedImages().contains("content/story1/image1.jpg"));
}
// ========================================
// Story Content Deletion Tests
// ========================================
@Test
@DisplayName("Should delete content images for story")
void testDeleteContentImages() {
// Act - Should not throw exception even if directory doesn't exist
assertDoesNotThrow(() -> {
imageService.deleteContentImages(testStoryId);
});
}
// ========================================
// Edge Cases
// ========================================
@Test
@DisplayName("Should handle HTML with multiple images")
void testMultipleImages() {
// Arrange
String html = "<p><img src=\"/local1.jpg\"><img src=\"/local2.jpg\"></p>";
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(html, testStoryId);
// Assert
assertNotNull(result);
// Local images should be skipped
assertTrue(result.getDownloadedImages().isEmpty());
}
@Test
@DisplayName("Should handle malformed HTML gracefully")
void testMalformedHtml() {
// Arrange
String malformedHtml = "<p>Unclosed <img src=\"/test.jpg\" <p>";
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(malformedHtml, testStoryId);
// Assert
assertNotNull(result);
}
@Test
@DisplayName("Should handle very long content")
void testVeryLongContent() {
// Arrange
StringBuilder longContent = new StringBuilder();
for (int i = 0; i < 10000; i++) {
longContent.append("<p>Paragraph ").append(i).append("</p>");
}
// Act
ImageService.ContentImageProcessingResult result =
imageService.processContentImages(longContent.toString(), testStoryId);
// Assert
assertNotNull(result);
}
// ========================================
// Helper Methods
// ========================================
/**
* Create minimal valid PNG data for testing.
* This is a 1x1 pixel transparent PNG image.
*/
private byte[] createMinimalPngData() {
return new byte[]{
(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n', // PNG signature
0x00, 0x00, 0x00, 0x0D, // IHDR chunk length
'I', 'H', 'D', 'R', // IHDR chunk type
0x00, 0x00, 0x00, 0x01, // Width: 1
0x00, 0x00, 0x00, 0x01, // Height: 1
0x08, // Bit depth: 8
0x06, // Color type: RGBA
0x00, 0x00, 0x00, // Compression, filter, interlace
0x1F, 0x15, (byte) 0xC4, (byte) 0x89, // CRC
0x00, 0x00, 0x00, 0x0A, // IDAT chunk length
'I', 'D', 'A', 'T', // IDAT chunk type
0x78, (byte) 0x9C, 0x62, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, // Image data
0x0D, 0x0A, 0x2D, (byte) 0xB4, // CRC
0x00, 0x00, 0x00, 0x00, // IEND chunk length
'I', 'E', 'N', 'D', // IEND chunk type
(byte) 0xAE, 0x42, 0x60, (byte) 0x82 // CRC
};
}
}

View File

@@ -0,0 +1,176 @@
package com.storycove.service;
import com.storycove.entity.RefreshToken;
import com.storycove.repository.RefreshTokenRepository;
import com.storycove.util.JwtUtil;
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.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class RefreshTokenServiceTest {
@Mock
private RefreshTokenRepository refreshTokenRepository;
@Mock
private JwtUtil jwtUtil;
@InjectMocks
private RefreshTokenService refreshTokenService;
@Test
void testCreateRefreshToken() {
// Arrange
String libraryId = "library-123";
String userAgent = "Mozilla/5.0";
String ipAddress = "192.168.1.1";
when(jwtUtil.getRefreshExpirationMs()).thenReturn(1209600000L); // 14 days
when(jwtUtil.generateRefreshToken()).thenReturn("test-refresh-token-12345");
RefreshToken savedToken = new RefreshToken("test-refresh-token-12345",
LocalDateTime.now().plusDays(14), libraryId, userAgent, ipAddress);
when(refreshTokenRepository.save(any(RefreshToken.class))).thenReturn(savedToken);
// Act
RefreshToken result = refreshTokenService.createRefreshToken(libraryId, userAgent, ipAddress);
// Assert
assertNotNull(result);
assertEquals("test-refresh-token-12345", result.getToken());
assertEquals(libraryId, result.getLibraryId());
assertEquals(userAgent, result.getUserAgent());
assertEquals(ipAddress, result.getIpAddress());
verify(jwtUtil).generateRefreshToken();
verify(refreshTokenRepository).save(any(RefreshToken.class));
}
@Test
void testFindByToken() {
// Arrange
String tokenString = "test-token";
RefreshToken token = new RefreshToken(tokenString,
LocalDateTime.now().plusDays(14), "lib-1", "UA", "127.0.0.1");
when(refreshTokenRepository.findByToken(tokenString)).thenReturn(Optional.of(token));
// Act
Optional<RefreshToken> result = refreshTokenService.findByToken(tokenString);
// Assert
assertTrue(result.isPresent());
assertEquals(tokenString, result.get().getToken());
verify(refreshTokenRepository).findByToken(tokenString);
}
@Test
void testVerifyRefreshToken_Valid() {
// Arrange
String tokenString = "valid-token";
RefreshToken token = new RefreshToken(tokenString,
LocalDateTime.now().plusDays(14), "lib-1", "UA", "127.0.0.1");
when(refreshTokenRepository.findByToken(tokenString)).thenReturn(Optional.of(token));
// Act
Optional<RefreshToken> result = refreshTokenService.verifyRefreshToken(tokenString);
// Assert
assertTrue(result.isPresent());
assertTrue(result.get().isValid());
}
@Test
void testVerifyRefreshToken_Expired() {
// Arrange
String tokenString = "expired-token";
RefreshToken token = new RefreshToken(tokenString,
LocalDateTime.now().minusDays(1), "lib-1", "UA", "127.0.0.1"); // Expired
when(refreshTokenRepository.findByToken(tokenString)).thenReturn(Optional.of(token));
// Act
Optional<RefreshToken> result = refreshTokenService.verifyRefreshToken(tokenString);
// Assert
assertFalse(result.isPresent()); // Expired tokens should be filtered out
}
@Test
void testVerifyRefreshToken_Revoked() {
// Arrange
String tokenString = "revoked-token";
RefreshToken token = new RefreshToken(tokenString,
LocalDateTime.now().plusDays(14), "lib-1", "UA", "127.0.0.1");
token.setRevokedAt(LocalDateTime.now()); // Revoked
when(refreshTokenRepository.findByToken(tokenString)).thenReturn(Optional.of(token));
// Act
Optional<RefreshToken> result = refreshTokenService.verifyRefreshToken(tokenString);
// Assert
assertFalse(result.isPresent()); // Revoked tokens should be filtered out
}
@Test
void testRevokeToken() {
// Arrange
RefreshToken token = new RefreshToken("token",
LocalDateTime.now().plusDays(14), "lib-1", "UA", "127.0.0.1");
when(refreshTokenRepository.save(any(RefreshToken.class))).thenReturn(token);
// Act
refreshTokenService.revokeToken(token);
// Assert
assertNotNull(token.getRevokedAt());
assertTrue(token.isRevoked());
verify(refreshTokenRepository).save(token);
}
@Test
void testRevokeAllByLibraryId() {
// Arrange
String libraryId = "library-123";
// Act
refreshTokenService.revokeAllByLibraryId(libraryId);
// Assert
verify(refreshTokenRepository).revokeAllByLibraryId(eq(libraryId), any(LocalDateTime.class));
}
@Test
void testRevokeAll() {
// Act
refreshTokenService.revokeAll();
// Assert
verify(refreshTokenRepository).revokeAll(any(LocalDateTime.class));
}
@Test
void testCleanupExpiredTokens() {
// Act
refreshTokenService.cleanupExpiredTokens();
// Assert
verify(refreshTokenRepository).deleteExpiredTokens(any(LocalDateTime.class));
}
}

View File

@@ -0,0 +1,490 @@
package com.storycove.service;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.entity.TagAlias;
import com.storycove.repository.TagAliasRepository;
import com.storycove.repository.TagRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
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.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TagServiceTest {
@Mock
private TagRepository tagRepository;
@Mock
private TagAliasRepository tagAliasRepository;
@InjectMocks
private TagService tagService;
private Tag testTag;
private UUID tagId;
@BeforeEach
void setUp() {
tagId = UUID.randomUUID();
testTag = new Tag();
testTag.setId(tagId);
testTag.setName("fantasy");
testTag.setStories(new HashSet<>());
}
// ========================================
// Basic CRUD Tests
// ========================================
@Test
@DisplayName("Should find tag by ID")
void testFindById() {
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
Tag result = tagService.findById(tagId);
assertNotNull(result);
assertEquals(tagId, result.getId());
assertEquals("fantasy", result.getName());
}
@Test
@DisplayName("Should throw exception when tag not found by ID")
void testFindByIdNotFound() {
when(tagRepository.findById(any())).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> {
tagService.findById(UUID.randomUUID());
});
}
@Test
@DisplayName("Should find tag by name")
void testFindByName() {
when(tagRepository.findByName("fantasy")).thenReturn(Optional.of(testTag));
Tag result = tagService.findByName("fantasy");
assertNotNull(result);
assertEquals("fantasy", result.getName());
}
@Test
@DisplayName("Should create new tag")
void testCreateTag() {
when(tagRepository.existsByName("fantasy")).thenReturn(false);
when(tagRepository.save(any(Tag.class))).thenReturn(testTag);
Tag result = tagService.create(testTag);
assertNotNull(result);
verify(tagRepository).save(testTag);
}
@Test
@DisplayName("Should throw exception when creating duplicate tag")
void testCreateDuplicateTag() {
when(tagRepository.existsByName("fantasy")).thenReturn(true);
assertThrows(DuplicateResourceException.class, () -> {
tagService.create(testTag);
});
verify(tagRepository, never()).save(any());
}
@Test
@DisplayName("Should update existing tag")
void testUpdateTag() {
Tag updates = new Tag();
updates.setName("sci-fi");
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagRepository.existsByName("sci-fi")).thenReturn(false);
when(tagRepository.save(any(Tag.class))).thenReturn(testTag);
Tag result = tagService.update(tagId, updates);
assertNotNull(result);
verify(tagRepository).save(testTag);
}
@Test
@DisplayName("Should throw exception when updating to duplicate name")
void testUpdateToDuplicateName() {
Tag updates = new Tag();
updates.setName("sci-fi");
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagRepository.existsByName("sci-fi")).thenReturn(true);
assertThrows(DuplicateResourceException.class, () -> {
tagService.update(tagId, updates);
});
}
@Test
@DisplayName("Should delete unused tag")
void testDeleteUnusedTag() {
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
doNothing().when(tagRepository).delete(testTag);
tagService.delete(tagId);
verify(tagRepository).delete(testTag);
}
@Test
@DisplayName("Should throw exception when deleting tag in use")
void testDeleteTagInUse() {
Story story = new Story();
testTag.getStories().add(story);
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
assertThrows(IllegalStateException.class, () -> {
tagService.delete(tagId);
});
verify(tagRepository, never()).delete(any());
}
// ========================================
// Tag Alias Tests
// ========================================
@Test
@DisplayName("Should add alias to tag")
void testAddAlias() {
TagAlias alias = new TagAlias();
alias.setAliasName("sci-fantasy");
alias.setCanonicalTag(testTag);
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagAliasRepository.existsByAliasNameIgnoreCase("sci-fantasy")).thenReturn(false);
when(tagRepository.existsByNameIgnoreCase("sci-fantasy")).thenReturn(false);
when(tagAliasRepository.save(any(TagAlias.class))).thenReturn(alias);
TagAlias result = tagService.addAlias(tagId, "sci-fantasy");
assertNotNull(result);
assertEquals("sci-fantasy", result.getAliasName());
verify(tagAliasRepository).save(any(TagAlias.class));
}
@Test
@DisplayName("Should throw exception when alias already exists")
void testAddDuplicateAlias() {
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagAliasRepository.existsByAliasNameIgnoreCase("sci-fantasy")).thenReturn(true);
assertThrows(DuplicateResourceException.class, () -> {
tagService.addAlias(tagId, "sci-fantasy");
});
verify(tagAliasRepository, never()).save(any());
}
@Test
@DisplayName("Should throw exception when alias conflicts with tag name")
void testAddAliasConflictsWithTagName() {
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagAliasRepository.existsByAliasNameIgnoreCase("sci-fi")).thenReturn(false);
when(tagRepository.existsByNameIgnoreCase("sci-fi")).thenReturn(true);
assertThrows(DuplicateResourceException.class, () -> {
tagService.addAlias(tagId, "sci-fi");
});
}
@Test
@DisplayName("Should remove alias from tag")
void testRemoveAlias() {
UUID aliasId = UUID.randomUUID();
TagAlias alias = new TagAlias();
alias.setId(aliasId);
alias.setCanonicalTag(testTag);
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagAliasRepository.findById(aliasId)).thenReturn(Optional.of(alias));
doNothing().when(tagAliasRepository).delete(alias);
tagService.removeAlias(tagId, aliasId);
verify(tagAliasRepository).delete(alias);
}
@Test
@DisplayName("Should throw exception when removing alias from wrong tag")
void testRemoveAliasFromWrongTag() {
UUID aliasId = UUID.randomUUID();
Tag differentTag = new Tag();
differentTag.setId(UUID.randomUUID());
TagAlias alias = new TagAlias();
alias.setId(aliasId);
alias.setCanonicalTag(differentTag);
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagAliasRepository.findById(aliasId)).thenReturn(Optional.of(alias));
assertThrows(IllegalArgumentException.class, () -> {
tagService.removeAlias(tagId, aliasId);
});
verify(tagAliasRepository, never()).delete(any());
}
@Test
@DisplayName("Should resolve tag by name")
void testResolveTagByName() {
when(tagRepository.findByNameIgnoreCase("fantasy")).thenReturn(Optional.of(testTag));
Tag result = tagService.resolveTagByName("fantasy");
assertNotNull(result);
assertEquals("fantasy", result.getName());
}
@Test
@DisplayName("Should resolve tag by alias")
void testResolveTagByAlias() {
TagAlias alias = new TagAlias();
alias.setAliasName("sci-fantasy");
alias.setCanonicalTag(testTag);
when(tagRepository.findByNameIgnoreCase("sci-fantasy")).thenReturn(Optional.empty());
when(tagAliasRepository.findByAliasNameIgnoreCase("sci-fantasy")).thenReturn(Optional.of(alias));
Tag result = tagService.resolveTagByName("sci-fantasy");
assertNotNull(result);
assertEquals("fantasy", result.getName());
}
@Test
@DisplayName("Should return null when tag/alias not found")
void testResolveTagNotFound() {
when(tagRepository.findByNameIgnoreCase(anyString())).thenReturn(Optional.empty());
when(tagAliasRepository.findByAliasNameIgnoreCase(anyString())).thenReturn(Optional.empty());
Tag result = tagService.resolveTagByName("nonexistent");
assertNull(result);
}
// ========================================
// Tag Merge Tests
// ========================================
@Test
@DisplayName("Should merge tags successfully")
void testMergeTags() {
UUID sourceId = UUID.randomUUID();
Tag sourceTag = new Tag();
sourceTag.setId(sourceId);
sourceTag.setName("sci-fi");
Story story = new Story();
story.setTags(new HashSet<>(Arrays.asList(sourceTag)));
sourceTag.setStories(new HashSet<>(Arrays.asList(story)));
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
when(tagRepository.findById(sourceId)).thenReturn(Optional.of(sourceTag));
when(tagAliasRepository.save(any(TagAlias.class))).thenReturn(new TagAlias());
when(tagRepository.save(any(Tag.class))).thenReturn(testTag);
doNothing().when(tagRepository).delete(sourceTag);
Tag result = tagService.mergeTags(List.of(sourceId), tagId);
assertNotNull(result);
verify(tagAliasRepository).save(any(TagAlias.class));
verify(tagRepository).delete(sourceTag);
}
@Test
@DisplayName("Should not merge tag with itself")
void testMergeTagWithItself() {
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
assertThrows(IllegalArgumentException.class, () -> {
tagService.mergeTags(List.of(tagId), tagId);
});
}
@Test
@DisplayName("Should throw exception when no valid source tags to merge")
void testMergeNoValidSourceTags() {
when(tagRepository.findById(tagId)).thenReturn(Optional.of(testTag));
assertThrows(IllegalArgumentException.class, () -> {
tagService.mergeTags(Collections.emptyList(), tagId);
});
}
// ========================================
// Search and Query Tests
// ========================================
@Test
@DisplayName("Should find all tags")
void testFindAll() {
when(tagRepository.findAll()).thenReturn(List.of(testTag));
List<Tag> result = tagService.findAll();
assertNotNull(result);
assertEquals(1, result.size());
}
@Test
@DisplayName("Should search tags by name")
void testSearchByName() {
when(tagRepository.findByNameContainingIgnoreCase("fan"))
.thenReturn(List.of(testTag));
List<Tag> result = tagService.searchByName("fan");
assertNotNull(result);
assertEquals(1, result.size());
}
@Test
@DisplayName("Should find used tags")
void testFindUsedTags() {
when(tagRepository.findUsedTags()).thenReturn(List.of(testTag));
List<Tag> result = tagService.findUsedTags();
assertNotNull(result);
assertEquals(1, result.size());
}
@Test
@DisplayName("Should find most used tags")
void testFindMostUsedTags() {
when(tagRepository.findMostUsedTags()).thenReturn(List.of(testTag));
List<Tag> result = tagService.findMostUsedTags();
assertNotNull(result);
assertEquals(1, result.size());
}
@Test
@DisplayName("Should find unused tags")
void testFindUnusedTags() {
when(tagRepository.findUnusedTags()).thenReturn(List.of(testTag));
List<Tag> result = tagService.findUnusedTags();
assertNotNull(result);
assertEquals(1, result.size());
}
@Test
@DisplayName("Should delete all unused tags")
void testDeleteUnusedTags() {
when(tagRepository.findUnusedTags()).thenReturn(List.of(testTag));
doNothing().when(tagRepository).deleteAll(anyList());
List<Tag> result = tagService.deleteUnusedTags();
assertNotNull(result);
assertEquals(1, result.size());
verify(tagRepository).deleteAll(anyList());
}
@Test
@DisplayName("Should find or create tag")
void testFindOrCreate() {
when(tagRepository.findByName("fantasy")).thenReturn(Optional.of(testTag));
Tag result = tagService.findOrCreate("fantasy");
assertNotNull(result);
assertEquals("fantasy", result.getName());
verify(tagRepository, never()).save(any());
}
@Test
@DisplayName("Should create tag when not found")
void testFindOrCreateNew() {
when(tagRepository.findByName("new-tag")).thenReturn(Optional.empty());
when(tagRepository.existsByName("new-tag")).thenReturn(false);
when(tagRepository.save(any(Tag.class))).thenReturn(testTag);
Tag result = tagService.findOrCreate("new-tag");
assertNotNull(result);
verify(tagRepository).save(any(Tag.class));
}
// ========================================
// Tag Suggestion Tests
// ========================================
@Test
@DisplayName("Should suggest tags based on content")
void testSuggestTags() {
when(tagRepository.findAll()).thenReturn(List.of(testTag));
var suggestions = tagService.suggestTags(
"Fantasy Adventure",
"A fantasy story about magic",
"Epic fantasy tale",
5
);
assertNotNull(suggestions);
assertFalse(suggestions.isEmpty());
}
@Test
@DisplayName("Should return empty suggestions for empty content")
void testSuggestTagsEmptyContent() {
when(tagRepository.findAll()).thenReturn(List.of(testTag));
var suggestions = tagService.suggestTags("", "", "", 5);
assertNotNull(suggestions);
assertTrue(suggestions.isEmpty());
}
// ========================================
// Statistics Tests
// ========================================
@Test
@DisplayName("Should count all tags")
void testCountAll() {
when(tagRepository.count()).thenReturn(10L);
long count = tagService.countAll();
assertEquals(10L, count);
}
@Test
@DisplayName("Should count used tags")
void testCountUsedTags() {
when(tagRepository.countUsedTags()).thenReturn(5L);
long count = tagService.countUsedTags();
assertEquals(5L, count);
}
}