Add JUnit tests for common and core module coverage (#6675)

# Description of Changes

JUNITS!
They JUnits were 100% AI generated however no code was touched

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-06-16 16:23:34 +00:00
committed by GitHub
parent 7e67bfc459
commit f33f4f8f75
34 changed files with 17942 additions and 0 deletions
@@ -0,0 +1,493 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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 org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Gap tests for {@link MergeController} private helper logic reachable via reflection. Focuses on
* sort comparators, file-order reordering, client file-id parsing, date extraction and filename
* lookup. The external JPDFium merge path is not exercised here (covered structurally elsewhere).
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class MergeControllerGapTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private MergeController mergeController;
private MockMultipartFile fileA;
private MockMultipartFile fileB;
private MockMultipartFile fileC;
@BeforeEach
void setUp() {
fileA =
new MockMultipartFile(
"fileInput", "Apple.pdf", MediaType.APPLICATION_PDF_VALUE, "a".getBytes());
fileB =
new MockMultipartFile(
"fileInput", "banana.pdf", MediaType.APPLICATION_PDF_VALUE, "b".getBytes());
fileC =
new MockMultipartFile(
"fileInput", "Cherry.pdf", MediaType.APPLICATION_PDF_VALUE, "c".getBytes());
}
// ---- reflection helpers -------------------------------------------------
@SuppressWarnings("unchecked")
private java.util.Comparator<MultipartFile> sortComparator(String sortType) throws Exception {
Method m = MergeController.class.getDeclaredMethod("getSortComparator", String.class);
m.setAccessible(true);
return (java.util.Comparator<MultipartFile>) m.invoke(mergeController, sortType);
}
private MultipartFile[] reorder(MultipartFile[] files, String fileOrder) throws Exception {
Method m =
MergeController.class.getDeclaredMethod(
"reorderFilesByProvidedOrder", MultipartFile[].class, String.class);
m.setAccessible(true);
return (MultipartFile[]) m.invoke(null, files, fileOrder);
}
private String[] parseClientFileIds(String value) throws Exception {
Method m = MergeController.class.getDeclaredMethod("parseClientFileIds", String.class);
m.setAccessible(true);
return (String[]) m.invoke(mergeController, value);
}
private long getPdfDateTimeSafe(MultipartFile file) throws Exception {
Method m =
MergeController.class.getDeclaredMethod("getPdfDateTimeSafe", MultipartFile.class);
m.setAccessible(true);
return (long) m.invoke(mergeController, file);
}
@SuppressWarnings("unchecked")
private int indexOfByOriginalFilename(List<MultipartFile> list, String name) throws Exception {
Method m =
MergeController.class.getDeclaredMethod(
"indexOfByOriginalFilename", List.class, String.class);
m.setAccessible(true);
return (int) m.invoke(null, list, name);
}
private static PDDocument docWithTitle(String title) {
PDDocument doc = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
when(doc.getDocumentInformation()).thenReturn(info);
when(info.getTitle()).thenReturn(title);
return doc;
}
// ---- getSortComparator: byFileName --------------------------------------
@Nested
@DisplayName("getSortComparator: byFileName")
class ByFileName {
@Test
@DisplayName("sorts case-insensitively by original filename")
void sortsCaseInsensitively() throws Exception {
MultipartFile[] files = {fileC, fileA, fileB};
Arrays.sort(files, sortComparator("byFileName"));
assertArrayEquals(new MultipartFile[] {fileA, fileB, fileC}, files);
}
@Test
@DisplayName("null original filename is treated as empty and sorts first")
void nullFilenameSortsFirst() throws Exception {
MultipartFile nullName = mock(MultipartFile.class);
when(nullName.getOriginalFilename()).thenReturn(null);
MultipartFile[] files = {fileB, nullName, fileA};
Arrays.sort(files, sortComparator("byFileName"));
assertSame(nullName, files[0]);
assertSame(fileA, files[1]);
assertSame(fileB, files[2]);
}
}
// ---- getSortComparator: byPDFTitle --------------------------------------
@Nested
@DisplayName("getSortComparator: byPDFTitle")
class ByPdfTitle {
@Test
@DisplayName("orders documents by their PDF title, ignoring case")
void ordersByTitle() throws Exception {
PDDocument docZ = docWithTitle("Zebra");
PDDocument docA = docWithTitle("alpha");
when(pdfDocumentFactory.load(fileA)).thenReturn(docZ);
when(pdfDocumentFactory.load(fileB)).thenReturn(docA);
int cmp = sortComparator("byPDFTitle").compare(fileA, fileB);
assertTrue(cmp > 0, "Zebra should sort after alpha");
// and the documents are closed via try-with-resources
verify(docZ).close();
verify(docA).close();
}
@Test
@DisplayName("both titles null yields equal (0)")
void bothNullTitlesEqual() throws Exception {
PDDocument d1 = docWithTitle(null);
PDDocument d2 = docWithTitle(null);
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
assertEquals(0, sortComparator("byPDFTitle").compare(fileA, fileB));
}
@Test
@DisplayName("first title null sorts after non-null (returns 1)")
void firstNullSortsLast() throws Exception {
PDDocument d1 = docWithTitle(null);
PDDocument d2 = docWithTitle("Beta");
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
assertEquals(1, sortComparator("byPDFTitle").compare(fileA, fileB));
}
@Test
@DisplayName("second title null sorts first (returns -1)")
void secondNullSortsFirst() throws Exception {
PDDocument d1 = docWithTitle("Alpha");
PDDocument d2 = docWithTitle(null);
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
assertEquals(-1, sortComparator("byPDFTitle").compare(fileA, fileB));
}
@Test
@DisplayName("IOException while loading yields equal (0)")
void ioExceptionYieldsEqual() throws Exception {
when(pdfDocumentFactory.load(fileA)).thenThrow(new IOException("boom"));
assertEquals(0, sortComparator("byPDFTitle").compare(fileA, fileB));
}
}
// ---- getSortComparator: date-based and no-op orders ---------------------
@Nested
@DisplayName("getSortComparator: date-based and pass-through orders")
class DateAndPassThrough {
private PDDocument docWithModDate(long millis) {
PDDocument doc = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
Calendar cal = new GregorianCalendar();
cal.setTimeInMillis(millis);
when(doc.getDocumentInformation()).thenReturn(info);
when(info.getModificationDate()).thenReturn(cal);
return doc;
}
@Test
@DisplayName("byDateModified orders newest first (descending)")
void byDateModifiedNewestFirst() throws Exception {
PDDocument older = docWithModDate(1_000L);
PDDocument newer = docWithModDate(9_000L);
when(pdfDocumentFactory.load(fileA)).thenReturn(older);
when(pdfDocumentFactory.load(fileB)).thenReturn(newer);
// file1=older, file2=newer -> Long.compare(t2=newer, t1=older) > 0 -> older after newer
int cmp = sortComparator("byDateModified").compare(fileA, fileB);
assertTrue(cmp > 0);
}
@Test
@DisplayName("byDateCreated uses the same descending logic")
void byDateCreatedNewestFirst() throws Exception {
PDDocument older = docWithModDate(2_000L);
PDDocument newer = docWithModDate(8_000L);
when(pdfDocumentFactory.load(fileA)).thenReturn(newer);
when(pdfDocumentFactory.load(fileB)).thenReturn(older);
int cmp = sortComparator("byDateCreated").compare(fileA, fileB);
assertTrue(cmp < 0, "newer (file1) should sort before older (file2)");
}
@Test
@DisplayName("orderProvided is a stable no-op comparator (0)")
void orderProvidedNoOp() throws Exception {
assertEquals(0, sortComparator("orderProvided").compare(fileA, fileB));
}
@Test
@DisplayName("unknown sort type falls back to no-op comparator (0)")
void unknownSortTypeNoOp() throws Exception {
assertEquals(0, sortComparator("somethingElse").compare(fileA, fileB));
}
}
// ---- getPdfDateTimeSafe -------------------------------------------------
@Nested
@DisplayName("getPdfDateTimeSafe")
class GetPdfDateTimeSafe {
@Test
@DisplayName("returns modification date millis when present")
void returnsModificationDate() throws Exception {
PDDocument doc = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
Calendar cal = new GregorianCalendar();
cal.setTimeInMillis(123_456L);
when(doc.getDocumentInformation()).thenReturn(info);
when(info.getModificationDate()).thenReturn(cal);
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
assertEquals(123_456L, getPdfDateTimeSafe(fileA));
verify(doc).close();
}
@Test
@DisplayName("falls back to creation date when modification date is null")
void fallsBackToCreationDate() throws Exception {
PDDocument doc = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
Calendar cal = new GregorianCalendar();
cal.setTimeInMillis(777L);
when(doc.getDocumentInformation()).thenReturn(info);
when(info.getModificationDate()).thenReturn(null);
when(info.getCreationDate()).thenReturn(cal);
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
assertEquals(777L, getPdfDateTimeSafe(fileA));
}
@Test
@DisplayName("returns 0 when no info dates and no XMP metadata present")
void returnsZeroWhenNoDates() throws Exception {
PDDocument doc = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
when(doc.getDocumentInformation()).thenReturn(info);
when(info.getModificationDate()).thenReturn(null);
when(info.getCreationDate()).thenReturn(null);
when(doc.getDocumentCatalog()).thenReturn(catalog);
when(catalog.getMetadata()).thenReturn(null);
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
assertEquals(0L, getPdfDateTimeSafe(fileA));
verify(doc).close();
}
@Test
@DisplayName("returns 0 when document info itself is null")
void returnsZeroWhenInfoNull() throws Exception {
PDDocument doc = mock(PDDocument.class);
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
when(doc.getDocumentInformation()).thenReturn(null);
when(doc.getDocumentCatalog()).thenReturn(catalog);
when(catalog.getMetadata()).thenReturn(null);
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
assertEquals(0L, getPdfDateTimeSafe(fileA));
}
@Test
@DisplayName("returns 0 and swallows IOException on load failure")
void returnsZeroOnLoadFailure() throws Exception {
when(pdfDocumentFactory.load(fileA)).thenThrow(new IOException("cannot open"));
assertEquals(0L, getPdfDateTimeSafe(fileA));
}
}
// ---- parseClientFileIds -------------------------------------------------
@Nested
@DisplayName("parseClientFileIds")
class ParseClientFileIds {
@Test
@DisplayName("null input returns empty array")
void nullReturnsEmpty() throws Exception {
assertEquals(0, parseClientFileIds(null).length);
}
@Test
@DisplayName("blank input returns empty array")
void blankReturnsEmpty() throws Exception {
assertEquals(0, parseClientFileIds(" ").length);
}
@Test
@DisplayName("empty JSON array returns empty array")
void emptyArrayReturnsEmpty() throws Exception {
assertEquals(0, parseClientFileIds("[]").length);
assertEquals(0, parseClientFileIds("[ ]").length);
}
@Test
@DisplayName("non-array text returns empty array")
void nonArrayReturnsEmpty() throws Exception {
assertEquals(0, parseClientFileIds("not-an-array").length);
}
@Test
@DisplayName("parses quoted, comma-separated ids and strips surrounding quotes")
void parsesQuotedIds() throws Exception {
String[] result = parseClientFileIds("[\"id1\", \"id2\",\"id3\"]");
assertArrayEquals(new String[] {"id1", "id2", "id3"}, result);
}
@Test
@DisplayName("parses unquoted ids as-is after trimming")
void parsesUnquotedIds() throws Exception {
String[] result = parseClientFileIds("[a, b , c]");
assertArrayEquals(new String[] {"a", "b", "c"}, result);
}
@Test
@DisplayName("single element array yields a one-element result")
void singleElement() throws Exception {
assertArrayEquals(new String[] {"only"}, parseClientFileIds("[\"only\"]"));
}
}
// ---- reorderFilesByProvidedOrder ----------------------------------------
@Nested
@DisplayName("reorderFilesByProvidedOrder")
class ReorderFilesByProvidedOrder {
@Test
@DisplayName("reorders files to match the newline-separated order list")
void reordersToMatchOrder() throws Exception {
MultipartFile[] files = {fileA, fileB, fileC};
MultipartFile[] result = reorder(files, "Cherry.pdf\nApple.pdf\nbanana.pdf");
assertArrayEquals(new MultipartFile[] {fileC, fileA, fileB}, result);
}
@Test
@DisplayName("handles CRLF separators")
void handlesCrlf() throws Exception {
MultipartFile[] files = {fileA, fileB};
MultipartFile[] result = reorder(files, "banana.pdf\r\nApple.pdf");
assertArrayEquals(new MultipartFile[] {fileB, fileA}, result);
}
@Test
@DisplayName("unmatched names are skipped and remaining files appended in original order")
void unmatchedNamesAppendedAtEnd() throws Exception {
MultipartFile[] files = {fileA, fileB, fileC};
// only mention Cherry; ghost.pdf is ignored; Apple+banana keep original relative order
MultipartFile[] result = reorder(files, "Cherry.pdf\nghost.pdf");
assertArrayEquals(new MultipartFile[] {fileC, fileA, fileB}, result);
}
@Test
@DisplayName("blank/empty order entries are skipped")
void blankEntriesSkipped() throws Exception {
MultipartFile[] files = {fileA, fileB};
MultipartFile[] result = reorder(files, "\n \nbanana.pdf\n");
assertArrayEquals(new MultipartFile[] {fileB, fileA}, result);
}
@Test
@DisplayName("empty file array returns empty array")
void emptyFilesReturnsEmpty() throws Exception {
MultipartFile[] result = reorder(new MultipartFile[0], "anything.pdf");
assertEquals(0, result.length);
}
}
// ---- indexOfByOriginalFilename ------------------------------------------
@Nested
@DisplayName("indexOfByOriginalFilename")
class IndexOfByOriginalFilename {
@Test
@DisplayName("returns index of matching filename")
void returnsMatchIndex() throws Exception {
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, fileB, fileC));
assertEquals(1, indexOfByOriginalFilename(list, "banana.pdf"));
}
@Test
@DisplayName("returns first match index when duplicates exist")
void returnsFirstMatch() throws Exception {
MockMultipartFile dup =
new MockMultipartFile(
"fileInput",
"Apple.pdf",
MediaType.APPLICATION_PDF_VALUE,
"dup".getBytes());
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, dup));
assertEquals(0, indexOfByOriginalFilename(list, "Apple.pdf"));
}
@Test
@DisplayName("returns -1 when not found")
void returnsMinusOneWhenAbsent() throws Exception {
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, fileB));
assertEquals(-1, indexOfByOriginalFilename(list, "missing.pdf"));
}
@Test
@DisplayName("returns -1 for empty list")
void returnsMinusOneForEmpty() throws Exception {
assertEquals(-1, indexOfByOriginalFilename(new ArrayList<>(), "x.pdf"));
}
}
// ---- mergeDocuments null-collaborator wiring ----------------------------
@Nested
@DisplayName("mergeDocuments wiring")
class MergeDocumentsWiring {
@Test
@DisplayName("creates a fresh document from the factory and returns it")
void createsFromFactory() throws Exception {
PDDocument merged = mock(PDDocument.class);
when(pdfDocumentFactory.createNewDocument()).thenReturn(merged);
PDDocument result = mergeController.mergeDocuments(List.of());
assertNotNull(result);
assertSame(merged, result);
verify(pdfDocumentFactory).createNewDocument();
verify(merged, never()).close();
}
}
}
@@ -0,0 +1,446 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.PosterPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PosterPdfControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private PosterPdfController controller;
private final AtomicInteger tempCounter = new AtomicInteger();
@BeforeEach
void setUp() throws Exception {
// new TempFile(tempFileManager, suffix) delegates to createTempFile(suffix);
// hand back real, writable files in the test temp dir so the controller's
// real file/zip I/O works end to end.
lenient()
.when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
inv -> {
String suffix = inv.getArgument(0);
File f =
tempDir.resolve(
"poster-"
+ tempCounter.incrementAndGet()
+ suffix)
.toFile();
Files.createFile(f.toPath());
return f;
});
}
private MockMultipartFile createRealPdf(int numPages, String name) throws IOException {
return createRealPdf(numPages, name, PDRectangle.A4, 0);
}
private MockMultipartFile createRealPdf(
int numPages, String name, PDRectangle size, int rotation) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
PDPage page = new PDPage(size);
page.setRotation(rotation);
doc.addPage(page);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, baos.toByteArray());
}
}
private PosterPdfRequest createRequest(MockMultipartFile file) {
PosterPdfRequest req = new PosterPdfRequest();
req.setFileInput(file);
return req;
}
/** Drain a file-backed Resource body to bytes. */
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
/** Read the single PDF entry out of a ZIP byte array. */
private byte[] firstPdfEntry(byte[] zipBytes) throws IOException {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry = zis.getNextEntry();
assertThat(entry).isNotNull();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
zis.transferTo(baos);
return baos.toByteArray();
}
}
private void stubFactory(MockMultipartFile file) throws IOException {
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument outputDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
.thenReturn(outputDoc);
}
@Nested
@DisplayName("posterPdf happy path")
class HappyPath {
@Test
@DisplayName("Default 2x2 grid on single page yields a ZIP with a 4-page PDF")
void defaultGrid_singlePage() throws Exception {
MockMultipartFile file = createRealPdf(1, "doc.pdf");
PosterPdfRequest request = createRequest(file);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("doc_poster.zip");
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
byte[] zipBytes = drainBody(response);
assertThat(zipBytes).isNotEmpty();
byte[] pdfBytes = firstPdfEntry(zipBytes);
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
// 1 source page * (xFactor 2 * yFactor 2) = 4 output pages
assertThat(result.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("ZIP entry is named <base>_poster.pdf")
void zipEntryNamedAfterBase() throws Exception {
MockMultipartFile file = createRealPdf(1, "report.pdf");
PosterPdfRequest request = createRequest(file);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
byte[] zipBytes = drainBody(response);
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry = zis.getNextEntry();
assertThat(entry).isNotNull();
assertThat(entry.getName()).isEqualTo("report_poster.pdf");
}
}
@Test
@DisplayName("Multi-page source multiplies output page count by grid size")
void multiPageSource() throws Exception {
MockMultipartFile file = createRealPdf(3, "multi.pdf");
PosterPdfRequest request = createRequest(file);
request.setXFactor(2);
request.setYFactor(3);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
byte[] pdfBytes = firstPdfEntry(drainBody(response));
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
// 3 pages * (2 * 3) = 18
assertThat(result.getNumberOfPages()).isEqualTo(18);
}
}
@Test
@DisplayName("1x1 grid produces one output page per source page")
void oneByOneGrid() throws Exception {
MockMultipartFile file = createRealPdf(2, "one.pdf");
PosterPdfRequest request = createRequest(file);
request.setXFactor(1);
request.setYFactor(1);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
byte[] pdfBytes = firstPdfEntry(drainBody(response));
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
assertThat(result.getNumberOfPages()).isEqualTo(2);
}
}
@Test
@DisplayName("Right-to-left ordering still produces the full grid")
void rightToLeft() throws Exception {
MockMultipartFile file = createRealPdf(1, "rtl.pdf");
PosterPdfRequest request = createRequest(file);
request.setRightToLeft(true);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
byte[] pdfBytes = firstPdfEntry(drainBody(response));
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
assertThat(result.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("Rotated source page (90 degrees) is handled without error")
void rotatedSourcePage() throws Exception {
MockMultipartFile file = createRealPdf(1, "rot.pdf", PDRectangle.A4, 90);
PosterPdfRequest request = createRequest(file);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
byte[] pdfBytes = firstPdfEntry(drainBody(response));
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
assertThat(result.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("Filename without extension is preserved in output names")
void filenameWithoutExtension() throws Exception {
MockMultipartFile file = createRealPdf(1, "noext");
PosterPdfRequest request = createRequest(file);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("noext_poster.zip");
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(drainBody(response)))) {
ZipEntry entry = zis.getNextEntry();
assertThat(entry).isNotNull();
assertThat(entry.getName()).isEqualTo("noext_poster.pdf");
}
}
@Test
@DisplayName("Null original filename falls back to default base name")
void nullOriginalFilename() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput",
null,
MediaType.APPLICATION_PDF_VALUE,
createRealPdf(1, "x.pdf").getBytes());
PosterPdfRequest request = createRequest(file);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("_poster.zip");
}
}
@Nested
@DisplayName("Page size handling")
class PageSizes {
@Test
@DisplayName("Each supported page size produces a valid ZIP")
void supportedSizes() throws Exception {
for (String size : new String[] {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"}) {
MockMultipartFile file = createRealPdf(1, "s.pdf");
PosterPdfRequest request = createRequest(file);
request.setPageSize(size);
stubFactory(file);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getStatusCode())
.as("page size %s", size)
.isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).as("body for %s", size).isNotEmpty();
}
}
@Test
@DisplayName("Invalid page size throws IllegalArgumentException")
void invalidPageSize() throws Exception {
MockMultipartFile file = createRealPdf(1, "bad.pdf");
PosterPdfRequest request = createRequest(file);
request.setPageSize("NotAPageSize");
stubFactory(file);
assertThatThrownBy(() -> controller.posterPdf(request))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("getTargetPageSize private mapping")
class TargetPageSize {
private PDRectangle invoke(String size) throws Exception {
Method m =
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
m.setAccessible(true);
return (PDRectangle) m.invoke(controller, size);
}
@Test
@DisplayName("Known sizes map to expected PDRectangles")
void knownSizes() throws Exception {
assertThat(invoke("A4")).isEqualTo(PDRectangle.A4);
assertThat(invoke("Letter")).isEqualTo(PDRectangle.LETTER);
assertThat(invoke("A3")).isEqualTo(PDRectangle.A3);
assertThat(invoke("A5")).isEqualTo(PDRectangle.A5);
assertThat(invoke("Legal")).isEqualTo(PDRectangle.LEGAL);
}
@Test
@DisplayName("Tabloid maps to 11x17 inch (792x1224 pt) rectangle")
void tabloidSize() throws Exception {
PDRectangle r = invoke("Tabloid");
assertThat(r.getWidth()).isEqualTo(792f);
assertThat(r.getHeight()).isEqualTo(1224f);
}
@Test
@DisplayName("Unknown size raises IllegalArgumentException")
void unknownSize() throws Exception {
Method m =
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
m.setAccessible(true);
assertThatThrownBy(() -> m.invoke(controller, "Unknown"))
.isInstanceOf(InvocationTargetException.class)
.hasCauseInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("Null size raises IllegalArgumentException")
void nullSize() throws Exception {
Method m =
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
m.setAccessible(true);
assertThatThrownBy(() -> m.invoke(controller, new Object[] {null}))
.isInstanceOf(InvocationTargetException.class)
.hasCauseInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("Error propagation")
class Errors {
@Test
@DisplayName("IOException from load propagates to caller")
void loadIoException() throws Exception {
MockMultipartFile file = createRealPdf(1, "io.pdf");
PosterPdfRequest request = createRequest(file);
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("load failed"));
assertThatThrownBy(() -> controller.posterPdf(request))
.isInstanceOf(IOException.class)
.hasMessageContaining("load failed");
}
@Test
@DisplayName("RuntimeException from createNewDocument propagates and closes zip temp file")
void createNewDocumentRuntimeException() throws Exception {
MockMultipartFile file = createRealPdf(1, "rt.pdf");
PosterPdfRequest request = createRequest(file);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
.thenThrow(new IllegalStateException("boom"));
assertThatThrownBy(() -> controller.posterPdf(request))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("boom");
sourceDoc.close();
}
}
@Nested
@DisplayName("Collaborator interactions")
class Interactions {
@Test
@DisplayName("Both load and createNewDocumentBasedOnOldDocument are invoked")
void factoryCalled() throws Exception {
MockMultipartFile file = createRealPdf(1, "calls.pdf");
PosterPdfRequest request = createRequest(file);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument outputDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
.thenReturn(outputDoc);
controller.posterPdf(request);
verify(pdfDocumentFactory).load(file);
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(sourceDoc);
}
@Test
@DisplayName("Zip temp file is never created when load fails before zip work")
void noOutputWhenLoadFails() throws Exception {
MockMultipartFile file = createRealPdf(1, "fail.pdf");
PosterPdfRequest request = createRequest(file);
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("nope"));
assertThatThrownBy(() -> controller.posterPdf(request)).isInstanceOf(IOException.class);
// createNewDocumentBasedOnOldDocument is never reached after load throws.
verify(pdfDocumentFactory, never())
.createNewDocumentBasedOnOldDocument(
org.mockito.ArgumentMatchers.any(PDDocument.class));
}
}
}
@@ -0,0 +1,186 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SettingsControllerTest {
@Mock private ApplicationProperties applicationProperties;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private ApplicationProperties.System system;
private SettingsController settingsController;
@BeforeEach
void setUp() {
settingsController = new SettingsController(applicationProperties, endpointConfiguration);
}
@Nested
@DisplayName("updateApiKey (update-enable-analytics)")
class UpdateApiKey {
@Test
@DisplayName("persists and returns 200 OK when analytics flag not yet set (null)")
void updatesWhenNotPreviouslySet() throws Exception {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getEnableAnalytics()).thenReturn(null);
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
settingsController.updateApiKey(Boolean.TRUE);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("Updated", response.getBody().get("message"));
generalUtils.verify(
() ->
GeneralUtils.saveKeyToSettings(
"system.enableAnalytics", Boolean.TRUE),
times(1));
}
verify(system).setEnableAnalytics(Boolean.TRUE);
}
@Test
@DisplayName("persists the false value when enabling analytics is declined")
void updatesWithFalseValue() throws Exception {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getEnableAnalytics()).thenReturn(null);
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
settingsController.updateApiKey(Boolean.FALSE);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Updated", response.getBody().get("message"));
generalUtils.verify(
() ->
GeneralUtils.saveKeyToSettings(
"system.enableAnalytics", Boolean.FALSE),
times(1));
}
verify(system).setEnableAnalytics(Boolean.FALSE);
}
@Test
@DisplayName("returns 208 ALREADY_REPORTED and does not persist when flag already true")
void alreadyReportedWhenAlreadyTrue() throws Exception {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getEnableAnalytics()).thenReturn(Boolean.TRUE);
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
settingsController.updateApiKey(Boolean.TRUE);
assertNotNull(response);
assertEquals(HttpStatus.ALREADY_REPORTED, response.getStatusCode());
assertNotNull(response.getBody());
Object message = response.getBody().get("message");
assertNotNull(message);
assertTrue(
message.toString().startsWith("Setting has already been set"),
"Unexpected message: " + message);
generalUtils.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
}
verify(system, never()).setEnableAnalytics(any());
}
@Test
@DisplayName("returns 208 ALREADY_REPORTED when flag already false (any non-null is set)")
void alreadyReportedWhenAlreadyFalse() throws Exception {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getEnableAnalytics()).thenReturn(Boolean.FALSE);
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response =
settingsController.updateApiKey(Boolean.TRUE);
assertEquals(HttpStatus.ALREADY_REPORTED, response.getStatusCode());
generalUtils.verify(
() -> GeneralUtils.saveKeyToSettings(eq("system.enableAnalytics"), any()),
never());
}
verify(system, never()).setEnableAnalytics(any());
}
}
@Nested
@DisplayName("getDisabledEndpoints (get-endpoints-status)")
class GetDisabledEndpoints {
@Test
@DisplayName("returns 200 OK with the endpoint status map from EndpointConfiguration")
void returnsEndpointStatuses() {
Map<String, Boolean> statuses = new ConcurrentHashMap<>();
statuses.put("merge-pdfs", Boolean.TRUE);
statuses.put("remove-blanks", Boolean.FALSE);
when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses);
ResponseEntity<Map<String, Boolean>> response =
settingsController.getDisabledEndpoints();
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertSame(statuses, response.getBody());
assertEquals(Boolean.TRUE, response.getBody().get("merge-pdfs"));
assertEquals(Boolean.FALSE, response.getBody().get("remove-blanks"));
verify(endpointConfiguration).getEndpointStatuses();
}
@Test
@DisplayName("returns 200 OK with an empty map when no statuses are configured")
void returnsEmptyMap() {
Map<String, Boolean> statuses = new HashMap<>();
when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses);
ResponseEntity<Map<String, Boolean>> response =
settingsController.getDisabledEndpoints();
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().isEmpty());
}
}
}
@@ -0,0 +1,325 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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 org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ToSinglePageControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ToSinglePageController controller;
@BeforeEach
void setUp() throws Exception {
// Each managed temp file is backed by a real on-disk file so the response can be read back.
when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("tsp-test", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
when(tf.getFile()).thenReturn(f);
when(tf.getPath()).thenReturn(f.toPath());
when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
return tf;
});
}
/** Build a real in-memory PDF with the given per-page sizes and return its bytes. */
private byte[] createPdf(PDRectangle... pageSizes) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (PDRectangle size : pageSizes) {
doc.addPage(new PDPage(size));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private PDFFile requestFor(String filename, byte[] pdfBytes) {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", filename, MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFFile request = new PDFFile();
request.setFileInput(file);
return request;
}
/**
* Stub the factory: load() returns a real PDDocument parsed from the PDFFile bytes, and
* createNewDocumentBasedOnOldDocument() returns a fresh empty document.
*/
private void setupFactory() throws IOException {
when(pdfDocumentFactory.load(any(PDFFile.class)))
.thenAnswer(
inv -> {
PDFFile pf = inv.getArgument(0);
return Loader.loadPDF(pf.getFileInput().getBytes());
});
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
}
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
@Nested
@DisplayName("Happy path")
class HappyPath {
@Test
@DisplayName("Multi-page PDF collapses to one tall page")
void multiPageCollapsesToSinglePage() throws Exception {
// Three A4 portrait pages.
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A4, PDRectangle.A4);
PDFFile request = requestFor("input.pdf", pdfBytes);
setupFactory();
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
byte[] out = drainBody(response);
assertTrue(out.length > 0, "output PDF must be non-empty");
try (PDDocument result = Loader.loadPDF(out)) {
assertEquals(1, result.getNumberOfPages(), "result must be a single page");
PDRectangle box = result.getPage(0).getMediaBox();
assertEquals(
PDRectangle.A4.getWidth(),
box.getWidth(),
0.5f,
"width matches the input width");
assertEquals(
PDRectangle.A4.getHeight() * 3,
box.getHeight(),
0.5f,
"height is the sum of all page heights");
}
}
@Test
@DisplayName("Single-page input produces a single page of the same size")
void singlePageInput() throws Exception {
byte[] pdfBytes = createPdf(PDRectangle.A4);
PDFFile request = requestFor("single.pdf", pdfBytes);
setupFactory();
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertEquals(200, response.getStatusCode().value());
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
assertEquals(1, result.getNumberOfPages());
PDRectangle box = result.getPage(0).getMediaBox();
assertEquals(PDRectangle.A4.getWidth(), box.getWidth(), 0.5f);
assertEquals(PDRectangle.A4.getHeight(), box.getHeight(), 0.5f);
}
}
@Test
@DisplayName("Mixed page sizes: width is the max, height is the sum")
void mixedPageSizes() throws Exception {
// A4 (595x842) + A3 (842x1191) -> width should be max (A3 width), height the sum.
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A3);
PDFFile request = requestFor("mixed.pdf", pdfBytes);
setupFactory();
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertEquals(200, response.getStatusCode().value());
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
assertEquals(1, result.getNumberOfPages());
PDRectangle box = result.getPage(0).getMediaBox();
assertEquals(PDRectangle.A3.getWidth(), box.getWidth(), 0.5f);
assertEquals(
PDRectangle.A4.getHeight() + PDRectangle.A3.getHeight(),
box.getHeight(),
0.5f);
}
}
@Test
@DisplayName("Landscape pages are handled (width from landscape, height summed)")
void landscapePages() throws Exception {
PDRectangle landscape = new PDRectangle(842, 595);
byte[] pdfBytes = createPdf(landscape, landscape);
PDFFile request = requestFor("landscape.pdf", pdfBytes);
setupFactory();
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertEquals(200, response.getStatusCode().value());
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
assertEquals(1, result.getNumberOfPages());
PDRectangle box = result.getPage(0).getMediaBox();
assertEquals(842f, box.getWidth(), 0.5f);
assertEquals(1190f, box.getHeight(), 0.5f);
}
}
}
@Nested
@DisplayName("Collaborator interactions")
class Collaborators {
@Test
@DisplayName("Source document is loaded from the request and then closed")
void loadsAndClosesSourceDocument() throws Exception {
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A4);
PDFFile request = requestFor("input.pdf", pdfBytes);
PDDocument sourceSpy = spy(Loader.loadPDF(pdfBytes));
when(pdfDocumentFactory.load(any(PDFFile.class))).thenReturn(sourceSpy);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
controller.pdfToSinglePage(request);
verify(pdfDocumentFactory).load(any(PDFFile.class));
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(any(PDDocument.class));
// try-with-resources must close the loaded source document.
verify(sourceSpy).close();
}
@Test
@DisplayName("A managed temp file is requested for the response body")
void requestsManagedTempFile() throws Exception {
byte[] pdfBytes = createPdf(PDRectangle.A4);
PDFFile request = requestFor("input.pdf", pdfBytes);
setupFactory();
controller.pdfToSinglePage(request);
verify(tempFileManager).createManagedTempFile(".pdf");
}
}
@Nested
@DisplayName("Filename handling")
class FilenameHandling {
@Test
@DisplayName("Original filename is reflected in the Content-Disposition header")
void filenameInContentDisposition() throws Exception {
byte[] pdfBytes = createPdf(PDRectangle.A4);
PDFFile request = requestFor("MyReport.pdf", pdfBytes);
setupFactory();
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
String disposition =
response.getHeaders()
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(disposition);
// generateFilename strips the extension and appends _singlePage.pdf
assertTrue(
disposition.contains("MyReport_singlePage.pdf"),
"disposition should carry the generated single-page filename: " + disposition);
}
@Test
@DisplayName("Null original filename does not throw and yields a default name")
void nullOriginalFilename() throws Exception {
byte[] pdfBytes = createPdf(PDRectangle.A4);
// MockMultipartFile with a null original filename.
MockMultipartFile file =
new MockMultipartFile(
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFFile request = new PDFFile();
request.setFileInput(file);
setupFactory();
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
String disposition =
response.getHeaders()
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(disposition);
assertTrue(
disposition.contains("_singlePage.pdf"),
"disposition should carry the empty-base single-page name: " + disposition);
}
}
@Nested
@DisplayName("Error branches")
class ErrorBranches {
@Test
@DisplayName("IOException from load() propagates to the caller")
void loadIOExceptionPropagates() throws Exception {
PDFFile request = requestFor("broken.pdf", new byte[] {1, 2, 3});
when(pdfDocumentFactory.load(any(PDFFile.class)))
.thenThrow(new IOException("cannot load"));
IOException ex =
assertThrows(IOException.class, () -> controller.pdfToSinglePage(request));
assertEquals("cannot load", ex.getMessage());
// No new document or temp file should be created when load fails.
verify(pdfDocumentFactory, never())
.createNewDocumentBasedOnOldDocument(any(PDDocument.class));
verifyNoInteractions(tempFileManager);
}
@Test
@DisplayName("IOException from temp file creation propagates")
void tempFileIOExceptionPropagates() throws Exception {
byte[] pdfBytes = createPdf(PDRectangle.A4);
PDFFile request = requestFor("input.pdf", pdfBytes);
setupFactory();
when(tempFileManager.createManagedTempFile(anyString()))
.thenThrow(new IOException("disk full"));
IOException ex =
assertThrows(IOException.class, () -> controller.pdfToSinglePage(request));
assertEquals("disk full", ex.getMessage());
}
}
}
@@ -0,0 +1,423 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UIDataControllerGapTest {
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.System system;
@Mock private ApplicationProperties.Legal legal;
@Mock private SharedSignatureService signatureService;
@Mock private UserServiceInterface userService;
@Mock private RuntimePathConfig runtimePathConfig;
private final ResourceLoader resourceLoader = new DefaultResourceLoader();
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private UIDataController controller(UserServiceInterface user) {
return new UIDataController(
applicationProperties,
signatureService,
user,
resourceLoader,
runtimePathConfig,
objectMapper);
}
@BeforeEach
void setUp() {
lenient().when(applicationProperties.getSystem()).thenReturn(system);
lenient().when(applicationProperties.getLegal()).thenReturn(legal);
}
@Nested
@DisplayName("getFooterData")
class FooterData {
@Test
@DisplayName("maps all legal and analytics fields onto the response body")
void mapsAllFields() {
when(system.getEnableAnalytics()).thenReturn(Boolean.TRUE);
when(legal.getTermsAndConditions()).thenReturn("https://terms");
when(legal.getPrivacyPolicy()).thenReturn("https://privacy");
when(legal.getAccessibilityStatement()).thenReturn("https://a11y");
when(legal.getCookiePolicy()).thenReturn("https://cookies");
when(legal.getImpressum()).thenReturn("https://impressum");
ResponseEntity<UIDataController.FooterData> response =
controller(userService).getFooterData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.FooterData body = response.getBody();
assertNotNull(body);
assertEquals(Boolean.TRUE, body.getAnalyticsEnabled());
assertEquals("https://terms", body.getTermsAndConditions());
assertEquals("https://privacy", body.getPrivacyPolicy());
assertEquals("https://a11y", body.getAccessibilityStatement());
assertEquals("https://cookies", body.getCookiePolicy());
assertEquals("https://impressum", body.getImpressum());
}
@Test
@DisplayName("propagates null/false values from configuration")
void handlesNulls() {
when(system.getEnableAnalytics()).thenReturn(Boolean.FALSE);
when(legal.getTermsAndConditions()).thenReturn(null);
when(legal.getPrivacyPolicy()).thenReturn(null);
when(legal.getAccessibilityStatement()).thenReturn(null);
when(legal.getCookiePolicy()).thenReturn(null);
when(legal.getImpressum()).thenReturn(null);
ResponseEntity<UIDataController.FooterData> response =
controller(userService).getFooterData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.FooterData body = response.getBody();
assertNotNull(body);
assertEquals(Boolean.FALSE, body.getAnalyticsEnabled());
assertNull(body.getTermsAndConditions());
assertNull(body.getPrivacyPolicy());
assertNull(body.getAccessibilityStatement());
assertNull(body.getCookiePolicy());
assertNull(body.getImpressum());
}
}
@Nested
@DisplayName("getHomeData")
class HomeData {
@Test
@DisplayName("returns OK with a populated body regardless of survey env var")
void returnsOk() {
ResponseEntity<UIDataController.HomeData> response =
controller(userService).getHomeData();
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
// SHOW_SURVEY is unset in the test JVM, so the default (true) applies.
assertTrue(response.getBody().isShowSurveyFromDocker());
}
}
@Nested
@DisplayName("getLicensesData")
class LicensesData {
@Test
@DisplayName("loads the bundled 3rdPartyLicenses.json from the classpath")
void loadsDependencies() {
ResponseEntity<UIDataController.LicensesData> response =
controller(userService).getLicensesData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.LicensesData body = response.getBody();
assertNotNull(body);
List<Dependency> deps = body.getDependencies();
assertNotNull(deps);
assertFalse(deps.isEmpty());
// Each parsed dependency should at least carry a module name.
assertNotNull(deps.get(0).getModuleName());
}
}
@Nested
@DisplayName("getPipelineData")
class PipelineData {
@Test
@DisplayName("returns the placeholder entry when the config directory is missing")
void missingDirectoryYieldsPlaceholder() {
String missing = Path.of("nonexistent-pipeline-dir-" + UUID.randomUUID()).toString();
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(missing);
ResponseEntity<UIDataController.PipelineData> response =
controller(userService).getPipelineData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.PipelineData body = response.getBody();
assertNotNull(body);
assertTrue(body.getPipelineConfigs().isEmpty());
assertEquals(1, body.getPipelineConfigsWithNames().size());
Map<String, String> placeholder = body.getPipelineConfigsWithNames().get(0);
assertEquals("", placeholder.get("json"));
assertEquals("No preloaded configs found", placeholder.get("name"));
}
@Test
@DisplayName("uses the embedded name field when present")
void readsConfigWithName(@TempDir Path dir) throws Exception {
Files.writeString(
dir.resolve("config1.json"),
"{\"name\":\"My Pipeline\",\"operations\":[]}",
StandardCharsets.UTF_8);
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
ResponseEntity<UIDataController.PipelineData> response =
controller(userService).getPipelineData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.PipelineData body = response.getBody();
assertNotNull(body);
assertEquals(1, body.getPipelineConfigs().size());
assertEquals(1, body.getPipelineConfigsWithNames().size());
assertEquals("My Pipeline", body.getPipelineConfigsWithNames().get(0).get("name"));
assertTrue(
body.getPipelineConfigsWithNames().get(0).get("json").contains("My Pipeline"));
}
@Test
@DisplayName("falls back to the filename (sans extension) when name is missing")
void fallsBackToFilenameWhenNameMissing(@TempDir Path dir) throws Exception {
Files.writeString(
dir.resolve("fallback-name.json"),
"{\"operations\":[]}",
StandardCharsets.UTF_8);
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
ResponseEntity<UIDataController.PipelineData> response =
controller(userService).getPipelineData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.PipelineData body = response.getBody();
assertNotNull(body);
assertEquals(1, body.getPipelineConfigsWithNames().size());
assertEquals("fallback-name", body.getPipelineConfigsWithNames().get(0).get("name"));
}
@Test
@DisplayName("falls back to the filename when name is blank")
void fallsBackToFilenameWhenNameBlank(@TempDir Path dir) throws Exception {
Files.writeString(
dir.resolve("blank-name.json"),
"{\"name\":\"\",\"operations\":[]}",
StandardCharsets.UTF_8);
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
ResponseEntity<UIDataController.PipelineData> response =
controller(userService).getPipelineData();
UIDataController.PipelineData body = response.getBody();
assertNotNull(body);
assertEquals("blank-name", body.getPipelineConfigsWithNames().get(0).get("name"));
}
@Test
@DisplayName("ignores non-json files in the config directory")
void ignoresNonJsonFiles(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("notes.txt"), "ignore me", StandardCharsets.UTF_8);
Files.writeString(
dir.resolve("real.json"), "{\"name\":\"Real\"}", StandardCharsets.UTF_8);
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
ResponseEntity<UIDataController.PipelineData> response =
controller(userService).getPipelineData();
UIDataController.PipelineData body = response.getBody();
assertNotNull(body);
assertEquals(1, body.getPipelineConfigs().size());
assertEquals(1, body.getPipelineConfigsWithNames().size());
assertEquals("Real", body.getPipelineConfigsWithNames().get(0).get("name"));
}
@Test
@DisplayName("returns the placeholder when the directory exists but holds no json")
void emptyDirectoryYieldsPlaceholder(@TempDir Path dir) {
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
ResponseEntity<UIDataController.PipelineData> response =
controller(userService).getPipelineData();
UIDataController.PipelineData body = response.getBody();
assertNotNull(body);
assertTrue(body.getPipelineConfigs().isEmpty());
assertEquals(1, body.getPipelineConfigsWithNames().size());
assertEquals(
"No preloaded configs found",
body.getPipelineConfigsWithNames().get(0).get("name"));
}
}
@Nested
@DisplayName("getSignData")
class SignData {
@Test
@DisplayName("uses the current username from the user service to fetch signatures")
void usesUsernameFromUserService() {
when(userService.getCurrentUsername()).thenReturn("alice");
List<SignatureFile> sigs = List.of(new SignatureFile("sig.png", "Personal"));
when(signatureService.getAvailableSignatures("alice")).thenReturn(sigs);
ResponseEntity<UIDataController.SignData> response =
controller(userService).getSignData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.SignData body = response.getBody();
assertNotNull(body);
assertEquals(sigs, body.getSignatures());
// Fonts come from the real resource loader; the list is never null.
assertNotNull(body.getFonts());
verify(userService).getCurrentUsername();
verify(signatureService).getAvailableSignatures("alice");
}
@Test
@DisplayName("falls back to an empty username when no user service is wired")
void nullUserServiceUsesEmptyUsername() {
when(signatureService.getAvailableSignatures("")).thenReturn(List.of());
ResponseEntity<UIDataController.SignData> response = controller(null).getSignData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.SignData body = response.getBody();
assertNotNull(body);
assertNotNull(body.getSignatures());
assertNotNull(body.getFonts());
verify(signatureService).getAvailableSignatures("");
}
}
@Nested
@DisplayName("getOcrPdfData")
class OcrData {
@Test
@DisplayName("returns an empty language list when the tessdata directory is absent")
void absentTessdataDirYieldsEmptyList() {
when(runtimePathConfig.getTessDataPath())
.thenReturn(Path.of("nonexistent-tessdata-" + UUID.randomUUID()).toString());
ResponseEntity<UIDataController.OcrData> response =
controller(userService).getOcrPdfData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.OcrData body = response.getBody();
assertNotNull(body);
assertNotNull(body.getLanguages());
assertTrue(body.getLanguages().isEmpty());
}
@Test
@DisplayName("lists trained languages, excludes osd, and sorts alphabetically")
void listsAndSortsTrainedLanguages(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("eng.traineddata"), "x", StandardCharsets.UTF_8);
Files.writeString(dir.resolve("deu.traineddata"), "x", StandardCharsets.UTF_8);
Files.writeString(dir.resolve("osd.traineddata"), "x", StandardCharsets.UTF_8);
// Non-traineddata files must be ignored.
Files.writeString(dir.resolve("readme.txt"), "x", StandardCharsets.UTF_8);
when(runtimePathConfig.getTessDataPath()).thenReturn(dir.toString());
ResponseEntity<UIDataController.OcrData> response =
controller(userService).getOcrPdfData();
assertEquals(HttpStatus.OK, response.getStatusCode());
UIDataController.OcrData body = response.getBody();
assertNotNull(body);
// osd filtered out; remaining sorted alphabetically.
assertEquals(List.of("deu", "eng"), body.getLanguages());
}
@Test
@DisplayName("excludes osd case-insensitively")
void excludesOsdCaseInsensitively(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("OSD.traineddata"), "x", StandardCharsets.UTF_8);
Files.writeString(dir.resolve("fra.traineddata"), "x", StandardCharsets.UTF_8);
when(runtimePathConfig.getTessDataPath()).thenReturn(dir.toString());
ResponseEntity<UIDataController.OcrData> response =
controller(userService).getOcrPdfData();
UIDataController.OcrData body = response.getBody();
assertNotNull(body);
assertEquals(List.of("fra"), body.getLanguages());
assertFalse(body.getLanguages().contains("OSD"));
}
}
@Nested
@DisplayName("FontResource format mapping")
class FontResourceMapping {
@Test
@DisplayName("maps known extensions to their CSS font-format strings")
void mapsKnownExtensions() {
assertEquals("truetype", new UIDataController.FontResource("Arial", "ttf").getType());
assertEquals("woff", new UIDataController.FontResource("Arial", "woff").getType());
assertEquals("woff2", new UIDataController.FontResource("Arial", "woff2").getType());
assertEquals(
"embedded-opentype",
new UIDataController.FontResource("Arial", "eot").getType());
assertEquals("svg", new UIDataController.FontResource("Arial", "svg").getType());
}
@Test
@DisplayName("maps unknown extensions to an empty type and preserves name/extension")
void mapsUnknownExtensionToEmpty() {
UIDataController.FontResource resource =
new UIDataController.FontResource("Arial", "otf");
assertEquals("", resource.getType());
assertEquals("Arial", resource.getName());
assertEquals("otf", resource.getExtension());
}
}
@Nested
@DisplayName("Cross-cutting behaviour")
class CrossCutting {
@Test
@DisplayName("getFooterData never touches the signature or user services")
void footerDataIsIndependentOfUserState() {
when(system.getEnableAnalytics()).thenReturn(null);
controller(userService).getFooterData();
verify(signatureService, never())
.getAvailableSignatures(org.mockito.ArgumentMatchers.anyString());
verify(userService, never()).getCurrentUsername();
}
}
}
@@ -0,0 +1,826 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.ImageType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertCbrToPdfRequest;
import stirling.software.SPDF.model.api.converters.ConvertCbzToPdfRequest;
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbrRequest;
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbzRequest;
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CbrUtils;
import stirling.software.common.util.CbzUtils;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfToCbrUtils;
import stirling.software.common.util.PdfToCbzUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Gap coverage for {@link ConvertImgPDFController}, exercising the comic-book and image converters
* left untested by ConvertImgPDFControllerTest. External binaries (Ghostscript, Python, RAR) are
* never invoked: the utility boundaries are mocked statically.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertImgPDFControllerGapTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertImgPDFController controller;
/** Builds a tiny, valid single-page A4 PDF as bytes. */
private static byte[] tinyPdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
doc.save(baos);
return baos.toByteArray();
}
}
/** Builds a fresh in-memory PDDocument (the factory must hand back a real, open document). */
private static PDDocument tinyDocument(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
private static MockMultipartFile pdfFile(String name, byte[] bytes) {
return new MockMultipartFile("fileInput", name, "application/pdf", bytes);
}
@Nested
@DisplayName("convertCbzToPdf")
class ConvertCbzToPdf {
@Test
@DisplayName("disables ebook optimization when Ghostscript is not enabled")
void disablesOptimizationWhenGhostscriptMissing() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "book.cbz", "application/zip", new byte[] {1});
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
request.setFileInput(file);
request.setOptimizeForEbook(true);
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
TempFile tempFile = Mockito.mock(TempFile.class);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
cbz.when(
() ->
CbzUtils.convertCbzToPdf(
eq(file),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(false)))
.thenReturn(tempFile);
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
.thenReturn("book_converted.pdf");
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(tempFile, "book_converted.pdf"))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
assertSame(expected, response);
cbz.verify(
() ->
CbzUtils.convertCbzToPdf(
eq(file),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(false)));
}
}
@Test
@DisplayName("keeps ebook optimization when Ghostscript is enabled")
void keepsOptimizationWhenGhostscriptEnabled() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "book.cbz", "application/zip", new byte[] {1});
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
request.setFileInput(file);
request.setOptimizeForEbook(true);
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
TempFile tempFile = Mockito.mock(TempFile.class);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
cbz.when(
() ->
CbzUtils.convertCbzToPdf(
eq(file),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(true)))
.thenReturn(tempFile);
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
.thenReturn("book_converted.pdf");
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(tempFile, "book_converted.pdf"))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
assertSame(expected, response);
cbz.verify(
() ->
CbzUtils.convertCbzToPdf(
eq(file),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(true)));
}
}
@Test
@DisplayName("falls back to the default comic name when the original filename is null")
void usesDefaultNameWhenFilenameNull() throws Exception {
MockMultipartFile file =
new MockMultipartFile("fileInput", null, "application/zip", new byte[] {1});
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
request.setFileInput(file);
request.setOptimizeForEbook(false);
TempFile tempFile = Mockito.mock(TempFile.class);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
.thenReturn(tempFile);
gu.when(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"))
.thenReturn("comic_converted.pdf");
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
tempFile, "comic_converted.pdf"))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
assertSame(expected, response);
// Default comic name is resolved when the upload carries no filename.
gu.verify(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"));
}
}
}
@Nested
@DisplayName("convertCbrToPdf")
class ConvertCbrToPdf {
@Test
@DisplayName("disables ebook optimization when Ghostscript is not enabled")
void disablesOptimizationWhenGhostscriptMissing() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "book.cbr", "application/x-rar", new byte[] {1});
ConvertCbrToPdfRequest request = new ConvertCbrToPdfRequest();
request.setFileInput(file);
request.setOptimizeForEbook(true);
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
byte[] pdfBytes = "converted-pdf".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
try (MockedStatic<CbrUtils> cbr = Mockito.mockStatic(CbrUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
cbr.when(
() ->
CbrUtils.convertCbrToPdf(
eq(file),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(false)))
.thenReturn(pdfBytes);
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
.thenReturn("book_converted.pdf");
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "book_converted.pdf"))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertCbrToPdf(request);
assertSame(expected, response);
cbr.verify(
() ->
CbrUtils.convertCbrToPdf(
eq(file),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(false)));
}
}
@Test
@DisplayName("keeps ebook optimization when Ghostscript is enabled")
void keepsOptimizationWhenGhostscriptEnabled() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "story.cbr", "application/x-rar", new byte[] {1});
ConvertCbrToPdfRequest request = new ConvertCbrToPdfRequest();
request.setFileInput(file);
request.setOptimizeForEbook(true);
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
byte[] pdfBytes = "converted-pdf".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
try (MockedStatic<CbrUtils> cbr = Mockito.mockStatic(CbrUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
cbr.when(
() ->
CbrUtils.convertCbrToPdf(
eq(file),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(true)))
.thenReturn(pdfBytes);
gu.when(() -> GeneralUtils.generateFilename("story", "_converted.pdf"))
.thenReturn("story_converted.pdf");
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "story_converted.pdf"))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertCbrToPdf(request);
assertSame(expected, response);
}
}
}
@Nested
@DisplayName("convertPdfToCbz")
class ConvertPdfToCbz {
@Test
@DisplayName("passes the requested DPI through and returns a zip response")
void passesThroughRequestedDpi() throws Exception {
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
ConvertPdfToCbzRequest request = new ConvertPdfToCbzRequest();
request.setFileInput(file);
request.setDpi(200);
TempFile cbzFile = Mockito.mock(TempFile.class);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<PdfToCbzUtils> p2c = Mockito.mockStatic(PdfToCbzUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
p2c.when(
() ->
PdfToCbzUtils.convertPdfToCbz(
eq(file),
eq(200),
eq(pdfDocumentFactory),
eq(tempFileManager)))
.thenReturn(cbzFile);
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbz"))
.thenReturn("doc_converted.cbz");
wr.when(() -> WebResponseUtils.zipFileToWebResponse(cbzFile, "doc_converted.cbz"))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.convertPdfToCbz(request);
assertSame(expected, response);
}
}
@Test
@DisplayName("defaults DPI to 300 when a non-positive value is supplied")
void defaultsDpiWhenNonPositive() throws Exception {
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
ConvertPdfToCbzRequest request = new ConvertPdfToCbzRequest();
request.setFileInput(file);
request.setDpi(0);
TempFile cbzFile = Mockito.mock(TempFile.class);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<PdfToCbzUtils> p2c = Mockito.mockStatic(PdfToCbzUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
p2c.when(
() ->
PdfToCbzUtils.convertPdfToCbz(
eq(file),
eq(300),
eq(pdfDocumentFactory),
eq(tempFileManager)))
.thenReturn(cbzFile);
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbz"))
.thenReturn("doc_converted.cbz");
wr.when(() -> WebResponseUtils.zipFileToWebResponse(cbzFile, "doc_converted.cbz"))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.convertPdfToCbz(request);
assertSame(expected, response);
// Negative/zero DPI is replaced by the 300 default before delegating.
p2c.verify(
() ->
PdfToCbzUtils.convertPdfToCbz(
eq(file),
eq(300),
eq(pdfDocumentFactory),
eq(tempFileManager)));
}
}
}
@Nested
@DisplayName("convertPdfToCbr")
class ConvertPdfToCbr {
@Test
@DisplayName("passes the requested DPI and wraps bytes as an octet-stream response")
void passesThroughRequestedDpi() throws Exception {
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
ConvertPdfToCbrRequest request = new ConvertPdfToCbrRequest();
request.setFileInput(file);
request.setDpi(150);
byte[] cbrBytes = "cbr-archive".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(cbrBytes);
try (MockedStatic<PdfToCbrUtils> p2c = Mockito.mockStatic(PdfToCbrUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
p2c.when(
() ->
PdfToCbrUtils.convertPdfToCbr(
eq(file), eq(150), eq(pdfDocumentFactory)))
.thenReturn(cbrBytes);
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbr"))
.thenReturn("doc_converted.cbr");
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(cbrBytes),
eq("doc_converted.cbr"),
eq(MediaType.APPLICATION_OCTET_STREAM)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertPdfToCbr(request);
assertSame(expected, response);
}
}
@Test
@DisplayName("defaults DPI to 300 when a non-positive value is supplied")
void defaultsDpiWhenNonPositive() throws Exception {
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
ConvertPdfToCbrRequest request = new ConvertPdfToCbrRequest();
request.setFileInput(file);
request.setDpi(-5);
byte[] cbrBytes = "cbr-archive".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(cbrBytes);
try (MockedStatic<PdfToCbrUtils> p2c = Mockito.mockStatic(PdfToCbrUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
p2c.when(
() ->
PdfToCbrUtils.convertPdfToCbr(
eq(file), eq(300), eq(pdfDocumentFactory)))
.thenReturn(cbrBytes);
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbr"))
.thenReturn("doc_converted.cbr");
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(cbrBytes),
eq("doc_converted.cbr"),
eq(MediaType.APPLICATION_OCTET_STREAM)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertPdfToCbr(request);
assertSame(expected, response);
p2c.verify(
() ->
PdfToCbrUtils.convertPdfToCbr(
eq(file), eq(300), eq(pdfDocumentFactory)));
}
}
}
@Nested
@DisplayName("convertToImage")
class ConvertToImage {
private MockMultipartFile imagePdf(byte[] bytes) {
return pdfFile("source.pdf", bytes);
}
@Test
@DisplayName("single-image PNG path returns the rendered bytes")
void singleImagePng() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
MockMultipartFile file = imagePdf(pdfBytes);
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(file);
request.setImageFormat("png");
request.setSingleOrMultiple("single");
request.setColorType("color");
request.setDpi(72);
request.setPageNumbers("all");
request.setIncludeAnnotations(false);
// rearrangePdfPages loads a real document; convertFromPdf is the boundary we stub.
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
byte[] imageBytes = "png-image".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("PNG"),
eq(ImageType.RGB),
eq(true),
eq(72),
any(String.class),
eq(false)))
.thenReturn(imageBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(imageBytes),
any(String.class),
any(MediaType.class)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertSame(expected, response);
}
}
@Test
@DisplayName("multiple-image path zips the rendered output with octet-stream type")
void multipleImagesZip() throws Exception {
byte[] pdfBytes = tinyPdfBytes(2);
MockMultipartFile file = imagePdf(pdfBytes);
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(file);
request.setImageFormat("jpg");
request.setSingleOrMultiple("multiple");
request.setColorType("greyscale");
request.setDpi(72);
request.setPageNumbers("all");
request.setIncludeAnnotations(true);
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(2));
byte[] zipBytes = "zip-bytes".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(zipBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
// greyscale -> ImageType.GRAY, multiple -> singleImage=false
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("JPG"),
eq(ImageType.GRAY),
eq(false),
eq(72),
any(String.class),
eq(true)))
.thenReturn(zipBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(zipBytes),
any(String.class),
eq(MediaType.APPLICATION_OCTET_STREAM)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertSame(expected, response);
}
}
@Test
@DisplayName("blackwhite color type maps to BINARY image type")
void blackwhiteMapsToBinary() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
MockMultipartFile file = imagePdf(pdfBytes);
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(file);
request.setImageFormat("png");
request.setSingleOrMultiple("single");
request.setColorType("blackwhite");
request.setDpi(72);
request.setPageNumbers("all");
request.setIncludeAnnotations(false);
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
byte[] imageBytes = "bw-image".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("PNG"),
eq(ImageType.BINARY),
eq(true),
eq(72),
any(String.class),
eq(false)))
.thenReturn(imageBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(imageBytes),
any(String.class),
any(MediaType.class)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertSame(expected, response);
}
}
@Test
@DisplayName("null page numbers fall back to all pages")
void nullPageNumbersDefaultsToAll() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
MockMultipartFile file = imagePdf(pdfBytes);
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(file);
request.setImageFormat("png");
request.setSingleOrMultiple("single");
request.setColorType("color");
request.setDpi(72);
request.setPageNumbers(null);
request.setIncludeAnnotations(null);
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
byte[] imageBytes = "png-image".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
// includeAnnotations null -> false
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("PNG"),
eq(ImageType.RGB),
eq(true),
eq(72),
any(String.class),
eq(false)))
.thenReturn(imageBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(imageBytes),
any(String.class),
any(MediaType.class)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertSame(expected, response);
}
}
@Test
@DisplayName("webp requested without Python throws the python-required IOException")
void webpWithoutPythonThrows() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
MockMultipartFile file = imagePdf(pdfBytes);
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(file);
request.setImageFormat("webp");
request.setSingleOrMultiple("single");
request.setColorType("color");
request.setDpi(72);
request.setPageNumbers("all");
request.setIncludeAnnotations(false);
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<CheckProgramInstall> cpi =
Mockito.mockStatic(CheckProgramInstall.class)) {
// webp renders to PNG first, then requires Python for the final conversion.
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("png"),
any(ImageType.class),
anyBoolean(),
anyInt(),
any(String.class),
anyBoolean()))
.thenReturn("png-image".getBytes());
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
assertThrows(IOException.class, () -> controller.convertToImage(request));
}
}
}
@Nested
@DisplayName("createConvertedFilename (via the comic converters)")
class CreateConvertedFilename {
@Test
@DisplayName("strips only the trailing extension from a multi-dot filename")
void stripsTrailingExtensionOnly() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "my.archive.cbz", "application/zip", new byte[] {1});
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
request.setFileInput(file);
request.setOptimizeForEbook(false);
TempFile tempFile = Mockito.mock(TempFile.class);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
.thenReturn(tempFile);
gu.when(() -> GeneralUtils.generateFilename("my.archive", "_converted.pdf"))
.thenReturn("my.archive_converted.pdf");
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
tempFile, "my.archive_converted.pdf"))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
assertSame(expected, response);
// Only the final ".cbz" is removed, the inner dot is preserved.
gu.verify(() -> GeneralUtils.generateFilename("my.archive", "_converted.pdf"));
}
}
@Test
@DisplayName("uses the default comic name when stripping leaves a blank base name")
void fallsBackToComicWhenBaseNameBlank() throws Exception {
// ".cbz" strips to an empty base, which the controller replaces with "comic".
MockMultipartFile file =
new MockMultipartFile("fileInput", ".cbz", "application/zip", new byte[] {1});
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
request.setFileInput(file);
request.setOptimizeForEbook(false);
TempFile tempFile = Mockito.mock(TempFile.class);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
.thenReturn(tempFile);
gu.when(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"))
.thenReturn("comic_converted.pdf");
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
tempFile, "comic_converted.pdf"))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
assertSame(expected, response);
gu.verify(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"));
}
}
}
@Test
@DisplayName("controller is constructed with its injected collaborators")
void controllerIsConstructed() {
assertNotNull(controller);
assertEquals(0, 0);
}
}
@@ -0,0 +1,869 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.TempFileManager;
/**
* Gap-filling unit tests for {@link ConvertPDFToPDFA}. Focuses on validation/option/error branches
* and the small pure helpers, mocking the collaborators so that no external binary (ghostscript,
* qpdf, libreoffice) or network is invoked.
*/
@DisplayName("ConvertPDFToPDFA gap tests")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertPDFToPDFAGapTest {
@TempDir Path tempDir;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private VeraPDFService veraPDFService;
@Mock private TempFileManager tempFileManager;
private ConvertPDFToPDFA newController() {
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
}
// ---- reflection helpers ----------------------------------------------------------------
@SuppressWarnings("unchecked")
private static <T> T invokeStatic(String methodName, Object... args) throws Exception {
Method method = findMethod(methodName, args.length);
method.setAccessible(true);
try {
return (T) method.invoke(null, args);
} catch (InvocationTargetException e) {
throw unwrap(e);
}
}
@SuppressWarnings("unchecked")
private static <T> T invokeInstance(Object target, String methodName, Object... args)
throws Exception {
Method method = findMethod(methodName, args.length);
method.setAccessible(true);
try {
return (T) method.invoke(target, args);
} catch (InvocationTargetException e) {
throw unwrap(e);
}
}
private static Method findMethod(String methodName, int argCount) {
for (Method method : ConvertPDFToPDFA.class.getDeclaredMethods()) {
if (method.getName().equals(methodName) && method.getParameterCount() == argCount) {
return method;
}
}
throw new IllegalStateException(
"No method named " + methodName + " with " + argCount + " params");
}
private static Exception unwrap(InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof Exception ex) {
return ex;
}
return new RuntimeException(cause);
}
// ---- pdf builders ----------------------------------------------------------------------
private PDDocument simplePdf() throws IOException {
PDDocument document = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(100, 700);
cs.showText("hello world");
cs.endText();
}
return document;
}
private byte[] simplePdfBytes() throws IOException {
try (PDDocument document = simplePdf()) {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
}
// =======================================================================================
@Nested
@DisplayName("PdfaProfile.fromRequest token resolution")
class PdfaProfileResolution {
// invokes the enum's static fromRequest via reflection, then reads getPart()/displayName
private Object resolveProfile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfaProfile")) {
enumClass = inner;
}
}
assertThat(enumClass).isNotNull();
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
private int partOf(Object profile) throws Exception {
Method getPart = profile.getClass().getDeclaredMethod("getPart");
getPart.setAccessible(true);
return (int) getPart.invoke(profile);
}
private String suffixOf(Object profile) throws Exception {
Method m = profile.getClass().getDeclaredMethod("outputSuffix");
m.setAccessible(true);
return (String) m.invoke(profile);
}
@Test
@DisplayName("null token defaults to PDF/A-2b")
void nullDefaultsToPdfA2() throws Exception {
Object profile = resolveProfile(null);
assertThat(partOf(profile)).isEqualTo(2);
assertThat(suffixOf(profile)).isEqualTo("_PDFA-2b.pdf");
}
@Test
@DisplayName("'pdfa-1' resolves to PDF/A-1b")
void pdfa1Resolves() throws Exception {
assertThat(partOf(resolveProfile("pdfa-1"))).isEqualTo(1);
assertThat(suffixOf(resolveProfile("pdfa-1"))).isEqualTo("_PDFA-1b.pdf");
}
@Test
@DisplayName("'pdfa' and 'pdfa-2b' resolve to PDF/A-2b")
void pdfa2Resolves() throws Exception {
assertThat(partOf(resolveProfile("pdfa"))).isEqualTo(2);
assertThat(partOf(resolveProfile("pdfa-2b"))).isEqualTo(2);
}
@Test
@DisplayName("'pdfa-3' and 'pdfa-3b' resolve to PDF/A-3b")
void pdfa3Resolves() throws Exception {
assertThat(partOf(resolveProfile("pdfa-3"))).isEqualTo(3);
assertThat(partOf(resolveProfile("pdfa-3b"))).isEqualTo(3);
assertThat(suffixOf(resolveProfile("pdfa-3"))).isEqualTo("_PDFA-3b.pdf");
}
@Test
@DisplayName("token is trimmed and case-insensitive")
void caseInsensitiveAndTrimmed() throws Exception {
assertThat(partOf(resolveProfile(" PDFA-1 "))).isEqualTo(1);
assertThat(partOf(resolveProfile("PDFA-3B"))).isEqualTo(3);
}
@Test
@DisplayName("unknown token falls back to PDF/A-2b")
void unknownFallsBack() throws Exception {
assertThat(partOf(resolveProfile("not-a-real-format"))).isEqualTo(2);
}
}
// =======================================================================================
@Nested
@DisplayName("PdfXProfile.fromRequest token resolution")
class PdfXProfileResolution {
private Object resolveProfile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfXProfile")) {
enumClass = inner;
}
}
assertThat(enumClass).isNotNull();
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
private String suffixOf(Object profile) throws Exception {
Method m = profile.getClass().getDeclaredMethod("outputSuffix");
m.setAccessible(true);
return (String) m.invoke(profile);
}
@Test
@DisplayName("null token defaults to PDF/X")
void nullDefaultsToPdfX() throws Exception {
assertThat(suffixOf(resolveProfile(null))).isEqualTo("_PDFX.pdf");
}
@Test
@DisplayName("'pdfx' resolves to the PDF/X profile")
void pdfxResolves() throws Exception {
assertThat(suffixOf(resolveProfile("pdfx"))).isEqualTo("_PDFX.pdf");
}
@Test
@DisplayName("unknown token falls back to PDF/X")
void unknownFallsBack() throws Exception {
assertThat(suffixOf(resolveProfile("garbage"))).isEqualTo("_PDFX.pdf");
}
}
// =======================================================================================
@Nested
@DisplayName("detectMimeTypeFromFilename")
class MimeTypeDetection {
private String detect(String fileName) throws Exception {
return invokeInstance(newController(), "detectMimeTypeFromFilename", fileName);
}
@Test
@DisplayName("known extensions map to their MIME type")
void knownExtensions() throws Exception {
assertThat(detect("data.xml")).isEqualTo("application/xml");
assertThat(detect("data.json")).isEqualTo("application/json");
assertThat(detect("notes.txt")).isEqualTo("text/plain");
assertThat(detect("image.png")).isEqualTo("image/png");
assertThat(detect("photo.JPEG")).isEqualTo("image/jpeg");
assertThat(detect("archive.zip")).isEqualTo("application/zip");
}
@Test
@DisplayName("unknown extension yields octet-stream default")
void unknownExtension() throws Exception {
assertThat(detect("file.unknownext")).isEqualTo("application/octet-stream");
}
@Test
@DisplayName("null and empty file names yield octet-stream default")
void nullAndEmpty() throws Exception {
assertThat(detect(null)).isEqualTo("application/octet-stream");
assertThat(detect("")).isEqualTo("application/octet-stream");
}
}
// =======================================================================================
@Nested
@DisplayName("countGlyphs / buildStandardType1GlyphSet")
class GlyphHelpers {
@Test
@DisplayName("countGlyphs counts forward slashes")
void countsSlashes() throws Exception {
assertThat((int) invokeStatic("countGlyphs", "/a/b/c")).isEqualTo(3);
assertThat((int) invokeStatic("countGlyphs", "no-slashes")).isEqualTo(0);
}
@Test
@DisplayName("countGlyphs handles null and empty")
void countsNullEmpty() throws Exception {
assertThat((int) invokeStatic("countGlyphs", (Object) null)).isEqualTo(0);
assertThat((int) invokeStatic("countGlyphs", "")).isEqualTo(0);
}
@Test
@DisplayName("standard glyph set is space-separated and contains core glyphs")
void standardGlyphSet() throws Exception {
String glyphs = invokeStatic("buildStandardType1GlyphSet");
assertThat(glyphs).isNotBlank();
assertThat(glyphs).contains(".notdef", "space", "A", "z", "zero", "period");
// space-separated; no leading slash format here
assertThat(glyphs.split(" ").length).isGreaterThan(100);
}
}
// =======================================================================================
@Nested
@DisplayName("isType1Font / quad and rect validation")
class TypeAndGeometryHelpers {
@Test
@DisplayName("isType1Font true for Standard14 Type1 font")
void isType1True() throws Exception {
PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
assertThat((boolean) invokeStatic("isType1Font", font)).isTrue();
}
@Test
@DisplayName("isValidQuadPoints accepts multiples of 8, rejects otherwise")
void quadValidation() throws Exception {
ConvertPDFToPDFA controller = newController();
assertThat(
(boolean)
invokeInstance(
controller, "isValidQuadPoints", (Object) new float[8]))
.isTrue();
assertThat(
(boolean)
invokeInstance(
controller,
"isValidQuadPoints",
(Object) new float[16]))
.isTrue();
assertThat(
(boolean)
invokeInstance(
controller, "isValidQuadPoints", (Object) new float[5]))
.isFalse();
assertThat((boolean) invokeInstance(controller, "isValidQuadPoints", (Object) null))
.isFalse();
}
@Test
@DisplayName("isZeroSizeRect distinguishes collapsed and real rectangles")
void zeroSizeRect() throws Exception {
ConvertPDFToPDFA controller = newController();
PDRectangle zero = new PDRectangle(10f, 10f, 0f, 0f);
PDRectangle real = new PDRectangle(0f, 0f, 100f, 50f);
assertThat((boolean) invokeInstance(controller, "isZeroSizeRect", zero)).isTrue();
assertThat((boolean) invokeInstance(controller, "isZeroSizeRect", real)).isFalse();
}
}
// =======================================================================================
@Nested
@DisplayName("findUnembeddedFontNames")
class UnembeddedFontDetection {
@Test
@DisplayName("standard 14 fonts (not embedded) are reported as missing")
void detectsStandardFontAsUnembedded() throws Exception {
try (PDDocument document = simplePdf()) {
Set<String> missing = invokeStatic("findUnembeddedFontNames", document);
assertThat(missing).isNotNull();
assertThat(missing).anyMatch(name -> name.contains("Helvetica"));
}
}
@Test
@DisplayName("page with no resources reports no missing fonts")
void pageWithoutResources() throws Exception {
try (PDDocument document = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
page.setResources(new PDResources());
document.addPage(page);
Set<String> missing = invokeStatic("findUnembeddedFontNames", document);
assertThat(missing).isEmpty();
}
}
}
// =======================================================================================
@Nested
@DisplayName("detectTransparentXObjects")
class TransparentXObjectDetection {
@Test
@DisplayName("page with no resources returns empty set")
void noResources() throws Exception {
PDPage page = new PDPage(PDRectangle.A4);
Set<COSName> result = invokeStatic("detectTransparentXObjects", page);
assertThat(result).isEmpty();
}
@Test
@DisplayName("page with empty resources returns empty set")
void emptyResources() throws Exception {
PDPage page = new PDPage(PDRectangle.A4);
page.setResources(new PDResources());
Set<COSName> result = invokeStatic("detectTransparentXObjects", page);
assertThat(result).isEmpty();
}
}
// =======================================================================================
@Nested
@DisplayName("sanitizePdfA part-specific behaviour")
class SanitizePdfAExtras {
@Test
@DisplayName("PDF/A-3 preserves embedded-file structures (FILESPEC type)")
void pdfA3PreservesFilespec() throws Exception {
COSDictionary dict = new COSDictionary();
dict.setItem(COSName.TYPE, COSName.FILESPEC);
dict.setItem(COSName.EF, new COSDictionary());
dict.setItem(COSName.URI, COSName.A);
invokeStatic("sanitizePdfA", dict, 3);
// For part 3, filespec dictionaries are skipped entirely, so URI survives.
assertThat(dict.containsKey(COSName.EF)).isTrue();
assertThat(dict.containsKey(COSName.URI)).isTrue();
}
@Test
@DisplayName("PDF/A-3 keeps URI on a normal dictionary but still strips JavaScript")
void pdfA3KeepsUriStripsJs() throws Exception {
COSDictionary dict = new COSDictionary();
dict.setString(COSName.URI, "http://example.com");
dict.setString(COSName.JAVA_SCRIPT, "app.alert('x');");
invokeStatic("sanitizePdfA", dict, 3);
assertThat(dict.containsKey(COSName.URI)).isTrue();
assertThat(dict.containsKey(COSName.JAVA_SCRIPT)).isFalse();
}
@Test
@DisplayName("recurses into nested arrays and dictionaries")
void recursesIntoNested() throws Exception {
COSDictionary child = new COSDictionary();
child.setString(COSName.JAVA_SCRIPT, "code");
COSArray array = new COSArray();
array.add(child);
COSDictionary parent = new COSDictionary();
parent.setItem(COSName.getPDFName("Kids"), array);
invokeStatic("sanitizePdfA", parent, 2);
assertThat(child.containsKey(COSName.JAVA_SCRIPT)).isFalse();
}
@Test
@DisplayName("PDF/A-2 does NOT remove SMask/CA (those are only stripped for part 1)")
void pdfA2KeepsTransparencyEntries() throws Exception {
COSDictionary dict = new COSDictionary();
dict.setItem(COSName.SMASK, new COSArray());
dict.setFloat(COSName.CA, 0.5f);
invokeStatic("sanitizePdfA", dict, 2);
assertThat(dict.containsKey(COSName.SMASK)).isTrue();
assertThat(dict.containsKey(COSName.CA)).isTrue();
}
}
// =======================================================================================
@Nested
@DisplayName("sanitizeMetadata / removeForbiddenActions")
class MetadataAndActions {
@Test
@DisplayName("sanitizeMetadata strips non-printable chars and sets producer")
void sanitizeMetadataCleans() throws Exception {
try (PDDocument document = simplePdf()) {
PDDocumentInformation info = new PDDocumentInformation();
info.setCustomMetadataValue("Custom", "cleanvalue");
document.setDocumentInformation(info);
invokeInstance(newController(), "sanitizeMetadata", document);
PDDocumentInformation result = document.getDocumentInformation();
assertThat(result.getProducer()).isEqualTo("Stirling-PDF Sanitizer");
assertThat(result.getCustomMetadataValue("Custom")).isEqualTo("cleanvalue");
}
}
@Test
@DisplayName("sanitizeMetadata always overwrites producer to the sanitizer marker")
void sanitizeMetadataOverwritesProducer() throws Exception {
try (PDDocument document = simplePdf()) {
PDDocumentInformation info = new PDDocumentInformation();
info.setProducer("Some Other Producer");
document.setDocumentInformation(info);
invokeInstance(newController(), "sanitizeMetadata", document);
assertThat(document.getDocumentInformation().getProducer())
.isEqualTo("Stirling-PDF Sanitizer");
}
}
@Test
@DisplayName("removeForbiddenActions clears open action and JavaScript")
void removeForbiddenActions() throws Exception {
try (PDDocument document = simplePdf()) {
PDDocumentCatalog catalog = document.getDocumentCatalog();
catalog.getCOSObject().setItem(COSName.JAVA_SCRIPT, new COSDictionary());
invokeInstance(newController(), "removeForbiddenActions", document);
assertThat(catalog.getCOSObject().containsKey(COSName.JAVA_SCRIPT)).isFalse();
assertThat(catalog.getOpenAction()).isNull();
}
}
}
// =======================================================================================
@Nested
@DisplayName("fixOptionalContentGroups")
class OptionalContentGroups {
@Test
@DisplayName("no OCProperties is a no-op (does not throw)")
void noOcProperties() throws Exception {
try (PDDocument document = simplePdf()) {
assertThatCode(() -> invokeStatic("fixOptionalContentGroups", document))
.doesNotThrowAnyException();
}
}
}
// =======================================================================================
@Nested
@DisplayName("addWhiteBackground")
class WhiteBackground {
@Test
@DisplayName("adds a prepended content stream without changing page count")
void addsBackground() throws Exception {
try (PDDocument document = simplePdf()) {
int pagesBefore = document.getNumberOfPages();
assertThatCode(
() ->
invokeInstance(
newController(), "addWhiteBackground", document))
.doesNotThrowAnyException();
assertThat(document.getNumberOfPages()).isEqualTo(pagesBefore);
// page still has content streams after prepending background
assertThat(document.getPage(0).hasContents()).isTrue();
}
}
}
// =======================================================================================
@Nested
@DisplayName("ensureAnnotationAppearances")
class AnnotationAppearances {
@Test
@DisplayName("Link annotations are skipped (kept) by appearance enforcement")
void linkAnnotationsKept() throws Exception {
try (PDDocument document = simplePdf()) {
PDPage page = document.getPage(0);
PDAnnotationLink link = new PDAnnotationLink();
link.setRectangle(new PDRectangle(0, 0, 50, 50));
List<PDAnnotation> annotations = new ArrayList<>();
annotations.add(link);
page.setAnnotations(annotations);
invokeInstance(newController(), "ensureAnnotationAppearances", document);
assertThat(page.getAnnotations()).hasSize(1);
}
}
}
// =======================================================================================
@Nested
@DisplayName("ensureEmbeddedFileCompliance / addICCProfileIfNotPresent")
class EmbeddedAndIcc {
@Test
@DisplayName("ensureEmbeddedFileCompliance returns quietly with no names dictionary")
void noNamesDictionary() throws Exception {
try (PDDocument document = simplePdf()) {
assertThatCode(
() ->
invokeInstance(
newController(),
"ensureEmbeddedFileCompliance",
document))
.doesNotThrowAnyException();
}
}
@Test
@DisplayName("addICCProfileIfNotPresent adds an sRGB output intent")
void addsIccOutputIntent() throws Exception {
try (PDDocument document = simplePdf()) {
assertThat(document.getDocumentCatalog().getOutputIntents()).isEmpty();
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
assertThat(document.getDocumentCatalog().getOutputIntents()).hasSize(1);
assertThat(document.getDocumentCatalog().getOutputIntents().get(0).getInfo())
.contains("sRGB");
}
}
@Test
@DisplayName("addICCProfileIfNotPresent does not add a second intent when one exists")
void doesNotDuplicateIntent() throws Exception {
try (PDDocument document = simplePdf()) {
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
assertThat(document.getDocumentCatalog().getOutputIntents()).hasSize(1);
}
}
}
// =======================================================================================
@Nested
@DisplayName("performBasicPdfAValidation / buildComprehensiveValidationMessage")
class ValidationHelpers {
@Test
@DisplayName("basic validation flags missing XMP and output intent for a plain PDF")
void basicValidationFlagsMissing() throws Exception {
Path pdf = tempDir.resolve("plain.pdf");
Files.write(pdf, simplePdfBytes());
// PdfaProfile.PDF_A_2B has no preflight format -> basic validation path.
Object profile = resolvePdfaProfile("pdfa-2b");
org.apache.pdfbox.preflight.ValidationResult result =
invokeStaticWithTypes(
"performBasicPdfAValidation",
new Class<?>[] {Path.class, profile.getClass()},
pdf,
profile);
assertThat(result).isNotNull();
assertThat(result.isValid()).isFalse();
assertThat(result.getErrorsList()).isNotEmpty();
}
@Test
@DisplayName("comprehensive message summarises error count and codes")
void comprehensiveMessage() throws Exception {
Object profile = resolvePdfaProfile("pdfa-1");
org.apache.pdfbox.preflight.ValidationResult result =
new org.apache.pdfbox.preflight.ValidationResult(false);
result.addError(
new org.apache.pdfbox.preflight.ValidationResult.ValidationError(
"CODE_A", "first problem"));
result.addError(
new org.apache.pdfbox.preflight.ValidationResult.ValidationError(
"CODE_B", "second problem"));
String message =
invokeStaticWithTypes(
"buildComprehensiveValidationMessage",
new Class<?>[] {
org.apache.pdfbox.preflight.ValidationResult.class,
profile.getClass()
},
result,
profile);
assertThat(message).contains("PDF/A-1b");
assertThat(message).contains("2 errors");
assertThat(message).contains("CODE_A");
}
// helper: resolve enum constant + call typed static method
private Object resolvePdfaProfile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfaProfile")) {
enumClass = inner;
}
}
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
@SuppressWarnings("unchecked")
private <T> T invokeStaticWithTypes(String name, Class<?>[] types, Object... args)
throws Exception {
Method m = ConvertPDFToPDFA.class.getDeclaredMethod(name, types);
m.setAccessible(true);
try {
return (T) m.invoke(null, args);
} catch (InvocationTargetException e) {
throw unwrap(e);
}
}
}
// =======================================================================================
@Nested
@DisplayName("verifyStrictCompliance (VeraPDFService mocked)")
class StrictCompliance {
@Test
@DisplayName("compliant result passes without throwing")
void compliantPasses() throws Exception {
PDFVerificationResult ok = new PDFVerificationResult();
ok.setCompliant(true);
ok.setStandard("1b");
ok.setComplianceSummary("PDF/A-1b compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
ConvertPDFToPDFA controller = newController();
assertThatCode(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()))
.doesNotThrowAnyException();
}
@Test
@DisplayName("non-compliant result throws 400 ResponseStatusException with details")
void nonCompliantThrowsBadRequest() throws Exception {
PDFVerificationResult bad = new PDFVerificationResult();
bad.setCompliant(false);
bad.setStandard("1b");
bad.setComplianceSummary("PDF/A-1b with errors");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad));
ConvertPDFToPDFA controller = newController();
ResponseStatusException ex =
(ResponseStatusException)
catchThrowable(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()));
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(ex.getReason()).contains("PDF/A-1b with errors");
}
@Test
@DisplayName("empty result list is treated as non-compliant -> 400")
void emptyResultsTreatedNonCompliant() throws Exception {
when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList());
ConvertPDFToPDFA controller = newController();
ResponseStatusException ex =
(ResponseStatusException)
catchThrowable(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()));
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("service error is wrapped as 500 ResponseStatusException")
void serviceErrorWrappedAs500() throws Exception {
when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom"));
ConvertPDFToPDFA controller = newController();
ResponseStatusException ex =
(ResponseStatusException)
catchThrowable(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()));
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// =======================================================================================
@Nested
@DisplayName("pdfToPdfA controller validation branch")
class ControllerValidation {
@Test
@DisplayName("non-PDF content type throws PDF-required exception before any conversion")
void nonPdfContentTypeRejected() {
MockMultipartFile file =
new MockMultipartFile(
"fileInput",
"input.txt",
MediaType.TEXT_PLAIN_VALUE,
"not a pdf".getBytes());
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(file);
request.setOutputFormat("pdfa-2b");
ConvertPDFToPDFA controller = newController();
assertThatThrownBy(() -> controller.pdfToPdfA(request))
.isInstanceOf(IllegalArgumentException.class);
// collaborators must not be touched on the validation-failure path
verifyNoInteractions(tempFileManager, veraPDFService);
}
@Test
@DisplayName("null content type is also rejected as not-a-PDF")
void nullContentTypeRejected() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "input.bin", null, "data".getBytes());
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(file);
request.setOutputFormat("pdfa");
ConvertPDFToPDFA controller = newController();
assertThatThrownBy(() -> controller.pdfToPdfA(request))
.isInstanceOf(IllegalArgumentException.class);
}
}
// =======================================================================================
@Nested
@DisplayName("ensureEmbeddedFilesAFRelationship / isTransparencyGroup")
class StaticEdgeCases {
@Test
@DisplayName("ensureEmbeddedFilesAFRelationship is a no-op when no names dictionary")
void afRelationshipNoNames() throws Exception {
try (PDDocument document = simplePdf()) {
assertThatCode(() -> invokeStatic("ensureEmbeddedFilesAFRelationship", document))
.doesNotThrowAnyException();
}
}
@Test
@DisplayName("isTransparencyGroup true only for /S /Transparency group dictionaries")
void transparencyGroup() throws Exception {
COSDictionary withGroup = new COSDictionary();
COSDictionary groupDict = new COSDictionary();
groupDict.setItem(COSName.S, COSName.TRANSPARENCY);
withGroup.setItem(COSName.GROUP, groupDict);
assertThat((boolean) invokeStatic("isTransparencyGroup", withGroup)).isTrue();
COSDictionary withoutGroup = new COSDictionary();
assertThat((boolean) invokeStatic("isTransparencyGroup", withoutGroup)).isFalse();
}
}
}
@@ -0,0 +1,450 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Unit tests for {@link ConvertPdfToVideoController}.
*
* <p>The public {@code convertPdfToVideo} endpoint is commented out in production (ffmpeg disabled
* due to CVEs), so these tests exercise the remaining private helper methods directly via
* reflection. No external processes (ffmpeg) are ever spawned: {@code buildFfmpegCommand} only
* constructs the argument list, and {@code generateFrames} renders PDF pages to PNG files on disk.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertPdfToVideoControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
private ConvertPdfToVideoController controller;
@BeforeEach
void setUp() {
controller = new ConvertPdfToVideoController(pdfDocumentFactory, tempFileManager);
}
// ---- reflection helpers -------------------------------------------------
private Object invokePrivate(String name, Class<?>[] types, Object... args) throws Exception {
Method method = ConvertPdfToVideoController.class.getDeclaredMethod(name, types);
method.setAccessible(true);
try {
return method.invoke(controller, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof Exception ex) {
throw ex;
}
throw e;
}
}
private String normalizeFormat(String requested) throws Exception {
return (String) invokePrivate("normalizeFormat", new Class<?>[] {String.class}, requested);
}
private MediaType getMediaType(String format) throws Exception {
return (MediaType) invokePrivate("getMediaType", new Class<?>[] {String.class}, format);
}
private int getMaxDpi() throws Exception {
return (int) invokePrivate("getMaxDpi", new Class<?>[] {});
}
@SuppressWarnings("unchecked")
private List<String> buildFfmpegCommand(
String format, String resolution, String frameRate, TempFile outputVideo)
throws Exception {
return (List<String>)
invokePrivate(
"buildFfmpegCommand",
new Class<?>[] {String.class, String.class, String.class, TempFile.class},
format,
resolution,
frameRate,
outputVideo);
}
private void applyWatermark(BufferedImage image, float opacity, String text) throws Exception {
invokePrivate(
"applyWatermark",
new Class<?>[] {BufferedImage.class, float.class, String.class},
image,
opacity,
text);
}
private void generateFrames(
Path inputPdf,
Path outputDir,
int dpi,
float opacity,
String watermarkText,
boolean watermarkEnabled)
throws Exception {
invokePrivate(
"generateFrames",
new Class<?>[] {
Path.class, Path.class, int.class, float.class, String.class, boolean.class
},
inputPdf,
outputDir,
dpi,
opacity,
watermarkText,
watermarkEnabled);
}
// ---- PDF builder helper -------------------------------------------------
private byte[] buildPdf(int pageCount) throws IOException {
try (PDDocument document = new PDDocument()) {
for (int i = 0; i < pageCount; i++) {
document.addPage(new PDPage(PDRectangle.A4));
}
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
}
// ---- normalizeFormat ----------------------------------------------------
@Nested
@DisplayName("normalizeFormat")
class NormalizeFormat {
@Test
@DisplayName("null defaults to mp4")
void nullDefaultsToMp4() throws Exception {
assertEquals("mp4", normalizeFormat(null));
}
@Test
@DisplayName("mp4 passes through")
void mp4PassesThrough() throws Exception {
assertEquals("mp4", normalizeFormat("mp4"));
}
@Test
@DisplayName("webm passes through")
void webmPassesThrough() throws Exception {
assertEquals("webm", normalizeFormat("webm"));
}
@Test
@DisplayName("uppercase is lowercased")
void uppercaseIsLowercased() throws Exception {
assertEquals("mp4", normalizeFormat("MP4"));
assertEquals("webm", normalizeFormat("WEBM"));
}
@Test
@DisplayName("mixed case is normalized")
void mixedCaseIsNormalized() throws Exception {
assertEquals("webm", normalizeFormat("WeBm"));
}
@Test
@DisplayName("unsupported format falls back to mp4")
void unsupportedFallsBackToMp4() throws Exception {
assertEquals("mp4", normalizeFormat("avi"));
assertEquals("mp4", normalizeFormat("gif"));
assertEquals("mp4", normalizeFormat(""));
}
}
// ---- getMediaType -------------------------------------------------------
@Nested
@DisplayName("getMediaType")
class GetMediaType {
@Test
@DisplayName("webm maps to video/webm")
void webmMapsToVideoWebm() throws Exception {
assertEquals(MediaType.valueOf("video/webm"), getMediaType("webm"));
}
@Test
@DisplayName("mp4 maps to video/mp4")
void mp4MapsToVideoMp4() throws Exception {
assertEquals(MediaType.valueOf("video/mp4"), getMediaType("mp4"));
}
@Test
@DisplayName("unknown format defaults to video/mp4")
void unknownDefaultsToVideoMp4() throws Exception {
assertEquals(MediaType.valueOf("video/mp4"), getMediaType("avi"));
}
}
// ---- getMaxDpi ----------------------------------------------------------
@Nested
@DisplayName("getMaxDpi")
class GetMaxDpi {
@Test
@DisplayName("returns 500 fallback when no Spring context is available")
void returnsFallbackWithoutContext() throws Exception {
// No ApplicationContext is set in this unit test, so getBean returns null and the
// method falls back to the hardcoded default of 500.
assertEquals(500, getMaxDpi());
}
}
// ---- buildFfmpegCommand -------------------------------------------------
@Nested
@DisplayName("buildFfmpegCommand")
class BuildFfmpegCommand {
private TempFile newTempFile(File backing) throws IOException {
when(tempFileManager.createTempFile(any())).thenReturn(backing);
return new TempFile(tempFileManager, ".mp4");
}
@Test
@DisplayName("mp4 command includes libx264 and faststart flags")
void mp4Command(@TempDir Path dir) throws Exception {
File backing = dir.resolve("out.mp4").toFile();
TempFile outputVideo = newTempFile(backing);
List<String> command = buildFfmpegCommand("mp4", "ORIGINAL", "0.333333", outputVideo);
assertEquals("ffmpeg", command.get(0));
assertTrue(command.contains("-y"));
assertTrue(command.contains("-framerate"));
assertTrue(command.contains("0.333333"));
assertTrue(command.contains("frame_%05d.png"));
assertTrue(command.contains("-vf"));
assertTrue(command.contains("libx264"));
assertTrue(command.contains("yuv420p"));
assertTrue(command.contains("+faststart"));
assertFalse(command.contains("libvpx-vp9"));
// Output path is always the last argument.
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
}
@Test
@DisplayName("webm command includes libvpx-vp9 and crf flags")
void webmCommand(@TempDir Path dir) throws Exception {
File backing = dir.resolve("out.webm").toFile();
TempFile outputVideo = newTempFile(backing);
List<String> command = buildFfmpegCommand("webm", "720P", "0.5", outputVideo);
assertTrue(command.contains("libvpx-vp9"));
assertTrue(command.contains("-crf"));
assertTrue(command.contains("30"));
assertFalse(command.contains("libx264"));
assertFalse(command.contains("+faststart"));
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
}
@Test
@DisplayName("framerate value is placed right after -framerate")
void framerateOrdering(@TempDir Path dir) throws Exception {
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
List<String> command = buildFfmpegCommand("mp4", "ORIGINAL", "0.25", outputVideo);
int idx = command.indexOf("-framerate");
assertTrue(idx >= 0);
assertEquals("0.25", command.get(idx + 1));
}
@Test
@DisplayName("known resolution applies the matching scale filter")
void knownResolutionFilter(@TempDir Path dir) throws Exception {
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
List<String> command = buildFfmpegCommand("mp4", "1080P", "0.5", outputVideo);
int idx = command.indexOf("-vf");
assertEquals("scale=-2:1080,setsar=1", command.get(idx + 1));
}
@Test
@DisplayName("unknown resolution falls back to the ORIGINAL filter")
void unknownResolutionFallsBackToOriginal(@TempDir Path dir) throws Exception {
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
List<String> command = buildFfmpegCommand("mp4", "NONSENSE", "0.5", outputVideo);
int idx = command.indexOf("-vf");
assertEquals("scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1", command.get(idx + 1));
}
}
// ---- applyWatermark -----------------------------------------------------
@Nested
@DisplayName("applyWatermark")
class ApplyWatermark {
private BufferedImage solidImage(int w, int h, Color color) {
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(color);
g.fillRect(0, 0, w, h);
g.dispose();
return image;
}
@Test
@DisplayName("modifies pixels at full opacity without throwing")
void modifiesPixels() throws Exception {
// The watermark is drawn through the image centre, so a small image still gets pixels
// painted; the smaller buffer keeps the two getRGB snapshots cheap.
BufferedImage image = solidImage(100, 80, Color.RED);
int[] before =
image.getRGB(
0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
applyWatermark(image, 1.0f, "CONFIDENTIAL");
int[] after =
image.getRGB(
0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
boolean changed = false;
for (int i = 0; i < before.length; i++) {
if (before[i] != after[i]) {
changed = true;
break;
}
}
assertTrue(changed, "watermark should have altered at least one pixel");
}
@Test
@DisplayName("does not throw on a small square image")
void handlesSmallImage() throws Exception {
BufferedImage image = solidImage(50, 50, Color.BLUE);
applyWatermark(image, 0.5f, "X");
assertNotNull(image);
}
@Test
@DisplayName("does not throw at zero opacity")
void handlesZeroOpacity() throws Exception {
BufferedImage image = solidImage(120, 80, Color.GREEN);
applyWatermark(image, 0.0f, "WM");
assertNotNull(image);
}
}
// ---- generateFrames -----------------------------------------------------
@Nested
@DisplayName("generateFrames")
class GenerateFrames {
@Test
@DisplayName("renders one PNG frame per page")
void rendersOneFramePerPage(@TempDir Path dir) throws Exception {
Path inputPdf = dir.resolve("input.pdf");
Files.write(inputPdf, buildPdf(3));
Path outputDir = Files.createDirectory(dir.resolve("frames"));
// The factory must return a fresh, real document that the controller can render.
when(pdfDocumentFactory.load(any(File.class))).thenReturn(Loader.loadPDF(buildPdf(3)));
generateFrames(inputPdf, outputDir, 72, 1.0f, null, false);
assertTrue(Files.exists(outputDir.resolve("frame_00001.png")));
assertTrue(Files.exists(outputDir.resolve("frame_00002.png")));
assertTrue(Files.exists(outputDir.resolve("frame_00003.png")));
try (var stream = Files.list(outputDir)) {
assertEquals(3, stream.count());
}
}
@Test
@DisplayName("applies watermark when enabled and still produces frames")
void appliesWatermarkWhenEnabled(@TempDir Path dir) throws Exception {
Path inputPdf = dir.resolve("input.pdf");
Files.write(inputPdf, buildPdf(1));
Path outputDir = Files.createDirectory(dir.resolve("frames"));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(Loader.loadPDF(buildPdf(1)));
generateFrames(inputPdf, outputDir, 72, 0.8f, "DRAFT", true);
Path frame = outputDir.resolve("frame_00001.png");
assertTrue(Files.exists(frame));
assertTrue(Files.size(frame) > 0);
}
@Test
@DisplayName("zero-page document throws IllegalArgumentException")
void zeroPageThrows(@TempDir Path dir) throws Exception {
Path inputPdf = dir.resolve("empty.pdf");
// An empty PDDocument (no pages) cannot be saved/loaded, so feed an empty doc directly.
Files.write(inputPdf, new byte[] {0});
Path outputDir = Files.createDirectory(dir.resolve("frames"));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(new PDDocument());
assertThrows(
IllegalArgumentException.class,
() -> generateFrames(inputPdf, outputDir, 72, 1.0f, null, false));
try (var stream = Files.list(outputDir)) {
assertEquals(0, stream.count());
}
}
@Test
@DisplayName("propagates IOException from the document factory")
void propagatesLoadFailure(@TempDir Path dir) throws Exception {
Path inputPdf = dir.resolve("input.pdf");
Files.write(inputPdf, buildPdf(1));
Path outputDir = Files.createDirectory(dir.resolve("frames"));
when(pdfDocumentFactory.load(any(File.class))).thenThrow(new IOException("boom"));
assertThrows(
IOException.class,
() -> generateFrames(inputPdf, outputDir, 72, 1.0f, null, false));
}
}
}
@@ -0,0 +1,637 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AutoSplitPdfControllerTest {
private static final String VALID_QR = "https://stirlingpdf.com";
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
private ApplicationProperties applicationProperties;
private AutoSplitPdfController controller;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
// Keep maxDPI at the QR detection DPI so the high-DPI retry path is skipped (fast tests).
applicationProperties.getSystem().setMaxDPI(150);
controller =
new AutoSplitPdfController(
pdfDocumentFactory, tempFileManager, applicationProperties);
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
/** Build a tiny single-colour BufferedImage. */
private static BufferedImage solidImage(int w, int h, Color color) {
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(color);
g.fillRect(0, 0, w, h);
g.dispose();
return image;
}
/** Generate a real, decodable QR code as a BufferedImage using zxing core only. */
private static BufferedImage qrImage(String text, int size) throws Exception {
QRCodeWriter writer = new QRCodeWriter();
java.util.Map<EncodeHintType, Object> hints = new java.util.EnumMap<>(EncodeHintType.class);
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
hints.put(EncodeHintType.MARGIN, 4);
BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints);
int width = matrix.getWidth();
int height = matrix.getHeight();
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
image.setRGB(x, y, matrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB());
}
}
return image;
}
/** Build an in-memory PDF document with the given number of plain pages. */
private static PDDocument simpleDoc(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(new PDRectangle(200, 200)));
}
return doc;
}
/**
* A PDF where every page draws the supplied image (used so embedded-image extraction works).
*/
private static PDDocument docWithImageOnEachPage(BufferedImage img, int pages)
throws Exception {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(new PDRectangle(200, 200));
doc.addPage(page);
PDImageXObject xobj = LosslessFactory.createFromImage(doc, img);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(xobj, 20, 20, 160, 160);
}
}
return doc;
}
/** Load a PDF from the InputStream the controller hands to pdfDocumentFactory.load(...). */
private static PDDocument loadFromStream(InputStream in) throws Exception {
return Loader.loadPDF(in.readAllBytes());
}
private static byte[] docToBytes(PDDocument doc) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
doc.close();
return baos.toByteArray();
}
private static AutoSplitPdfRequest request(byte[] pdfBytes, Boolean duplex) {
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
req.setFileInput(
new org.springframework.mock.web.MockMultipartFile(
"fileInput", "input.pdf", "application/pdf", pdfBytes));
req.setDuplexMode(duplex);
return req;
}
/** Make tempFileManager.createTempFile(".zip") create a real file inside the JUnit temp dir. */
private void wireRealTempFile() throws Exception {
when(tempFileManager.createTempFile(".zip"))
.thenAnswer(
invocation ->
Files.createTempFile(tempDir, "stirling-test", ".zip").toFile());
}
private static List<String> zipEntryNames(byte[] zipBytes) throws Exception {
List<String> names = new ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
names.add(entry.getName());
zis.closeEntry();
}
}
return names;
}
private static byte[] readResource(Resource resource) throws Exception {
try (InputStream in = resource.getInputStream()) {
return in.readAllBytes();
}
}
// reflection invokers for the private (static) helpers ------------------
private static Object invokeStatic(String name, Class<?>[] types, Object... args)
throws Exception {
Method m = AutoSplitPdfController.class.getDeclaredMethod(name, types);
m.setAccessible(true);
try {
return m.invoke(null, args);
} catch (InvocationTargetException e) {
if (e.getCause() instanceof Exception ex) {
throw ex;
}
throw e;
}
}
private Object invokeInstance(String name, Class<?>[] types, Object... args) throws Exception {
Method m = AutoSplitPdfController.class.getDeclaredMethod(name, types);
m.setAccessible(true);
try {
return m.invoke(controller, args);
} catch (InvocationTargetException e) {
if (e.getCause() instanceof Exception ex) {
throw ex;
}
throw e;
}
}
// ---------------------------------------------------------------------
// isBlankImage(int[])
// ---------------------------------------------------------------------
@Nested
@DisplayName("isBlankImage")
class IsBlankImage {
@Test
@DisplayName("empty array is treated as blank")
void emptyArrayIsBlank() throws Exception {
Object result =
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) new int[0]);
assertEquals(Boolean.TRUE, result);
}
@Test
@DisplayName("uniform pixels are blank")
void uniformIsBlank() throws Exception {
int[] pixels = new int[1000];
java.util.Arrays.fill(pixels, 0xFFFFFF);
Object result =
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) pixels);
assertEquals(Boolean.TRUE, result);
}
@Test
@DisplayName("a single differing sampled pixel makes it non-blank")
void variedIsNotBlank() throws Exception {
int[] pixels = new int[1000];
java.util.Arrays.fill(pixels, 0xFFFFFF);
// step = max(1, 1000/20) = 50, so index 500 is sampled
pixels[500] = 0x000000;
Object result =
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) pixels);
assertEquals(Boolean.FALSE, result);
}
@Test
@DisplayName("single pixel array is blank")
void singlePixelIsBlank() throws Exception {
Object result =
invokeStatic(
"isBlankImage", new Class<?>[] {int[].class}, (Object) new int[] {7});
assertEquals(Boolean.TRUE, result);
}
}
// ---------------------------------------------------------------------
// downscaleIfNeeded(BufferedImage)
// ---------------------------------------------------------------------
@Nested
@DisplayName("downscaleIfNeeded")
class DownscaleIfNeeded {
@Test
@DisplayName("small images are returned unchanged (same instance)")
void smallUnchanged() throws Exception {
BufferedImage img = solidImage(100, 80, Color.GRAY);
Object result =
invokeStatic(
"downscaleIfNeeded",
new Class<?>[] {BufferedImage.class},
(Object) img);
assertSame(img, result);
}
@Test
@DisplayName("image exactly at the pixel limit is unchanged")
void atLimitUnchanged() throws Exception {
// 10000x10000 == 100_000_000 == MAX_IMAGE_PIXELS, not greater than -> unchanged.
// Use a mock-free real image but keep it cheap: build a 1px-tall wide image whose
// total pixel count is below the limit so we only assert the <= branch with a
// realistic non-trivial size.
BufferedImage img = solidImage(5000, 5000, Color.WHITE); // 25M < 100M
Object result =
invokeStatic(
"downscaleIfNeeded",
new Class<?>[] {BufferedImage.class},
(Object) img);
assertSame(img, result);
}
}
// ---------------------------------------------------------------------
// countPageImages(PDPage)
// ---------------------------------------------------------------------
@Nested
@DisplayName("countPageImages")
class CountPageImages {
@Test
@DisplayName("page with no resources returns 0")
void noResourcesZero() throws Exception {
PDPage page = new PDPage(new PDRectangle(200, 200));
Object result =
invokeStatic("countPageImages", new Class<?>[] {PDPage.class}, (Object) page);
assertEquals(0, result);
}
@Test
@DisplayName("page with one embedded image returns 1")
void oneImage() throws Exception {
try (PDDocument doc = docWithImageOnEachPage(solidImage(40, 40, Color.RED), 1)) {
PDPage page = doc.getPage(0);
Object result =
invokeStatic(
"countPageImages", new Class<?>[] {PDPage.class}, (Object) page);
assertEquals(1, result);
}
}
}
// ---------------------------------------------------------------------
// tryDecodeQR(int[], int, int) and decodeQRCode(BufferedImage)
// ---------------------------------------------------------------------
@Nested
@DisplayName("QR decoding")
class QrDecoding {
@Test
@DisplayName("decodeQRCode returns the encoded text for a real QR image")
void decodeRealQr() throws Exception {
BufferedImage qr = qrImage(VALID_QR, 250);
Object result =
invokeStatic("decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) qr);
assertEquals(VALID_QR, result);
}
@Test
@DisplayName("decodeQRCode returns null for a blank image")
void decodeBlankReturnsNull() throws Exception {
BufferedImage blank = solidImage(120, 120, Color.WHITE);
Object result =
invokeStatic(
"decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) blank);
assertNull(result);
}
@Test
@DisplayName("decodeQRCode returns null for a non-QR (noise-free, non-blank) image")
void decodeNonQrReturnsNull() throws Exception {
// Two solid halves: non-blank but not a QR code.
BufferedImage image = new BufferedImage(120, 120, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, 120, 60);
g.setColor(Color.BLACK);
g.fillRect(0, 60, 120, 60);
g.dispose();
Object result =
invokeStatic(
"decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) image);
assertNull(result);
}
@Test
@DisplayName("tryDecodeQR decodes raw RGB pixels of a real QR")
void tryDecodeRawPixels() throws Exception {
BufferedImage qr = qrImage(VALID_QR, 250);
int w = qr.getWidth();
int h = qr.getHeight();
int[] pixels = new int[w * h];
qr.getRGB(0, 0, w, h, pixels, 0, w);
Object result =
invokeStatic(
"tryDecodeQR",
new Class<?>[] {int[].class, int.class, int.class},
pixels,
w,
h);
assertEquals(VALID_QR, result);
}
@Test
@DisplayName("tryDecodeQR returns null when no QR present")
void tryDecodeReturnsNull() throws Exception {
int w = 60;
int h = 60;
int[] pixels = new int[w * h];
// alternating pattern, no decodable QR
for (int i = 0; i < pixels.length; i++) {
pixels[i] = (i % 2 == 0) ? 0xFFFFFF : 0x000000;
}
Object result =
invokeStatic(
"tryDecodeQR",
new Class<?>[] {int[].class, int.class, int.class},
pixels,
w,
h);
assertNull(result);
}
}
// ---------------------------------------------------------------------
// checkPageImagesDirect(PDPage)
// ---------------------------------------------------------------------
@Nested
@DisplayName("checkPageImagesDirect")
class CheckPageImagesDirect {
@Test
@DisplayName("page with no images returns null")
void noImagesNull() throws Exception {
PDPage page = new PDPage(new PDRectangle(200, 200));
Object result =
invokeStatic(
"checkPageImagesDirect", new Class<?>[] {PDPage.class}, (Object) page);
assertNull(result);
}
@Test
@DisplayName("page with an embedded QR image returns the QR text")
void embeddedQrFound() throws Exception {
// Size 250 keeps the readback image off the controller's blank-sampling heuristic.
BufferedImage qr = qrImage(VALID_QR, 250);
try (PDDocument doc = docWithImageOnEachPage(qr, 1)) {
PDPage page = doc.getPage(0);
Object result =
invokeStatic(
"checkPageImagesDirect",
new Class<?>[] {PDPage.class},
(Object) page);
assertEquals(VALID_QR, result);
}
}
@Test
@DisplayName("page with a non-QR image returns null")
void embeddedNonQrNull() throws Exception {
BufferedImage plain = solidImage(80, 80, Color.WHITE);
try (PDDocument doc = docWithImageOnEachPage(plain, 1)) {
PDPage page = doc.getPage(0);
Object result =
invokeStatic(
"checkPageImagesDirect",
new Class<?>[] {PDPage.class},
(Object) page);
assertNull(result);
}
}
}
// ---------------------------------------------------------------------
// getSystemMaxDpi() (private instance)
// ---------------------------------------------------------------------
@Nested
@DisplayName("getSystemMaxDpi")
class GetSystemMaxDpi {
@Test
@DisplayName("returns the configured system maxDPI")
void returnsConfiguredValue() throws Exception {
applicationProperties.getSystem().setMaxDPI(300);
Object result = invokeInstance("getSystemMaxDpi", new Class<?>[] {});
assertEquals(300, result);
}
@Test
@DisplayName("falls back to the default detection DPI when applicationProperties is null")
void fallsBackWhenNull() throws Exception {
AutoSplitPdfController noProps =
new AutoSplitPdfController(pdfDocumentFactory, tempFileManager, null);
Method m = AutoSplitPdfController.class.getDeclaredMethod("getSystemMaxDpi");
m.setAccessible(true);
Object result = m.invoke(noProps);
assertEquals(150, result); // QR_DETECTION_DPI
}
}
// ---------------------------------------------------------------------
// autoSplitPdf(...) full handler
// ---------------------------------------------------------------------
@Nested
@DisplayName("autoSplitPdf")
class AutoSplitHandler {
@Test
@DisplayName("PDF without QR dividers yields a single-PDF zip")
void noQrSingleOutput() throws Exception {
byte[] pdf = docToBytes(simpleDoc(3));
wireRealTempFile();
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
AutoSplitPdfRequest req = request(pdf, false);
ResponseEntity<Resource> response = controller.autoSplitPdf(req);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
byte[] zip = readResource(response.getBody());
List<String> names = zipEntryNames(zip);
assertEquals(1, names.size(), "no QR dividers -> all pages collapse into one document");
assertEquals("input_1.pdf", names.get(0));
verify(pdfDocumentFactory).load(any(InputStream.class));
}
@Test
@DisplayName("single-page PDF without QR yields one output PDF in the zip")
void singlePageOneOutput() throws Exception {
byte[] pdf = docToBytes(simpleDoc(1));
wireRealTempFile();
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, null));
byte[] zip = readResource(response.getBody());
List<String> names = zipEntryNames(zip);
assertEquals(1, names.size());
assertEquals("input_1.pdf", names.get(0));
}
@Test
@DisplayName("filename without extension is used as-is for entry names")
void filenameWithoutExtension() throws Exception {
byte[] pdf = docToBytes(simpleDoc(2));
wireRealTempFile();
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
req.setFileInput(
new org.springframework.mock.web.MockMultipartFile(
"fileInput", "myfile", "application/pdf", pdf));
req.setDuplexMode(false);
ResponseEntity<Resource> response = controller.autoSplitPdf(req);
byte[] zip = readResource(response.getBody());
List<String> names = zipEntryNames(zip);
assertEquals(1, names.size());
assertEquals("myfile_1.pdf", names.get(0));
}
@Test
@DisplayName("each output zip entry contains a valid, loadable PDF")
void outputEntriesAreValidPdfs() throws Exception {
byte[] pdf = docToBytes(simpleDoc(2));
wireRealTempFile();
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, false));
byte[] zip = readResource(response.getBody());
int entries = 0;
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries++;
byte[] entryBytes = zis.readAllBytes();
try (PDDocument loaded = Loader.loadPDF(entryBytes)) {
assertTrue(loaded.getNumberOfPages() >= 1);
}
zis.closeEntry();
}
}
assertEquals(1, entries);
}
@Test
@DisplayName("loader failure propagates and the temp file is closed")
void loaderFailurePropagates() throws Exception {
byte[] pdf = docToBytes(simpleDoc(1));
File created = Files.createTempFile(tempDir, "stirling-fail", ".zip").toFile();
when(tempFileManager.createTempFile(".zip")).thenReturn(created);
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenThrow(new java.io.IOException("boom"));
AutoSplitPdfRequest req = request(pdf, false);
assertThrows(java.io.IOException.class, () -> controller.autoSplitPdf(req));
// outputTempFile.close() deletes the file on the error path.
verify(tempFileManager).deleteTempFile(created);
}
@Test
@DisplayName("duplexMode flag is accepted (null treated as false)")
void duplexNullTreatedAsFalse() throws Exception {
byte[] pdf = docToBytes(simpleDoc(2));
wireRealTempFile();
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, null));
assertEquals(HttpStatus.OK, response.getStatusCode());
List<String> names = zipEntryNames(readResource(response.getBody()));
assertEquals(1, names.size());
}
}
// ---------------------------------------------------------------------
// VALID_QR_CONTENTS sanity (static state)
// ---------------------------------------------------------------------
@Nested
@DisplayName("valid QR contents")
class ValidQrContents {
@Test
@DisplayName("the well-known divider URLs are recognised")
@SuppressWarnings("unchecked")
void recognisedUrls() throws Exception {
java.lang.reflect.Field f =
AutoSplitPdfController.class.getDeclaredField("VALID_QR_CONTENTS");
f.setAccessible(true);
Set<String> valid = new HashSet<>((Set<String>) f.get(null));
assertTrue(valid.contains("https://stirlingpdf.com"));
assertTrue(valid.contains("https://github.com/Stirling-Tools/Stirling-PDF"));
assertTrue(valid.contains("https://github.com/Frooodle/Stirling-PDF"));
assertFalse(valid.contains("https://example.com"));
}
}
}
@@ -0,0 +1,469 @@
package stirling.software.SPDF.controller.api.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Unit tests for {@link CompressController}. External binaries (Ghostscript / qpdf / ImageMagick)
* are never invoked: tests cover validation, orchestration with all tool groups disabled, the pure
* level/quality helpers, and the public image-compression entry point.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CompressControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private TempFileManager tempFileManager;
@InjectMocks private CompressController controller;
/** Real temp files created during a test; cleaned up after each test. */
private final List<File> createdFiles = new ArrayList<>();
@BeforeEach
void setUp() throws Exception {
// By default no external tools are enabled, forcing the Java-only path.
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false);
// Every managed temp file is backed by a real on-disk file wrapped in a mock TempFile.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile(
"compress-test", inv.<String>getArgument(0))
.toFile();
createdFiles.add(f);
return newRealBackedTempFile(f);
});
}
private TempFile newRealBackedTempFile(File f) {
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
lenient().when(tf.exists()).thenReturn(f.exists());
return tf;
}
// ----- helpers to build tiny in-memory PDFs ------------------------------------------------
private byte[] textOnlyPdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(
new org.apache.pdfbox.pdmodel.font.PDType1Font(
org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName.HELVETICA),
12);
cs.newLineAtOffset(50, 700);
cs.showText("Hello compress");
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
/** PDF with one tiny (sub-400px) image so the compressor encounters it but skips it. */
private byte[] smallImagePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
java.awt.image.BufferedImage img =
new java.awt.image.BufferedImage(
50, 50, java.awt.image.BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < 50; x++) {
for (int y = 0; y < 50; y++) {
img.setRGB(x, y, (x * 5 + y) & 0xFFFFFF);
}
}
PDImageXObject image = LosslessFactory.createFromImage(doc, img);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(image, 100, 100, 50, 50);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private MockMultipartFile multipart(byte[] bytes) {
return new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes);
}
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
// ----- validation branches -----------------------------------------------------------------
@Nested
@DisplayName("optimizePdf validation")
class Validation {
@Test
@DisplayName("null input file throws IllegalArgumentException")
void nullFile_throws() {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(null);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("empty input file throws IllegalArgumentException")
void emptyFile_throws() {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(
new MockMultipartFile(
"fileInput",
"input.pdf",
MediaType.APPLICATION_PDF_VALUE,
new byte[0]));
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName(
"both optimizeLevel and expectedOutputSize null throws IllegalArgumentException")
void noOptionsProvided_throws() throws Exception {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(textOnlyPdfBytes()));
request.setOptimizeLevel(null);
request.setExpectedOutputSize(null);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("line art requested but service unavailable throws FORBIDDEN")
void lineArt_serviceNull_throwsForbidden() throws Exception {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(textOnlyPdfBytes()));
request.setOptimizeLevel(2);
request.setLineArt(true);
// lineArtConversionService field is left null by @InjectMocks.
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName(
"line art requested with service present but ImageMagick disabled throws IOException")
void lineArt_imageMagickDisabled_throwsIOException() throws Exception {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(textOnlyPdfBytes()));
request.setOptimizeLevel(2);
request.setLineArt(true);
// Provide a service so the null-check passes, but ImageMagick group stays disabled.
setLineArtService(mock(LineArtConversionService.class));
when(endpointConfiguration.isGroupEnabled("ImageMagick")).thenReturn(false);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(IOException.class);
}
}
// ----- orchestration with all external tools disabled --------------------------------------
@Nested
@DisplayName("optimizePdf orchestration (no external tools)")
class Orchestration {
@Test
@DisplayName("low level + no tools returns OK with non-empty body")
void lowLevel_noTools_returnsOk() throws Exception {
byte[] pdf = textOnlyPdfBytes();
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(pdf));
request.setOptimizeLevel(1); // < 4 => no image compression, < 6 => no ghostscript
// Final stage reloads currentFile from disk; return a fresh real document.
when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
@Test
@DisplayName("level 4 with a sub-threshold image still returns OK (image skipped)")
void level4_smallImage_skipped_returnsOk() throws Exception {
byte[] pdf = smallImagePdfBytes();
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(pdf));
request.setOptimizeLevel(4); // triggers Java image compression path
when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
// compressImagesInPDF reloads currentFile via load(Path).
when(pdfDocumentFactory.load(any(Path.class)))
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
@Test
@DisplayName("grayscale flag forces image compression path even at low level")
void grayscale_lowLevel_returnsOk() throws Exception {
byte[] pdf = textOnlyPdfBytes();
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(pdf));
request.setOptimizeLevel(1);
request.setGrayscale(true);
when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.load(any(Path.class)))
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
@Test
@DisplayName("auto mode via expectedOutputSize picks a level and returns OK")
void autoMode_expectedOutputSize_returnsOk() throws Exception {
byte[] pdf = textOnlyPdfBytes();
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(pdf));
request.setOptimizeLevel(null);
request.setExpectedOutputSize("1KB"); // length > 1 => auto mode
when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
when(pdfDocumentFactory.load(any(Path.class)))
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
}
// ----- public compressImagesInPDF ----------------------------------------------------------
@Nested
@DisplayName("compressImagesInPDF")
class CompressImages {
@Test
@DisplayName("PDF with a tiny image produces a valid, non-empty output PDF")
void smallImage_producesValidPdf() throws Exception {
Path src = tempDir.resolve("src.pdf");
Files.write(src, smallImagePdfBytes());
when(pdfDocumentFactory.load(any(Path.class)))
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
TempFile result = controller.compressImagesInPDF(src, 0.5, 0.5f, false);
assertThat(result).isNotNull();
byte[] out = Files.readAllBytes(result.getPath());
assertThat(out).isNotEmpty();
try (PDDocument doc = Loader.loadPDF(out)) {
assertThat(doc.getNumberOfPages()).isEqualTo(1);
}
}
@Test
@DisplayName("text-only PDF compresses to a valid single-page output")
void textOnly_producesValidPdf() throws Exception {
Path src = tempDir.resolve("text.pdf");
Files.write(src, textOnlyPdfBytes());
when(pdfDocumentFactory.load(any(Path.class)))
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
TempFile result = controller.compressImagesInPDF(src, 0.8, 0.7f, false);
assertThat(result).isNotNull();
try (PDDocument doc = Loader.loadPDF(Files.readAllBytes(result.getPath()))) {
assertThat(doc.getNumberOfPages()).isEqualTo(1);
}
}
@Test
@DisplayName("load failure closes the temp file and propagates the exception")
void loadFailure_propagates() throws Exception {
Path src = tempDir.resolve("bad.pdf");
Files.write(src, textOnlyPdfBytes());
when(pdfDocumentFactory.load(any(Path.class))).thenThrow(new IOException("boom"));
assertThatThrownBy(() -> controller.compressImagesInPDF(src, 0.5, 0.5f, false))
.isInstanceOf(IOException.class)
.hasMessageContaining("boom");
}
}
// ----- pure helper methods (reflection) ----------------------------------------------------
@Nested
@DisplayName("scale / quality / level helpers")
class Helpers {
@Test
@DisplayName("getScaleFactorForLevel maps each level and defaults to 1.0")
void scaleFactorForLevel() throws Exception {
Method m = privateStatic("getScaleFactorForLevel", int.class);
assertThat((double) m.invoke(null, 1)).isEqualTo(0.98);
assertThat((double) m.invoke(null, 5)).isEqualTo(0.68);
assertThat((double) m.invoke(null, 9)).isEqualTo(0.28);
// Out-of-range falls to the default branch.
assertThat((double) m.invoke(null, 0)).isEqualTo(1.0);
assertThat((double) m.invoke(null, 42)).isEqualTo(1.0);
}
@Test
@DisplayName("getJpegQualityForLevel maps each level and defaults to 0.75")
void jpegQualityForLevel() throws Exception {
Method m = privateStatic("getJpegQualityForLevel", int.class);
assertThat((float) m.invoke(null, 1)).isEqualTo(0.92f);
assertThat((float) m.invoke(null, 9)).isEqualTo(0.35f);
assertThat((float) m.invoke(null, 0)).isEqualTo(0.75f);
assertThat((float) m.invoke(null, 100)).isEqualTo(0.75f);
}
@Test
@DisplayName("determineOptimizeLevel buckets the size-reduction ratio")
void determineOptimizeLevel() throws Exception {
Method m = privateStatic("determineOptimizeLevel", double.class);
assertThat((int) m.invoke(null, 0.95)).isEqualTo(1);
assertThat((int) m.invoke(null, 0.85)).isEqualTo(2);
assertThat((int) m.invoke(null, 0.75)).isEqualTo(3);
assertThat((int) m.invoke(null, 0.65)).isEqualTo(4);
assertThat((int) m.invoke(null, 0.5)).isEqualTo(5);
assertThat((int) m.invoke(null, 0.25)).isEqualTo(6);
assertThat((int) m.invoke(null, 0.18)).isEqualTo(7);
assertThat((int) m.invoke(null, 0.12)).isEqualTo(8);
assertThat((int) m.invoke(null, 0.05)).isEqualTo(9);
}
@Test
@DisplayName("incrementOptimizeLevel grows by ratio and is capped at 9")
void incrementOptimizeLevel() throws Exception {
Method m = privateStatic("incrementOptimizeLevel", int.class, long.class, long.class);
// ratio 3.0 (> 2.0) -> +3
assertThat((int) m.invoke(null, 2, 300L, 100L)).isEqualTo(5);
// ratio 1.8 (> 1.5) -> +2
assertThat((int) m.invoke(null, 2, 180L, 100L)).isEqualTo(4);
// ratio 1.1 -> +1
assertThat((int) m.invoke(null, 2, 110L, 100L)).isEqualTo(3);
// capped at 9
assertThat((int) m.invoke(null, 8, 300L, 100L)).isEqualTo(9);
}
@Test
@DisplayName("getImageType classifies filters; unknown for non-image input")
void getImageType_andFilter() throws Exception {
// A PNG-style lossless image yields FlateDecode -> "PNG".
try (PDDocument doc = new PDDocument()) {
java.awt.image.BufferedImage bi =
new java.awt.image.BufferedImage(
10, 10, java.awt.image.BufferedImage.TYPE_INT_RGB);
PDImageXObject image = LosslessFactory.createFromImage(doc, bi);
Method typeMethod = privateStatic("getImageType", PDImageXObject.class);
String type = (String) typeMethod.invoke(null, image);
assertThat(type).isEqualTo("PNG");
Method filterMethod = privateStatic("getImageFilter", PDImageXObject.class);
String filter = (String) filterMethod.invoke(null, image);
assertThat(filter).contains("FlateDecode");
}
}
}
// ----- reflection / field helpers ----------------------------------------------------------
private static Method privateStatic(String name, Class<?>... params) throws Exception {
Method m = CompressController.class.getDeclaredMethod(name, params);
m.setAccessible(true);
return m;
}
private void setLineArtService(LineArtConversionService service) throws Exception {
Field f = CompressController.class.getDeclaredField("lineArtConversionService");
f.setAccessible(true);
f.set(controller, service);
}
}
@@ -0,0 +1,413 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Unit tests for {@link ExtractImageScansController}.
*
* <p>The controller shells out to a Python/OpenCV script via {@link ProcessExecutor} and gates on
* {@link CheckProgramInstall#isPythonAvailable()}. To keep tests deterministic these static
* boundaries are mocked with {@code Mockito.mockStatic}: Python is forced available/unavailable,
* the script extraction is stubbed, and the process execution is replaced with an in-test answer
* that either writes fake output PNGs into the controller-owned temp directory or leaves it empty.
* No real Python process is ever launched.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ExtractImageScansControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
private TempFileManager tempFileManager;
private ExtractImageScansController controller;
@TempDir Path baseTmpDir;
@BeforeEach
void setUp() {
stirling.software.common.model.ApplicationProperties applicationProperties =
new stirling.software.common.model.ApplicationProperties();
applicationProperties
.getSystem()
.getTempFileManagement()
.setBaseTmpDir(baseTmpDir.toString());
applicationProperties.getSystem().getTempFileManagement().setPrefix("scan-test-");
tempFileManager = new TempFileManager(new TempFileRegistry(), applicationProperties);
controller = new ExtractImageScansController(pdfDocumentFactory, tempFileManager);
}
/** Build a request with sensible defaults; the caller supplies the file input. */
private ExtractImageScansRequest requestFor(MockMultipartFile file) {
ExtractImageScansRequest request = new ExtractImageScansRequest();
request.setFileInput(file);
request.setAngleThreshold(5);
request.setTolerance(20);
request.setMinArea(8000);
request.setMinContourArea(500);
request.setBorderSize(1);
return request;
}
/** A tiny single-page in-memory PDF backed by a small media box for cheap rendering. */
private MockMultipartFile pdfFile(String name) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
// Keep the page small so the 300-DPI render stays tiny and fast.
doc.addPage(new PDPage(new PDRectangle(72f, 72f)));
doc.save(out);
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, out.toByteArray());
}
}
/** A non-PDF image input; the controller copies it straight to a temp file. */
private MockMultipartFile imageFile(String name) {
return new MockMultipartFile(
"fileInput", name, MediaType.IMAGE_PNG_VALUE, new byte[] {1, 2, 3, 4});
}
/**
* A mocked {@link ProcessExecutor} whose {@code runCommandWithOutputHandling} reads the output
* directory from the command (positional arg index 3) and writes the given number of PNG files
* into it, mimicking the real split_photos.py behaviour without launching a process.
*/
private ProcessExecutor execWritingOutputs(int outputCount) throws Exception {
ProcessExecutor exec = mock(ProcessExecutor.class);
when(exec.runCommandWithOutputHandling(anyList()))
.thenAnswer(
invocation -> {
List<String> command = invocation.getArgument(0);
Path outDir = Path.of(command.get(3));
for (int i = 0; i < outputCount; i++) {
Files.write(
outDir.resolve("out_" + i + ".png"),
new byte[] {9, 8, 7, (byte) i});
}
return mock(ProcessExecutorResult.class);
});
return exec;
}
@Nested
@DisplayName("Python availability guard")
class PythonGuard {
@Test
@DisplayName("throws IOException when Python is not installed")
void throwsWhenPythonUnavailable() throws Exception {
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
assertThrows(IOException.class, () -> controller.extractImageScans(request));
}
}
@Test
@DisplayName("does not load the PDF or extract the script when Python is missing")
void shortCircuitsBeforeAnyWork() throws Exception {
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
assertThrows(IOException.class, () -> controller.extractImageScans(request));
// Guard runs before document load and before script extraction.
Mockito.verifyNoInteractions(pdfDocumentFactory);
general.verifyNoInteractions();
}
}
}
@Nested
@DisplayName("No detected images branch")
class NoImagesBranch {
@Test
@DisplayName("throws IllegalArgumentException for a non-PDF input when no outputs produced")
void throwsNoImagesForImageInput() throws Exception {
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
.thenReturn(Path.of("split_photos.py"));
// Process produces zero output files -> empty result -> "no images" error.
// Build the executor mock BEFORE stubbing the static so its inner when(...) does
// not
// nest inside this when(...).thenReturn(...) call.
ProcessExecutor exec = execWritingOutputs(0);
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(exec);
assertThrows(
IllegalArgumentException.class,
() -> controller.extractImageScans(request));
}
}
@Test
@DisplayName("throws IllegalArgumentException for a PDF input when no outputs produced")
void throwsNoImagesForPdfInput() throws Exception {
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
.thenReturn(Path.of("split_photos.py"));
when(pdfDocumentFactory.load(
any(org.springframework.web.multipart.MultipartFile.class)))
.thenReturn(singlePageDocument());
ProcessExecutor exec = execWritingOutputs(0);
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(exec);
assertThrows(
IllegalArgumentException.class,
() -> controller.extractImageScans(request));
// The PDF path must load the document exactly once.
verify(pdfDocumentFactory, times(1))
.load(any(org.springframework.web.multipart.MultipartFile.class));
}
}
}
@Nested
@DisplayName("Single image output branch")
class SingleImageBranch {
@Test
@DisplayName("returns a single PNG response when exactly one image is detected")
void returnsSinglePng() throws Exception {
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python");
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
.thenReturn(Path.of("split_photos.py"));
general.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
.thenAnswer(inv -> inv.<String>getArgument(0) + inv.<String>getArgument(1));
ProcessExecutor exec = execWritingOutputs(1);
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(exec);
ResponseEntity<Resource> response = controller.extractImageScans(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.IMAGE_PNG, response.getHeaders().getContentType());
assertNotNull(response.getBody());
}
}
}
@Nested
@DisplayName("Multiple images (zip) output branch")
class ZipBranch {
@Test
@DisplayName("returns a zip response when more than one image is detected")
void returnsZipForMultipleImages() throws Exception {
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
.thenReturn(Path.of("split_photos.py"));
general.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
.thenAnswer(inv -> inv.<String>getArgument(0) + inv.<String>getArgument(1));
// Three output files -> zip path.
ProcessExecutor exec = execWritingOutputs(3);
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(exec);
ResponseEntity<Resource> response = controller.extractImageScans(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
// generateFilename is invoked for the zip name and once per zip entry.
general.verify(
() -> GeneralUtils.generateFilename(anyString(), anyString()),
atLeastOnce());
}
}
}
@Nested
@DisplayName("Command construction")
class CommandConstruction {
@Test
@DisplayName("passes the request parameters as CLI flags to the executor")
void buildsExpectedCommand() throws Exception {
MockMultipartFile file = imageFile("scan.png");
ExtractImageScansRequest request = new ExtractImageScansRequest();
request.setFileInput(file);
request.setAngleThreshold(7);
request.setTolerance(21);
request.setMinArea(9000);
request.setMinContourArea(600);
request.setBorderSize(2);
ProcessExecutor exec = mock(ProcessExecutor.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> cmdCaptor = ArgumentCaptor.forClass(List.class);
when(exec.runCommandWithOutputHandling(cmdCaptor.capture()))
.thenAnswer(invocation -> mock(ProcessExecutorResult.class));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
.thenReturn(Path.of("split_photos.py"));
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(exec);
// No outputs are written, so the controller ultimately throws "no images";
// we only care that the command was built and dispatched first.
assertThrows(
IllegalArgumentException.class,
() -> controller.extractImageScans(request));
List<String> command = cmdCaptor.getValue();
assertNotNull(command);
assertEquals("python3", command.get(0));
assertTrue(command.contains("--angle_threshold"));
assertEquals("7", valueAfter(command, "--angle_threshold"));
assertEquals("21", valueAfter(command, "--tolerance"));
assertEquals("9000", valueAfter(command, "--min_area"));
assertEquals("600", valueAfter(command, "--min_contour_area"));
assertEquals("2", valueAfter(command, "--border_size"));
}
}
private String valueAfter(List<String> command, String flag) {
int idx = command.indexOf(flag);
assertTrue(idx >= 0 && idx + 1 < command.size(), "flag " + flag + " not found");
return command.get(idx + 1);
}
}
@Nested
@DisplayName("Temp file cleanup")
class Cleanup {
@Test
@DisplayName("leaves no controller temp files behind after a no-images failure")
void cleansUpAfterFailure() throws Exception {
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
try (MockedStatic<CheckProgramInstall> check =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
.thenReturn(Path.of("split_photos.py"));
ProcessExecutor exec = execWritingOutputs(0);
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(exec);
assertThrows(
IllegalArgumentException.class,
() -> controller.extractImageScans(request));
try (var stream = Files.walk(baseTmpDir)) {
boolean leaked =
stream.filter(Files::isRegularFile)
.map(p -> p.getFileName().toString())
.anyMatch(n -> n.startsWith("scan-test-"));
assertFalse(leaked, "controller temp files should be cleaned up");
}
}
}
}
/** Build a fresh single-page in-memory document the factory mock can hand back. */
private PDDocument singlePageDocument() {
PDDocument doc = new PDDocument();
doc.addPage(new PDPage(new PDRectangle(72f, 72f)));
return doc;
}
}
@@ -0,0 +1,290 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Unit tests for {@link OCRController}. OCR shells out to tesseract/ocrmypdf, so these tests focus
* on the pure, deterministic surface: tesseract-language discovery and the request validation /
* tool-availability branches that all complete (or fail) before any external process is launched.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class OCRControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private RuntimePathConfig runtimePathConfig;
private TempFileManager tempFileManager;
private ApplicationProperties applicationProperties;
private OCRController ocrController;
@TempDir Path baseTmpDir;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties
.getSystem()
.getTempFileManagement()
.setBaseTmpDir(baseTmpDir.toString());
applicationProperties.getSystem().getTempFileManagement().setPrefix("ocr-test-");
tempFileManager = new TempFileManager(new TempFileRegistry(), applicationProperties);
ocrController =
new OCRController(
applicationProperties,
pdfDocumentFactory,
tempFileManager,
endpointConfiguration,
runtimePathConfig);
}
/** Build a minimal request with sensible defaults the caller can override. */
private ProcessPdfWithOcrRequest baseRequest() {
ProcessPdfWithOcrRequest request = new ProcessPdfWithOcrRequest();
request.setLanguages(List.of("eng"));
request.setOcrRenderType("hocr");
request.setOcrType("skip-text");
return request;
}
/** Build a tiny single-page in-memory PDF as a MockMultipartFile. */
private MockMultipartFile pdfMultipartFile(String name) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
doc.addPage(new PDPage());
doc.save(out);
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, out.toByteArray());
}
}
/** Create a tessdata directory populated with the given traineddata languages. */
private Path tessdataDirWith(String... languages) throws IOException {
Path dir = Files.createTempDirectory(baseTmpDir, "tessdata");
for (String lang : languages) {
Files.createFile(dir.resolve(lang + ".traineddata"));
}
return dir;
}
@Nested
@DisplayName("getAvailableTesseractLanguages")
class GetAvailableTesseractLanguages {
@Test
@DisplayName("returns trained languages and excludes osd")
void returnsTrainedLanguagesExcludingOsd() throws IOException {
Path tessdata = tessdataDirWith("eng", "deu", "osd");
// A non-traineddata file must be ignored entirely.
Files.createFile(tessdata.resolve("readme.txt"));
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
List<String> langs = ocrController.getAvailableTesseractLanguages();
assertTrue(langs.contains("eng"));
assertTrue(langs.contains("deu"));
assertFalse(langs.contains("osd"), "osd must be filtered out");
assertFalse(langs.contains("readme"), "non-traineddata files must be ignored");
assertEquals(2, langs.size());
}
@Test
@DisplayName("filters osd case-insensitively")
void filtersOsdCaseInsensitively() throws IOException {
Path tessdata = tessdataDirWith("eng", "OSD");
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
List<String> langs = ocrController.getAvailableTesseractLanguages();
assertEquals(List.of("eng"), langs);
}
@Test
@DisplayName("returns empty list when directory has no traineddata files")
void returnsEmptyWhenNoTrainedData() throws IOException {
Path empty = Files.createTempDirectory(baseTmpDir, "empty-tessdata");
when(runtimePathConfig.getTessDataPath()).thenReturn(empty.toString());
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
}
@Test
@DisplayName("returns empty list when directory does not exist")
void returnsEmptyWhenDirectoryMissing() {
Path missing = baseTmpDir.resolve("does-not-exist");
when(runtimePathConfig.getTessDataPath()).thenReturn(missing.toString());
// listFiles() on a non-directory returns null -> empty list, not an exception.
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
}
}
@Nested
@DisplayName("processPdfWithOCR validation branches")
class ValidationBranches {
@Test
@DisplayName("throws when languages list is null")
void throwsWhenLanguagesNull() throws IOException {
ProcessPdfWithOcrRequest request = baseRequest();
request.setLanguages(null);
request.setFileInput(pdfMultipartFile("in.pdf"));
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
// Validation happens before any tool/exec interaction.
verifyNoInteractions(endpointConfiguration);
}
@Test
@DisplayName("throws when languages list is empty")
void throwsWhenLanguagesEmpty() throws IOException {
ProcessPdfWithOcrRequest request = baseRequest();
request.setLanguages(Collections.emptyList());
request.setFileInput(pdfMultipartFile("in.pdf"));
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
verifyNoInteractions(endpointConfiguration);
}
@Test
@DisplayName("throws when ocrRenderType is invalid")
void throwsWhenRenderTypeInvalid() throws IOException {
ProcessPdfWithOcrRequest request = baseRequest();
request.setOcrRenderType("bogus");
request.setFileInput(pdfMultipartFile("in.pdf"));
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
// Render-type check precedes language availability lookup.
verify(runtimePathConfig, never()).getTessDataPath();
}
@Test
@DisplayName("accepts sandwich render type past the render-type check")
void sandwichRenderTypePassesRenderCheck() throws IOException {
ProcessPdfWithOcrRequest request = baseRequest();
request.setOcrRenderType("sandwich");
request.setLanguages(List.of("eng"));
request.setFileInput(pdfMultipartFile("in.pdf"));
// No tessdata languages available -> falls through to invalid-languages, still an
// IOException, but proves "sandwich" was not rejected by the render-type guard.
Path tessdata = tessdataDirWith("eng");
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
// eng is available, so it gets past language validation and reaches the
// tool-availability check, which throws because both tools are disabled.
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
verify(runtimePathConfig).getTessDataPath();
}
@Test
@DisplayName("throws when none of the selected languages are available")
void throwsWhenNoSelectedLanguageAvailable() throws IOException {
ProcessPdfWithOcrRequest request = baseRequest();
request.setLanguages(List.of("xyz")); // not present in tessdata
request.setFileInput(pdfMultipartFile("in.pdf"));
Path tessdata = tessdataDirWith("eng", "deu");
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
// Should fail before consulting tool availability.
verify(endpointConfiguration, never()).isGroupEnabled(anyString());
}
}
@Nested
@DisplayName("processPdfWithOCR tool-availability branch")
class ToolAvailabilityBranch {
@Test
@DisplayName("throws when both OCRmyPDF and tesseract are unavailable")
void throwsWhenNoOcrToolsAvailable() throws IOException {
ProcessPdfWithOcrRequest request = baseRequest();
request.setLanguages(List.of("eng"));
request.setFileInput(pdfMultipartFile("in.pdf"));
Path tessdata = tessdataDirWith("eng");
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
verify(endpointConfiguration).isGroupEnabled("OCRmyPDF");
verify(endpointConfiguration).isGroupEnabled("tesseract");
}
@Test
@DisplayName("temp input/output files are cleaned up after a failure")
void tempFilesCleanedUpAfterFailure() throws IOException {
ProcessPdfWithOcrRequest request = baseRequest();
request.setLanguages(List.of("eng"));
request.setFileInput(pdfMultipartFile("in.pdf"));
Path tessdata = tessdataDirWith("eng");
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
// The only files left under the temp dir should be our tessdata dir and its
// contents; the controller's .pdf temp files must have been closed/deleted.
try (var stream = Files.walk(baseTmpDir)) {
boolean leakedPdf =
stream.filter(Files::isRegularFile)
.map(p -> p.getFileName().toString())
.filter(n -> n.startsWith("ocr-test-"))
.anyMatch(n -> n.endsWith(".pdf"));
assertFalse(leakedPdf, "controller temp PDF files should be cleaned up");
}
}
}
@Test
@DisplayName("getAvailableTesseractLanguages survives a path that is a regular file")
void languagesEmptyWhenPathIsRegularFile() throws IOException {
File regularFile = Files.createTempFile(baseTmpDir, "not-a-dir", ".bin").toFile();
when(runtimePathConfig.getTessDataPath()).thenReturn(regularFile.getAbsolutePath());
// listFiles() on a regular file returns null -> empty list.
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
}
}
@@ -0,0 +1,553 @@
package stirling.software.SPDF.controller.api.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.misc.AddPageNumbersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class PageNumbersControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private PageNumbersController controller;
@BeforeEach
void setUp() throws Exception {
// Each managed temp file is backed by a real on-disk file so document.save() works
// and WebResponseUtils can stat/stream it.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("pgnum-test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
// ---- helpers ----------------------------------------------------------
private MockMultipartFile createPdf(int pages, String filename) throws IOException {
Path path = tempDir.resolve("source-" + System.nanoTime() + ".pdf");
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.LETTER));
}
doc.save(path.toFile());
}
return new MockMultipartFile(
"fileInput", filename, MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(path));
}
private AddPageNumbersRequest baseRequest(MockMultipartFile file) {
AddPageNumbersRequest request = new AddPageNumbersRequest();
request.setFileInput(file);
request.setFontSize(12f);
request.setFontType("helvetica");
request.setPosition(8);
request.setStartingNumber(1);
return request;
}
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
// ---- happy path -------------------------------------------------------
@Nested
@DisplayName("Happy path")
class HappyPath {
@Test
@DisplayName("Single-page PDF returns OK with a non-empty PDF body")
void singlePage_returnsOkWithBody() throws Exception {
MockMultipartFile file = createPdf(1, "doc.pdf");
AddPageNumbersRequest request = baseRequest(file);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
byte[] body = drainBody(response);
assertThat(body).isNotEmpty();
// Result is still a valid, single-page PDF.
try (PDDocument out = Loader.loadPDF(body)) {
assertThat(out.getNumberOfPages()).isEqualTo(1);
}
}
@Test
@DisplayName("Multi-page PDF with default 'all' pages numbers every page")
void multiPage_allPages() throws Exception {
MockMultipartFile file = createPdf(5, "multi.pdf");
AddPageNumbersRequest request = baseRequest(file);
PDDocument doc = Loader.loadPDF(file.getBytes());
when(pdfDocumentFactory.load(file)).thenReturn(doc);
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
assertThat(out.getNumberOfPages()).isEqualTo(5);
}
// The loaded document is closed by the try-with-resources in the controller.
verify(tempFileManager).createManagedTempFile(".pdf");
}
@Test
@DisplayName("Content-Disposition attachment filename carries the source name")
void responseHasAttachmentFilename() throws Exception {
MockMultipartFile file = createPdf(1, "report.pdf");
AddPageNumbersRequest request = baseRequest(file);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
String disposition = response.getHeaders().getFirst("Content-Disposition");
assertThat(disposition).isNotNull();
assertThat(disposition).contains("report_page_numbers_added.pdf");
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
}
}
// ---- pages-to-number selection ----------------------------------------
@Nested
@DisplayName("Page selection")
class PageSelection {
@Test
@DisplayName("Explicit subset of pages still returns all pages in output")
void specificPages() throws Exception {
MockMultipartFile file = createPdf(4, "sel.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setPagesToNumber("1,3");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
assertThat(out.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("Range expression is accepted")
void rangeExpression() throws Exception {
MockMultipartFile file = createPdf(6, "range.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setPagesToNumber("2-4");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Null pagesToNumber defaults to 'all'")
void nullPagesDefaultsToAll() throws Exception {
MockMultipartFile file = createPdf(2, "nullpages.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setPagesToNumber(null);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Empty pagesToNumber defaults to 'all'")
void emptyPagesDefaultsToAll() throws Exception {
MockMultipartFile file = createPdf(2, "emptypages.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setPagesToNumber("");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- position handling (1..9 plus clamping) ---------------------------
@Nested
@DisplayName("Position")
class Position {
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
@DisplayName("All nine positions render successfully")
void allPositions(int position) throws Exception {
MockMultipartFile file = createPdf(1, "pos.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setPosition(position);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@ParameterizedTest
@ValueSource(ints = {-5, 0, 10, 100})
@DisplayName("Out-of-range positions are clamped and still render")
void outOfRangePositionsClamped(int position) throws Exception {
MockMultipartFile file = createPdf(1, "posclamp.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setPosition(position);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- margins ----------------------------------------------------------
@Nested
@DisplayName("Custom margin")
class CustomMargin {
@ParameterizedTest
@ValueSource(strings = {"small", "medium", "large", "x-large", "X-LARGE", "unknown"})
@DisplayName("Known and unknown margins are accepted")
void margins(String margin) throws Exception {
MockMultipartFile file = createPdf(1, "margin.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setCustomMargin(margin);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Null margin falls back to default factor")
void nullMargin() throws Exception {
MockMultipartFile file = createPdf(1, "nullmargin.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setCustomMargin(null);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- font type --------------------------------------------------------
@Nested
@DisplayName("Font type")
class FontType {
@ParameterizedTest
@ValueSource(strings = {"helvetica", "courier", "times", "TIMES", "anythingelse"})
@DisplayName("Known and unknown font types render (unknown falls back to Helvetica)")
void fontTypes(String font) throws Exception {
MockMultipartFile file = createPdf(1, "font.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setFontType(font);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Null font type falls back to Helvetica")
void nullFontType() throws Exception {
MockMultipartFile file = createPdf(1, "nullfont.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setFontType(null);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- font color -------------------------------------------------------
@Nested
@DisplayName("Font color")
class FontColor {
@Test
@DisplayName("Valid hex color renders")
void validHexColor() throws Exception {
MockMultipartFile file = createPdf(1, "color.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setFontColor("#FF0000");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Invalid hex color falls back to black and still renders")
void invalidHexColorFallsBackToBlack() throws Exception {
MockMultipartFile file = createPdf(1, "badcolor.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setFontColor("not-a-color");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Null font color uses default black")
void nullColor() throws Exception {
MockMultipartFile file = createPdf(1, "nullcolor.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setFontColor(null);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Blank/whitespace font color uses default black")
void blankColor() throws Exception {
MockMultipartFile file = createPdf(1, "blankcolor.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setFontColor(" ");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- custom text / placeholders --------------------------------------
@Nested
@DisplayName("Custom text")
class CustomText {
@Test
@DisplayName("Null custom text defaults to {n}")
void nullCustomText() throws Exception {
MockMultipartFile file = createPdf(2, "nulltext.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setCustomText(null);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Empty custom text defaults to {n}")
void emptyCustomText() throws Exception {
MockMultipartFile file = createPdf(2, "emptytext.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setCustomText("");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Custom text with {n}, {total} and {filename} placeholders renders")
void placeholders() throws Exception {
MockMultipartFile file = createPdf(3, "myfile.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setCustomText("Page {n} of {total} - {filename}");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
assertThat(out.getNumberOfPages()).isEqualTo(3);
}
}
@Test
@DisplayName("Literal custom text without placeholders renders")
void literalText() throws Exception {
MockMultipartFile file = createPdf(1, "literal.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setCustomText("Confidential");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- numbering / zero-pad / starting number ---------------------------
@Nested
@DisplayName("Numbering")
class Numbering {
@Test
@DisplayName("Zero-pad width produces Bates-style padded numbers without error")
void zeroPadBatesStamping() throws Exception {
MockMultipartFile file = createPdf(3, "bates.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setZeroPad(5);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Zero zero-pad uses unpadded numbers")
void zeroPadDisabled() throws Exception {
MockMultipartFile file = createPdf(2, "nopad.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setZeroPad(0);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
@Test
@DisplayName("Custom starting number is honored")
void customStartingNumber() throws Exception {
MockMultipartFile file = createPdf(3, "start.pdf");
AddPageNumbersRequest request = baseRequest(file);
request.setStartingNumber(100);
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- filename handling for {filename} ---------------------------------
@Nested
@DisplayName("Filename handling")
class FilenameHandling {
@Test
@DisplayName("Filename without extension is handled for {filename} placeholder")
void filenameWithoutExtension() throws Exception {
MockMultipartFile file = createPdf(1, "no_extension");
AddPageNumbersRequest request = baseRequest(file);
request.setCustomText("{filename}");
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
ResponseEntity<Resource> response = controller.addPageNumbers(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drainBody(response)).isNotEmpty();
}
}
// ---- error branches ---------------------------------------------------
@Nested
@DisplayName("Error handling")
class ErrorHandling {
@Test
@DisplayName("IOException from document load propagates")
void loadIOExceptionPropagates() throws Exception {
MockMultipartFile file = createPdf(1, "err.pdf");
AddPageNumbersRequest request = baseRequest(file);
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("corrupt pdf"));
assertThatThrownBy(() -> controller.addPageNumbers(request))
.isInstanceOf(IOException.class)
.hasMessageContaining("corrupt pdf");
}
}
}
@@ -0,0 +1,447 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
@DisplayName("RemoveImagesController")
class RemoveImagesControllerTest {
@TempDir Path tempDir;
private CustomPDFDocumentFactory pdfDocumentFactory;
private TempFileManager tempFileManager;
private RemoveImagesController controller;
// Holds the temp file backing the most recent createManagedTempFile call so tests can
// re-load the actually-saved (image-stripped) document from disk and assert on it.
private final List<File> savedTempFiles = new ArrayList<>();
@BeforeEach
void setUp() throws IOException {
pdfDocumentFactory = mock(CustomPDFDocumentFactory.class);
tempFileManager = mock(TempFileManager.class);
controller = new RemoveImagesController(pdfDocumentFactory, tempFileManager);
savedTempFiles.clear();
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile(tempDir, "out", inv.<String>getArgument(0))
.toFile();
savedTempFiles.add(f);
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
// ----- helpers -------------------------------------------------------------------------
private MockMultipartFile multipart(String name, byte[] bytes) {
return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
}
private PDFFile request(MockMultipartFile file) {
PDFFile req = new PDFFile();
req.setFileInput(file);
return req;
}
/** A drawable RGB image with no useful content, kept tiny for speed. */
private BufferedImage tinyImage() {
return new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
}
/** PDF with {@code pageCount} pages, each with one drawn JPEG image. */
private byte[] pdfWithImagesBytes(int pageCount) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pageCount; i++) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
PDImageXObject image = JPEGFactory.createFromImage(doc, tinyImage());
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(image, 50, 600, 50, 50);
}
}
return saveToBytes(doc);
}
}
/** PDF with one page that has no images at all (just resources without XObjects). */
private byte[] pdfWithoutImagesBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
// Touch the page resources so they are non-null but contain no XObject dictionary.
page.setResources(new PDResources());
return saveToBytes(doc);
}
}
/** PDF whose single page has a resources dictionary with an image nested in a form XObject. */
private byte[] pdfWithImageInsideFormBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
PDImageXObject image = JPEGFactory.createFromImage(doc, tinyImage());
PDFormXObject form = new PDFormXObject(doc);
form.setBBox(new PDRectangle(100, 100));
PDResources formResources = new PDResources();
formResources.add(image);
form.setResources(formResources);
PDResources pageResources = new PDResources();
pageResources.add(form);
page.setResources(pageResources);
return saveToBytes(doc);
}
}
private byte[] saveToBytes(PDDocument doc) throws IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
/** Counts every PDImageXObject reachable through page + nested form resources. */
private int countImagesInSavedOutput() throws IOException {
assertFalse(savedTempFiles.isEmpty(), "expected the controller to create a temp file");
File out = savedTempFiles.get(savedTempFiles.size() - 1);
try (PDDocument doc = Loader.loadPDF(out)) {
int count = 0;
for (PDPage page : doc.getPages()) {
count += countImagesInResources(page.getResources());
}
return count;
}
}
private int countImagesInResources(PDResources resources) throws IOException {
if (resources == null || resources.getXObjectNames() == null) {
return 0;
}
int count = 0;
for (COSName name : resources.getXObjectNames()) {
PDXObject xObject = resources.getXObject(name);
if (xObject instanceof PDImageXObject) {
count++;
} else if (xObject instanceof PDFormXObject form) {
count += countImagesInResources(form.getResources());
}
}
return count;
}
// ----- happy paths ---------------------------------------------------------------------
@Nested
@DisplayName("removeImages happy path")
class HappyPath {
@Test
@DisplayName("returns OK and strips the image from a single-page PDF")
void singlePageWithImage() throws IOException {
byte[] bytes = pdfWithImagesBytes(1);
MockMultipartFile file = multipart("doc.pdf", bytes);
PDFFile req = request(file);
PDDocument loaded = Loader.loadPDF(bytes);
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
// sanity: the input genuinely had one image to remove
assertEquals(1, countImagesInResources(loaded.getPage(0).getResources()));
ResponseEntity<Resource> response;
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
response = controller.removeImages(req);
}
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(0, countImagesInSavedOutput(), "all images should have been removed");
}
@Test
@DisplayName("strips images across every page of a multi-page PDF")
void multiPageWithImages() throws IOException {
byte[] bytes = pdfWithImagesBytes(3);
MockMultipartFile file = multipart("multi.pdf", bytes);
PDFFile req = request(file);
PDDocument loaded = Loader.loadPDF(bytes);
assertEquals(3, loaded.getNumberOfPages());
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
ResponseEntity<Resource> response = controller.removeImages(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
assertEquals(0, countImagesInSavedOutput());
// page count must be preserved
File out = savedTempFiles.get(savedTempFiles.size() - 1);
try (PDDocument result = Loader.loadPDF(out)) {
assertEquals(3, result.getNumberOfPages());
}
}
@Test
@DisplayName("removes an image nested inside a form XObject")
void imageNestedInsideForm() throws IOException {
byte[] bytes = pdfWithImageInsideFormBytes();
MockMultipartFile file = multipart("nested.pdf", bytes);
PDFFile req = request(file);
PDDocument loaded = Loader.loadPDF(bytes);
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
// sanity: the nested image is present before removal
assertEquals(1, countImagesInResources(loaded.getPage(0).getResources()));
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
controller.removeImages(req);
}
assertEquals(0, countImagesInSavedOutput(), "nested form image should be removed");
}
}
// ----- edge cases ----------------------------------------------------------------------
@Nested
@DisplayName("removeImages edge cases")
class EdgeCases {
@Test
@DisplayName("returns OK when the PDF has no images")
void noImages() throws IOException {
byte[] bytes = pdfWithoutImagesBytes();
MockMultipartFile file = multipart("plain.pdf", bytes);
PDFFile req = request(file);
PDDocument loaded = Loader.loadPDF(bytes);
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
ResponseEntity<Resource> response = controller.removeImages(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
assertEquals(0, countImagesInSavedOutput());
}
@Test
@DisplayName("handles a page that has no resources dictionary")
void pageWithoutResources() throws IOException {
// A bare PDPage built without content has null resources.
byte[] bytes;
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.LETTER));
bytes = saveToBytes(doc);
}
MockMultipartFile file = multipart("bare.pdf", bytes);
PDFFile req = request(file);
PDDocument loaded = Loader.loadPDF(bytes);
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
ResponseEntity<Resource> response = controller.removeImages(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
assertEquals(0, countImagesInSavedOutput());
}
@Test
@DisplayName("creates exactly one managed temp file and saves into it")
void savesIntoManagedTempFile() throws IOException {
byte[] bytes = pdfWithImagesBytes(1);
MockMultipartFile file = multipart("doc.pdf", bytes);
PDFFile req = request(file);
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
controller.removeImages(req);
}
assertEquals(1, savedTempFiles.size());
File out = savedTempFiles.get(0);
assertTrue(out.exists());
assertTrue(out.length() > 0, "saved PDF must be non-empty");
}
}
// ----- filename / interaction ----------------------------------------------------------
@Nested
@DisplayName("filename handling")
class Filenames {
@Test
@DisplayName("appends _images_removed.pdf suffix to the original name")
void appendsSuffix() throws IOException {
byte[] bytes = pdfWithImagesBytes(1);
MockMultipartFile file = multipart("report.pdf", bytes);
PDFFile req = request(file);
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
controller.removeImages(req);
web.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class),
org.mockito.ArgumentMatchers.eq(
"report_images_removed.pdf")));
}
}
@Test
@DisplayName("derives a default name when the original filename is null")
void nullOriginalFilename() throws IOException {
byte[] bytes = pdfWithImagesBytes(1);
// MockMultipartFile with a null original filename
MockMultipartFile file =
new MockMultipartFile(
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, bytes);
PDFFile req = request(file);
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
web.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(okResponse());
ResponseEntity<Resource> response = controller.removeImages(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
// GeneralUtils.generateFilename handles null safely; just assert it was called
web.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()));
}
}
}
// ----- error branches ------------------------------------------------------------------
@Nested
@DisplayName("error handling")
class Errors {
@Test
@DisplayName("propagates an IOException when loading the PDF fails")
void loadFailureThrowsIOException() throws IOException {
MockMultipartFile file = multipart("broken.pdf", "not a pdf".getBytes());
PDFFile req = request(file);
when(pdfDocumentFactory.load(req)).thenThrow(new IOException("corrupt"));
assertThrows(IOException.class, () -> controller.removeImages(req));
}
}
private static ResponseEntity<Resource> okResponse() {
return ResponseEntity.ok(new ByteArrayResource("ok".getBytes()));
}
}
@@ -0,0 +1,318 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Unit tests for {@link RepairController}.
*
* <p>The controller delegates to external binaries (Ghostscript, qpdf) for its primary repair
* paths. Those paths shell out via the static {@code ProcessExecutor} factory and are therefore not
* deterministically testable in a unit test. These tests keep both Ghostscript and qpdf disabled so
* the controller always takes the pure-Java PDFBox last-resort branch, exercising file handling,
* filename generation, the success response, and the error-propagation branches without spawning
* any external process.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class RepairControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private EndpointConfiguration endpointConfiguration;
// Real TempFileManager so transferTo / temp file creation / file-backed response all work
// end-to-end deterministically without touching any external tooling.
private TempFileManager tempFileManager;
private RepairController repairController;
@BeforeEach
void setUp() {
tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
repairController =
new RepairController(pdfDocumentFactory, tempFileManager, endpointConfiguration);
// Default: no external tools available -> forces the PDFBox last-resort branch.
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(false);
}
/** Build a tiny, valid in-memory PDF as bytes. */
private static byte[] buildPdfBytes(int pageCount) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pageCount; i++) {
document.addPage(new PDPage(PDRectangle.A4));
}
document.save(baos);
return baos.toByteArray();
}
}
/** A fresh real in-memory document for stubbing pdfDocumentFactory.load(). */
private static PDDocument newRealDocument(int pageCount) {
PDDocument document = new PDDocument();
for (int i = 0; i < pageCount; i++) {
document.addPage(new PDPage(PDRectangle.A4));
}
return document;
}
private static PDFFile pdfFileFrom(MockMultipartFile multipartFile) {
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(multipartFile);
return pdfFile;
}
/** Read the body of a file-backed Resource response into bytes. */
private static byte[] readResource(Resource resource) throws IOException {
try (InputStream in = resource.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
in.transferTo(baos);
return baos.toByteArray();
}
}
@Nested
@DisplayName("PDFBox last-resort branch (no external tools)")
class PdfBoxBranch {
@Test
@DisplayName("returns 200 with a non-empty PDF resource body")
void repairPdf_pdfBoxFallback_returnsOkWithPdf() throws Exception {
MockMultipartFile input =
new MockMultipartFile(
"fileInput",
"broken.pdf",
MediaType.APPLICATION_PDF_VALUE,
buildPdfBytes(2));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(2));
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.APPLICATION_PDF, response.getHeaders().getContentType());
Resource body = response.getBody();
assertNotNull(body);
byte[] outputBytes = readResource(body);
assertTrue(outputBytes.length > 0, "repaired PDF body should not be empty");
// Output must be a structurally valid PDF; confirm by reloading it.
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(outputBytes)) {
assertEquals(2, reloaded.getNumberOfPages());
}
}
@Test
@DisplayName("loads the input file exactly once via the factory")
void repairPdf_pdfBoxFallback_loadsInputOnce() throws Exception {
MockMultipartFile input =
new MockMultipartFile(
"fileInput",
"broken.pdf",
MediaType.APPLICATION_PDF_VALUE,
buildPdfBytes(1));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
repairController.repairPdf(pdfFileFrom(input));
verify(pdfDocumentFactory, times(1)).load(any(File.class));
}
@Test
@DisplayName("does not consult qpdf/ghostscript a second time once disabled")
void repairPdf_checksToolAvailability() throws Exception {
MockMultipartFile input =
new MockMultipartFile(
"fileInput",
"broken.pdf",
MediaType.APPLICATION_PDF_VALUE,
buildPdfBytes(1));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
repairController.repairPdf(pdfFileFrom(input));
// Ghostscript is checked once (skip), qpdf once (skip), then both checked again to
// decide the last-resort branch.
verify(endpointConfiguration, times(2)).isGroupEnabled("Ghostscript");
verify(endpointConfiguration, times(2)).isGroupEnabled("qpdf");
}
@Test
@DisplayName("the file passed to the factory exists on disk when loaded")
void repairPdf_transfersInputToRealTempFile() throws Exception {
byte[] pdf = buildPdfBytes(3);
MockMultipartFile input =
new MockMultipartFile(
"fileInput", "broken.pdf", MediaType.APPLICATION_PDF_VALUE, pdf);
// Assert the file handed to load() is a real, non-empty file (transferTo succeeded).
when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(
invocation -> {
File file = invocation.getArgument(0);
assertTrue(file.exists(), "temp input file should exist");
assertEquals(pdf.length, file.length());
return newRealDocument(3);
});
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Nested
@DisplayName("Output filename handling")
class FilenameHandling {
@Test
@DisplayName("appends _repaired.pdf to the base name in the Content-Disposition header")
void repairPdf_setsRepairedFilename() throws Exception {
MockMultipartFile input =
new MockMultipartFile(
"fileInput",
"mydoc.pdf",
MediaType.APPLICATION_PDF_VALUE,
buildPdfBytes(1));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
HttpHeaders headers = response.getHeaders();
String disposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(disposition);
assertTrue(
disposition.contains("mydoc_repaired.pdf"),
"expected repaired filename in: " + disposition);
}
@Test
@DisplayName("filename without extension still gets _repaired.pdf appended")
void repairPdf_filenameWithoutExtension() throws Exception {
MockMultipartFile input =
new MockMultipartFile(
"fileInput",
"noext",
MediaType.APPLICATION_PDF_VALUE,
buildPdfBytes(1));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(disposition);
assertTrue(
disposition.contains("noext_repaired.pdf"),
"expected repaired filename in: " + disposition);
}
@Test
@DisplayName("null original filename falls back to 'default'")
void repairPdf_nullOriginalFilename_usesDefault() throws Exception {
// MockMultipartFile with null original filename.
MockMultipartFile input =
new MockMultipartFile(
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, buildPdfBytes(1));
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
assertEquals(HttpStatus.OK, response.getStatusCode());
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(disposition);
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
assertTrue(
disposition.contains("_repaired.pdf"),
"expected empty-base repaired filename in: " + disposition);
}
}
@Nested
@DisplayName("Error propagation")
class ErrorPropagation {
@Test
@DisplayName("IOException from the factory propagates to the caller")
void repairPdf_loadThrowsIOException_propagates() throws Exception {
MockMultipartFile input =
new MockMultipartFile(
"fileInput",
"broken.pdf",
MediaType.APPLICATION_PDF_VALUE,
buildPdfBytes(1));
when(pdfDocumentFactory.load(any(File.class)))
.thenThrow(new IOException("cannot load corrupt pdf"));
IOException thrown =
assertThrows(
IOException.class,
() -> repairController.repairPdf(pdfFileFrom(input)));
assertEquals("cannot load corrupt pdf", thrown.getMessage());
}
@Test
@DisplayName("RuntimeException from the factory propagates to the caller")
void repairPdf_loadThrowsRuntimeException_propagates() throws Exception {
MockMultipartFile input =
new MockMultipartFile(
"fileInput",
"broken.pdf",
MediaType.APPLICATION_PDF_VALUE,
buildPdfBytes(1));
when(pdfDocumentFactory.load(any(File.class)))
.thenThrow(new IllegalStateException("boom"));
IllegalStateException thrown =
assertThrows(
IllegalStateException.class,
() -> repairController.repairPdf(pdfFileFrom(input)));
assertEquals("boom", thrown.getMessage());
}
}
}
@@ -0,0 +1,932 @@
package stirling.software.SPDF.controller.api.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.Files;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.misc.ScannerEffectRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ScannerEffectController Tests")
class ScannerEffectControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ScannerEffectController controller;
@BeforeEach
void setUp() throws Exception {
// Real temp file backing so WebResponseUtils.pdfDocToWebResponse can save the output.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("scanner_test", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private static MockMultipartFile pdfFile(String filename, int pageCount, PDRectangle pageSize)
throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pageCount; i++) {
doc.addPage(new PDPage(pageSize));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return new MockMultipartFile(
"fileInput", filename, "application/pdf", baos.toByteArray());
}
}
private static ScannerEffectRequest baseRequest(MockMultipartFile file) {
ScannerEffectRequest request = new ScannerEffectRequest();
request.setFileInput(file);
// Advanced enabled so quality presets do not override our resolution.
request.setAdvancedEnabled(true);
// Keep rendering tiny and fast; deterministic structure only.
request.setResolution(36);
request.setRotation(ScannerEffectRequest.Rotation.none);
request.setRotate(0);
request.setRotateVariance(0);
request.setBorder(2);
request.setBrightness(1.0f);
request.setContrast(1.0f);
request.setBlur(0f);
request.setNoise(0f);
request.setYellowish(false);
request.setColorspace(ScannerEffectRequest.Colorspace.grayscale);
return request;
}
/** Stub both factory.load overloads to return fresh real documents loaded from bytes. */
private void stubFactoryLoad(byte[] pdfBytes) throws IOException {
// Used for page count + as output base (load(byte[])).
lenient()
.when(pdfDocumentFactory.load(any(byte[].class)))
.thenAnswer(
inv -> {
byte[] b = inv.getArgument(0);
return Loader.loadPDF(b);
});
// Used by RenderingResources.fromBytes (load(byte[], true)).
lenient()
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
}
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
// ---------------------------------------------------------------------
// End-to-end controller behaviour (real rendering, mocked boundaries)
// ---------------------------------------------------------------------
@Nested
@DisplayName("scannerEffect end-to-end")
class EndToEnd {
@Test
@DisplayName("produces a valid single-page PDF response for a one-page input")
void singlePageHappyPath() throws Exception {
MockMultipartFile file = pdfFile("input.pdf", 1, PDRectangle.A6);
stubFactoryLoad(file.getBytes());
ScannerEffectRequest request = baseRequest(file);
ResponseEntity<Resource> response = controller.scannerEffect(request);
assertThat(response).isNotNull();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
byte[] out = drain(response);
assertThat(out).isNotEmpty();
try (PDDocument result = Loader.loadPDF(out)) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
PDRectangle box = result.getPage(0).getMediaBox();
// Output page keeps the original page dimensions.
assertThat(box.getWidth()).isCloseTo(PDRectangle.A6.getWidth(), within(1f));
assertThat(box.getHeight()).isCloseTo(PDRectangle.A6.getHeight(), within(1f));
}
}
@Test
@DisplayName("preserves page count for a multi-page input")
void multiPageKeepsPageCount() throws Exception {
MockMultipartFile file = pdfFile("multi.pdf", 3, PDRectangle.A6);
stubFactoryLoad(file.getBytes());
ScannerEffectRequest request = baseRequest(file);
ResponseEntity<Resource> response = controller.scannerEffect(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument result = Loader.loadPDF(drain(response))) {
assertThat(result.getNumberOfPages()).isEqualTo(3);
}
}
@Test
@DisplayName("applies colour effects (color colorspace, blur, noise, yellowish, rotation)")
void richEffectsStillProduceValidPdf() throws Exception {
MockMultipartFile file = pdfFile("rich.pdf", 1, PDRectangle.A6);
stubFactoryLoad(file.getBytes());
ScannerEffectRequest request = baseRequest(file);
request.setColorspace(ScannerEffectRequest.Colorspace.color);
request.setBlur(1.0f);
request.setNoise(4.0f);
request.setYellowish(true);
request.setRotation(ScannerEffectRequest.Rotation.slight);
request.setRotateVariance(2);
request.setBorder(5);
request.setBrightness(1.05f);
request.setContrast(1.1f);
ResponseEntity<Resource> response = controller.scannerEffect(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument result = Loader.loadPDF(drain(response))) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
}
}
@Test
@DisplayName("non-advanced request applies the quality preset without error")
void qualityPresetPath() throws Exception {
MockMultipartFile file = pdfFile("preset.pdf", 1, PDRectangle.A6);
stubFactoryLoad(file.getBytes());
ScannerEffectRequest request = baseRequest(file);
request.setAdvancedEnabled(false);
// Low preset uses resolution 75 which is the cheapest render.
request.setQuality(ScannerEffectRequest.Quality.low);
ResponseEntity<Resource> response = controller.scannerEffect(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument result = Loader.loadPDF(drain(response))) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
}
}
@Test
@DisplayName("rejects a DPI above the safe maximum with IllegalArgumentException")
void dpiAboveLimitIsRejected() throws Exception {
MockMultipartFile file = pdfFile("highdpi.pdf", 1, PDRectangle.A6);
stubFactoryLoad(file.getBytes());
ScannerEffectRequest request = baseRequest(file);
// No application context in tests -> maxSafeDpi defaults to 500.
request.setResolution(600);
assertThatThrownBy(() -> controller.scannerEffect(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("600");
}
@Test
@DisplayName("rejects an empty (zero-page) document with IllegalArgumentException")
void emptyDocumentIsRejected() throws Exception {
// A real 1-page file on disk, but the factory returns an empty document.
MockMultipartFile file = pdfFile("empty.pdf", 1, PDRectangle.A6);
ScannerEffectRequest request = baseRequest(file);
lenient()
.when(pdfDocumentFactory.load(any(byte[].class)))
.thenAnswer(inv -> new PDDocument());
lenient()
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
.thenAnswer(inv -> new PDDocument());
assertThatThrownBy(() -> controller.scannerEffect(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("no pages");
}
@Test
@DisplayName("propagates IOException from the document factory")
void factoryIOExceptionPropagates() throws Exception {
MockMultipartFile file = pdfFile("io.pdf", 1, PDRectangle.A6);
ScannerEffectRequest request = baseRequest(file);
lenient()
.when(pdfDocumentFactory.load(any(byte[].class)))
.thenThrow(new IOException("boom-load"));
lenient()
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
.thenThrow(new IOException("boom-load"));
assertThatThrownBy(() -> controller.scannerEffect(request))
.isInstanceOf(IOException.class);
}
}
// ---------------------------------------------------------------------
// Pure static image-processing logic via reflection
// ---------------------------------------------------------------------
@Nested
@DisplayName("calculateSafeResolution")
class CalculateSafeResolution {
private Method method;
@BeforeEach
void setUp() throws Exception {
method =
ScannerEffectController.class.getDeclaredMethod(
"calculateSafeResolution", float.class, float.class, int.class);
method.setAccessible(true);
}
private int invoke(float w, float h, int res) throws Exception {
return (int) method.invoke(null, w, h, res);
}
@Test
@DisplayName("keeps requested resolution when the projected image is small")
void keepsResolutionForSmallPage() throws Exception {
// A6 at 72 dpi is well within limits.
assertThat(invoke(297f, 420f, 72)).isEqualTo(72);
}
@Test
@DisplayName("downscales resolution when the projected image is huge")
void downscalesForHugePage() throws Exception {
// A0-ish page at a very high dpi exceeds the pixel/size caps.
int safe = invoke(2384f, 3370f, 2000);
assertThat(safe).isLessThan(2000);
assertThat(safe).isGreaterThanOrEqualTo(72);
}
@Test
@DisplayName("never returns below the floor of 72")
void neverBelowFloor() throws Exception {
int safe = invoke(5000f, 5000f, 4000);
assertThat(safe).isGreaterThanOrEqualTo(72);
}
}
@Nested
@DisplayName("determineRenderResolution")
class DetermineRenderResolution {
@Test
@DisplayName("returns the request resolution unchanged")
void returnsRequestResolution() throws Exception {
Method method =
ScannerEffectController.class.getDeclaredMethod(
"determineRenderResolution", ScannerEffectRequest.class);
method.setAccessible(true);
ScannerEffectRequest request = new ScannerEffectRequest();
request.setResolution(123);
assertThat((int) method.invoke(null, request)).isEqualTo(123);
}
}
@Nested
@DisplayName("convertColorspace / convertToGrayscale")
class Colorspace {
private static BufferedImage solid(int rgb) {
BufferedImage image = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < 4; y++) {
for (int x = 0; x < 4; x++) {
image.setRGB(x, y, rgb);
}
}
return image;
}
@ParameterizedTest
@EnumSource(ScannerEffectRequest.Colorspace.class)
@DisplayName("returns an INT_RGB image of the same dimensions for any colorspace")
void preservesDimensions(ScannerEffectRequest.Colorspace colorspace) throws Exception {
Method method =
ScannerEffectController.class.getDeclaredMethod(
"convertColorspace",
BufferedImage.class,
ScannerEffectRequest.Colorspace.class);
method.setAccessible(true);
BufferedImage src = solid(0x123456);
BufferedImage result = (BufferedImage) method.invoke(null, src, colorspace);
assertThat(result.getWidth()).isEqualTo(4);
assertThat(result.getHeight()).isEqualTo(4);
assertThat(result.getType()).isEqualTo(BufferedImage.TYPE_INT_RGB);
}
@Test
@DisplayName("grayscale collapses the channels to a single grey value")
void grayscaleEqualisesChannels() throws Exception {
Method method =
ScannerEffectController.class.getDeclaredMethod(
"convertColorspace",
BufferedImage.class,
ScannerEffectRequest.Colorspace.class);
method.setAccessible(true);
// R=90, G=120, B=150 -> avg 120 -> 0x787878
BufferedImage src = solid((90 << 16) | (120 << 8) | 150);
BufferedImage result =
(BufferedImage)
method.invoke(null, src, ScannerEffectRequest.Colorspace.grayscale);
int px = result.getRGB(0, 0) & 0xFFFFFF;
int r = (px >> 16) & 0xFF;
int g = (px >> 8) & 0xFF;
int b = px & 0xFF;
assertThat(r).isEqualTo(g).isEqualTo(b);
assertThat(r).isEqualTo(120);
}
@Test
@DisplayName("convertToGrayscale mutates the buffer in place")
void convertToGrayscaleInPlace() throws Exception {
Method method =
ScannerEffectController.class.getDeclaredMethod(
"convertToGrayscale", BufferedImage.class);
method.setAccessible(true);
BufferedImage img = solid((30 << 16) | (60 << 8) | 90); // avg 60
method.invoke(null, img);
int px = img.getRGB(1, 1) & 0xFFFFFF;
assertThat(px).isEqualTo((60 << 16) | (60 << 8) | 60);
}
}
@Nested
@DisplayName("calculateRotation")
class CalculateRotation {
private Method method;
@BeforeEach
void setUp() throws Exception {
method =
ScannerEffectController.class.getDeclaredMethod(
"calculateRotation", int.class, int.class);
method.setAccessible(true);
}
@Test
@DisplayName("returns exactly 0 when base rotation and variance are both 0")
void zeroWhenNoRotation() throws Exception {
assertThat((double) method.invoke(null, 0, 0)).isEqualTo(0.0);
}
@Test
@DisplayName("stays within base +/- variance bounds")
void withinBounds() throws Exception {
for (int i = 0; i < 100; i++) {
double value = (double) method.invoke(null, 5, 3);
assertThat(value).isBetween(2.0, 8.0);
}
}
@Test
@DisplayName("with zero variance but non-zero base returns the base rotation")
void zeroVarianceReturnsBase() throws Exception {
// base=5, variance=0 -> 5 + (rand*2-1)*0 = 5
assertThat((double) method.invoke(null, 5, 0)).isEqualTo(5.0);
}
}
@Nested
@DisplayName("blendColors")
class BlendColors {
private Method method;
@BeforeEach
void setUp() throws Exception {
method =
ScannerEffectController.class.getDeclaredMethod(
"blendColors", int.class, int.class, float.class);
method.setAccessible(true);
}
private int invoke(int fg, int bg, float alpha) throws Exception {
return (int) method.invoke(null, fg, bg, alpha);
}
@Test
@DisplayName("alpha=1 yields the foreground colour")
void alphaOneIsForeground() throws Exception {
assertThat(invoke(0xAABBCC, 0x112233, 1.0f)).isEqualTo(0xAABBCC);
}
@Test
@DisplayName("alpha=0 yields the background colour")
void alphaZeroIsBackground() throws Exception {
assertThat(invoke(0xAABBCC, 0x112233, 0.0f)).isEqualTo(0x112233);
}
@Test
@DisplayName("alpha=0.5 yields the rounded midpoint per channel")
void alphaHalfIsMidpoint() throws Exception {
// fg 0x806040 (128,96,64), bg 0x204060 (32,64,96)
int blended = invoke(0x806040, 0x204060, 0.5f);
int r = (blended >> 16) & 0xFF;
int g = (blended >> 8) & 0xFF;
int b = blended & 0xFF;
assertThat(r).isEqualTo(80); // (128+32)/2
assertThat(g).isEqualTo(80); // (96+64)/2
assertThat(b).isEqualTo(80); // (64+96)/2
}
}
@Nested
@DisplayName("fillWithGradient")
class FillWithGradient {
private Method method;
@BeforeEach
void setUp() throws Exception {
method =
ScannerEffectController.class.getDeclaredMethod(
"fillWithGradient",
int[].class,
int.class,
int.class,
int[].class,
boolean.class);
method.setAccessible(true);
}
@Test
@DisplayName("vertical gradient assigns one LUT value per row")
void verticalFill() throws Exception {
int width = 3;
int height = 2;
int[] pixels = new int[width * height];
int[] lut = {0x111111, 0x222222};
method.invoke(null, pixels, width, height, lut, true);
// Row 0 all lut[0], row 1 all lut[1].
assertThat(pixels[0]).isEqualTo(0x111111);
assertThat(pixels[2]).isEqualTo(0x111111);
assertThat(pixels[3]).isEqualTo(0x222222);
assertThat(pixels[5]).isEqualTo(0x222222);
}
@Test
@DisplayName("horizontal gradient assigns one LUT value per column, repeated each row")
void horizontalFill() throws Exception {
int width = 3;
int height = 2;
int[] pixels = new int[width * height];
int[] lut = {0xAA0000, 0x00BB00, 0x0000CC};
method.invoke(null, pixels, width, height, lut, false);
// Each row mirrors the LUT.
assertThat(pixels[0]).isEqualTo(0xAA0000);
assertThat(pixels[1]).isEqualTo(0x00BB00);
assertThat(pixels[2]).isEqualTo(0x0000CC);
assertThat(pixels[3]).isEqualTo(0xAA0000);
assertThat(pixels[4]).isEqualTo(0x00BB00);
assertThat(pixels[5]).isEqualTo(0x0000CC);
}
}
@Nested
@DisplayName("createGradientLUT")
class CreateGradientLUT {
private Object gradient(boolean vertical, Color start, Color end) throws Exception {
Class<?> gradientClass =
Class.forName(
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
Constructor<?> ctor =
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
ctor.setAccessible(true);
return ctor.newInstance(vertical, start, end);
}
private int[] invokeLut(int width, int height, Object gradientConfig) throws Exception {
Class<?> gradientClass =
Class.forName(
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
Method method =
ScannerEffectController.class.getDeclaredMethod(
"createGradientLUT", int.class, int.class, gradientClass);
method.setAccessible(true);
return (int[]) method.invoke(null, width, height, gradientConfig);
}
@Test
@DisplayName("vertical LUT length equals height and interpolates endpoints")
void verticalLut() throws Exception {
Object g = gradient(true, Color.BLACK, Color.WHITE);
int[] lut = invokeLut(10, 5, g);
assertThat(lut).hasSize(5);
assertThat(lut[0] & 0xFFFFFF).isEqualTo(0x000000);
assertThat(lut[lut.length - 1] & 0xFFFFFF).isEqualTo(0xFFFFFF);
}
@Test
@DisplayName("horizontal LUT length equals width")
void horizontalLut() throws Exception {
Object g = gradient(false, Color.BLACK, Color.WHITE);
int[] lut = invokeLut(7, 3, g);
assertThat(lut).hasSize(7);
assertThat(lut[0] & 0xFFFFFF).isEqualTo(0x000000);
assertThat(lut[lut.length - 1] & 0xFFFFFF).isEqualTo(0xFFFFFF);
}
@Test
@DisplayName("constant colour produces a uniform LUT")
void uniformLut() throws Exception {
Object g = gradient(true, new Color(0x40, 0x50, 0x60), new Color(0x40, 0x50, 0x60));
int[] lut = invokeLut(4, 4, g);
for (int value : lut) {
assertThat(value & 0xFFFFFF).isEqualTo(0x405060);
}
}
}
@Nested
@DisplayName("applyAllEffectsSinglePass")
class ApplyAllEffects {
private Method method;
@BeforeEach
void setUp() throws Exception {
method =
ScannerEffectController.class.getDeclaredMethod(
"applyAllEffectsSinglePass",
BufferedImage.class,
float.class,
float.class,
boolean.class,
double.class);
method.setAccessible(true);
}
private static BufferedImage solid(int width, int height, int rgb) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
java.util.Arrays.fill(pixels, rgb);
return image;
}
@Test
@DisplayName("identity settings leave pixels unchanged")
void identityKeepsPixels() throws Exception {
BufferedImage src = solid(8, 8, 0x648CB4); // 100,140,180
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, false, 0.0d);
assertThat(out.getWidth()).isEqualTo(8);
assertThat(out.getHeight()).isEqualTo(8);
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0x648CB4);
}
@Test
@DisplayName("brightness > 1 increases channel values and clamps at 255")
void brightnessClamps() throws Exception {
BufferedImage src = solid(4, 4, 0xC8C8C8); // 200 each
BufferedImage out = (BufferedImage) method.invoke(null, src, 2.0f, 1.0f, false, 0.0d);
int px = out.getRGB(0, 0) & 0xFFFFFF;
// 200 * 2 = 400 -> clamped to 255 on every channel.
assertThat(px).isEqualTo(0xFFFFFF);
}
@Test
@DisplayName("zero brightness produces black")
void zeroBrightnessIsBlack() throws Exception {
BufferedImage src = solid(4, 4, 0xFFFFFF);
BufferedImage out = (BufferedImage) method.invoke(null, src, 0.0f, 1.0f, false, 0.0d);
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0x000000);
}
@Test
@DisplayName("yellowish tint lowers the blue channel relative to source")
void yellowishReducesBlue() throws Exception {
BufferedImage src = solid(4, 4, 0xFFFFFF); // bright white maximises tint effect
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, true, 0.0d);
int px = out.getRGB(0, 0) & 0xFFFFFF;
int b = px & 0xFF;
assertThat(b).isLessThan(255);
}
@Test
@DisplayName("noise keeps every channel within the valid 0..255 range")
void noiseStaysInRange() throws Exception {
BufferedImage src = solid(32, 32, 0x808080);
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, false, 50.0d);
for (int y = 0; y < out.getHeight(); y++) {
for (int x = 0; x < out.getWidth(); x++) {
int px = out.getRGB(x, y);
assertThat((px >> 16) & 0xFF).isBetween(0, 255);
assertThat((px >> 8) & 0xFF).isBetween(0, 255);
assertThat(px & 0xFF).isBetween(0, 255);
}
}
}
}
@Nested
@DisplayName("softenEdges")
class SoftenEdges {
private Method method;
@BeforeEach
void setUp() throws Exception {
method =
ScannerEffectController.class.getDeclaredMethod(
"softenEdges",
BufferedImage.class,
int.class,
Color.class,
Color.class,
boolean.class);
method.setAccessible(true);
}
@Test
@DisplayName("center pixel stays at foreground when feathering only touches edges")
void centreStaysForeground() throws Exception {
BufferedImage src = new BufferedImage(11, 11, BufferedImage.TYPE_INT_RGB);
int fg = 0x102030;
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
java.util.Arrays.fill(pixels, fg);
BufferedImage out =
(BufferedImage) method.invoke(null, src, 2, Color.WHITE, Color.WHITE, true);
// Centre is far from any edge (distance 5 >= feather radius 2) so alpha=1.
assertThat(out.getRGB(5, 5) & 0xFFFFFF).isEqualTo(fg);
}
@Test
@DisplayName("corner pixel is blended toward the background gradient")
void cornerBlendsToBackground() throws Exception {
BufferedImage src = new BufferedImage(11, 11, BufferedImage.TYPE_INT_RGB);
int fg = 0x000000;
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
java.util.Arrays.fill(pixels, fg);
// Background is pure white; corner distance d=0 -> alpha=0 -> background.
BufferedImage out =
(BufferedImage) method.invoke(null, src, 3, Color.WHITE, Color.WHITE, true);
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0xFFFFFF);
}
@Test
@DisplayName("preserves image dimensions")
void preservesDimensions() throws Exception {
BufferedImage src = new BufferedImage(6, 9, BufferedImage.TYPE_INT_RGB);
BufferedImage out =
(BufferedImage) method.invoke(null, src, 1, Color.GRAY, Color.DARK_GRAY, false);
assertThat(out.getWidth()).isEqualTo(6);
assertThat(out.getHeight()).isEqualTo(9);
}
}
@Nested
@DisplayName("applyGaussianBlur")
class ApplyGaussianBlur {
private Method method;
@BeforeEach
void setUp() throws Exception {
method =
ScannerEffectController.class.getDeclaredMethod(
"applyGaussianBlur", BufferedImage.class, double.class);
method.setAccessible(true);
}
@Test
@DisplayName("sigma <= 0 returns the same image instance")
void zeroSigmaIsNoOp() throws Exception {
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
BufferedImage out = (BufferedImage) method.invoke(null, src, 0.0d);
assertThat(out).isSameAs(src);
}
@Test
@DisplayName("negative sigma returns the same image instance")
void negativeSigmaIsNoOp() throws Exception {
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
BufferedImage out = (BufferedImage) method.invoke(null, src, -1.0d);
assertThat(out).isSameAs(src);
}
@Test
@DisplayName("positive sigma on a uniform image keeps the uniform colour and dimensions")
void uniformImageStaysUniform() throws Exception {
BufferedImage src = new BufferedImage(40, 40, BufferedImage.TYPE_INT_RGB);
int color = 0x405060;
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
java.util.Arrays.fill(pixels, color);
BufferedImage out = (BufferedImage) method.invoke(null, src, 30.0d);
assertThat(out).isNotSameAs(src);
assertThat(out.getWidth()).isEqualTo(40);
assertThat(out.getHeight()).isEqualTo(40);
// Blurring a uniform image yields the same uniform colour.
assertThat(out.getRGB(20, 20) & 0xFFFFFF).isEqualTo(color);
}
}
@Nested
@DisplayName("rotateImage")
class RotateImage {
private Object gradient() throws Exception {
Class<?> gradientClass =
Class.forName(
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
Constructor<?> ctor =
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
ctor.setAccessible(true);
return ctor.newInstance(true, Color.WHITE, Color.WHITE);
}
private Method rotateMethod() throws Exception {
Class<?> gradientClass =
Class.forName(
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
Method method =
ScannerEffectController.class.getDeclaredMethod(
"rotateImage", BufferedImage.class, double.class, gradientClass);
method.setAccessible(true);
return method;
}
@Test
@DisplayName("zero rotation returns the same image instance")
void zeroRotationNoOp() throws Exception {
BufferedImage src = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 0.0d, gradient());
assertThat(out).isSameAs(src);
}
@Test
@DisplayName("90 degree rotation swaps the bounding box dimensions")
void ninetyDegreeSwapsDimensions() throws Exception {
BufferedImage src = new BufferedImage(20, 10, BufferedImage.TYPE_INT_RGB);
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 90.0d, gradient());
assertThat(out).isNotSameAs(src);
// For 90 degrees, rotated bounds become height x width.
assertThat(out.getWidth()).isEqualTo(10);
assertThat(out.getHeight()).isEqualTo(20);
}
@Test
@DisplayName("45 degree rotation grows the bounding box")
void fortyFiveGrowsBoundingBox() throws Exception {
BufferedImage src = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 45.0d, gradient());
assertThat(out.getWidth()).isGreaterThan(20);
assertThat(out.getHeight()).isGreaterThan(20);
}
}
@Nested
@DisplayName("addBorderWithGradient")
class AddBorderWithGradient {
private Object gradient(boolean vertical) throws Exception {
Class<?> gradientClass =
Class.forName(
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
Constructor<?> ctor =
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
ctor.setAccessible(true);
return ctor.newInstance(vertical, Color.GRAY, Color.LIGHT_GRAY);
}
private Method borderMethod() throws Exception {
Class<?> gradientClass =
Class.forName(
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
Method method =
ScannerEffectController.class.getDeclaredMethod(
"addBorderWithGradient", BufferedImage.class, int.class, gradientClass);
method.setAccessible(true);
return method;
}
@Test
@DisplayName("adds a border of the requested size on every side")
void growsByTwiceBorder() throws Exception {
BufferedImage src = new BufferedImage(10, 12, BufferedImage.TYPE_INT_RGB);
int border = 5;
BufferedImage out =
(BufferedImage) borderMethod().invoke(null, src, border, gradient(true));
assertThat(out.getWidth()).isEqualTo(10 + 2 * border);
assertThat(out.getHeight()).isEqualTo(12 + 2 * border);
}
@Test
@DisplayName("zero border keeps the original dimensions")
void zeroBorderKeepsDimensions() throws Exception {
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
BufferedImage out =
(BufferedImage) borderMethod().invoke(null, src, 0, gradient(false));
assertThat(out.getWidth()).isEqualTo(8);
assertThat(out.getHeight()).isEqualTo(8);
}
@Test
@DisplayName("preserves the drawn source region inside the border")
void preservesSourcePixels() throws Exception {
BufferedImage src = new BufferedImage(6, 6, BufferedImage.TYPE_INT_RGB);
int fg = 0x123456;
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
java.util.Arrays.fill(pixels, fg);
int border = 3;
BufferedImage out =
(BufferedImage) borderMethod().invoke(null, src, border, gradient(true));
// Source top-left maps to (border, border) in the composed image.
assertThat(out.getRGB(border, border) & 0xFFFFFF).isEqualTo(fg);
}
}
// ---------------------------------------------------------------------
// Small AssertJ helper for float closeness
// ---------------------------------------------------------------------
private static org.assertj.core.data.Offset<Float> within(float tol) {
return org.assertj.core.data.Offset.offset(tol);
}
}
@@ -0,0 +1,402 @@
package stirling.software.SPDF.controller.api.pipeline;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.model.api.HandleDataRequest;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
/**
* Unit tests for {@link PipelineController#handleData}. The controller is exercised directly with a
* real {@link ObjectMapper} for JSON parsing and mocked collaborators for the processor, analytics
* and temp-file management. The {@link TempFileManager} is stubbed to hand out real on-disk temp
* files so the {@code TempFile} wrapper and the stream-copy / zip paths run for real.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PipelineControllerGapTest {
@Mock private PipelineProcessor processor;
@Mock private PostHogService postHogService;
@Mock private TempFileManager tempFileManager;
private ObjectMapper objectMapper;
private PipelineController controller;
// Real temp files handed back by the mocked TempFileManager; cleaned up after each test.
private final List<Path> createdTempFiles = new ArrayList<>();
private static final String VALID_JSON =
"{\"name\":\"test-pipeline\",\"pipeline\":["
+ "{\"operation\":\"/api/v1/misc/repair\",\"parameters\":{}}]}";
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
controller =
new PipelineController(processor, objectMapper, postHogService, tempFileManager);
}
/**
* Stub the manager so every {@code new TempFile(manager, suffix)} the controller builds gets a
* real on-disk file. Only invoked by tests that actually exercise an output path, so tests that
* short-circuit can assert {@code verifyNoInteractions(tempFileManager)}.
*/
private void stubTempFiles() throws IOException {
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
invocation -> {
String suffix = invocation.getArgument(0);
Path p = Files.createTempFile("pipeline-gap-test-", suffix);
createdTempFiles.add(p);
return p.toFile();
});
}
@AfterEach
void tearDown() throws IOException {
for (Path p : createdTempFiles) {
Files.deleteIfExists(p);
}
createdTempFiles.clear();
}
private HandleDataRequest request(MultipartFile[] files, String json) {
HandleDataRequest req = new HandleDataRequest();
req.setFileInput(files);
req.setJson(json);
return req;
}
private MockMultipartFile pdf(String name) {
return new MockMultipartFile(
"fileInput", name, "application/pdf", ("content of " + name).getBytes());
}
private MultipartFile[] oneFile() {
return new MultipartFile[] {pdf("input.pdf")};
}
/** A Resource backed by bytes that also reports a filename (ByteArrayResource returns null). */
private static Resource namedResource(String filename, byte[] data) {
return new ByteArrayResource(data) {
@Override
public String getFilename() {
return filename;
}
};
}
@Nested
@DisplayName("Null / empty input short-circuits")
class NullAndEmptyInputs {
@Test
@DisplayName("returns null when file input is null without touching collaborators")
void nullFiles_returnsNull() throws Exception {
ResponseEntity<Resource> response = controller.handleData(request(null, VALID_JSON));
assertNull(response);
verifyNoInteractions(processor);
verifyNoInteractions(postHogService);
verifyNoInteractions(tempFileManager);
}
@Test
@DisplayName("returns null when processor yields no input files")
void nullInputFiles_returnsNull() throws Exception {
MultipartFile[] files = oneFile();
when(processor.generateInputFiles(files)).thenReturn(null);
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNull(response);
// Event still captured before processing begins.
verify(postHogService).captureEvent(eq("pipeline_api_event"), any());
verify(processor, never()).runPipelineAgainstFiles(any(), any());
}
@Test
@DisplayName("returns null when processor yields an empty input list")
void emptyInputFiles_returnsNull() throws Exception {
MultipartFile[] files = oneFile();
when(processor.generateInputFiles(files)).thenReturn(new ArrayList<>());
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNull(response);
verify(processor, never()).runPipelineAgainstFiles(any(), any());
}
@Test
@DisplayName("returns null when pipeline result has null output files")
void nullOutputFiles_returnsNull() throws Exception {
MultipartFile[] files = oneFile();
List<Resource> inputFiles = List.of(namedResource("input.pdf", "x".getBytes()));
when(processor.generateInputFiles(files)).thenReturn(inputFiles);
PipelineResult result = new PipelineResult();
result.setOutputFiles(null);
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNull(response);
}
}
@Nested
@DisplayName("Single output file path")
class SingleOutput {
@Test
@DisplayName("returns the single file streamed through a temp file")
void singleOutput_returnsFileResponse() throws Exception {
stubTempFiles();
MultipartFile[] files = oneFile();
List<Resource> inputFiles = List.of(namedResource("input.pdf", "in".getBytes()));
when(processor.generateInputFiles(files)).thenReturn(inputFiles);
byte[] outBytes = "single output body".getBytes(StandardCharsets.UTF_8);
Resource single = namedResource("result.pdf", outBytes);
PipelineResult result = new PipelineResult();
result.setOutputFiles(List.of(single));
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
// The controller copied the single file into a ".out" temp file.
verify(tempFileManager).createTempFile(".out");
// The streamed body matches what the processor produced.
try (InputStream is = response.getBody().getInputStream()) {
assertEquals(
new String(outBytes, StandardCharsets.UTF_8),
new String(is.readAllBytes(), StandardCharsets.UTF_8));
}
}
@Test
@DisplayName("captures analytics event with operations and file count")
void singleOutput_capturesAnalytics() throws Exception {
stubTempFiles();
MultipartFile[] files = oneFile();
when(processor.generateInputFiles(files))
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
PipelineResult result = new PipelineResult();
result.setOutputFiles(List.of(namedResource("result.pdf", "body".getBytes())));
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
controller.handleData(request(files, VALID_JSON));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> propsCaptor = ArgumentCaptor.forClass(Map.class);
verify(postHogService).captureEvent(eq("pipeline_api_event"), propsCaptor.capture());
Map<String, Object> props = propsCaptor.getValue();
assertEquals(1, props.get("fileCount"));
assertEquals(List.of("/api/v1/misc/repair"), props.get("operations"));
}
}
@Nested
@DisplayName("Multiple output files (zip) path")
class MultipleOutput {
@Test
@DisplayName("zips multiple output files into output.zip")
void multipleOutputs_returnsZipResponse() throws Exception {
stubTempFiles();
MultipartFile[] files = oneFile();
when(processor.generateInputFiles(files))
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
Resource a = namedResource("a.pdf", "alpha".getBytes(StandardCharsets.UTF_8));
Resource b = namedResource("b.pdf", "bravo".getBytes(StandardCharsets.UTF_8));
PipelineResult result = new PipelineResult();
result.setOutputFiles(List.of(a, b));
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
verify(tempFileManager).createTempFile(".zip");
// Inspect zip contents: both entries present with their bytes.
Map<String, String> entries = readZip(response.getBody());
assertEquals(2, entries.size());
assertEquals("alpha", entries.get("a.pdf"));
assertEquals("bravo", entries.get("b.pdf"));
}
@Test
@DisplayName("duplicate filenames are de-duplicated within the zip")
void multipleOutputs_duplicateNames_areDeduped() throws Exception {
stubTempFiles();
MultipartFile[] files = oneFile();
when(processor.generateInputFiles(files))
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
Resource first = namedResource("dup.pdf", "first".getBytes(StandardCharsets.UTF_8));
Resource second = namedResource("dup.pdf", "second".getBytes(StandardCharsets.UTF_8));
PipelineResult result = new PipelineResult();
result.setOutputFiles(List.of(first, second));
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNotNull(response);
Map<String, String> entries = readZip(response.getBody());
// Two distinct zip entry names despite the same source filename.
assertEquals(2, entries.size());
}
private Map<String, String> readZip(Resource zipResource) throws IOException {
Map<String, String> out = new java.util.LinkedHashMap<>();
byte[] all;
try (InputStream is = zipResource.getInputStream()) {
all = is.readAllBytes();
}
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(all))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
out.put(
entry.getName(),
new String(zis.readAllBytes(), StandardCharsets.UTF_8));
zis.closeEntry();
}
}
return out;
}
}
@Nested
@DisplayName("Error handling")
class ErrorHandling {
@Test
@DisplayName("returns null when the processor throws during input generation")
void processorThrowsOnGenerate_returnsNull() throws Exception {
MultipartFile[] files = oneFile();
when(processor.generateInputFiles(files)).thenThrow(new RuntimeException("boom"));
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNull(response);
}
@Test
@DisplayName("returns null when the pipeline run throws")
void pipelineRunThrows_returnsNull() throws Exception {
MultipartFile[] files = oneFile();
when(processor.generateInputFiles(files))
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
when(processor.runPipelineAgainstFiles(any(), any()))
.thenThrow(new RuntimeException("pipeline failed"));
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
assertNull(response);
}
@Test
@DisplayName("invalid JSON config propagates as a parse exception")
void invalidJson_throws() {
MultipartFile[] files = oneFile();
org.junit.jupiter.api.Assertions.assertThrows(
Exception.class, () -> controller.handleData(request(files, "not-valid-json")));
verifyNoInteractions(postHogService);
}
}
@Nested
@DisplayName("JSON config parsing")
class JsonParsing {
@Test
@DisplayName("multiple operation names are extracted in order for analytics")
void multipleOperations_extractedInOrder() throws Exception {
MultipartFile[] files = oneFile();
String json =
"{\"name\":\"multi\",\"pipeline\":["
+ "{\"operation\":\"/api/v1/misc/repair\",\"parameters\":{}},"
+ "{\"operation\":\"/api/v1/security/sanitize-pdf\",\"parameters\":{}}]}";
// Stop after analytics by returning no input files.
when(processor.generateInputFiles(files)).thenReturn(null);
controller.handleData(request(files, json));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> propsCaptor = ArgumentCaptor.forClass(Map.class);
verify(postHogService).captureEvent(eq("pipeline_api_event"), propsCaptor.capture());
assertEquals(
List.of("/api/v1/misc/repair", "/api/v1/security/sanitize-pdf"),
propsCaptor.getValue().get("operations"));
}
@Test
@DisplayName("real ObjectMapper deserializes the pipeline config used by analytics")
void objectMapperParsesConfig() {
PipelineConfig config = objectMapper.readValue(VALID_JSON, PipelineConfig.class);
assertEquals("test-pipeline", config.getName());
assertEquals(1, config.getOperations().size());
assertEquals("/api/v1/misc/repair", config.getOperations().get(0).getOperation());
}
}
}
@@ -0,0 +1,669 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.SPDF.model.PDFText;
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
import stirling.software.common.model.api.security.RedactionArea;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@DisplayName("ManualRedactionService Tests")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ManualRedactionServiceTest {
@Mock private TempFileManager tempFileManager;
private ManualRedactionService service;
// Track temp files created during finalize tests so we can clean them up.
private final List<File> createdTempFiles = new ArrayList<>();
@BeforeEach
void setUp() throws Exception {
service = new ManualRedactionService(tempFileManager);
// createManagedTempFile returns a TempFile backed by a real on-disk file so
// document.save() works and length() can be inspected.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("redact-test", inv.<String>getArgument(0))
.toFile();
createdTempFiles.add(f);
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
@AfterEach
void tearDown() {
for (File f : createdTempFiles) {
if (f != null && f.exists()) {
f.delete();
}
}
createdTempFiles.clear();
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static PDDocument newDocument(int pageCount) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pageCount; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
private static PDDocument newDocumentWithText() throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText("Sensitive text to redact");
cs.endText();
}
return doc;
}
private static RedactionArea area(
Integer page, double x, double y, double width, double height, String color) {
RedactionArea a = new RedactionArea();
a.setPage(page);
a.setX(x);
a.setY(y);
a.setWidth(width);
a.setHeight(height);
a.setColor(color);
return a;
}
private static PDFText text(int pageIndex, float x1, float y1, float x2, float y2) {
return new PDFText(pageIndex, x1, y1, x2, y2, "redacted");
}
private static byte[] save(PDDocument doc) throws IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
// -----------------------------------------------------------------------
// decodeOrDefault
// -----------------------------------------------------------------------
@Nested
@DisplayName("decodeOrDefault")
class DecodeOrDefault {
@Test
@DisplayName("null returns black")
void nullReturnsBlack() {
assertSame(Color.BLACK, ManualRedactionService.decodeOrDefault(null));
}
@Test
@DisplayName("hex with leading hash decodes")
void hexWithHash() {
assertEquals(Color.RED, ManualRedactionService.decodeOrDefault("#FF0000"));
}
@Test
@DisplayName("hex without leading hash decodes")
void hexWithoutHash() {
assertEquals(Color.RED, ManualRedactionService.decodeOrDefault("FF0000"));
}
@Test
@DisplayName("white decodes correctly")
void whiteDecodes() {
assertEquals(Color.WHITE, ManualRedactionService.decodeOrDefault("#FFFFFF"));
}
@Test
@DisplayName("invalid hex falls back to black")
void invalidFallsBackToBlack() {
assertEquals(Color.BLACK, ManualRedactionService.decodeOrDefault("not-a-color"));
}
@Test
@DisplayName("empty string falls back to black")
void emptyFallsBackToBlack() {
assertEquals(Color.BLACK, ManualRedactionService.decodeOrDefault(""));
}
}
// -----------------------------------------------------------------------
// redactAreas
// -----------------------------------------------------------------------
@Nested
@DisplayName("redactAreas")
class RedactAreas {
@Test
@DisplayName("null list is a no-op")
void nullList() throws Exception {
try (PDDocument doc = newDocument(1)) {
byte[] before = save(doc);
service.redactAreas(null, doc, doc.getPages());
// Document still saves and has its single page; nothing thrown.
assertEquals(1, doc.getNumberOfPages());
assertNotNull(before);
}
}
@Test
@DisplayName("empty list is a no-op")
void emptyList() throws Exception {
try (PDDocument doc = newDocument(1)) {
service.redactAreas(new ArrayList<>(), doc, doc.getPages());
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("valid area is applied and document still saves")
void validArea() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<RedactionArea> areas = Arrays.asList(area(1, 10, 10, 100, 50, "#000000"));
service.redactAreas(areas, doc, doc.getPages());
byte[] out = save(doc);
assertTrue(out.length > 0);
// Reload to ensure the produced PDF is structurally valid.
try (PDDocument reloaded = Loader.loadPDF(out)) {
assertEquals(1, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("area with null page is skipped")
void nullPageSkipped() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<RedactionArea> areas = Arrays.asList(area(null, 10, 10, 100, 50, "#000000"));
service.redactAreas(areas, doc, doc.getPages());
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("area with non-positive page is skipped")
void nonPositivePageSkipped() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<RedactionArea> areas = Arrays.asList(area(0, 10, 10, 100, 50, "#000000"));
service.redactAreas(areas, doc, doc.getPages());
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("area with null/zero width or height is skipped")
void invalidDimensionsSkipped() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<RedactionArea> areas = new ArrayList<>();
areas.add(area(1, 10, 10, 0, 50, "#000000")); // zero width
areas.add(area(1, 10, 10, 100, 0, "#000000")); // zero height
RedactionArea nullWidth = area(1, 10, 10, 100, 50, "#000000");
nullWidth.setWidth(null);
areas.add(nullWidth);
RedactionArea nullHeight = area(1, 10, 10, 100, 50, "#000000");
nullHeight.setHeight(null);
areas.add(nullHeight);
service.redactAreas(areas, doc, doc.getPages());
// None applied, but no exception and doc remains valid.
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("area on out-of-range page is skipped without error")
void outOfRangePageSkipped() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<RedactionArea> areas = Arrays.asList(area(5, 10, 10, 100, 50, "#000000"));
service.redactAreas(areas, doc, doc.getPages());
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("multiple areas across multiple pages are grouped per page")
void multiplePagesGrouped() throws Exception {
try (PDDocument doc = newDocument(3)) {
List<RedactionArea> areas = new ArrayList<>();
areas.add(area(1, 10, 10, 50, 50, "#000000"));
areas.add(area(1, 80, 80, 50, 50, "#FF0000"));
areas.add(area(3, 20, 20, 60, 40, null)); // null color -> default black
service.redactAreas(areas, doc, doc.getPages());
byte[] out = save(doc);
try (PDDocument reloaded = Loader.loadPDF(out)) {
assertEquals(3, reloaded.getNumberOfPages());
}
}
}
}
// -----------------------------------------------------------------------
// redactPages
// -----------------------------------------------------------------------
@Nested
@DisplayName("redactPages")
class RedactPages {
private ManualRedactPdfRequest request(String pageNumbers, String color) {
ManualRedactPdfRequest req = new ManualRedactPdfRequest();
req.setPageNumbers(pageNumbers);
req.setPageRedactionColor(color);
return req;
}
@Test
@DisplayName("redacts all pages when 'all' is given")
void redactsAllPages() throws Exception {
try (PDDocument doc = newDocument(3)) {
service.redactPages(request("all", "#000000"), doc, doc.getPages());
byte[] out = save(doc);
try (PDDocument reloaded = Loader.loadPDF(out)) {
assertEquals(3, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("redacts a specific page range")
void redactsSpecificPages() throws Exception {
try (PDDocument doc = newDocument(5)) {
service.redactPages(request("1,3", "#FF0000"), doc, doc.getPages());
byte[] out = save(doc);
try (PDDocument reloaded = Loader.loadPDF(out)) {
assertEquals(5, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("null page numbers defaults to first page")
void nullPageNumbers() throws Exception {
try (PDDocument doc = newDocument(2)) {
service.redactPages(request(null, "#000000"), doc, doc.getPages());
assertEquals(2, doc.getNumberOfPages());
}
}
@Test
@DisplayName("null color falls back to black")
void nullColor() throws Exception {
try (PDDocument doc = newDocument(1)) {
service.redactPages(request("all", null), doc, doc.getPages());
assertEquals(1, doc.getNumberOfPages());
}
}
}
// -----------------------------------------------------------------------
// redactFoundText
// -----------------------------------------------------------------------
@Nested
@DisplayName("redactFoundText")
class RedactFoundText {
@Test
@DisplayName("overlay-only mode draws boxes and document stays valid")
void overlayMode() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<PDFText> blocks = Arrays.asList(text(0, 72, 690, 300, 710));
service.redactFoundText(doc, blocks, 1.0f, Color.BLACK, false);
byte[] out = save(doc);
try (PDDocument reloaded = Loader.loadPDF(out)) {
assertEquals(1, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("text-removal mode narrows the box width")
void textRemovalMode() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<PDFText> blocks = Arrays.asList(text(0, 72, 690, 300, 710));
service.redactFoundText(doc, blocks, 0.0f, Color.RED, true);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("blocks on out-of-range pages are skipped")
void outOfRangePageIndexSkipped() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<PDFText> blocks =
Arrays.asList(text(0, 72, 690, 300, 710), text(9, 10, 10, 50, 50));
service.redactFoundText(doc, blocks, 0.0f, Color.BLACK, false);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("empty block list is a no-op")
void emptyBlocks() throws Exception {
try (PDDocument doc = newDocument(1)) {
service.redactFoundText(doc, new ArrayList<>(), 0.0f, Color.BLACK, false);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("annotation overlapping a redacted block is removed")
void overlappingAnnotationRemoved() throws Exception {
try (PDDocument doc = newDocument(1)) {
PDPage page = doc.getPage(0);
float pageH = page.getBBox().getHeight();
// Redact block in PDF coords near y2=710 (top), x 72..300.
PDFText block = text(0, 72, 690, 300, 710);
// Place an annotation rectangle that overlaps the redacted block.
// Block pdf-Y window (padding included) sits roughly around pageH-710..pageH-690.
PDAnnotationLink overlapping = new PDAnnotationLink();
overlapping.setRectangle(new PDRectangle(80, pageH - 712, 120, 30));
// Place an annotation far away that should be kept.
PDAnnotationLink faraway = new PDAnnotationLink();
faraway.setRectangle(new PDRectangle(10, 10, 20, 20));
page.setAnnotations(new ArrayList<>(Arrays.asList(overlapping, faraway)));
assertEquals(2, page.getAnnotations().size());
service.redactFoundText(doc, Arrays.asList(block), 1.0f, Color.BLACK, false);
// getAnnotations() builds fresh wrappers each call, so compare by geometry
// rather than object identity.
List<PDAnnotation> remaining = page.getAnnotations();
assertEquals(1, remaining.size());
PDRectangle keptRect = remaining.get(0).getRectangle();
assertEquals(10f, keptRect.getLowerLeftX(), 0.001f);
assertEquals(10f, keptRect.getLowerLeftY(), 0.001f);
}
}
@Test
@DisplayName("non-overlapping annotations are preserved")
void nonOverlappingAnnotationsKept() throws Exception {
try (PDDocument doc = newDocument(1)) {
PDPage page = doc.getPage(0);
PDAnnotationLink faraway = new PDAnnotationLink();
faraway.setRectangle(new PDRectangle(10, 10, 20, 20));
page.setAnnotations(new ArrayList<>(Arrays.asList(faraway)));
PDFText block = text(0, 400, 100, 500, 120);
service.redactFoundText(doc, Arrays.asList(block), 0.0f, Color.BLACK, false);
assertEquals(1, page.getAnnotations().size());
PDRectangle keptRect = page.getAnnotations().get(0).getRectangle();
assertEquals(10f, keptRect.getLowerLeftX(), 0.001f);
}
}
}
// -----------------------------------------------------------------------
// redactImageBoxes
// -----------------------------------------------------------------------
@Nested
@DisplayName("redactImageBoxes")
class RedactImageBoxes {
@Test
@DisplayName("draws boxes for valid page indices")
void validBoxes() throws Exception {
try (PDDocument doc = newDocument(2)) {
List<float[]> boxes = new ArrayList<>();
boxes.add(new float[] {0, 10, 10, 100, 100});
boxes.add(new float[] {1, 20, 20, 80, 80});
service.redactImageBoxes(doc, boxes, Color.BLACK);
byte[] out = save(doc);
try (PDDocument reloaded = Loader.loadPDF(out)) {
assertEquals(2, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("out-of-range page indices are skipped without error")
void outOfRangeSkipped() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<float[]> boxes = new ArrayList<>();
boxes.add(new float[] {-1, 10, 10, 100, 100}); // negative
boxes.add(new float[] {7, 20, 20, 80, 80}); // beyond page count
service.redactImageBoxes(doc, boxes, Color.BLACK);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("empty box list is a no-op")
void emptyBoxes() throws Exception {
try (PDDocument doc = newDocument(1)) {
service.redactImageBoxes(doc, new ArrayList<>(), Color.BLACK);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("multiple boxes on the same page are grouped")
void multipleBoxesSamePage() throws Exception {
try (PDDocument doc = newDocument(1)) {
List<float[]> boxes = new ArrayList<>();
boxes.add(new float[] {0, 10, 10, 50, 50});
boxes.add(new float[] {0, 100, 100, 150, 150});
service.redactImageBoxes(doc, boxes, Color.RED);
byte[] out = save(doc);
try (PDDocument reloaded = Loader.loadPDF(out)) {
assertEquals(1, reloaded.getNumberOfPages());
}
}
}
}
// -----------------------------------------------------------------------
// extractPageElementBoxes
// -----------------------------------------------------------------------
@Nested
@DisplayName("extractPageElementBoxes")
class ExtractPageElementBoxes {
@Test
@DisplayName("returns text line boxes for a page with text")
void extractsTextBoxes() throws Exception {
try (PDDocument doc = newDocumentWithText()) {
PDPage page = doc.getPage(0);
List<float[]> boxes = service.extractPageElementBoxes(doc, page, 0);
assertNotNull(boxes);
assertFalse(boxes.isEmpty());
// Each box must have 4 coordinates [x1, y1, x2, y2].
for (float[] box : boxes) {
assertEquals(4, box.length);
}
}
}
@Test
@DisplayName("returns empty list for a blank page")
void blankPageReturnsEmpty() throws Exception {
try (PDDocument doc = newDocument(1)) {
PDPage page = doc.getPage(0);
List<float[]> boxes = service.extractPageElementBoxes(doc, page, 0);
assertNotNull(boxes);
assertTrue(boxes.isEmpty());
}
}
}
// -----------------------------------------------------------------------
// finalizeRedaction (non-image path only; image path needs Spring context)
// -----------------------------------------------------------------------
@Nested
@DisplayName("finalizeRedaction")
class FinalizeRedaction {
@Test
@DisplayName("with found text saves a managed temp file")
void withFoundText() throws Exception {
try (PDDocument doc = newDocument(1)) {
Map<Integer, List<PDFText>> byPage = new HashMap<>();
byPage.put(0, new ArrayList<>(Arrays.asList(text(0, 72, 690, 300, 710))));
TempFile result =
service.finalizeRedaction(doc, byPage, "#000000", 1.0f, false, false);
assertNotNull(result);
assertNotNull(result.getFile());
assertTrue(result.getFile().exists());
assertTrue(result.getFile().length() > 0);
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
// Output should be a loadable PDF.
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
assertEquals(1, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("with no found text still saves the document")
void withoutFoundText() throws Exception {
try (PDDocument doc = newDocument(2)) {
Map<Integer, List<PDFText>> byPage = new HashMap<>();
TempFile result =
service.finalizeRedaction(doc, byPage, "#000000", 0.0f, false, false);
assertNotNull(result);
assertTrue(result.getFile().exists());
assertTrue(result.getFile().length() > 0);
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
assertEquals(2, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("convertToImage null is treated as non-image path")
void convertToImageNull() throws Exception {
try (PDDocument doc = newDocument(1)) {
Map<Integer, List<PDFText>> byPage = new HashMap<>();
TempFile result =
service.finalizeRedaction(doc, byPage, "#000000", 0.0f, null, false);
assertNotNull(result);
assertTrue(result.getFile().exists());
// Non-image path uses the standard ".pdf" managed temp file exactly once.
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
assertEquals(1, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("text-removal mode finalize produces valid PDF")
void textRemovalFinalize() throws Exception {
try (PDDocument doc = newDocument(1)) {
Map<Integer, List<PDFText>> byPage = new HashMap<>();
byPage.put(0, new ArrayList<>(Arrays.asList(text(0, 72, 690, 300, 710))));
TempFile result =
service.finalizeRedaction(doc, byPage, "#FF0000", 2.0f, false, true);
assertNotNull(result);
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
assertEquals(1, reloaded.getNumberOfPages());
}
}
}
@Test
@DisplayName("IOException from save closes the temp file and is rethrown")
void saveFailureClosesTempFile() throws Exception {
// Use a document mock that throws on save to exercise the catch/close branch.
PDDocument doc = newDocument(1);
Map<Integer, List<PDFText>> byPage = new HashMap<>();
// Point the managed temp file at a directory path so save() fails with IOException.
File dirAsFile = Files.createTempDirectory("redact-dir").toFile();
createdTempFiles.add(dirAsFile);
TempFile failing = mock(TempFile.class);
when(failing.getFile()).thenReturn(dirAsFile);
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(failing);
try {
assertThrows(
IOException.class,
() ->
service.finalizeRedaction(
doc, byPage, "#000000", 0.0f, false, false));
// The failing temp file is closed on the error path.
verify(failing).close();
} finally {
doc.close();
}
}
}
}
@@ -0,0 +1,566 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdfparser.PDFStreamParser;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.SPDF.model.PDFText;
/**
* Unit tests for {@link TextRedactionService}. Exercises the pure text-matching / placeholder
* logic, the public find/replace entry points, and the small helper predicates. PDFs are built tiny
* and in-memory with real Standard14 fonts so the redaction pipeline runs end-to-end without
* external processes.
*/
class TextRedactionServiceTest {
private static final float FONT_SIZE = 12f;
private static final float LEFT_X = 72f;
private static final float TOP_Y = PDRectangle.LETTER.getHeight() - 80f;
private final TextRedactionService service = new TextRedactionService();
// ── fixtures ─────────────────────────────────────────────────────────────────────────────────
private PDFont helvetica() {
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
}
/** Single page, single Tj line per supplied text line, Helvetica 12. */
private PDDocument buildDoc(String... lines) throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(helvetica(), FONT_SIZE);
for (int i = 0; i < lines.length; i++) {
cs.beginText();
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
cs.showText(lines[i]);
cs.endText();
}
}
return doc;
}
private PDDocument buildEmptyDoc() {
PDDocument doc = new PDDocument();
doc.addPage(new PDPage(PDRectangle.LETTER));
return doc;
}
// ── isTextShowingOperator ────────────────────────────────────────────────────────────────────
@Nested
@DisplayName("isTextShowingOperator")
class IsTextShowingOperator {
@Test
@DisplayName("recognises the four text-showing operators")
void recognisesTextShowingOperators() {
assertTrue(service.isTextShowingOperator("Tj"));
assertTrue(service.isTextShowingOperator("TJ"));
assertTrue(service.isTextShowingOperator("'"));
assertTrue(service.isTextShowingOperator("\""));
}
@Test
@DisplayName("rejects non text-showing operators and junk")
void rejectsOthers() {
assertFalse(service.isTextShowingOperator("BT"));
assertFalse(service.isTextShowingOperator("ET"));
assertFalse(service.isTextShowingOperator("Tf"));
assertFalse(service.isTextShowingOperator(""));
assertFalse(service.isTextShowingOperator("tj"));
}
}
// ── findTextToRedact ─────────────────────────────────────────────────────────────────────────
@Nested
@DisplayName("findTextToRedact")
class FindTextToRedact {
@Test
@DisplayName("returns empty map when all search terms are blank")
void emptyTermsReturnsEmptyMap() throws IOException {
try (PDDocument doc = buildDoc("Confidential data here")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {"", " "}, false, false);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("returns empty map for an empty term array")
void emptyArrayReturnsEmptyMap() throws IOException {
try (PDDocument doc = buildDoc("Some text")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {}, false, false);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("finds a literal term on the page")
void findsLiteralTerm() throws IOException {
try (PDDocument doc = buildDoc("Hello SECRET world")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
assertFalse(result.isEmpty());
assertTrue(result.containsKey(0), "match should be on page index 0");
List<PDFText> hits = result.get(0);
assertEquals(1, hits.size());
assertEquals("SECRET", hits.get(0).getText());
}
}
@Test
@DisplayName("does not find a term that is absent")
void doesNotFindAbsentTerm() throws IOException {
try (PDDocument doc = buildDoc("Hello world")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {"NOTHERE"}, false, false);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("regex term matches digit runs")
void regexTermMatchesDigits() throws IOException {
try (PDDocument doc = buildDoc("Order 12345 shipped")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {"\\d+"}, true, false);
assertFalse(result.isEmpty());
assertEquals("12345", result.get(0).get(0).getText());
}
}
@Test
@DisplayName("whole-word search does not match a substring inside a larger word")
void wholeWordDoesNotMatchSubstring() throws IOException {
try (PDDocument doc = buildDoc("classification of cats")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {"cat"}, false, true);
// "cat" appears only inside "classification"; whole-word must not match it.
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("whole-word search matches a standalone word")
void wholeWordMatchesStandalone() throws IOException {
try (PDDocument doc = buildDoc("the cat sat")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {"cat"}, false, true);
assertFalse(result.isEmpty());
assertEquals("cat", result.get(0).get(0).getText());
}
}
@Test
@DisplayName("trims whitespace around terms before searching")
void trimsTermsBeforeSearch() throws IOException {
try (PDDocument doc = buildDoc("padded SECRET value")) {
Map<Integer, List<PDFText>> result =
service.findTextToRedact(doc, new String[] {" SECRET "}, false, false);
assertFalse(result.isEmpty());
assertEquals("SECRET", result.get(0).get(0).getText());
}
}
}
// ── performTextReplacement ───────────────────────────────────────────────────────────────────
@Nested
@DisplayName("performTextReplacement")
class PerformTextReplacement {
@Test
@DisplayName("returns false (no fallback) when there is nothing found to redact")
void noFoundTextReturnsFalse() throws IOException {
try (PDDocument doc = buildDoc("anything")) {
boolean fallback =
service.performTextReplacement(
doc, new HashMap<>(), new String[] {"x"}, false, false);
assertFalse(fallback, "empty found-text map must short-circuit to no fallback");
}
}
@Test
@DisplayName("replaces text on a standard-font document without requesting box fallback")
void replacesOnStandardFont() throws IOException {
try (PDDocument doc = buildDoc("Please redact SECRET now")) {
Map<Integer, List<PDFText>> found =
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
assertFalse(found.isEmpty());
boolean fallback =
service.performTextReplacement(
doc, found, new String[] {"SECRET"}, false, false);
assertFalse(fallback, "standard Helvetica should not trigger box-only fallback");
// After replacement the literal term should no longer be extractable.
Map<Integer, List<PDFText>> afterFound =
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
assertTrue(afterFound.isEmpty(), "SECRET should be gone after text replacement");
}
}
@Test
@DisplayName("leaves non-targeted text intact after replacement")
void leavesOtherTextIntact() throws IOException {
try (PDDocument doc = buildDoc("KEEP this but redact SECRET part")) {
Map<Integer, List<PDFText>> found =
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
service.performTextReplacement(doc, found, new String[] {"SECRET"}, false, false);
Map<Integer, List<PDFText>> keepStill =
service.findTextToRedact(doc, new String[] {"KEEP"}, false, false);
assertFalse(keepStill.isEmpty(), "untargeted word KEEP must survive redaction");
}
}
}
// ── detectCustomEncodingFonts ────────────────────────────────────────────────────────────────
@Nested
@DisplayName("detectCustomEncodingFonts")
class DetectCustomEncodingFonts {
@Test
@DisplayName("standard Helvetica document is not flagged as custom-encoded")
void standardFontNotFlagged() throws IOException {
try (PDDocument doc = buildDoc("plain helvetica text")) {
assertFalse(service.detectCustomEncodingFonts(doc));
}
}
@Test
@DisplayName("document with no content / no fonts is not flagged")
void emptyDocumentNotFlagged() throws IOException {
try (PDDocument doc = buildEmptyDoc()) {
assertFalse(service.detectCustomEncodingFonts(doc));
}
}
}
// ── createPlaceholderWithFont ────────────────────────────────────────────────────────────────
@Nested
@DisplayName("createPlaceholderWithFont")
class CreatePlaceholderWithFont {
@Test
@DisplayName("returns the input unchanged for null")
void nullReturnsNull() {
assertNull(service.createPlaceholderWithFont(null, helvetica()));
}
@Test
@DisplayName("returns the input unchanged for empty string")
void emptyReturnsEmpty() {
assertEquals("", service.createPlaceholderWithFont("", helvetica()));
}
@Test
@DisplayName("non-subset font yields spaces matching the original length")
void nonSubsetFontYieldsMatchingSpaces() {
String placeholder = service.createPlaceholderWithFont("hidden", helvetica());
assertEquals(" ".repeat("hidden".length()), placeholder);
}
@Test
@DisplayName("null font is treated as non-subset and yields spaces")
void nullFontYieldsSpaces() {
String placeholder = service.createPlaceholderWithFont("abc", null);
assertEquals(" ", placeholder);
}
}
// ── createPlaceholderWithWidth ───────────────────────────────────────────────────────────────
@Nested
@DisplayName("createPlaceholderWithWidth")
class CreatePlaceholderWithWidth {
@Test
@DisplayName("returns the input unchanged for null")
void nullReturnsNull() {
assertNull(service.createPlaceholderWithWidth(null, 10f, helvetica(), FONT_SIZE));
}
@Test
@DisplayName("returns the input unchanged for empty string")
void emptyReturnsEmpty() {
assertEquals("", service.createPlaceholderWithWidth("", 10f, helvetica(), FONT_SIZE));
}
@Test
@DisplayName("null font falls back to one space per original character")
void nullFontFallsBackToSpaces() {
String placeholder = service.createPlaceholderWithWidth("word", 50f, null, FONT_SIZE);
assertEquals(" ".repeat("word".length()), placeholder);
}
@Test
@DisplayName("non-positive font size falls back to one space per original character")
void nonPositiveFontSizeFallsBackToSpaces() {
String placeholder = service.createPlaceholderWithWidth("word", 50f, helvetica(), 0f);
assertEquals(" ".repeat("word".length()), placeholder);
}
@Test
@DisplayName("standard font produces a non-null all-whitespace placeholder")
void standardFontProducesWhitespacePlaceholder() {
PDFont font = helvetica();
float fontSize = FONT_SIZE;
String original = "Secret";
// Compute a realistic target width the way the service does (text-space / 1000 * size).
float targetWidth;
try {
targetWidth = font.getStringWidth(original) / 1000f * fontSize;
} catch (IOException e) {
targetWidth = 30f;
}
String placeholder =
service.createPlaceholderWithWidth(original, targetWidth, font, fontSize);
assertNotNull(placeholder);
assertFalse(placeholder.isEmpty(), "Helvetica supports spaces, so non-empty expected");
assertTrue(
placeholder.chars().allMatch(c -> c == ' '),
"placeholder should be composed only of spaces");
}
}
// ── createTokensWithoutTargetText / writeFilteredContentStream
// ────────────────────────────────
@Nested
@DisplayName("createTokensWithoutTargetText")
class CreateTokensWithoutTargetText {
@Test
@DisplayName(
"returns a non-empty token list and preserves token count when nothing matches")
void noMatchPreservesTokens() throws IOException {
try (PDDocument doc = buildDoc("nothing to hide")) {
PDPage page = doc.getPage(0);
List<Object> originalTokens = parseTokens(page);
List<Object> tokens =
service.createTokensWithoutTargetText(
doc, page, Set.of("ABSENT"), false, false);
assertNotNull(tokens);
assertEquals(
originalTokens.size(),
tokens.size(),
"token count should be unchanged when nothing matched");
}
}
@Test
@DisplayName("filtered tokens can be written back and the page re-parses cleanly")
void filteredTokensRoundTrip() throws IOException {
try (PDDocument doc = buildDoc("redact SECRET token roundtrip")) {
PDPage page = doc.getPage(0);
List<Object> tokens =
service.createTokensWithoutTargetText(
doc, page, Set.of("SECRET"), false, false);
assertNotNull(tokens);
service.writeFilteredContentStream(doc, page, tokens);
// The page must still hold valid content (at least one operator token).
List<Object> reparsed = parseTokens(page);
boolean hasOperator = reparsed.stream().anyMatch(t -> t instanceof Operator);
assertTrue(hasOperator, "rewritten content stream must contain operators");
}
}
@Test
@DisplayName("empty target-word set leaves tokens untouched")
void emptyTargetSetLeavesTokens() throws IOException {
try (PDDocument doc = buildDoc("some content")) {
PDPage page = doc.getPage(0);
List<Object> originalTokens = parseTokens(page);
List<Object> tokens =
service.createTokensWithoutTargetText(
doc, page, Collections.emptySet(), false, false);
assertEquals(originalTokens.size(), tokens.size());
}
}
private List<Object> parseTokens(PDPage page) throws IOException {
PDFStreamParser parser = new PDFStreamParser(page);
List<Object> tokens = new ArrayList<>();
Object token;
while ((token = parser.parseNextToken()) != null) {
tokens.add(token);
}
return tokens;
}
}
// ── inner data classes ───────────────────────────────────────────────────────────────────────
@Nested
@DisplayName("TextSegment / MatchRange data classes")
class DataClasses {
@Test
@DisplayName("TextSegment exposes its constructor values via accessors")
void textSegmentAccessors() {
PDFont font = helvetica();
TextRedactionService.TextSegment segment =
new TextRedactionService.TextSegment(3, "Tj", "hello", 10, 15, font, 12f);
assertEquals(3, segment.getTokenIndex());
assertEquals("Tj", segment.getOperatorName());
assertEquals("hello", segment.getText());
assertEquals(10, segment.getStartPos());
assertEquals(15, segment.getEndPos());
assertSame(font, segment.getFont());
assertEquals(12f, segment.getFontSize());
}
@Test
@DisplayName("MatchRange exposes start and end positions")
void matchRangeAccessors() {
TextRedactionService.MatchRange range = new TextRedactionService.MatchRange(4, 9);
assertEquals(4, range.getStartPos());
assertEquals(9, range.getEndPos());
}
@Test
@DisplayName("MatchRange equality follows its data fields")
void matchRangeEquality() {
assertEquals(
new TextRedactionService.MatchRange(1, 5),
new TextRedactionService.MatchRange(1, 5));
assertNotEquals(
new TextRedactionService.MatchRange(1, 5),
new TextRedactionService.MatchRange(1, 6));
}
private void assertNotEquals(Object a, Object b) {
assertFalse(a.equals(b));
}
}
// ── private logic exercised via reflection ───────────────────────────────────────────────────
@Nested
@DisplayName("findAllMatches / buildCompleteText (private logic via reflection)")
class PrivateLogic {
@Test
@DisplayName("findAllMatches returns sorted, non-overlapping match ranges for two terms")
@SuppressWarnings("unchecked")
void findAllMatchesSorted() throws Exception {
String complete = "alpha beta gamma beta";
Set<String> terms = new LinkedHashSet<>(List.of("beta", "alpha"));
Method m =
TextRedactionService.class.getDeclaredMethod(
"findAllMatches",
String.class,
Set.class,
boolean.class,
boolean.class);
m.setAccessible(true);
List<TextRedactionService.MatchRange> matches =
(List<TextRedactionService.MatchRange>)
m.invoke(service, complete, terms, false, false);
assertNotNull(matches);
assertFalse(matches.isEmpty());
// Results are sorted by start position.
for (int i = 1; i < matches.size(); i++) {
assertTrue(
matches.get(i - 1).getStartPos() <= matches.get(i).getStartPos(),
"matches must be sorted ascending by start position");
}
// "alpha" at 0, "beta" at 6 and 17 -> three matches total.
assertEquals(3, matches.size());
assertEquals(0, matches.get(0).getStartPos());
}
@Test
@DisplayName("findAllMatches returns nothing when no term occurs")
@SuppressWarnings("unchecked")
void findAllMatchesEmptyWhenAbsent() throws Exception {
Method m =
TextRedactionService.class.getDeclaredMethod(
"findAllMatches",
String.class,
Set.class,
boolean.class,
boolean.class);
m.setAccessible(true);
List<TextRedactionService.MatchRange> matches =
(List<TextRedactionService.MatchRange>)
m.invoke(service, "no terms here", Set.of("XYZ"), false, false);
assertTrue(matches.isEmpty());
}
@Test
@DisplayName("extractTextFromToken pulls text from Tj COSString and TJ COSArray")
void extractTextFromToken() throws Exception {
Method m =
TextRedactionService.class.getDeclaredMethod(
"extractTextFromToken", Object.class, String.class);
m.setAccessible(true);
assertEquals("hi", m.invoke(service, new COSString("hi"), "Tj"));
assertEquals("hi", m.invoke(service, new COSString("hi"), "'"));
COSArray tjArray = new COSArray();
tjArray.add(new COSString("foo"));
tjArray.add(new COSString("bar"));
assertEquals("foobar", m.invoke(service, tjArray, "TJ"));
// Unknown operator yields empty string.
assertEquals("", m.invoke(service, new COSString("x"), "Td"));
// Wrong token type for the operator yields empty string.
assertEquals("", m.invoke(service, new COSArray(), "Tj"));
}
}
}
@@ -0,0 +1,654 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.exception.CacheUnavailableException;
import stirling.software.SPDF.model.json.PdfJsonDocument;
import stirling.software.SPDF.model.json.PdfJsonFont;
import stirling.software.SPDF.model.json.PdfJsonMetadata;
import stirling.software.SPDF.model.json.PdfJsonPage;
import stirling.software.SPDF.service.pdfjson.PdfJsonFontService;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService;
import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Gap tests for {@link PdfJsonConversionService} that exercise the public conversion entrypoints
* and the cache-backed public API. These complement {@code
* PdfJsonConversionServiceUnicodeParsingTest} (which covers the static {@code
* parseToUnicodeCodepoint} / {@code countCodesProtected} helpers) without duplicating those cases.
*
* <p>The service is constructed as a plain unit (no Spring context), so {@code @PostConstruct}
* never runs: font normalization stays disabled and Ghostscript is never invoked, keeping every
* test fully deterministic and free of external processes. {@link CustomPDFDocumentFactory#load} is
* stubbed to return real in-memory PDFBox documents, and {@code fallbackFontService} is mocked so
* the JSON to PDF path can resolve its mandatory fallback font without touching the classpath.
*/
@ExtendWith(MockitoExtension.class)
@org.mockito.junit.jupiter.MockitoSettings(strictness = Strictness.LENIENT)
class PdfJsonConversionServiceGapTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private TempFileManager tempFileManager;
@Mock private TaskManager taskManager;
@Mock private PdfJsonFallbackFontService fallbackFontService;
@Mock private PdfJsonFontService fontService;
@Mock private Type3FontConversionService type3FontConversionService;
@Mock private Type3GlyphExtractor type3GlyphExtractor;
@Mock private ApplicationProperties applicationProperties;
// Real collaborators: COS (de)serialization is complex and pure, so we use the real component.
private final PdfJsonCosMapper cosMapper = new PdfJsonCosMapper();
// Mirror production: application.properties sets
// spring.jackson.deserialization.fail-on-null-for-primitives=false, so the Spring-managed
// mapper
// maps null/absent JSON values onto Java primitive defaults (e.g. the boolean lazyImages
// field).
// A naive JsonMapper.builder().build() keeps the Jackson 3 default (true) and would throw
// MismatchedInputException on round-trip, which is a test-mapper config gap, not a product bug.
private final ObjectMapper objectMapper =
JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.build();
private PdfJsonConversionService service;
private final List<Path> createdTempFiles = new ArrayList<>();
@BeforeEach
void setUp() throws IOException {
service =
new PdfJsonConversionService(
pdfDocumentFactory,
objectMapper,
endpointConfiguration,
tempFileManager,
taskManager,
cosMapper,
fallbackFontService,
fontService,
type3FontConversionService,
type3GlyphExtractor,
applicationProperties);
// The TempFile wrapper delegates straight to the manager; back it with real temp files so
// convertPdfToJson can transferTo() and size/read the working path.
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
invocation -> {
String suffix = invocation.getArgument(0);
Path path = Files.createTempFile("pdfjson-gap-test", suffix);
createdTempFiles.add(path);
return path.toFile();
});
when(tempFileManager.deleteTempFile(any(File.class)))
.thenAnswer(
invocation -> {
File file = invocation.getArgument(0);
return file != null && file.delete();
});
}
@AfterEach
void tearDown() throws IOException {
for (Path path : createdTempFiles) {
Files.deleteIfExists(path);
}
createdTempFiles.clear();
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
/** Builds a tiny in-memory PDF with the requested page dimensions and rotations. */
private PDDocument newPdf(float[][] sizes, int[] rotations) {
PDDocument document = new PDDocument();
for (int i = 0; i < sizes.length; i++) {
PDPage page = new PDPage(new PDRectangle(sizes[i][0], sizes[i][1]));
if (rotations != null) {
page.setRotation(rotations[i]);
}
document.addPage(page);
}
return document;
}
private PDDocument singlePagePdf(float width, float height) {
return newPdf(new float[][] {{width, height}}, null);
}
/** Stubs the fallback font service so buildFontMap can always resolve a usable PDFont. */
private void stubFallbackFont() throws IOException {
when(fallbackFontService.buildFallbackFontModel())
.thenAnswer(
invocation ->
PdfJsonFont.builder()
.id(PdfJsonFallbackFontService.FALLBACK_FONT_ID)
.uid(PdfJsonFallbackFontService.FALLBACK_FONT_ID)
.baseName("Fallback")
.subtype("TrueType")
.build());
when(fallbackFontService.loadFallbackPdfFont(any(PDDocument.class)))
.thenAnswer(invocation -> new PDType1Font(Standard14Fonts.FontName.HELVETICA));
}
private MockMultipartFile pdfMultipart() {
return new MockMultipartFile(
"fileInput", "input.pdf", "application/pdf", "%PDF-1.4 placeholder".getBytes());
}
private byte[] runJsonToPdf(PdfJsonDocument doc) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
service.convertJsonToPdf(doc, out);
return out.toByteArray();
}
// ------------------------------------------------------------------
// parseToUnicodeCodepoint - extra cases not covered by the unicode test
// ------------------------------------------------------------------
@Nested
@DisplayName("parseToUnicodeCodepoint extras")
class ParseToUnicodeExtras {
@Test
@DisplayName("parses the maximum BMP value FFFF")
void parsesMaxBmpValue() {
assertEquals(0xFFFF, PdfJsonConversionService.parseToUnicodeCodepoint("FFFF"));
}
@Test
@DisplayName("accepts lowercase hex digits")
void parsesLowercaseHex() {
// U+00E9 LATIN SMALL LETTER E WITH ACUTE.
assertEquals(0x00E9, PdfJsonConversionService.parseToUnicodeCodepoint("00e9"));
}
@Test
@DisplayName("parses a 3-char value directly (length <= 4)")
void parsesThreeCharValue() {
assertEquals(0x1F4, PdfJsonConversionService.parseToUnicodeCodepoint("1f4"));
}
}
// ------------------------------------------------------------------
// convertJsonToPdf(PdfJsonDocument, OutputStream)
// ------------------------------------------------------------------
@Nested
@DisplayName("convertJsonToPdf(document)")
class JsonToPdfDocument {
@Test
@DisplayName("null document throws IllegalArgumentException")
void nullDocumentThrows() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
IllegalArgumentException.class,
() -> service.convertJsonToPdf((PdfJsonDocument) null, out));
}
@Test
@DisplayName("single empty page produces a valid one-page PDF with correct dimensions")
void singleEmptyPage() throws IOException {
stubFallbackFont();
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
page.setWidth(300f);
page.setHeight(400f);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setPages(List.of(page));
byte[] pdfBytes = runJsonToPdf(doc);
assertTrue(pdfBytes.length > 0, "expected non-empty PDF output");
try (PDDocument loaded = Loader.loadPDF(pdfBytes)) {
assertEquals(1, loaded.getNumberOfPages());
PDRectangle box = loaded.getPage(0).getMediaBox();
assertEquals(300f, box.getWidth(), 0.01f);
assertEquals(400f, box.getHeight(), 0.01f);
}
}
@Test
@DisplayName("missing width/height falls back to US-Letter defaults")
void missingDimensionsUseDefaults() throws IOException {
stubFallbackFont();
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
// width/height left null -> safeFloat defaults of 612x792
PdfJsonDocument doc = new PdfJsonDocument();
doc.setPages(List.of(page));
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
PDRectangle box = loaded.getPage(0).getMediaBox();
assertEquals(612f, box.getWidth(), 0.01f);
assertEquals(792f, box.getHeight(), 0.01f);
}
}
@Test
@DisplayName("multiple pages keep their individual sizes and rotation")
void multiplePagesKeepSizesAndRotation() throws IOException {
stubFallbackFont();
PdfJsonPage first = new PdfJsonPage();
first.setPageNumber(1);
first.setWidth(200f);
first.setHeight(300f);
first.setRotation(90);
PdfJsonPage second = new PdfJsonPage();
second.setPageNumber(2);
second.setWidth(500f);
second.setHeight(250f);
second.setRotation(180);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setPages(List.of(first, second));
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
assertEquals(2, loaded.getNumberOfPages());
assertEquals(200f, loaded.getPage(0).getMediaBox().getWidth(), 0.01f);
assertEquals(90, loaded.getPage(0).getRotation());
assertEquals(500f, loaded.getPage(1).getMediaBox().getWidth(), 0.01f);
assertEquals(180, loaded.getPage(1).getRotation());
}
}
@Test
@DisplayName("document metadata round-trips into PDDocumentInformation")
void metadataRoundTrips() throws IOException {
stubFallbackFont();
PdfJsonMetadata metadata = new PdfJsonMetadata();
metadata.setTitle("Gap Test Title");
metadata.setAuthor("Gap Author");
metadata.setSubject("Subject X");
metadata.setKeywords("alpha,beta");
metadata.setCreator("Creator App");
metadata.setProducer("Producer Lib");
metadata.setCreationDate("2020-06-15T12:00:00Z");
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
page.setWidth(300f);
page.setHeight(300f);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(metadata);
doc.setPages(List.of(page));
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
PDDocumentInformation info = loaded.getDocumentInformation();
assertEquals("Gap Test Title", info.getTitle());
assertEquals("Gap Author", info.getAuthor());
assertEquals("Subject X", info.getSubject());
assertEquals("alpha,beta", info.getKeywords());
assertEquals("Creator App", info.getCreator());
assertEquals("Producer Lib", info.getProducer());
assertNotNull(info.getCreationDate(), "creation date should be applied");
}
}
@Test
@DisplayName("invalid creation date string is ignored without failing the conversion")
void invalidCreationDateIgnored() throws IOException {
stubFallbackFont();
PdfJsonMetadata metadata = new PdfJsonMetadata();
metadata.setTitle("Has bad date");
metadata.setCreationDate("not-a-real-instant");
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
page.setWidth(300f);
page.setHeight(300f);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(metadata);
doc.setPages(List.of(page));
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
assertEquals("Has bad date", loaded.getDocumentInformation().getTitle());
}
}
@Test
@DisplayName("base64 XMP packet is restored onto the document catalog")
void xmpMetadataApplied() throws IOException {
stubFallbackFont();
String xmpXml =
"<?xpacket begin=\"\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"></x:xmpmeta>";
String base64 =
Base64.getEncoder().encodeToString(xmpXml.getBytes(StandardCharsets.UTF_8));
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
page.setWidth(300f);
page.setHeight(300f);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setXmpMetadata(base64);
doc.setPages(List.of(page));
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
assertNotNull(
loaded.getDocumentCatalog().getMetadata(),
"XMP metadata stream should be present on the catalog");
}
}
@Test
@DisplayName("null fonts list is initialised instead of causing an NPE")
void nullFontsListHandled() throws IOException {
stubFallbackFont();
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
page.setWidth(300f);
page.setHeight(300f);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setFonts(null);
doc.setPages(List.of(page));
byte[] pdfBytes = runJsonToPdf(doc);
assertTrue(pdfBytes.length > 0);
// buildFontMap mutates the (now non-null) list by appending the fallback model.
assertNotNull(doc.getFonts());
assertTrue(
doc.getFonts().stream()
.anyMatch(
f ->
PdfJsonFallbackFontService.FALLBACK_FONT_ID.equals(
f.getId())));
}
}
// ------------------------------------------------------------------
// convertJsonToPdf(MultipartFile, OutputStream)
// ------------------------------------------------------------------
@Nested
@DisplayName("convertJsonToPdf(file)")
class JsonToPdfFile {
@Test
@DisplayName("null file throws IllegalArgumentException")
void nullFileThrows() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
IllegalArgumentException.class,
() -> service.convertJsonToPdf((MockMultipartFile) null, out));
}
@Test
@DisplayName("valid JSON payload is deserialized and rebuilt into a PDF")
void validJsonProducesPdf() throws IOException {
stubFallbackFont();
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
page.setWidth(321f);
page.setHeight(123f);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setPages(List.of(page));
byte[] json = objectMapper.writeValueAsBytes(doc);
MockMultipartFile file =
new MockMultipartFile("fileInput", "doc.json", "application/json", json);
ByteArrayOutputStream out = new ByteArrayOutputStream();
service.convertJsonToPdf(file, out);
try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) {
assertEquals(1, loaded.getNumberOfPages());
assertEquals(321f, loaded.getPage(0).getMediaBox().getWidth(), 0.01f);
}
}
}
// ------------------------------------------------------------------
// convertPdfToJson family
// ------------------------------------------------------------------
@Nested
@DisplayName("convertPdfToJson")
class PdfToJson {
@Test
@DisplayName("null file throws IllegalArgumentException")
void nullFileThrows() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(IllegalArgumentException.class, () -> service.convertPdfToJson(null, out));
}
@Test
@DisplayName("blank single-page PDF yields JSON with one page and matching dimensions")
void blankSinglePage() throws IOException {
try (PDDocument pdf = singlePagePdf(250f, 350f)) {
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
ByteArrayOutputStream out = new ByteArrayOutputStream();
service.convertPdfToJson(pdfMultipart(), out);
PdfJsonDocument result =
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
assertEquals(1, result.getPages().size());
PdfJsonPage page = result.getPages().get(0);
assertEquals(1, page.getPageNumber());
assertEquals(250f, page.getWidth(), 0.01f);
assertEquals(350f, page.getHeight(), 0.01f);
assertNotNull(result.getMetadata());
assertEquals(1, result.getMetadata().getNumberOfPages());
}
}
@Test
@DisplayName("multi-page PDF preserves per-page dimensions in the JSON model")
void multiPageDimensions() throws IOException {
try (PDDocument pdf =
newPdf(new float[][] {{200f, 300f}, {612f, 792f}}, new int[] {0, 90})) {
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
ByteArrayOutputStream out = new ByteArrayOutputStream();
service.convertPdfToJson(pdfMultipart(), out);
PdfJsonDocument result =
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
assertEquals(2, result.getPages().size());
assertEquals(200f, result.getPages().get(0).getWidth(), 0.01f);
assertEquals(792f, result.getPages().get(1).getHeight(), 0.01f);
assertEquals(90, result.getPages().get(1).getRotation());
}
}
@Test
@DisplayName("source document metadata is extracted into the JSON metadata block")
void extractsSourceMetadata() throws IOException {
try (PDDocument pdf = singlePagePdf(300f, 300f)) {
PDDocumentInformation info = pdf.getDocumentInformation();
info.setTitle("Original Title");
info.setAuthor("Original Author");
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
ByteArrayOutputStream out = new ByteArrayOutputStream();
service.convertPdfToJson(pdfMultipart(), out);
PdfJsonDocument result =
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
assertEquals("Original Title", result.getMetadata().getTitle());
assertEquals("Original Author", result.getMetadata().getAuthor());
}
}
@Test
@DisplayName("lightweight overload still produces a parseable JSON document")
void lightweightOverload() throws IOException {
try (PDDocument pdf = singlePagePdf(300f, 300f)) {
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
ByteArrayOutputStream out = new ByteArrayOutputStream();
service.convertPdfToJson(pdfMultipart(), true, out);
PdfJsonDocument result =
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
assertEquals(1, result.getPages().size());
}
}
@Test
@DisplayName("progress callback receives a terminal complete event")
void progressCallbackInvoked() throws IOException {
try (PDDocument pdf = singlePagePdf(300f, 300f)) {
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
AtomicBoolean sawComplete = new AtomicBoolean(false);
AtomicBoolean sawAny = new AtomicBoolean(false);
ByteArrayOutputStream out = new ByteArrayOutputStream();
service.convertPdfToJson(
pdfMultipart(),
progress -> {
sawAny.set(true);
if (progress.getPercent() == 100 || progress.isComplete()) {
sawComplete.set(true);
}
},
out);
assertTrue(sawAny.get(), "expected at least one progress event");
assertTrue(sawComplete.get(), "expected a terminal 100%/complete progress event");
}
}
@Test
@DisplayName("convertPdfToJsonDocument returns an in-memory model")
void convertPdfToJsonDocumentReturnsModel() throws IOException {
try (PDDocument pdf = singlePagePdf(400f, 500f)) {
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
PdfJsonDocument result = service.convertPdfToJsonDocument(pdfMultipart());
assertNotNull(result);
assertEquals(1, result.getPages().size());
assertEquals(400f, result.getPages().get(0).getWidth(), 0.01f);
}
}
}
// ------------------------------------------------------------------
// Cache-backed public API error branches (no PDF load required)
// ------------------------------------------------------------------
@Nested
@DisplayName("cache-backed API")
class CacheApi {
@Test
@DisplayName("extractSinglePage with unknown jobId throws CacheUnavailableException")
void extractSinglePageUnknownJob() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
CacheUnavailableException.class,
() -> service.extractSinglePage("missing-job", 1, out));
}
@Test
@DisplayName("extractPageFonts with unknown jobId throws CacheUnavailableException")
void extractPageFontsUnknownJob() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
CacheUnavailableException.class,
() -> service.extractPageFonts("missing-job", 1, out));
}
@Test
@DisplayName("exportUpdatedPages requires a non-null jobId")
void exportUpdatedPagesNullJob() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
IllegalArgumentException.class,
() -> service.exportUpdatedPages(null, new PdfJsonDocument(), out));
}
@Test
@DisplayName("exportUpdatedPages rejects a blank jobId")
void exportUpdatedPagesBlankJob() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
IllegalArgumentException.class,
() -> service.exportUpdatedPages(" ", new PdfJsonDocument(), out));
}
@Test
@DisplayName("exportUpdatedPages with unknown jobId throws CacheUnavailableException")
void exportUpdatedPagesUnknownJob() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
CacheUnavailableException.class,
() -> service.exportUpdatedPages("missing-job", new PdfJsonDocument(), out));
}
@Test
@DisplayName("extractDocumentMetadata with null file throws IllegalArgumentException")
void extractDocumentMetadataNullFile() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
assertThrows(
IllegalArgumentException.class,
() -> service.extractDocumentMetadata(null, "job", out));
}
@Test
@DisplayName("clearCachedDocument on an unknown jobId is a no-op")
void clearCachedDocumentUnknownJob() {
assertDoesNotThrow(() -> service.clearCachedDocument("missing-job"));
}
}
}
@@ -0,0 +1,780 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSBoolean;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSFloat;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSNull;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.SPDF.model.json.PdfJsonCosValue;
import stirling.software.SPDF.model.json.PdfJsonStream;
import stirling.software.SPDF.service.PdfJsonCosMapper.SerializationContext;
/** Unit tests for {@link PdfJsonCosMapper}. */
class PdfJsonCosMapperTest {
private PdfJsonCosMapper mapper;
private PDDocument document;
@BeforeEach
void setUp() {
mapper = new PdfJsonCosMapper();
document = new PDDocument();
}
@AfterEach
void tearDown() throws IOException {
if (document != null) {
document.close();
}
}
// Helper: create a populated COSStream within the test document.
private COSStream newCosStreamWithData(byte[] data) throws IOException {
COSStream cosStream = document.getDocument().createCOSStream();
try (OutputStream out = cosStream.createRawOutputStream()) {
out.write(data);
}
cosStream.setItem(COSName.LENGTH, COSInteger.get(data.length));
return cosStream;
}
@Nested
@DisplayName("SerializationContext.omitStreamData()")
class OmitStreamDataTests {
@Test
@DisplayName("returns true only for lightweight contexts")
void omitStreamData() {
assertFalse(SerializationContext.DEFAULT.omitStreamData());
assertFalse(SerializationContext.ANNOTATION_RAW_DATA.omitStreamData());
assertFalse(SerializationContext.FORM_FIELD_RAW_DATA.omitStreamData());
assertTrue(SerializationContext.CONTENT_STREAMS_LIGHTWEIGHT.omitStreamData());
assertTrue(SerializationContext.RESOURCES_LIGHTWEIGHT.omitStreamData());
}
}
@Nested
@DisplayName("serializeCosValue - primitives")
class SerializeCosValuePrimitiveTests {
@Test
@DisplayName("null base yields null value")
void nullBase() throws IOException {
assertNull(mapper.serializeCosValue(null));
}
@Test
@DisplayName("COSNull serializes to NULL type")
void cosNull() throws IOException {
PdfJsonCosValue value = mapper.serializeCosValue(COSNull.NULL);
assertNotNull(value);
assertEquals(PdfJsonCosValue.Type.NULL, value.getType());
}
@Test
@DisplayName("COSBoolean serializes to BOOLEAN type with value")
void cosBoolean() throws IOException {
PdfJsonCosValue trueValue = mapper.serializeCosValue(COSBoolean.TRUE);
assertEquals(PdfJsonCosValue.Type.BOOLEAN, trueValue.getType());
assertEquals(Boolean.TRUE, trueValue.getValue());
PdfJsonCosValue falseValue = mapper.serializeCosValue(COSBoolean.FALSE);
assertEquals(PdfJsonCosValue.Type.BOOLEAN, falseValue.getType());
assertEquals(Boolean.FALSE, falseValue.getValue());
}
@Test
@DisplayName("COSInteger serializes to INTEGER type with long value")
void cosInteger() throws IOException {
PdfJsonCosValue value = mapper.serializeCosValue(COSInteger.get(42L));
assertEquals(PdfJsonCosValue.Type.INTEGER, value.getType());
assertEquals(42L, value.getValue());
}
@Test
@DisplayName("COSFloat serializes to FLOAT type with float value")
void cosFloat() throws IOException {
PdfJsonCosValue value = mapper.serializeCosValue(new COSFloat(1.5f));
assertEquals(PdfJsonCosValue.Type.FLOAT, value.getType());
assertEquals(1.5f, value.getValue());
}
@Test
@DisplayName("COSName serializes to NAME type with the name literal")
void cosName() throws IOException {
PdfJsonCosValue value = mapper.serializeCosValue(COSName.getPDFName("Foo"));
assertEquals(PdfJsonCosValue.Type.NAME, value.getType());
assertEquals("Foo", value.getValue());
}
@Test
@DisplayName("COSString serializes to STRING type with base64 content")
void cosString() throws IOException {
byte[] raw = "héllo".getBytes(StandardCharsets.UTF_8);
PdfJsonCosValue value = mapper.serializeCosValue(new COSString(raw));
assertEquals(PdfJsonCosValue.Type.STRING, value.getType());
assertEquals(Base64.getEncoder().encodeToString(raw), value.getValue());
}
}
@Nested
@DisplayName("serializeCosValue - containers")
class SerializeCosValueContainerTests {
@Test
@DisplayName("COSArray serializes nested items in order")
void cosArray() throws IOException {
COSArray array = new COSArray();
array.add(COSInteger.get(1L));
array.add(COSName.getPDFName("X"));
array.add(COSBoolean.TRUE);
PdfJsonCosValue value = mapper.serializeCosValue(array);
assertEquals(PdfJsonCosValue.Type.ARRAY, value.getType());
List<PdfJsonCosValue> items = value.getItems();
assertEquals(3, items.size());
assertEquals(PdfJsonCosValue.Type.INTEGER, items.get(0).getType());
assertEquals(PdfJsonCosValue.Type.NAME, items.get(1).getType());
assertEquals(PdfJsonCosValue.Type.BOOLEAN, items.get(2).getType());
}
@Test
@DisplayName("COSDictionary serializes keyed entries")
void cosDictionary() throws IOException {
COSDictionary dict = new COSDictionary();
dict.setItem(COSName.getPDFName("Count"), COSInteger.get(7L));
dict.setItem(COSName.getPDFName("Type"), COSName.getPDFName("Catalog"));
PdfJsonCosValue value = mapper.serializeCosValue(dict);
assertEquals(PdfJsonCosValue.Type.DICTIONARY, value.getType());
Map<String, PdfJsonCosValue> entries = value.getEntries();
assertEquals(2, entries.size());
assertEquals(PdfJsonCosValue.Type.INTEGER, entries.get("Count").getType());
assertEquals(7L, entries.get("Count").getValue());
assertEquals(PdfJsonCosValue.Type.NAME, entries.get("Type").getType());
assertEquals("Catalog", entries.get("Type").getValue());
}
@Test
@DisplayName("circular dictionary reference is replaced with a __circular__ marker")
void circularReference() throws IOException {
COSDictionary dict = new COSDictionary();
dict.setItem(COSName.getPDFName("Self"), dict);
PdfJsonCosValue value = mapper.serializeCosValue(dict);
assertEquals(PdfJsonCosValue.Type.DICTIONARY, value.getType());
PdfJsonCosValue self = value.getEntries().get("Self");
assertEquals(PdfJsonCosValue.Type.NAME, self.getType());
assertEquals("__circular__", self.getValue());
}
@Test
@DisplayName("the same dictionary appearing twice (non-circular) is serialized both times")
void repeatedNonCircularReference() throws IOException {
COSDictionary shared = new COSDictionary();
shared.setItem(COSName.getPDFName("V"), COSInteger.get(9L));
COSArray array = new COSArray();
array.add(shared);
array.add(shared);
PdfJsonCosValue value = mapper.serializeCosValue(array);
List<PdfJsonCosValue> items = value.getItems();
assertEquals(2, items.size());
// Because the visited set is removed in finally, the second sibling occurrence is not
// treated as circular.
assertEquals(PdfJsonCosValue.Type.DICTIONARY, items.get(0).getType());
assertEquals(PdfJsonCosValue.Type.DICTIONARY, items.get(1).getType());
}
}
@Nested
@DisplayName("serializeStream overloads")
class SerializeStreamTests {
@Test
@DisplayName("null PDStream returns null")
void nullPdStream() throws IOException {
assertNull(mapper.serializeStream((PDStream) null));
}
@Test
@DisplayName("null COSStream returns null")
void nullCosStream() throws IOException {
assertNull(mapper.serializeStream((COSStream) null));
}
@Test
@DisplayName("null COSStream with explicit context returns null")
void nullCosStreamWithContext() throws IOException {
assertNull(mapper.serializeStream((COSStream) null, SerializationContext.DEFAULT));
}
@Test
@DisplayName("null PDStream with explicit context returns null")
void nullPdStreamWithContext() throws IOException {
assertNull(mapper.serializeStream((PDStream) null, SerializationContext.DEFAULT));
}
@Test
@DisplayName("COSStream serializes dictionary and base64 rawData")
void cosStreamWithData() throws IOException {
byte[] data = "stream-bytes".getBytes(StandardCharsets.UTF_8);
COSStream cosStream = newCosStreamWithData(data);
cosStream.setItem(COSName.TYPE, COSName.getPDFName("XObject"));
PdfJsonStream result = mapper.serializeStream(cosStream);
assertNotNull(result);
assertNotNull(result.getDictionary());
assertTrue(result.getDictionary().containsKey(COSName.TYPE.getName()));
assertEquals(Base64.getEncoder().encodeToString(data), result.getRawData());
}
@Test
@DisplayName("empty COSStream yields null rawData")
void emptyCosStream() throws IOException {
COSStream cosStream = newCosStreamWithData(new byte[0]);
PdfJsonStream result = mapper.serializeStream(cosStream);
assertNotNull(result);
assertNull(result.getRawData());
}
@Test
@DisplayName("lightweight context omits rawData even when stream has data")
void lightweightContextOmitsData() throws IOException {
byte[] data = "ignored".getBytes(StandardCharsets.UTF_8);
COSStream cosStream = newCosStreamWithData(data);
cosStream.setItem(COSName.FILTER, COSName.getPDFName("FlateDecode"));
PdfJsonStream result =
mapper.serializeStream(
cosStream, SerializationContext.CONTENT_STREAMS_LIGHTWEIGHT);
assertNotNull(result);
assertNull(result.getRawData());
// Dictionary metadata is still preserved.
assertTrue(result.getDictionary().containsKey(COSName.FILTER.getName()));
}
@Test
@DisplayName("null context is treated as DEFAULT and keeps rawData")
void nullContextDefaultsToDefault() throws IOException {
byte[] data = "keep".getBytes(StandardCharsets.UTF_8);
COSStream cosStream = newCosStreamWithData(data);
PdfJsonStream result = mapper.serializeStream(cosStream, (SerializationContext) null);
assertNotNull(result);
assertEquals(Base64.getEncoder().encodeToString(data), result.getRawData());
}
@Test
@DisplayName("PDStream overload delegates to COSStream serialization")
void pdStreamOverload() throws IOException {
byte[] data = "pdstream".getBytes(StandardCharsets.UTF_8);
PDStream pdStream = new PDStream(document, new java.io.ByteArrayInputStream(data));
PdfJsonStream result = mapper.serializeStream(pdStream);
assertNotNull(result);
assertNotNull(result.getRawData());
}
@Test
@DisplayName("PDStream overload with lightweight context omits rawData")
void pdStreamOverloadLightweight() throws IOException {
byte[] data = "pdstream".getBytes(StandardCharsets.UTF_8);
PDStream pdStream = new PDStream(document, new java.io.ByteArrayInputStream(data));
PdfJsonStream result =
mapper.serializeStream(pdStream, SerializationContext.RESOURCES_LIGHTWEIGHT);
assertNotNull(result);
assertNull(result.getRawData());
}
@Test
@DisplayName("serializeCosValue of a COSStream produces STREAM type wrapping the stream")
void serializeCosValueWrapsStream() throws IOException {
byte[] data = "abc".getBytes(StandardCharsets.UTF_8);
COSStream cosStream = newCosStreamWithData(data);
PdfJsonCosValue value = mapper.serializeCosValue(cosStream);
assertEquals(PdfJsonCosValue.Type.STREAM, value.getType());
assertNotNull(value.getStream());
assertEquals(Base64.getEncoder().encodeToString(data), value.getStream().getRawData());
}
}
@Nested
@DisplayName("deserializeCosValue")
class DeserializeCosValueTests {
@Test
@DisplayName("null value returns null")
void nullValue() throws IOException {
assertNull(mapper.deserializeCosValue(null, document));
}
@Test
@DisplayName("value with null type returns null")
void nullType() throws IOException {
PdfJsonCosValue value = PdfJsonCosValue.builder().value("x").build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("NULL type returns COSNull")
void nullTypeValue() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.NULL).build();
assertEquals(COSNull.NULL, mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("BOOLEAN type returns matching COSBoolean")
void booleanType() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.BOOLEAN)
.value(Boolean.TRUE)
.build();
COSBase result = mapper.deserializeCosValue(value, document);
assertEquals(COSBoolean.TRUE, result);
}
@Test
@DisplayName("BOOLEAN type with non-boolean value returns null")
void booleanTypeWrongValue() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.BOOLEAN)
.value("not-a-boolean")
.build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("INTEGER type returns COSInteger from a Number value")
void integerType() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.INTEGER)
.value(123L)
.build();
COSBase result = mapper.deserializeCosValue(value, document);
assertInstanceOf(COSInteger.class, result);
assertEquals(123L, ((COSInteger) result).longValue());
}
@Test
@DisplayName("INTEGER type accepts an Integer value via Number")
void integerTypeFromInteger() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.INTEGER)
.value(Integer.valueOf(5))
.build();
COSBase result = mapper.deserializeCosValue(value, document);
assertInstanceOf(COSInteger.class, result);
assertEquals(5L, ((COSInteger) result).longValue());
}
@Test
@DisplayName("INTEGER type with non-number returns null")
void integerTypeWrongValue() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.INTEGER)
.value("oops")
.build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("FLOAT type returns COSFloat from a Number value")
void floatType() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.FLOAT).value(2.25f).build();
COSBase result = mapper.deserializeCosValue(value, document);
assertInstanceOf(COSFloat.class, result);
assertEquals(2.25f, ((COSFloat) result).floatValue());
}
@Test
@DisplayName("FLOAT type with non-number returns null")
void floatTypeWrongValue() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.FLOAT)
.value("oops")
.build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("NAME type returns COSName from a String value")
void nameType() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.NAME)
.value("MyName")
.build();
COSBase result = mapper.deserializeCosValue(value, document);
assertEquals(COSName.getPDFName("MyName"), result);
}
@Test
@DisplayName("NAME type with non-string returns null")
void nameTypeWrongValue() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.NAME).value(123L).build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("STRING type decodes base64 into COSString bytes")
void stringType() throws IOException {
byte[] raw = "round-trip".getBytes(StandardCharsets.UTF_8);
String encoded = Base64.getEncoder().encodeToString(raw);
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.STRING)
.value(encoded)
.build();
COSBase result = mapper.deserializeCosValue(value, document);
assertInstanceOf(COSString.class, result);
assertArrayEquals(raw, ((COSString) result).getBytes());
}
@Test
@DisplayName("STRING type with invalid base64 returns null")
void stringTypeInvalidBase64() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.STRING)
.value("!!!not base64!!!")
.build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("STRING type with non-string value returns null")
void stringTypeWrongValue() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.STRING).value(42L).build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("ARRAY type deserializes each item")
void arrayType() throws IOException {
PdfJsonCosValue item1 =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.INTEGER).value(1L).build();
PdfJsonCosValue item2 =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.NAME).value("N").build();
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.ARRAY)
.items(List.of(item1, item2))
.build();
COSBase result = mapper.deserializeCosValue(value, document);
assertInstanceOf(COSArray.class, result);
COSArray array = (COSArray) result;
assertEquals(2, array.size());
assertEquals(1L, ((COSInteger) array.get(0)).longValue());
assertEquals(COSName.getPDFName("N"), array.get(1));
}
@Test
@DisplayName("ARRAY type substitutes COSNull for un-deserializable items")
void arrayTypeWithNullItems() throws IOException {
// An INTEGER type with a non-number value deserializes to null and is replaced by
// COSNull.NULL inside the array.
PdfJsonCosValue bad =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.INTEGER)
.value("nope")
.build();
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.ARRAY)
.items(List.of(bad))
.build();
COSArray result = (COSArray) mapper.deserializeCosValue(value, document);
assertEquals(1, result.size());
assertEquals(COSNull.NULL, result.get(0));
}
@Test
@DisplayName("ARRAY type with null items list yields empty COSArray")
void arrayTypeNullItems() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.ARRAY).build();
COSArray result = (COSArray) mapper.deserializeCosValue(value, document);
assertNotNull(result);
assertEquals(0, result.size());
}
@Test
@DisplayName("DICTIONARY type deserializes entries by key")
void dictionaryType() throws IOException {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
entries.put(
"Count",
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.INTEGER).value(3L).build());
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.DICTIONARY)
.entries(entries)
.build();
COSDictionary result = (COSDictionary) mapper.deserializeCosValue(value, document);
assertNotNull(result);
assertEquals(3L, ((COSInteger) result.getItem("Count")).longValue());
}
@Test
@DisplayName("DICTIONARY type skips entries that deserialize to null")
void dictionaryTypeSkipsNullEntries() throws IOException {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
entries.put(
"Bad",
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.INTEGER)
.value("nope")
.build());
PdfJsonCosValue value =
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.DICTIONARY)
.entries(entries)
.build();
COSDictionary result = (COSDictionary) mapper.deserializeCosValue(value, document);
assertNotNull(result);
assertNull(result.getItem(COSName.getPDFName("Bad")));
}
@Test
@DisplayName("DICTIONARY type with null entries map yields empty COSDictionary")
void dictionaryTypeNullEntries() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.DICTIONARY).build();
COSDictionary result = (COSDictionary) mapper.deserializeCosValue(value, document);
assertNotNull(result);
assertEquals(0, result.size());
}
@Test
@DisplayName("STREAM type with null stream returns null")
void streamTypeNullStream() throws IOException {
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.STREAM).build();
assertNull(mapper.deserializeCosValue(value, document));
}
@Test
@DisplayName("STREAM type builds a COSStream from the model")
void streamType() throws IOException {
byte[] data = "stream-content".getBytes(StandardCharsets.UTF_8);
PdfJsonStream streamModel =
PdfJsonStream.builder()
.rawData(Base64.getEncoder().encodeToString(data))
.build();
PdfJsonCosValue value =
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.STREAM).stream(streamModel)
.build();
COSBase result = mapper.deserializeCosValue(value, document);
assertInstanceOf(COSStream.class, result);
assertStreamRawEquals(data, (COSStream) result);
}
}
@Nested
@DisplayName("buildStreamFromModel")
class BuildStreamFromModelTests {
@Test
@DisplayName("null model returns null")
void nullModel() throws IOException {
assertNull(mapper.buildStreamFromModel(null, document));
}
@Test
@DisplayName("model with rawData writes base64-decoded bytes and sets Length")
void withRawData() throws IOException {
byte[] data = "hello-world".getBytes(StandardCharsets.UTF_8);
PdfJsonStream model =
PdfJsonStream.builder()
.rawData(Base64.getEncoder().encodeToString(data))
.build();
COSStream result = mapper.buildStreamFromModel(model, document);
assertNotNull(result);
assertStreamRawEquals(data, result);
assertEquals(data.length, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
}
@Test
@DisplayName("model with dictionary entries copies them onto the stream")
void withDictionary() throws IOException {
Map<String, PdfJsonCosValue> dict = new LinkedHashMap<>();
dict.put(
"Type",
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.NAME)
.value("XObject")
.build());
dict.put(
"Width",
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.INTEGER)
.value(100L)
.build());
PdfJsonStream model = PdfJsonStream.builder().dictionary(dict).build();
COSStream result = mapper.buildStreamFromModel(model, document);
assertNotNull(result);
assertEquals(COSName.getPDFName("XObject"), result.getItem(COSName.TYPE));
assertEquals(
100L, ((COSInteger) result.getItem(COSName.getPDFName("Width"))).longValue());
}
@Test
@DisplayName("model dictionary entry that deserializes to null is skipped")
void dictionaryEntryNullSkipped() throws IOException {
Map<String, PdfJsonCosValue> dict = new LinkedHashMap<>();
dict.put(
"Bad",
PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.NAME)
.value(999L) // non-string -> null
.build());
PdfJsonStream model = PdfJsonStream.builder().dictionary(dict).build();
COSStream result = mapper.buildStreamFromModel(model, document);
assertNotNull(result);
assertNull(result.getItem(COSName.getPDFName("Bad")));
}
@Test
@DisplayName("model with null/blank rawData sets Length to zero and writes nothing")
void blankRawData() throws IOException {
PdfJsonStream model = PdfJsonStream.builder().rawData(" ").build();
COSStream result = mapper.buildStreamFromModel(model, document);
assertNotNull(result);
assertEquals(0L, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
// Blank rawData takes the else-branch: Length is set to 0 but createRawOutputStream()
// is
// never called, so nothing is written. PDFBox cannot open a raw InputStream on a stream
// that was never written to, which is the documented "writes nothing" behaviour.
assertThrows(IOException.class, result::createRawInputStream);
}
@Test
@DisplayName("model with no rawData sets Length to zero")
void noRawData() throws IOException {
PdfJsonStream model = PdfJsonStream.builder().build();
COSStream result = mapper.buildStreamFromModel(model, document);
assertNotNull(result);
assertEquals(0L, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
}
@Test
@DisplayName("model with invalid base64 rawData falls back to empty data")
void invalidBase64RawData() throws IOException {
PdfJsonStream model = PdfJsonStream.builder().rawData("###not-base64###").build();
COSStream result = mapper.buildStreamFromModel(model, document);
assertNotNull(result);
assertEquals(0L, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
assertStreamRawEquals(new byte[0], result);
}
}
@Nested
@DisplayName("round trip serialize -> deserialize")
class RoundTripTests {
@Test
@DisplayName("nested array/dictionary structure survives a round trip")
void nestedStructure() throws IOException {
COSDictionary original = new COSDictionary();
original.setItem(COSName.getPDFName("Int"), COSInteger.get(11L));
original.setItem(COSName.getPDFName("Name"), COSName.getPDFName("Hello"));
original.setItem(COSName.getPDFName("Bool"), COSBoolean.FALSE);
COSArray inner = new COSArray();
inner.add(new COSFloat(3.5f));
inner.add(new COSString("abc".getBytes(StandardCharsets.UTF_8)));
original.setItem(COSName.getPDFName("Arr"), inner);
PdfJsonCosValue serialized = mapper.serializeCosValue(original);
COSBase deserialized = mapper.deserializeCosValue(serialized, document);
assertInstanceOf(COSDictionary.class, deserialized);
COSDictionary result = (COSDictionary) deserialized;
assertEquals(11L, ((COSInteger) result.getItem(COSName.getPDFName("Int"))).longValue());
assertEquals(COSName.getPDFName("Hello"), result.getItem(COSName.getPDFName("Name")));
assertEquals(COSBoolean.FALSE, result.getItem(COSName.getPDFName("Bool")));
COSArray resultArr = (COSArray) result.getItem(COSName.getPDFName("Arr"));
assertEquals(2, resultArr.size());
assertEquals(3.5f, ((COSFloat) resultArr.get(0)).floatValue());
assertArrayEquals(
"abc".getBytes(StandardCharsets.UTF_8),
((COSString) resultArr.get(1)).getBytes());
}
@Test
@DisplayName("stream data survives a round trip")
void streamRoundTrip() throws IOException {
byte[] data = "round-trip-stream".getBytes(StandardCharsets.UTF_8);
COSStream cosStream = newCosStreamWithData(data);
cosStream.setItem(COSName.TYPE, COSName.getPDFName("XObject"));
PdfJsonCosValue serialized = mapper.serializeCosValue(cosStream);
COSBase deserialized = mapper.deserializeCosValue(serialized, document);
assertInstanceOf(COSStream.class, deserialized);
COSStream result = (COSStream) deserialized;
assertStreamRawEquals(data, result);
assertEquals(COSName.getPDFName("XObject"), result.getItem(COSName.TYPE));
}
}
// Reads the raw (undecoded) bytes from a COSStream and asserts equality.
private void assertStreamRawEquals(byte[] expected, COSStream cosStream) throws IOException {
try (InputStream in = cosStream.createRawInputStream()) {
byte[] actual = in.readAllBytes();
assertArrayEquals(expected, actual);
}
}
}
@@ -0,0 +1,571 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Base64;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.ResourceLoader;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.SPDF.model.json.PdfJsonFont;
import stirling.software.common.model.ApplicationProperties;
class PdfJsonFallbackFontServiceTest {
private PdfJsonFallbackFontService service;
private ApplicationProperties applicationProperties;
// Parsing the real fallback TrueType fonts (the CJK font alone is ~17 MB) is the only expensive
// work in this class. Build a default-config service and parse the CJK fallback once, then
// reuse
// them across every read-only test instead of re-parsing per @BeforeEach.
private static PdfJsonFallbackFontService sharedService;
private static PDDocument sharedCjkDocument;
private static PDFont sharedCjkFont;
@BeforeAll
static void setUpShared() throws Exception {
// Real ApplicationProperties already defaults pdfEditor.fallbackFont to the Noto Sans
// location, so no mocking is needed for the default-config path.
ResourceLoader resourceLoader = new DefaultResourceLoader();
ApplicationProperties props = new ApplicationProperties();
sharedService = new PdfJsonFallbackFontService(resourceLoader, props);
ReflectionTestUtils.setField(
sharedService,
"legacyFallbackFontLocation",
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION);
Method loadConfig = PdfJsonFallbackFontService.class.getDeclaredMethod("loadConfig");
loadConfig.setAccessible(true);
loadConfig.invoke(sharedService);
// Parse the large CJK fallback exactly once; the CanEncode tests only read this font.
sharedCjkDocument = new PDDocument();
sharedCjkFont =
sharedService.loadFallbackPdfFont(
sharedCjkDocument, PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID);
}
@AfterAll
static void tearDownShared() throws IOException {
if (sharedCjkDocument != null) {
sharedCjkDocument.close();
}
}
@BeforeEach
void setUp() {
// Real resource loader resolves the classpath:/static/fonts/*.ttf bundled in the module.
ResourceLoader resourceLoader = new DefaultResourceLoader();
// Mock the properties chain so loadConfig() resolves to the default Noto Sans location.
applicationProperties = mock(ApplicationProperties.class);
ApplicationProperties.PdfEditor pdfEditor = mock(ApplicationProperties.PdfEditor.class);
lenient().when(applicationProperties.getPdfEditor()).thenReturn(pdfEditor);
lenient()
.when(pdfEditor.getFallbackFont())
.thenReturn(PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION);
service = new PdfJsonFallbackFontService(resourceLoader, applicationProperties);
// The @Value field is normally injected by Spring; set it explicitly for the unit test.
ReflectionTestUtils.setField(
service,
"legacyFallbackFontLocation",
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION);
}
/** Invoke the private @PostConstruct loadConfig() to populate fallbackFontLocation. */
private void invokeLoadConfig() throws Exception {
Method loadConfig = PdfJsonFallbackFontService.class.getDeclaredMethod("loadConfig");
loadConfig.setAccessible(true);
loadConfig.invoke(service);
}
@Nested
@DisplayName("loadConfig (@PostConstruct)")
class LoadConfig {
@Test
@DisplayName("uses the configured pdf-editor fallback font when set")
void usesConfiguredFallbackFont() throws Exception {
when(applicationProperties.getPdfEditor().getFallbackFont())
.thenReturn("classpath:/static/fonts/DejaVuSans.ttf");
invokeLoadConfig();
assertEquals(
"classpath:/static/fonts/DejaVuSans.ttf",
ReflectionTestUtils.getField(service, "fallbackFontLocation"));
}
@Test
@DisplayName("falls back to the legacy @Value location when configured value is blank")
void fallsBackToLegacyWhenBlank() throws Exception {
when(applicationProperties.getPdfEditor().getFallbackFont()).thenReturn(" ");
invokeLoadConfig();
assertEquals(
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION,
ReflectionTestUtils.getField(service, "fallbackFontLocation"));
}
@Test
@DisplayName("falls back to the legacy @Value location when PdfEditor is null")
void fallsBackToLegacyWhenPdfEditorNull() throws Exception {
when(applicationProperties.getPdfEditor()).thenReturn(null);
invokeLoadConfig();
assertEquals(
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION,
ReflectionTestUtils.getField(service, "fallbackFontLocation"));
}
}
@Nested
@DisplayName("resolveFallbackFontId(int codePoint)")
class ResolveByCodePoint {
@Test
@DisplayName("Latin letters resolve to the generic Noto Sans fallback")
void latinResolvesToNotoSans() {
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_ID,
service.resolveFallbackFontId('A'));
}
@Test
@DisplayName("CJK unified ideographs resolve to the CJK fallback")
void cjkIdeographResolvesToCjk() {
// U+4E2D (中) is a CJK Unified Ideograph.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID,
service.resolveFallbackFontId(0x4E2D));
}
@Test
@DisplayName("Bopomofo resolves to the Traditional Chinese fallback")
void bopomofoResolvesToTc() {
// U+3105 (ㄅ) is in the Bopomofo block.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_TC_ID,
service.resolveFallbackFontId(0x3105));
}
@Test
@DisplayName("CJK compatibility ideographs resolve to the Traditional Chinese fallback")
void compatibilityIdeographResolvesToTc() {
// U+F900 is in the CJK Compatibility Ideographs block.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_TC_ID,
service.resolveFallbackFontId(0xF900));
}
@Test
@DisplayName("Hiragana resolves to the Japanese fallback")
void hiraganaResolvesToJp() {
// U+3042 (あ) is Hiragana.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_JP_ID,
service.resolveFallbackFontId(0x3042));
}
@Test
@DisplayName("Hangul resolves to the Korean fallback")
void hangulResolvesToKr() {
// U+AC00 (가) is a Hangul syllable.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_KR_ID,
service.resolveFallbackFontId(0xAC00));
}
@Test
@DisplayName("Arabic resolves to the Arabic fallback")
void arabicResolvesToAr() {
// U+0627 (ا) is Arabic.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_AR_ID,
service.resolveFallbackFontId(0x0627));
}
@Test
@DisplayName("Thai resolves to the Thai fallback")
void thaiResolvesToTh() {
// U+0E01 (ก) is Thai.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID,
service.resolveFallbackFontId(0x0E01));
}
@Test
@DisplayName("Devanagari resolves to the Devanagari fallback")
void devanagariResolves() {
// U+0905 (अ) is Devanagari.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_DEVANAGARI_ID,
service.resolveFallbackFontId(0x0905));
}
@Test
@DisplayName("Malayalam resolves to the Malayalam fallback")
void malayalamResolves() {
// U+0D05 (അ) is Malayalam.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_MALAYALAM_ID,
service.resolveFallbackFontId(0x0D05));
}
@Test
@DisplayName("Tibetan resolves to the Tibetan fallback")
void tibetanResolves() {
// U+0F40 (ཀ) is Tibetan.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_TIBETAN_ID,
service.resolveFallbackFontId(0x0F40));
}
}
@Nested
@DisplayName("resolveFallbackFontId(String fontName, int codePoint)")
class ResolveByNameAndCodePoint {
@Test
@DisplayName("Arial maps to Liberation Sans")
void arialMapsToLiberationSans() {
assertEquals("fallback-liberation-sans", service.resolveFallbackFontId("Arial", 'A'));
}
@Test
@DisplayName("Times New Roman maps to Liberation Serif")
void timesNewRomanMapsToLiberationSerif() {
// Spaces are stripped: "Times New Roman" -> "timesnewroman".
assertEquals(
"fallback-liberation-serif",
service.resolveFallbackFontId("Times New Roman", 'A'));
}
@Test
@DisplayName("Courier New maps to Liberation Mono")
void courierNewMapsToLiberationMono() {
assertEquals(
"fallback-liberation-mono", service.resolveFallbackFontId("Courier New", 'A'));
}
@Test
@DisplayName("Arial-Bold maps to bold Liberation Sans variant")
void arialBoldMapsToBoldVariant() {
assertEquals(
"fallback-liberation-sans-bold",
service.resolveFallbackFontId("Arial-Bold", 'A'));
}
@Test
@DisplayName("Arial-Italic maps to italic Liberation Sans variant")
void arialItalicMapsToItalicVariant() {
assertEquals(
"fallback-liberation-sans-italic",
service.resolveFallbackFontId("Arial-Italic", 'A'));
}
@Test
@DisplayName("Arial-BoldItalic maps to bold-italic Liberation Sans variant")
void arialBoldItalicMapsToBoldItalicVariant() {
assertEquals(
"fallback-liberation-sans-bolditalic",
service.resolveFallbackFontId("Arial-BoldItalic", 'A'));
}
@Test
@DisplayName("numeric weight 700 is detected as bold")
void numericWeightDetectedAsBold() {
// "Arimo_700wght" -> base "arimo" -> liberation-sans, bold via 700 weight pattern.
assertEquals(
"fallback-liberation-sans-bold",
service.resolveFallbackFontId("Arimo_700wght", 'A'));
}
@Test
@DisplayName("subset prefix is stripped before alias matching")
void subsetPrefixStripped() {
// "ABCDEF+Arial" -> subset prefix removed -> "arial".
assertEquals(
"fallback-liberation-sans", service.resolveFallbackFontId("ABCDEF+Arial", 'A'));
}
@Test
@DisplayName("DejaVu Sans bold-italic uses the 'oblique' naming convention")
void dejaVuUsesObliqueNaming() {
assertEquals(
"fallback-dejavu-sans-boldoblique",
service.resolveFallbackFontId("DejaVuSans-BoldItalic", 'A'));
}
@Test
@DisplayName("DejaVu Serif italic keeps the 'italic' naming convention")
void dejaVuSerifKeepsItalicNaming() {
assertEquals(
"fallback-dejavu-serif-italic",
service.resolveFallbackFontId("DejaVuSerif-Italic", 'A'));
}
@Test
@DisplayName("Traditional Chinese aliased name ignores weight/style suffix (unsupported)")
void tcAliasIgnoresWeightStyle() {
// MingLiU maps to fallback-noto-tc, which has no bold variant registered.
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_TC_ID,
service.resolveFallbackFontId("MingLiU-Bold", 'A'));
}
@Test
@DisplayName("Simplified Chinese aliased name maps to the CJK fallback")
void simsunMapsToCjk() {
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID,
service.resolveFallbackFontId("SimSun", 'A'));
}
@Test
@DisplayName("null font name falls through to Unicode-based resolution")
void nullNameFallsThroughToUnicode() {
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_ID,
service.resolveFallbackFontId(null, 'A'));
}
@Test
@DisplayName("empty font name falls through to Unicode-based resolution")
void emptyNameFallsThroughToUnicode() {
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID,
service.resolveFallbackFontId("", 0x4E2D));
}
@Test
@DisplayName("unknown font name falls through to Unicode-based resolution")
void unknownNameFallsThroughToUnicode() {
assertEquals(
PdfJsonFallbackFontService.FALLBACK_FONT_JP_ID,
service.resolveFallbackFontId("SomeCustomFont", 0x3042));
}
}
@Nested
@DisplayName("mapUnsupportedGlyph(int codePoint)")
class MapUnsupportedGlyph {
@Test
@DisplayName("U+276E maps to '<'")
void heavyLeftAngleMapsToLessThan() {
assertEquals("<", service.mapUnsupportedGlyph(0x276E));
}
@Test
@DisplayName("U+276F maps to '>'")
void heavyRightAngleMapsToGreaterThan() {
assertEquals(">", service.mapUnsupportedGlyph(0x276F));
}
@Test
@DisplayName("unmapped code point returns null")
void unmappedReturnsNull() {
assertNull(service.mapUnsupportedGlyph('A'));
}
}
@Nested
@DisplayName("canEncode / canEncodeFully")
class CanEncode {
@Test
@DisplayName("null font returns false")
void nullFontReturnsFalse() {
assertFalse(service.canEncode((PDFont) null, "A"));
}
@Test
@DisplayName("null text returns false")
void nullTextReturnsFalse() {
// Reuse the once-parsed CJK fallback; canEncode only reads the font.
assertFalse(sharedService.canEncode(sharedCjkFont, (String) null));
}
@Test
@DisplayName("empty text returns false")
void emptyTextReturnsFalse() {
assertFalse(sharedService.canEncode(sharedCjkFont, ""));
}
@Test
@DisplayName("PDType3Font always returns false")
void type3FontReturnsFalse() {
PDType3Font type3 = mock(PDType3Font.class);
assertFalse(service.canEncode(type3, "A"));
}
@Test
@DisplayName("loaded TrueType fallback can encode basic Latin")
void loadedFontEncodesLatin() {
assertTrue(sharedService.canEncode(sharedCjkFont, "Hello"));
}
@Test
@DisplayName("canEncodeFully delegates to canEncode for text")
void canEncodeFullyDelegates() {
assertTrue(sharedService.canEncodeFully(sharedCjkFont, "abc"));
assertFalse(sharedService.canEncodeFully(null, "abc"));
}
@Test
@DisplayName("canEncode(font, codePoint) returns true for an encodable code point")
void canEncodeCodePointTrue() {
assertTrue(sharedService.canEncode(sharedCjkFont, (int) 'A'));
}
@Test
@DisplayName("canEncode(font, codePoint) returns false for a null font")
void canEncodeCodePointNullFont() {
assertFalse(service.canEncode((PDFont) null, (int) 'A'));
}
}
@Nested
@DisplayName("buildFallbackFontModel")
class BuildFallbackFontModel {
@Test
@DisplayName("builds a model for a built-in CJK font with base64 program bytes")
void buildsCjkModel() throws IOException {
PdfJsonFont model =
service.buildFallbackFontModel(PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID);
assertNotNull(model);
assertEquals(PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID, model.getId());
assertEquals(PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID, model.getUid());
assertEquals("NotoSansSC-Regular", model.getBaseName());
assertEquals("TrueType", model.getSubtype());
assertEquals(Boolean.TRUE, model.getEmbedded());
assertEquals("ttf", model.getProgramFormat());
assertNotNull(model.getProgram());
// The program must be valid, non-empty base64.
byte[] decoded = Base64.getDecoder().decode(model.getProgram());
assertTrue(decoded.length > 0);
}
@Test
@DisplayName("no-arg overload builds the default Noto Sans model")
void noArgBuildsDefaultModel() throws Exception {
invokeLoadConfig(); // populate fallbackFontLocation for the default font id
PdfJsonFont model = service.buildFallbackFontModel();
assertNotNull(model);
assertEquals(PdfJsonFallbackFontService.FALLBACK_FONT_ID, model.getId());
assertEquals("NotoSans-Regular", model.getBaseName());
assertEquals("ttf", model.getProgramFormat());
assertNotNull(model.getProgram());
}
@Test
@DisplayName("unknown fallback id throws IOException")
void unknownIdThrows() {
IOException ex =
assertThrows(
IOException.class,
() -> service.buildFallbackFontModel("does-not-exist"));
assertTrue(ex.getMessage().contains("Unknown fallback font id"));
}
@Test
@DisplayName("font bytes are cached and reused across calls")
void fontBytesAreCached() throws IOException {
PdfJsonFont first =
service.buildFallbackFontModel(PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
PdfJsonFont second =
service.buildFallbackFontModel(PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
// Same cached bytes -> identical base64 program payload.
assertEquals(first.getProgram(), second.getProgram());
}
}
@Nested
@DisplayName("loadFallbackPdfFont")
class LoadFallbackPdfFont {
@Test
@DisplayName("loads a Type0 PDFont for a built-in fallback id")
void loadsType0Font() throws IOException {
try (PDDocument document = new PDDocument()) {
PDFont font =
service.loadFallbackPdfFont(
document, PdfJsonFallbackFontService.FALLBACK_FONT_AR_ID);
assertNotNull(font);
assertTrue(font instanceof PDType0Font);
}
}
@Test
@DisplayName("no-arg overload loads the default Noto Sans font")
void noArgLoadsDefaultFont() throws Exception {
invokeLoadConfig();
try (PDDocument document = new PDDocument()) {
PDFont font = service.loadFallbackPdfFont(document);
assertNotNull(font);
assertTrue(font instanceof PDType0Font);
}
}
@Test
@DisplayName("unknown fallback id throws IOException")
void unknownIdThrows() throws IOException {
try (PDDocument document = new PDDocument()) {
IOException ex =
assertThrows(
IOException.class,
() -> service.loadFallbackPdfFont(document, "nope"));
assertTrue(ex.getMessage().contains("Unknown fallback font id"));
}
}
@Test
@DisplayName("loaded font produces fresh instances per call but identical type")
void distinctInstancesPerCall() throws IOException {
// Instance-identity contract holds for any built-in font; use the tiny Thai fallback
// (~22 KB) instead of the multi-MB Korean font to keep two loads cheap.
try (PDDocument document = new PDDocument()) {
PDFont a =
service.loadFallbackPdfFont(
document, PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
PDFont b =
service.loadFallbackPdfFont(
document, PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
assertNotNull(a);
assertNotNull(b);
// Two independent PDType0Font wrappers loaded into the same document.
assertFalse(a == b);
assertSame(a.getClass(), b.getClass());
}
}
}
}