Fix tests

This commit is contained in:
acx10
2026-01-29 18:18:37 -07:00
parent 3a4de95c8c
commit b215dbb692
5 changed files with 110 additions and 68 deletions

View File

@@ -38,6 +38,7 @@ import reactor.core.scheduler.Schedulers;
import java.io.File;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -148,9 +149,9 @@ public class BookMetadataService {
.seriesName(request.getSeriesName())
.seriesTotal(request.getSeriesTotal())
.publishedDate(request.getPublishedDate())
.categories(request.getGenres())
.moods(request.getMoods())
.tags(request.getTags())
.categories(request.getGenres() != null ? request.getGenres() : Collections.emptySet())
.moods(request.getMoods() != null ? request.getMoods() : Collections.emptySet())
.tags(request.getTags() != null ? request.getTags() : Collections.emptySet())
.build();
for (Long bookId : request.getBookIds()) {

View File

@@ -52,7 +52,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
}
private static final List<IdentifierMapping> IDENTIFIER_PREFIX_MAPPINGS = List.of(
new IdentifierMapping("urn:isbn:", "isbn", null), // Special handling for ISBN URNs
new IdentifierMapping("urn:isbn:", "isbn", null),
new IdentifierMapping("urn:amazon:", "asin", BookMetadata.BookMetadataBuilder::asin),
new IdentifierMapping("urn:goodreads:", "goodreadsId", BookMetadata.BookMetadataBuilder::goodreadsId),
new IdentifierMapping("urn:google:", "googleId", BookMetadata.BookMetadataBuilder::googleId),
@@ -109,6 +109,17 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
}
}
if (coverImage == null) {
String metaCoverId = findMetaCoverIdInOpf(epubFile);
if (metaCoverId != null) {
String href = findHrefForManifestId(epubFile, metaCoverId);
if (href != null) {
byte[] data = extractFileFromZip(epubFile, href);
if (data != null) return data;
}
}
}
if (coverImage == null) {
for (io.documentnode.epub4j.domain.Resource res : epub.getResources().getAll()) {
String id = res.getId();
@@ -348,7 +359,6 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
if (value.startsWith(mapping.prefix)) {
String extractedValue = value.substring(mapping.prefix.length());
// Special handling for ISBN URNs - pass to ISBN processor
if ("isbn".equals(mapping.fieldName)) {
processIsbnIdentifier(extractedValue, builder, processedFields);
return true;
@@ -472,7 +482,6 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
value = value.trim();
// Check for year-only format first (e.g., "2024") - common in EPUB metadata
if (YEAR_ONLY_PATTERN.matcher(value).matches()) {
int year = Integer.parseInt(value);
if (year >= 1 && year <= 9999) {
@@ -490,7 +499,6 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
} catch (Exception ignored) {
}
// Try parsing first 10 characters for ISO date format with extra content
if (value.length() >= 10) {
try {
return LocalDate.parse(value.substring(0, 10));
@@ -544,10 +552,91 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
return null;
}
private String findMetaCoverIdInOpf(File epubFile) {
try (ZipFile zip = new ZipFile(epubFile)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
DocumentBuilder builder = dbf.newDocumentBuilder();
FileHeader containerHdr = zip.getFileHeader("META-INF/container.xml");
if (containerHdr == null) return null;
try (InputStream cis = zip.getInputStream(containerHdr)) {
Document containerDoc = builder.parse(cis);
NodeList roots = containerDoc.getElementsByTagName("rootfile");
if (roots.getLength() == 0) return null;
String opfPath = ((Element) roots.item(0)).getAttribute("full-path");
if (StringUtils.isBlank(opfPath)) return null;
FileHeader opfHdr = zip.getFileHeader(opfPath);
if (opfHdr == null) return null;
try (InputStream in = zip.getInputStream(opfHdr)) {
Document doc = builder.parse(in);
NodeList metaElements = doc.getElementsByTagName("meta");
for (int i = 0; i < metaElements.getLength(); i++) {
Element meta = (Element) metaElements.item(i);
String name = meta.getAttribute("name");
if ("cover".equals(name)) {
return meta.getAttribute("content");
}
}
}
}
} catch (Exception e) {
log.debug("Failed to find meta cover ID in OPF: {}", e.getMessage());
}
return null;
}
private String findHrefForManifestId(File epubFile, String manifestId) {
try (ZipFile zip = new ZipFile(epubFile)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
DocumentBuilder builder = dbf.newDocumentBuilder();
FileHeader containerHdr = zip.getFileHeader("META-INF/container.xml");
if (containerHdr == null) return null;
try (InputStream cis = zip.getInputStream(containerHdr)) {
Document containerDoc = builder.parse(cis);
NodeList roots = containerDoc.getElementsByTagName("rootfile");
if (roots.getLength() == 0) return null;
String opfPath = ((Element) roots.item(0)).getAttribute("full-path");
if (StringUtils.isBlank(opfPath)) return null;
FileHeader opfHdr = zip.getFileHeader(opfPath);
if (opfHdr == null) return null;
try (InputStream in = zip.getInputStream(opfHdr)) {
Document doc = builder.parse(in);
NodeList manifestItems = doc.getElementsByTagName("item");
for (int i = 0; i < manifestItems.getLength(); i++) {
Element item = (Element) manifestItems.item(i);
String id = item.getAttribute("id");
if (manifestId.equals(id)) {
String href = item.getAttribute("href");
String decodedHref = URLDecoder.decode(href, StandardCharsets.UTF_8);
return resolvePath(opfPath, decodedHref);
}
}
}
}
} catch (Exception e) {
log.debug("Failed to find href for manifest ID {}: {}", manifestId, e.getMessage());
}
return null;
}
private String resolvePath(String opfPath, String href) {
if (href == null || href.isEmpty()) return null;
// If href is absolute within the zip (starts with /), return it without leading /
if (href.startsWith("/")) return href.substring(1);
int lastSlash = opfPath.lastIndexOf('/');
@@ -555,7 +644,6 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
String combined = basePath + href;
// Normalize path components to handle ".." and "."
java.util.LinkedList<String> parts = new java.util.LinkedList<>();
for (String part : combined.split("/")) {
if ("..".equals(part)) {
@@ -635,4 +723,5 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
log.debug("Failed to process Calibre user_metadata: {}", e.getMessage());
}
}
}
}

View File

@@ -91,7 +91,11 @@ class BookMetadataServiceConcurrencyTest {
private java.util.Optional<BookEntity> getUserBook(Long id) {
BookEntity book = new BookEntity();
book.setId(id);
book.setMetadata(new BookMetadataEntity());
BookMetadataEntity metadata = new BookMetadataEntity();
metadata.setCategories(java.util.Collections.emptySet());
book.setMetadata(metadata);
return java.util.Optional.of(book);
}
}

View File

@@ -390,7 +390,6 @@ class EpubMetadataExtractorTest {
@Test
@DisplayName("Should extract formatted ISBN-10 as ISBN-10, not ISBN-13")
void extractMetadata_withFormattedIsbn10_returnsIsbn10() throws IOException {
// "90-206-1280-8" is 13 characters long, causing it to be mistaken for ISBN-13 if formatting isn't stripped
File epubFile = createEpubWithIsbn(null, "90-206-1280-8");
BookMetadata result = extractor.extractMetadata(epubFile);
@@ -445,7 +444,6 @@ class EpubMetadataExtractorTest {
@DisplayName("Should extract cover declared with properties='cover-image' even if ID/href doesn't contain 'cover'")
void extractCover_propertiesCoverImage_returnsCoverBytes() throws IOException {
byte[] pngImage = createMinimalPngImage();
// Use an ID and HREF that do not contain "cover"
File epubFile = createEpubWithPropertiesCover(pngImage, "image123", "images/img001.png");
byte[] cover = extractor.extractCover(epubFile);
@@ -459,8 +457,6 @@ class EpubMetadataExtractorTest {
@DisplayName("Should extract cover using meta name='cover' attribute fallback with URL-encoded href")
void extractCover_metaCoverAttribute_returnsCoverBytes() throws IOException {
byte[] pngImage = createMinimalPngImage();
// Create EPUB where cover is only discoverable via <meta name="cover" content="img-id"/>
// Use URL-encoded href (cover+.png -> cover%2B.png) to verify proper decoding
File epubFile = createEpubWithMetaCoverAttribute(pngImage, "image-id", "images/img001%2B.png");
byte[] cover = extractor.extractCover(epubFile);
@@ -782,55 +778,7 @@ class EpubMetadataExtractorTest {
return epubFile;
}
@Nested
@DisplayName("URL Decoding Tests")
class UrlDecodingTests {
@Test
@DisplayName("Should properly decode unicode characters in cover href")
void extractCover_withUnicodeHref_decodesCorrectly() throws IOException {
byte[] pngImage = createMinimalPngImage();
String encodedHref = "cover%C3%A1.png"; // coverá.png URL-encoded
File epubFile = createEpubWithUnicodeCover(pngImage, "cover_image", encodedHref);
byte[] cover = extractor.extractCover(epubFile);
assertNotNull(cover, "Cover should be extracted from EPUB with URL-encoded href");
assertTrue(cover.length > 0);
}
@Test
@DisplayName("Should extract cover with URL-encoded characters in manifest")
void findCoverImageHrefInOpf_withEncodedHref_returnsDecodedPath() throws IOException {
byte[] pngImage = createMinimalPngImage();
String encodedHref = "images%2Fcover%C3%A1.jpg"; // images/coverá.jpg URL-encoded
File epubFile = createEpubWithUnicodeCover(pngImage, "cover_image", encodedHref);
byte[] cover = extractor.extractCover(epubFile);
assertNotNull(cover, "Cover should be extracted even with encoded path");
assertArrayEquals(pngImage, cover, "Extracted cover should match original image");
}
@Test
@DisplayName("Should handle multiple encoded unicode characters")
void extractCover_withMultipleEncodedChars_handlesCorrectly() throws IOException {
byte[] pngImage = createMinimalPngImage();
String encodedHref = "c%C3%B3ver%20t%C3%ADtle%20%C3%A1nd%20%C3%B1ame.png";
File epubFile = createEpubWithUnicodeCover(pngImage, "multi_unicode_cover", encodedHref);
byte[] cover = extractor.extractCover(epubFile);
assertNotNull(cover, "Cover should be extracted from filename with multiple encoded chars");
assertTrue(cover.length > 0);
}
}
private File createEpubWithMetaCoverAttribute(byte[] coverImageData, String id, String encodedHref) throws IOException {
// This EPUB uses <meta name="cover" content="id"/> to reference the cover image
// The ID and href do NOT contain "cover" and there's no properties="cover-image"
// This tests the fallback path: getMetaAttribute("cover") -> getById(coverId)
// The href may be URL-encoded (e.g., image%2B.png for image+.png)
String opfContent = String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
@@ -855,9 +803,6 @@ class EpubMetadataExtractorTest {
</container>
""";
// Decode the href for the actual file path in the ZIP
String decodedHref = java.net.URLDecoder.decode(encodedHref, StandardCharsets.UTF_8);
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(epubFile))) {
zos.putNextEntry(new ZipEntry("mimetype"));
zos.write("application/epub+zip".getBytes(StandardCharsets.UTF_8));
@@ -871,7 +816,8 @@ class EpubMetadataExtractorTest {
zos.write(opfContent.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("OEBPS/" + decodedHref));
String decodedPath = java.net.URLDecoder.decode(encodedHref, StandardCharsets.UTF_8);
zos.putNextEntry(new ZipEntry("OEBPS/" + decodedPath));
zos.write(coverImageData);
zos.closeEntry();
}
@@ -916,7 +862,6 @@ class EpubMetadataExtractorTest {
zos.write(opfContent.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
// The actual file path in the zip should match the decoded href
String decodedPath = java.net.URLDecoder.decode(encodedHref, java.nio.charset.StandardCharsets.UTF_8);
zos.putNextEntry(new ZipEntry("OEBPS/" + decodedPath));
zos.write(coverImageData);

View File

@@ -14,6 +14,7 @@ import com.adityachandel.booklore.model.enums.ReadStatus;
import com.adityachandel.booklore.model.enums.ResetProgressType;
import com.adityachandel.booklore.repository.*;
import com.adityachandel.booklore.service.kobo.KoboReadingStateService;
import com.adityachandel.booklore.service.hardcover.HardcoverSyncService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@@ -42,6 +43,8 @@ class ReadingProgressServiceTest {
private AuthenticationService authenticationService;
@Mock
private KoboReadingStateService koboReadingStateService;
@Mock
private HardcoverSyncService hardcoverSyncService;
@InjectMocks
private ReadingProgressService readingProgressService;