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,481 @@
package stirling.software.SPDF.config;
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 java.util.List;
import java.util.Set;
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.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.model.ApplicationProperties;
/**
* Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in
* {@code init()} during construction and then applies environment overrides. We build it with a
* real {@link ApplicationProperties} (whose System/Endpoints sub-objects are non-null by default)
* so the constructor runs cleanly without any mocking.
*/
class EndpointConfigurationGapTest {
private ApplicationProperties applicationProperties;
/**
* Construct an EndpointConfiguration with the given pro flag and current applicationProperties.
*/
private EndpointConfiguration build(boolean runningProOrHigher) {
return new EndpointConfiguration(applicationProperties, runningProOrHigher);
}
/** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */
private EndpointConfiguration buildDefault() {
return build(false);
}
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
}
@Nested
@DisplayName("endpointKeyForUri (static)")
class EndpointKeyForUriTests {
@Test
@DisplayName("returns null for null uri")
void nullUri() {
assertNull(EndpointConfiguration.endpointKeyForUri(null));
}
@Test
@DisplayName("returns null when uri does not contain /api/v1")
void notApiPath() {
assertNull(EndpointConfiguration.endpointKeyForUri("/foo/bar/baz"));
assertNull(EndpointConfiguration.endpointKeyForUri("https://example.com/home"));
}
@Test
@DisplayName("returns null when uri has too few path segments")
void tooFewSegments() {
// "/api/v1/general" splits to ["", "api", "v1", "general"] -> length 4, not > 4
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
}
@Test
@DisplayName("extracts plain endpoint key from a standard /api/v1/<group>/<endpoint> uri")
void plainEndpoint() {
assertEquals(
"remove-pages",
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
}
@Test
@DisplayName("builds a <from>-to-<to> key for convert endpoints")
void convertEndpoint() {
assertEquals(
"pdf-to-img",
EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
}
@Test
@DisplayName("convert path without a target segment falls back to the segment after group")
void convertWithoutTarget() {
// "/api/v1/convert/pdf" -> length 5, the convert branch needs length > 5
assertEquals("pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf"));
}
}
@Nested
@DisplayName("enable / disable endpoint")
class EnableDisableEndpointTests {
@Test
@DisplayName("a freshly registered endpoint is enabled by default")
void enabledByDefault() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("disableEndpoint marks the endpoint disabled")
void disableEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("enableEndpoint re-enables a previously disabled endpoint")
void reEnableEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabled("merge-pdfs"));
config.enableEndpoint("merge-pdfs");
assertTrue(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("leading slash is normalized away on disable")
void leadingSlashNormalizedOnDisable() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("/merge-pdfs");
// both forms resolve to the same key
assertFalse(config.isEndpointEnabled("merge-pdfs"));
assertFalse(config.isEndpointEnabled("/merge-pdfs"));
}
@Test
@DisplayName("isEndpointEnabled tolerates a leading slash on the query")
void leadingSlashOnQuery() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabled("/merge-pdfs"));
}
@Test
@DisplayName("disabling clears with enable, removing the disable reason")
void enableClearsReason() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("split-pages", DisableReason.DEPENDENCY);
assertEquals(
DisableReason.DEPENDENCY,
config.getEndpointAvailability("split-pages").getReason());
config.enableEndpoint("split-pages");
EndpointAvailability availability = config.getEndpointAvailability("split-pages");
assertTrue(availability.isEnabled());
assertNull(availability.getReason());
}
}
@Nested
@DisplayName("isEndpointEnabledForUri")
class IsEndpointEnabledForUriTests {
@Test
@DisplayName("translates a /api/v1 uri to a key and reports its status")
void translatesUri() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
}
@Test
@DisplayName("falls back to treating a non-api uri as a raw key")
void fallsBackToRawKey() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
// non-api path: key resolution returns null, so the uri itself is used as the key
assertFalse(config.isEndpointEnabledForUri("merge-pdfs"));
}
}
@Nested
@DisplayName("group enable / disable")
class GroupTests {
@Test
@DisplayName("a functional group with all endpoints enabled reports enabled")
void functionalGroupEnabled() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("disabling a functional group cascades to all its endpoints")
void disableFunctionalGroupCascades() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
assertFalse(config.isGroupEnabled("PageOps"));
assertFalse(config.isEndpointEnabled("remove-pages"));
assertFalse(config.isEndpointEnabled("split-pages"));
}
@Test
@DisplayName("re-enabling a functional group re-enables its endpoints")
void enableFunctionalGroupRestores() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
assertFalse(config.isEndpointEnabled("remove-pages"));
config.enableGroup("PageOps");
assertTrue(config.isEndpointEnabled("remove-pages"));
assertTrue(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("a functional group with one disabled endpoint is not enabled")
void functionalGroupWithDisabledEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("remove-pages");
assertFalse(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("disabledGroups reflects disabled groups and getDisabledGroups returns a copy")
void getDisabledGroupsReturnsCopy() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
Set<String> disabled = config.getDisabledGroups();
assertTrue(disabled.contains("PageOps"));
// mutating the returned set must not affect internal state
disabled.clear();
assertTrue(config.getDisabledGroups().contains("PageOps"));
}
@Test
@DisplayName("an unknown group with no endpoints is not enabled")
void unknownGroupNotEnabled() {
EndpointConfiguration config = buildDefault();
assertFalse(config.isGroupEnabled("NoSuchGroupXyz"));
}
}
@Nested
@DisplayName("tool group semantics")
class ToolGroupTests {
@Test
@DisplayName("a tool group is enabled until explicitly disabled")
void toolGroupEnabledUntilDisabled() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isGroupEnabled("qpdf"));
config.disableGroup("qpdf");
assertFalse(config.isGroupEnabled("qpdf"));
}
@Test
@DisplayName("disabling a tool group does NOT cascade to its endpoints directly")
void toolGroupNoCascade() {
EndpointConfiguration config = buildDefault();
// repair has alternatives (qpdf, Ghostscript); disabling only qpdf keeps it enabled
config.disableGroup("qpdf");
assertTrue(config.isEndpointEnabled("repair"));
}
@Test
@DisplayName("endpoint with alternatives is disabled only when all tool groups are gone")
void allAlternativesDisabled() {
EndpointConfiguration config = buildDefault();
config.disableGroup("qpdf");
config.disableGroup("Ghostscript");
// repair's only alternatives are qpdf and Ghostscript
assertFalse(config.isEndpointEnabled("repair"));
}
@Test
@DisplayName("endpoint with a still-enabled alternative stays enabled")
void oneAlternativeRemains() {
EndpointConfiguration config = buildDefault();
// compress-pdf alternatives: qpdf, Ghostscript, Java
config.disableGroup("qpdf");
config.disableGroup("Ghostscript");
assertTrue(config.isEndpointEnabled("compress-pdf"));
config.disableGroup("Java");
assertFalse(config.isEndpointEnabled("compress-pdf"));
}
@Test
@DisplayName("single-dependency endpoint (no alternatives) disabled when its tool group is")
void singleDependencyDisabled() {
EndpointConfiguration config = buildDefault();
// pdf-to-epub depends on Calibre, no alternatives registered
assertTrue(config.isEndpointEnabled("pdf-to-epub"));
config.disableGroup("Calibre");
assertFalse(config.isEndpointEnabled("pdf-to-epub"));
}
}
@Nested
@DisplayName("addEndpointToGroup / addEndpointAlternative")
class RegistrationTests {
@Test
@DisplayName("addEndpointToGroup makes the endpoint part of the group")
void addEndpointToGroup() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
Set<String> endpoints = config.getEndpointsForGroup("CustomGroup");
assertTrue(endpoints.contains("custom-endpoint"));
}
@Test
@DisplayName("disabling a custom functional group disables its added endpoint")
void customFunctionalGroupCascades() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
assertTrue(config.isEndpointEnabled("custom-endpoint"));
config.disableGroup("CustomGroup");
assertFalse(config.isEndpointEnabled("custom-endpoint"));
}
@Test
@DisplayName("getEndpointsForGroup returns an empty set for unknown groups")
void unknownGroupEmptySet() {
EndpointConfiguration config = buildDefault();
Set<String> endpoints = config.getEndpointsForGroup("NoSuchGroupXyz");
assertNotNull(endpoints);
assertTrue(endpoints.isEmpty());
}
}
@Nested
@DisplayName("getEndpointAvailability / determineDisableReason")
class AvailabilityTests {
@Test
@DisplayName("an enabled endpoint has a null disable reason")
void enabledHasNullReason() {
EndpointConfiguration config = buildDefault();
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
assertTrue(availability.isEnabled());
assertNull(availability.getReason());
}
@Test
@DisplayName("explicit disable preserves the supplied reason")
void explicitDisableReason() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs", DisableReason.DEPENDENCY);
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
assertFalse(availability.isEnabled());
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
}
@Test
@DisplayName("default disableEndpoint reason is CONFIG")
void defaultDisableReasonIsConfig() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertEquals(
DisableReason.CONFIG, config.getEndpointAvailability("merge-pdfs").getReason());
}
@Test
@DisplayName("endpoint disabled via functional group reports the group's reason")
void functionalGroupReason() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps", DisableReason.DEPENDENCY);
EndpointAvailability availability = config.getEndpointAvailability("crop");
assertFalse(availability.isEnabled());
// crop is disabled both via group cascade and group membership; reason is DEPENDENCY
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
}
}
@Nested
@DisplayName("getAllEndpoints")
class GetAllEndpointsTests {
@Test
@DisplayName("aggregates endpoints across all groups")
void aggregatesAcrossGroups() {
EndpointConfiguration config = buildDefault();
Set<String> all = config.getAllEndpoints();
assertTrue(all.contains("merge-pdfs"));
assertTrue(all.contains("compress-pdf"));
assertTrue(all.contains("ocr-pdf"));
assertFalse(all.isEmpty());
}
@Test
@DisplayName("custom endpoints registered after init appear in getAllEndpoints")
void includesCustomEndpoints() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "brand-new-endpoint");
assertTrue(config.getAllEndpoints().contains("brand-new-endpoint"));
}
}
@Nested
@DisplayName("environment / constructor driven configuration")
class EnvironmentConfigTests {
@Test
@DisplayName("url-to-pdf is disabled when enableUrlToPDF is false (default)")
void urlToPdfDisabledByDefault() {
EndpointConfiguration config = buildDefault();
assertFalse(config.isEndpointEnabled("url-to-pdf"));
}
@Test
@DisplayName("url-to-pdf stays enabled when enableUrlToPDF is true")
void urlToPdfEnabledWhenFlagSet() {
applicationProperties.getSystem().setEnableUrlToPDF(true);
EndpointConfiguration config = build(false);
assertTrue(config.isEndpointEnabled("url-to-pdf"));
}
@Test
@DisplayName("endpoints.toRemove disables the listed endpoints at construction")
void endpointsToRemove() {
applicationProperties
.getEndpoints()
.setToRemove(List.of(" merge-pdfs ", "split-pages"));
EndpointConfiguration config = build(false);
// values are trimmed before disabling
assertFalse(config.isEndpointEnabled("merge-pdfs"));
assertFalse(config.isEndpointEnabled("split-pages"));
}
@Test
@DisplayName("endpoints.groupsToRemove disables the listed groups at construction")
void groupsToRemove() {
applicationProperties.getEndpoints().setGroupsToRemove(List.of(" PageOps "));
EndpointConfiguration config = build(false);
assertTrue(config.getDisabledGroups().contains("PageOps"));
assertFalse(config.isEndpointEnabled("remove-pages"));
}
@Test
@DisplayName("non-pro build disables the enterprise group")
void nonProDisablesEnterprise() {
EndpointConfiguration config = build(false);
assertTrue(config.getDisabledGroups().contains("enterprise"));
}
@Test
@DisplayName("pro build does not disable the enterprise group")
void proDoesNotDisableEnterprise() {
EndpointConfiguration config = build(true);
assertFalse(config.getDisabledGroups().contains("enterprise"));
}
}
@Nested
@DisplayName("getEndpointStatuses (Lombok getter) and logging summary")
class MiscTests {
@Test
@DisplayName("getEndpointStatuses reflects explicit disable state")
void endpointStatusesReflectDisable() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertEquals(Boolean.FALSE, config.getEndpointStatuses().get("merge-pdfs"));
}
@Test
@DisplayName("logDisabledEndpointsSummary runs without throwing")
void logSummaryDoesNotThrow() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
config.disableGroup("qpdf");
// purely a smoke test of the logging branch coverage
config.logDisabledEndpointsSummary();
}
@Test
@DisplayName("logDisabledEndpointsSummary runs when nothing is disabled")
void logSummaryNothingDisabled() {
applicationProperties.getSystem().setEnableUrlToPDF(true);
EndpointConfiguration config = build(true);
config.logDisabledEndpointsSummary();
}
}
}
@@ -0,0 +1,345 @@
package stirling.software.SPDF.pdf.parser;
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.assertTrue;
import static stirling.software.SPDF.pdf.parser.PdfModels.RawPage;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableCell;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableRow;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
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.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;
/**
* Unit tests for {@link TabulaTableParser}. Tables are built in-memory with PDFBox so the tests are
* deterministic and need no fixtures, network, or external processes.
*/
class TabulaTableParserGapTest {
private final TabulaTableParser parser = new TabulaTableParser();
// ── error / empty branches ───────────────────────────────────────────────
@Nested
@DisplayName("Empty and error branches")
class EmptyAndErrorBranches {
@Test
@DisplayName("page number 0 is out of Tabula's 1-based range -> empty list, no throw")
void pageNumberZeroReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, 0);
assertNotNull(result);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("page number beyond the document -> empty list, exception swallowed")
void pageNumberOutOfRangeReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, 99);
assertNotNull(result);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("negative page number -> empty list")
void negativePageNumberReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(parser.parse(doc, -5).isEmpty());
}
}
@Test
@DisplayName("lattice mode on a page with no ruled lines -> no tables")
void latticeWithNoRulingsReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"just some prose", "no table here"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(result);
assertTrue(
result.isEmpty(), "borderless text must not be detected in lattice mode");
}
}
@Test
@DisplayName("blank page in lattice mode -> empty list")
void blankPageLatticeReturnsEmpty() throws Exception {
byte[] pdf = blankPdf();
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())).isEmpty());
}
}
}
// ── stream mode (BasicExtractionAlgorithm) ───────────────────────────────
@Nested
@DisplayName("Stream mode")
class StreamMode {
@Test
@DisplayName("page with text yields at least one well-formed fragment")
void streamOnTextProducesFragment() throws Exception {
byte[] pdf =
pdfWithText(new String[] {"Name Age City", "Alice 30 Paris", "Bob 25 Rome"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(fragments);
assertFalse(fragments.isEmpty(), "stream mode always builds a table from text");
assertFragmentWellFormed(fragments.get(0), 1, 0);
}
}
@Test
@DisplayName("fragment ids encode page and index")
void streamFragmentIdFormat() throws Exception {
byte[] pdf = pdfWithText(new String[] {"col1 col2", "a b"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
assertEquals("tbl-p1-0", fragments.get(0).tableId());
assertEquals(1, fragments.get(0).pageNumber());
}
}
@Test
@DisplayName("rawRows and the parsed rows stay in lockstep")
void streamRowsMatchRawRows() throws Exception {
byte[] pdf = pdfWithText(new String[] {"x y", "1 2", "3 4"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
TableFragment f = fragments.get(0);
assertEquals(f.rawRows().size(), f.rows().size());
}
}
}
// ── lattice mode with a real bordered grid ───────────────────────────────
@Nested
@DisplayName("Lattice mode")
class LatticeMode {
@Test
@DisplayName("bordered grid is detected and produces well-formed fragments")
void latticeDetectsBorderedTable() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(fragments);
assertFalse(
fragments.isEmpty(), "a clean ruled grid must be detected in lattice mode");
TableFragment f = fragments.get(0);
assertFragmentWellFormed(f, 1, 0);
assertTrue(f.columnCount() >= 1, "a detected grid must have at least one column");
assertFalse(f.rawRows().isEmpty(), "a detected grid must have rows");
}
}
@Test
@DisplayName("convenience overload with page number routes to lattice mode")
void parseByPageNumberDetectsGrid() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments = parser.parse(doc, 1);
assertNotNull(fragments);
assertFalse(fragments.isEmpty());
assertEquals(1, fragments.get(0).pageNumber());
}
}
@Test
@DisplayName("cell text is normalised (trimmed, newlines collapsed)")
void latticeCellTextIsNormalised() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
for (List<String> row : fragments.get(0).rawRows()) {
for (String cell : row) {
assertNotNull(cell);
assertFalse(cell.contains("\n"), "newlines must be collapsed");
assertFalse(cell.contains("\r"), "carriage returns must be collapsed");
assertEquals(cell.trim(), cell, "cell text must be trimmed");
}
}
}
}
}
// ── contract invariants ──────────────────────────────────────────────────
@Nested
@DisplayName("Contract invariants")
class ContractInvariants {
@Test
@DisplayName("parse never returns null")
void parseNeverReturnsNull() throws Exception {
byte[] pdf = pdfWithText(new String[] {"abc"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertNotNull(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())));
assertNotNull(parser.parse(doc, 1));
assertNotNull(parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of())));
}
}
@Test
@DisplayName("the document is not closed by the parser")
void documentRemainsOpenAfterParse() throws Exception {
byte[] pdf = pdfWithText(new String[] {"keep me open"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
// ObjectExtractor.close() would close the underlying COSDocument; the parser must
// not.
assertFalse(
doc.getDocument().isClosed(),
"parser must not close the caller's document");
assertEquals(1, doc.getNumberOfPages());
}
}
}
// ── helpers ──────────────────────────────────────────────────────────────
/** Asserts every field of a fragment satisfies the documented contract. */
private static void assertFragmentWellFormed(
TableFragment f, int expectedPage, int expectedIndex) {
assertNotNull(f);
assertEquals(expectedPage, f.pageNumber());
assertEquals("tbl-p" + expectedPage + "-" + expectedIndex, f.tableId());
assertNotNull(f.bounds());
assertNotNull(f.headers());
assertTrue(f.headers().isEmpty(), "headers are deferred to v2 and must be empty");
assertNotNull(f.rows());
assertNotNull(f.rawRows());
assertNotNull(f.warnings());
assertSame(null, f.continuedFromPage(), "continuedFromPage is deferred to v2");
assertTrue(f.columnCount() >= 0);
assertTrue(f.confidence() >= 0f && f.confidence() <= 1f, "confidence must be within [0,1]");
assertEquals(f.rawRows().size(), f.rows().size());
for (TableRow row : f.rows()) {
assertNotNull(row.cells());
for (TableCell cell : row.cells()) {
assertNotNull(cell.text());
assertNotNull(cell.bounds());
assertEquals(1, cell.colSpan(), "colSpan is always 1 in v1");
assertEquals(1, cell.rowSpan(), "rowSpan is always 1 in v1");
}
}
}
private static byte[] pdfWithText(String[] lines) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.setNonStrokingColor(Color.BLACK);
float y = 720f;
for (String line : lines) {
cs.beginText();
cs.newLineAtOffset(72f, y);
cs.showText(line);
cs.endText();
y -= 20f;
}
}
return save(doc);
}
}
private static byte[] blankPdf() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
return save(doc);
}
}
/**
* Builds a small 3-row x 3-column ruled grid with text in each cell. The ruled lines make the
* table detectable by lattice mode.
*/
private static byte[] pdfWithGrid() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
float left = 100f;
float right = 400f;
float top = 700f;
float bottom = 550f;
int cols = 3;
int rows = 3;
float colStep = (right - left) / cols;
float rowStep = (top - bottom) / rows;
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setStrokingColor(Color.BLACK);
cs.setLineWidth(1f);
// vertical lines
for (int c = 0; c <= cols; c++) {
float x = left + c * colStep;
cs.moveTo(x, bottom);
cs.lineTo(x, top);
}
// horizontal lines
for (int r = 0; r <= rows; r++) {
float yLine = bottom + r * rowStep;
cs.moveTo(left, yLine);
cs.lineTo(right, yLine);
}
cs.stroke();
// cell text
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
cs.setNonStrokingColor(Color.BLACK);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
cs.beginText();
cs.newLineAtOffset(left + c * colStep + 5f, top - (r + 1) * rowStep + 6f);
cs.showText("R" + r + "C" + c);
cs.endText();
}
}
}
return save(doc);
}
}
private static byte[] save(PDDocument doc) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
@@ -0,0 +1,520 @@
package stirling.software.common.configuration;
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.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
import stirling.software.common.model.ApplicationProperties.ProcessExecutor.UnoServerEndpoint;
/**
* Unit tests for {@link RuntimePathConfig}. All of the resolution logic lives in the constructor,
* so each test builds a real {@link ApplicationProperties} (a plain @Data POJO with sensible
* defaults), constructs the config, and asserts on the exposed getters.
*/
class RuntimePathConfigTest {
/** The base path the production code derives from {@link InstallationPathConfig#getPath()}. */
private static final String BASE_PATH = InstallationPathConfig.getPath();
private static ApplicationProperties newProperties() {
return new ApplicationProperties();
}
private static RuntimePathConfig build(ApplicationProperties properties) {
return new RuntimePathConfig(properties);
}
@Nested
@DisplayName("Pipeline directory resolution")
class PipelinePaths {
@Test
@DisplayName("Defaults to <basePath>/pipeline and derived sub-folders")
void defaultPipelinePaths() {
RuntimePathConfig config = build(newProperties());
String expectedPipeline = Path.of(BASE_PATH, "pipeline").toString();
assertEquals(expectedPipeline, config.getPipelinePath());
// Watched folders are resolved to an absolute, normalized path by the production code.
assertEquals(
Path.of(expectedPipeline, "watchedFolders")
.toAbsolutePath()
.normalize()
.toString(),
config.getPipelineWatchedFoldersPath());
assertEquals(
Path.of(expectedPipeline, "finishedFolders").toString(),
config.getPipelineFinishedFoldersPath());
assertEquals(
Path.of(expectedPipeline, "defaultWebUIConfigs").toString(),
config.getPipelineDefaultWebUiConfigs());
}
@Test
@DisplayName("Custom pipelineDir overrides the default pipeline path")
void customPipelineDir() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setPipelineDir("/custom/pipeline");
RuntimePathConfig config = build(properties);
assertEquals("/custom/pipeline", config.getPipelinePath());
// Sub-folders are derived from the (already-resolved) custom pipeline path.
assertEquals(
Path.of("/custom/pipeline", "finishedFolders").toString(),
config.getPipelineFinishedFoldersPath());
assertEquals(
Path.of("/custom/pipeline", "defaultWebUIConfigs").toString(),
config.getPipelineDefaultWebUiConfigs());
}
@Test
@DisplayName("Blank pipelineDir falls back to the default")
void blankPipelineDirFallsBackToDefault() {
ApplicationProperties properties = newProperties();
properties.getSystem().getCustomPaths().getPipeline().setPipelineDir(" ");
RuntimePathConfig config = build(properties);
assertEquals(Path.of(BASE_PATH, "pipeline").toString(), config.getPipelinePath());
}
@Test
@DisplayName("Custom finished and webUI configs dirs override defaults")
void customFinishedAndWebUiDirs() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setFinishedFoldersDir("/custom/finished");
pipeline.setWebUIConfigsDir("/custom/webui");
RuntimePathConfig config = build(properties);
assertEquals("/custom/finished", config.getPipelineFinishedFoldersPath());
assertEquals("/custom/webui", config.getPipelineDefaultWebUiConfigs());
}
}
@Nested
@DisplayName("Watched folder resolution")
class WatchedFolders {
@Test
@DisplayName("Default watched folder is <pipeline>/watchedFolders and list has one entry")
void defaultWatchedFolder() {
RuntimePathConfig config = build(newProperties());
// Watched folders are resolved to an absolute, normalized path by the production code.
String expected =
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
.toAbsolutePath()
.normalize()
.toString();
assertEquals(expected, config.getPipelineWatchedFoldersPath());
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expected, config.getPipelineWatchedFoldersPaths().get(0));
}
@Test
@DisplayName("Legacy single watchedFoldersDir is used when no list is provided")
void legacyWatchedFolder() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDir("relativeWatched");
RuntimePathConfig config = build(properties);
// Legacy paths are normalized to absolute.
String expected = Path.of("relativeWatched").toAbsolutePath().normalize().toString();
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expected, config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("New list config takes precedence over the legacy single dir")
void listTakesPrecedenceOverLegacy() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setWatchedFoldersDir("legacyDir");
pipeline.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("listDirA", "listDirB")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(2, paths.size());
assertEquals(Path.of("listDirA").toAbsolutePath().normalize().toString(), paths.get(0));
assertEquals(Path.of("listDirB").toAbsolutePath().normalize().toString(), paths.get(1));
// The legacy value must NOT appear when the list is present.
assertFalse(
paths.contains(Path.of("legacyDir").toAbsolutePath().normalize().toString()));
}
@Test
@DisplayName("Duplicate paths in the list are de-duplicated after normalization")
void duplicatePathsAreDeduplicated() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(
new ArrayList<>(Arrays.asList("dupDir", "dupDir", "otherDir")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(2, paths.size());
assertEquals(Path.of("dupDir").toAbsolutePath().normalize().toString(), paths.get(0));
assertEquals(Path.of("otherDir").toAbsolutePath().normalize().toString(), paths.get(1));
}
@Test
@DisplayName("Blank and whitespace-only list entries are sanitized out")
void blankListEntriesAreFiltered() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(
new ArrayList<>(Arrays.asList(" ", "", "validDir", " ")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(1, paths.size());
assertEquals(Path.of("validDir").toAbsolutePath().normalize().toString(), paths.get(0));
}
@Test
@DisplayName("List entries are trimmed before resolution")
void listEntriesAreTrimmed() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList(" spacedDir ")));
RuntimePathConfig config = build(properties);
assertEquals(
Path.of("spacedDir").toAbsolutePath().normalize().toString(),
config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("An all-blank list falls back to the legacy dir, then default")
void allBlankListFallsBackToDefault() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("", " ")));
RuntimePathConfig config = build(properties);
// sanitizePathList strips everything -> empty -> falls through to default watched
// folder.
// The default is also resolved to an absolute, normalized path by the production code.
String expectedDefault =
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
.toAbsolutePath()
.normalize()
.toString();
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expectedDefault, config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("First watched folder path is always exposed via the singular getter")
void singularGetterReturnsFirstEntry() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("firstDir", "secondDir")));
RuntimePathConfig config = build(properties);
assertEquals(
config.getPipelineWatchedFoldersPaths().get(0),
config.getPipelineWatchedFoldersPath());
assertEquals(
Path.of("firstDir").toAbsolutePath().normalize().toString(),
config.getPipelineWatchedFoldersPath());
}
}
@Nested
@DisplayName("Operation tool path resolution")
class OperationPaths {
@Test
@DisplayName("Defaults to bare command names when not running in Docker")
void defaultOperationPaths() {
// The test host has no /.dockerenv, so the non-docker defaults apply.
RuntimePathConfig config = build(newProperties());
assertEquals("weasyprint", config.getWeasyPrintPath());
assertEquals("unoconvert", config.getUnoConvertPath());
assertEquals("ebook-convert", config.getCalibrePath());
assertEquals("ocrmypdf", config.getOcrMyPdfPath());
assertEquals("soffice", config.getSOfficePath());
}
@Test
@DisplayName("Custom operation paths override the defaults")
void customOperationPaths() {
ApplicationProperties properties = newProperties();
Operations operations = properties.getSystem().getCustomPaths().getOperations();
operations.setWeasyprint("/opt/custom/weasyprint");
operations.setUnoconvert("/opt/custom/unoconvert");
operations.setCalibre("/opt/custom/ebook-convert");
operations.setOcrmypdf("/opt/custom/ocrmypdf");
operations.setSoffice("/opt/custom/soffice");
RuntimePathConfig config = build(properties);
assertEquals("/opt/custom/weasyprint", config.getWeasyPrintPath());
assertEquals("/opt/custom/unoconvert", config.getUnoConvertPath());
assertEquals("/opt/custom/ebook-convert", config.getCalibrePath());
assertEquals("/opt/custom/ocrmypdf", config.getOcrMyPdfPath());
assertEquals("/opt/custom/soffice", config.getSOfficePath());
}
@Test
@DisplayName("Blank custom operation path falls back to the default")
void blankOperationPathFallsBack() {
ApplicationProperties properties = newProperties();
properties.getSystem().getCustomPaths().getOperations().setWeasyprint(" ");
RuntimePathConfig config = build(properties);
assertEquals("weasyprint", config.getWeasyPrintPath());
}
@Test
@DisplayName("A single custom path leaves the other operation paths at defaults")
void partialOperationOverride() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getOperations()
.setSoffice("/usr/local/soffice");
RuntimePathConfig config = build(properties);
assertEquals("/usr/local/soffice", config.getSOfficePath());
assertEquals("weasyprint", config.getWeasyPrintPath());
assertEquals("unoconvert", config.getUnoConvertPath());
}
}
@Nested
@DisplayName("Tesseract data path resolution")
class TessdataPath {
@Test
@DisplayName("Explicit tessdataDir config wins over env var and default")
void configuredTessdataDirWins() {
ApplicationProperties properties = newProperties();
properties.getSystem().setTessdataDir("/my/tessdata");
RuntimePathConfig config = build(properties);
// Config setting has the highest priority regardless of TESSDATA_PREFIX env state.
assertEquals("/my/tessdata", config.getTessDataPath());
}
@Test
@DisplayName("tessDataPath is never null even with no config")
void tessDataPathNeverNull() {
RuntimePathConfig config = build(newProperties());
// With no config setting, the value comes from TESSDATA_PREFIX or the hard default,
// either of which is non-null.
assertNotNull(config.getTessDataPath());
assertFalse(config.getTessDataPath().isEmpty());
}
}
@Nested
@DisplayName("UNO server endpoint resolution")
class UnoServerEndpoints {
@Test
@DisplayName("Auto mode builds one endpoint when session limit is unset (defaults to 1)")
void autoSingleEndpointByDefault() {
// Default ApplicationProperties: autoUnoServer = true, libreOfficeSessionLimit = 0 ->
// 1.
RuntimePathConfig config = build(newProperties());
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
@Test
@DisplayName("Auto mode builds N endpoints on consecutive even ports")
void autoMultipleEndpoints() {
ApplicationProperties properties = newProperties();
properties.getProcessExecutor().getSessionLimit().setLibreOfficeSessionLimit(3);
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(3, endpoints.size());
assertEquals(2003, endpoints.get(0).getPort());
assertEquals(2005, endpoints.get(1).getPort());
assertEquals(2007, endpoints.get(2).getPort());
for (UnoServerEndpoint endpoint : endpoints) {
assertEquals("127.0.0.1", endpoint.getHost());
}
}
@Test
@DisplayName("Manual mode returns the configured (valid) endpoints")
void manualEndpointsAreUsed() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
UnoServerEndpoint endpoint = new UnoServerEndpoint();
endpoint.setHost("10.0.0.5");
endpoint.setPort(4000);
processExecutor.setUnoServerEndpoints(new ArrayList<>(Arrays.asList(endpoint)));
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("10.0.0.5", endpoints.get(0).getHost());
assertEquals(4000, endpoints.get(0).getPort());
}
@Test
@DisplayName("Manual mode filters out endpoints with blank host or non-positive port")
void manualEndpointsAreSanitized() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
UnoServerEndpoint valid = new UnoServerEndpoint();
valid.setHost("192.168.1.10");
valid.setPort(5000);
UnoServerEndpoint blankHost = new UnoServerEndpoint();
blankHost.setHost(" ");
blankHost.setPort(5001);
UnoServerEndpoint badPort = new UnoServerEndpoint();
badPort.setHost("192.168.1.11");
badPort.setPort(0);
processExecutor.setUnoServerEndpoints(
new ArrayList<>(Arrays.asList(valid, blankHost, badPort)));
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("192.168.1.10", endpoints.get(0).getHost());
assertEquals(5000, endpoints.get(0).getPort());
}
@Test
@DisplayName("Manual mode with no usable endpoints falls back to a single default endpoint")
void manualModeNoEndpointsFallsBackToDefault() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
processExecutor.setUnoServerEndpoints(new ArrayList<>());
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
@Test
@DisplayName("Null processExecutor defaults to a single UNO endpoint")
void nullProcessExecutorDefaultsToSingleEndpoint() {
ApplicationProperties properties = newProperties();
properties.setProcessExecutor(null);
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
}
@Nested
@DisplayName("General contract")
class GeneralContract {
@Test
@DisplayName("getProperties returns the same instance passed to the constructor")
void propertiesAccessorReturnsSameInstance() {
ApplicationProperties properties = newProperties();
RuntimePathConfig config = build(properties);
assertSame(properties, config.getProperties());
}
@Test
@DisplayName("basePath matches InstallationPathConfig.getPath()")
void basePathMatchesInstallationPath() {
RuntimePathConfig config = build(newProperties());
assertEquals(BASE_PATH, config.getBasePath());
}
@Test
@DisplayName("All resolved path getters are non-null")
void allPathsNonNull() {
RuntimePathConfig config = build(newProperties());
assertNotNull(config.getPipelinePath());
assertNotNull(config.getPipelineWatchedFoldersPath());
assertNotNull(config.getPipelineWatchedFoldersPaths());
assertNotNull(config.getPipelineFinishedFoldersPath());
assertNotNull(config.getPipelineDefaultWebUiConfigs());
assertNotNull(config.getWeasyPrintPath());
assertNotNull(config.getUnoConvertPath());
assertNotNull(config.getCalibrePath());
assertNotNull(config.getOcrMyPdfPath());
assertNotNull(config.getSOfficePath());
assertNotNull(config.getTessDataPath());
assertNotNull(config.getUnoServerEndpoints());
assertTrue(config.getUnoServerEndpoints().size() >= 1);
}
}
}
@@ -0,0 +1,471 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
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.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.MobileScannerService.FileMetadata;
import stirling.software.common.service.MobileScannerService.SessionInfo;
/**
* Unit tests for {@link MobileScannerService}. The service stores uploaded files in a temp
* directory. To keep tests isolated and deterministic, the {@code tempDirectory} field is
* redirected to a JUnit {@link TempDir} via reflection after construction.
*/
class MobileScannerServiceTest {
@TempDir Path tempDir;
private MobileScannerService service;
@BeforeEach
void setUp() throws IOException {
service = new MobileScannerService();
// Redirect the service's temp directory to the isolated test temp dir.
ReflectionTestUtils.setField(service, "tempDirectory", tempDir);
}
private MultipartFile file(String name, String content) {
return new MockMultipartFile(
"file", name, "text/plain", content.getBytes(StandardCharsets.UTF_8));
}
private MultipartFile emptyFile(String name) {
return new MockMultipartFile("file", name, "text/plain", new byte[0]);
}
@Nested
@DisplayName("createSession")
class CreateSession {
@Test
@DisplayName("creates a session and returns coherent SessionInfo")
void createsSession() {
SessionInfo info = service.createSession("abc-123");
assertNotNull(info);
assertEquals("abc-123", info.getSessionId());
assertTrue(info.getCreatedAt() > 0);
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
assertEquals(info.getCreatedAt() + info.getTimeoutMs(), info.getExpiresAt());
}
@Test
@DisplayName("session is retrievable via validateSession after creation")
void createdSessionIsValid() {
service.createSession("sess1");
assertNotNull(service.validateSession("sess1"));
}
@Test
@DisplayName("rejects null session ID")
void rejectsNull() {
assertThrows(IllegalArgumentException.class, () -> service.createSession(null));
}
@Test
@DisplayName("rejects blank session ID")
void rejectsBlank() {
assertThrows(IllegalArgumentException.class, () -> service.createSession(" "));
}
@Test
@DisplayName("rejects session ID with invalid characters")
void rejectsInvalidChars() {
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad/id"));
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad id"));
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad_id"));
}
@Test
@DisplayName("accepts alphanumeric and hyphen session IDs")
void acceptsValidChars() {
assertNotNull(service.createSession("ABC-def-123"));
}
}
@Nested
@DisplayName("validateSession")
class ValidateSession {
@Test
@DisplayName("returns null for unknown session")
void unknownReturnsNull() {
assertNull(service.validateSession("does-not-exist"));
}
@Test
@DisplayName("returns SessionInfo for an existing session")
void existingReturnsInfo() {
service.createSession("s1");
SessionInfo info = service.validateSession("s1");
assertNotNull(info);
assertEquals("s1", info.getSessionId());
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
}
@Test
@DisplayName("expires and removes a session whose last access is in the past")
void expiredSessionRemoved() {
service.createSession("expired");
// Force the underlying session's last access far into the past.
forceLastAccess("expired", System.currentTimeMillis() - (20 * 60 * 1000L));
assertNull(service.validateSession("expired"));
// After expiry the session should be gone entirely.
assertNull(service.validateSession("expired"));
}
}
@Nested
@DisplayName("uploadFiles")
class UploadFiles {
@Test
@DisplayName("stores files and records metadata")
void storesFiles() throws IOException {
service.createSession("up1");
service.uploadFiles("up1", List.of(file("scan.txt", "hello")));
List<FileMetadata> metas = service.getSessionFiles("up1");
assertEquals(1, metas.size());
FileMetadata meta = metas.get(0);
assertEquals("scan.txt", meta.getFilename());
assertEquals(5, meta.getSize());
assertEquals("text/plain", meta.getContentType());
// File physically exists on disk.
Path stored = tempDir.resolve("up1").resolve("scan.txt");
assertTrue(Files.exists(stored));
assertEquals("hello", Files.readString(stored));
}
@Test
@DisplayName("auto-creates a session when uploading to an unregistered session ID")
void autoCreatesSession() throws IOException {
service.uploadFiles("new-session", List.of(file("a.txt", "data")));
List<FileMetadata> metas = service.getSessionFiles("new-session");
assertEquals(1, metas.size());
}
@Test
@DisplayName("skips empty files")
void skipsEmptyFiles() throws IOException {
service.createSession("up2");
service.uploadFiles("up2", List.of(emptyFile("empty.txt"), file("real.txt", "x")));
List<FileMetadata> metas = service.getSessionFiles("up2");
assertEquals(1, metas.size());
assertEquals("real.txt", metas.get(0).getFilename());
}
@Test
@DisplayName("sanitizes dangerous filename characters")
void sanitizesFilename() throws IOException {
service.createSession("up3");
service.uploadFiles("up3", List.of(file("we ird@na#me.txt", "x")));
List<FileMetadata> metas = service.getSessionFiles("up3");
assertEquals(1, metas.size());
String stored = metas.get(0).getFilename();
// Disallowed chars replaced with underscores; allowed set is [a-zA-Z0-9._-].
assertTrue(stored.matches("[a-zA-Z0-9._-]+"), "unexpected filename: " + stored);
assertTrue(Files.exists(tempDir.resolve("up3").resolve(stored)));
}
@Test
@DisplayName("handles duplicate filenames by appending a counter")
void handlesDuplicateFilenames() throws IOException {
service.createSession("up4");
service.uploadFiles("up4", List.of(file("dup.txt", "one")));
service.uploadFiles("up4", List.of(file("dup.txt", "two")));
List<FileMetadata> metas = service.getSessionFiles("up4");
assertEquals(2, metas.size());
Path original = tempDir.resolve("up4").resolve("dup.txt");
Path renamed = tempDir.resolve("up4").resolve("dup-1.txt");
assertTrue(Files.exists(original));
assertTrue(Files.exists(renamed));
assertEquals("one", Files.readString(original));
assertEquals("two", Files.readString(renamed));
}
@Test
@DisplayName("falls back to a generated name when original filename is null")
void generatesNameWhenNull() throws IOException {
service.createSession("up5");
MultipartFile noName =
new MockMultipartFile("file", null, "text/plain", "x".getBytes());
service.uploadFiles("up5", List.of(noName));
List<FileMetadata> metas = service.getSessionFiles("up5");
assertEquals(1, metas.size());
assertTrue(metas.get(0).getFilename().startsWith("upload-"));
}
@Test
@DisplayName("rejects invalid session ID before any storage")
void rejectsInvalidSessionId() {
assertThrows(
IllegalArgumentException.class,
() -> service.uploadFiles("bad/id", List.of(file("a.txt", "x"))));
}
@Test
@DisplayName("uploading an empty list leaves no files")
void emptyListNoFiles() throws IOException {
service.createSession("up6");
service.uploadFiles("up6", List.of());
assertTrue(service.getSessionFiles("up6").isEmpty());
}
}
@Nested
@DisplayName("getSessionFiles")
class GetSessionFiles {
@Test
@DisplayName("returns empty list for unknown session")
void unknownReturnsEmpty() {
assertTrue(service.getSessionFiles("nope").isEmpty());
}
@Test
@DisplayName("returns a defensive copy of the metadata list")
void returnsDefensiveCopy() throws IOException {
service.createSession("g1");
service.uploadFiles("g1", List.of(file("a.txt", "x")));
List<FileMetadata> first = service.getSessionFiles("g1");
first.clear();
// Mutating the returned list must not affect the service's internal state.
assertEquals(1, service.getSessionFiles("g1").size());
}
}
@Nested
@DisplayName("getFile")
class GetFile {
@Test
@DisplayName("returns the path of an uploaded file")
void returnsPath() throws IOException {
service.createSession("f1");
service.uploadFiles("f1", List.of(file("doc.txt", "body")));
Path path = service.getFile("f1", "doc.txt");
assertTrue(Files.exists(path));
assertEquals("body", Files.readString(path));
}
@Test
@DisplayName("throws when the session does not exist")
void unknownSessionThrows() {
IOException ex =
assertThrows(IOException.class, () -> service.getFile("ghost", "doc.txt"));
assertTrue(ex.getMessage().contains("Session not found"));
}
@Test
@DisplayName("throws when the file does not exist in an existing session")
void unknownFileThrows() throws IOException {
service.createSession("f2");
service.uploadFiles("f2", List.of(file("present.txt", "x")));
IOException ex =
assertThrows(IOException.class, () -> service.getFile("f2", "missing.txt"));
assertTrue(ex.getMessage().contains("File not found"));
}
@Test
@DisplayName("rejects filenames containing path separators")
void rejectsPathSeparators() throws IOException {
service.createSession("f3");
service.uploadFiles("f3", List.of(file("ok.txt", "x")));
assertThrows(IOException.class, () -> service.getFile("f3", "../escape.txt"));
assertThrows(IOException.class, () -> service.getFile("f3", "sub/file.txt"));
assertThrows(IOException.class, () -> service.getFile("f3", "sub\\file.txt"));
}
@Test
@DisplayName("rejects blank filename")
void rejectsBlankFilename() throws IOException {
service.createSession("f4");
service.uploadFiles("f4", List.of(file("ok.txt", "x")));
assertThrows(IOException.class, () -> service.getFile("f4", " "));
}
}
@Nested
@DisplayName("deleteFileAfterDownload")
class DeleteFileAfterDownload {
@Test
@DisplayName("deletes a single file but keeps the session if others remain")
void deletesOneFile() throws IOException {
service.createSession("d1");
service.uploadFiles("d1", List.of(file("a.txt", "x"), file("b.txt", "y")));
service.deleteFileAfterDownload("d1", "a.txt");
assertFalse(Files.exists(tempDir.resolve("d1").resolve("a.txt")));
// Session still present because not all files have been downloaded.
assertNotNull(service.validateSession("d1"));
}
@Test
@DisplayName("deletes the entire session once all files are marked downloaded")
void deletesSessionWhenAllDownloaded() throws IOException {
service.createSession("d2");
service.uploadFiles("d2", List.of(file("only.txt", "x")));
// Mark the file as downloaded via getFile, then delete it.
service.getFile("d2", "only.txt");
service.deleteFileAfterDownload("d2", "only.txt");
assertNull(service.validateSession("d2"));
assertFalse(Files.exists(tempDir.resolve("d2")));
}
@Test
@DisplayName("does not throw for an unknown session")
void unknownSessionNoThrow() {
assertDoesNotThrow(() -> service.deleteFileAfterDownload("ghost", "a.txt"));
}
@Test
@DisplayName("swallows invalid filename input without throwing")
void invalidFilenameNoThrow() throws IOException {
service.createSession("d3");
service.uploadFiles("d3", List.of(file("a.txt", "x")));
assertDoesNotThrow(() -> service.deleteFileAfterDownload("d3", "../escape.txt"));
// Original file untouched.
assertTrue(Files.exists(tempDir.resolve("d3").resolve("a.txt")));
}
}
@Nested
@DisplayName("deleteSession")
class DeleteSession {
@Test
@DisplayName("removes the session and all its files")
void removesSessionAndFiles() throws IOException {
service.createSession("x1");
service.uploadFiles("x1", List.of(file("a.txt", "x"), file("b.txt", "y")));
assertTrue(Files.exists(tempDir.resolve("x1")));
service.deleteSession("x1");
assertNull(service.validateSession("x1"));
assertFalse(Files.exists(tempDir.resolve("x1")));
}
@Test
@DisplayName("is a no-op for an unknown session")
void unknownSessionNoOp() {
assertDoesNotThrow(() -> service.deleteSession("never-existed"));
}
}
@Nested
@DisplayName("cleanupExpiredSessions")
class CleanupExpiredSessions {
@Test
@DisplayName("removes sessions past the timeout")
void removesExpired() throws IOException {
service.createSession("old");
service.uploadFiles("old", List.of(file("a.txt", "x")));
forceLastAccess("old", System.currentTimeMillis() - (20 * 60 * 1000L));
service.cleanupExpiredSessions();
assertNull(service.validateSession("old"));
assertFalse(Files.exists(tempDir.resolve("old")));
}
@Test
@DisplayName("keeps sessions that are still fresh")
void keepsFresh() {
service.createSession("fresh");
service.cleanupExpiredSessions();
assertNotNull(service.validateSession("fresh"));
}
@Test
@DisplayName("does not throw when there are no sessions")
void noSessionsNoThrow() {
assertDoesNotThrow(() -> service.cleanupExpiredSessions());
}
}
@Nested
@DisplayName("SessionInfo accessors")
class SessionInfoAccessors {
@Test
@DisplayName("exposes all constructor values")
void exposesValues() {
SessionInfo info = new SessionInfo("id", 100L, 200L, 50L);
assertEquals("id", info.getSessionId());
assertEquals(100L, info.getCreatedAt());
assertEquals(200L, info.getExpiresAt());
assertEquals(50L, info.getTimeoutMs());
}
}
@Nested
@DisplayName("FileMetadata accessors")
class FileMetadataAccessors {
@Test
@DisplayName("exposes all constructor values")
void exposesValues() {
FileMetadata meta = new FileMetadata("name.pdf", 1234L, "application/pdf");
assertEquals("name.pdf", meta.getFilename());
assertEquals(1234L, meta.getSize());
assertEquals("application/pdf", meta.getContentType());
}
}
/**
* Reaches into the internal SessionData for a given session and forces its lastAccessTime, used
* to deterministically simulate expiry without sleeping.
*/
@SuppressWarnings("unchecked")
private void forceLastAccess(String sessionId, long lastAccessTime) {
java.util.Map<String, Object> sessions =
(java.util.Map<String, Object>)
ReflectionTestUtils.getField(service, "activeSessions");
assertNotNull(sessions);
Object sessionData = sessions.get(sessionId);
assertNotNull(sessionData, "session not found: " + sessionId);
ReflectionTestUtils.setField(sessionData, "lastAccessTime", lastAccessTime);
}
}
@@ -0,0 +1,416 @@
package stirling.software.common.service;
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.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.CustomMetadata;
import stirling.software.common.model.PdfMetadata;
class PdfMetadataServiceTest {
private static final String LABEL = "Stirling-PDF v1.0.0";
/**
* Builds a service whose pro-features are disabled (real ApplicationProperties, all defaults).
*/
private PdfMetadataService nonProService(UserServiceInterface userService) {
return new PdfMetadataService(new ApplicationProperties(), LABEL, false, userService);
}
@Nested
@DisplayName("toCalendar(ZonedDateTime)")
class ToCalendarTests {
@Test
@DisplayName("returns null for null input")
void nullReturnsNull() {
assertNull(PdfMetadataService.toCalendar(null));
}
@Test
@DisplayName("converts ZonedDateTime preserving the instant")
void convertsInstant() {
ZonedDateTime zdt = ZonedDateTime.of(2021, 6, 15, 10, 30, 45, 0, ZoneId.of("UTC"));
Calendar cal = PdfMetadataService.toCalendar(zdt);
assertNotNull(cal);
assertEquals(zdt.toInstant().toEpochMilli(), cal.getTimeInMillis());
}
}
@Nested
@DisplayName("parseToCalendar(String)")
class ParseToCalendarTests {
@Test
@DisplayName("returns null for null input")
void nullReturnsNull() {
assertNull(PdfMetadataService.parseToCalendar(null));
}
@Test
@DisplayName("returns null for empty / blank input")
void blankReturnsNull() {
assertNull(PdfMetadataService.parseToCalendar(""));
assertNull(PdfMetadataService.parseToCalendar(" "));
}
@Test
@DisplayName("returns null for unparsable input")
void invalidReturnsNull() {
assertNull(PdfMetadataService.parseToCalendar("not a date"));
assertNull(PdfMetadataService.parseToCalendar("2021-06-15"));
assertNull(PdfMetadataService.parseToCalendar("2021/13/40 99:99:99"));
}
@Test
@DisplayName("parses a valid 'yyyy/MM/dd HH:mm:ss' string")
void parsesValidDate() {
Calendar cal = PdfMetadataService.parseToCalendar("2021/06/15 10:30:45");
assertNotNull(cal);
// Build the expected instant the same way the implementation does so the
// assertion is independent of the JVM's default time zone.
long expectedMillis =
LocalDateTime.of(2021, 6, 15, 10, 30, 45)
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
assertEquals(expectedMillis, cal.getTimeInMillis());
}
}
@Nested
@DisplayName("extractMetadataFromPdf(PDDocument)")
class ExtractMetadataTests {
@Test
@DisplayName("returns all-null fields for a fresh empty document")
void emptyDocumentYieldsNulls() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
PdfMetadata md = service.extractMetadataFromPdf(doc);
assertNotNull(md);
assertNull(md.getAuthor());
assertNull(md.getProducer());
assertNull(md.getTitle());
assertNull(md.getCreator());
assertNull(md.getSubject());
assertNull(md.getKeywords());
assertNull(md.getCreationDate());
assertNull(md.getModificationDate());
}
}
@Test
@DisplayName("reads back string and date fields set on the document")
void readsBackPopulatedFields() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
PDDocumentInformation info = doc.getDocumentInformation();
info.setAuthor("Alice");
info.setProducer("ProducerX");
info.setTitle("My Title");
info.setCreator("CreatorY");
info.setSubject("Subject Z");
info.setKeywords("k1, k2");
Calendar creation = Calendar.getInstance();
creation.setTimeInMillis(1_600_000_000_000L);
Calendar modification = Calendar.getInstance();
modification.setTimeInMillis(1_700_000_000_000L);
info.setCreationDate(creation);
info.setModificationDate(modification);
PdfMetadata md = service.extractMetadataFromPdf(doc);
assertEquals("Alice", md.getAuthor());
assertEquals("ProducerX", md.getProducer());
assertEquals("My Title", md.getTitle());
assertEquals("CreatorY", md.getCreator());
assertEquals("Subject Z", md.getSubject());
assertEquals("k1, k2", md.getKeywords());
assertNotNull(md.getCreationDate());
assertNotNull(md.getModificationDate());
assertEquals(1_600_000_000_000L, md.getCreationDate().toInstant().toEpochMilli());
assertEquals(
1_700_000_000_000L, md.getModificationDate().toInstant().toEpochMilli());
}
}
}
@Nested
@DisplayName("setMetadataToPdf / setDefaultMetadata (non-pro path)")
class SetMetadataNonProTests {
@Test
@DisplayName("writes producer label, title, subject, keywords and author from metadata")
void writesCommonMetadata() throws Exception {
PdfMetadataService service = nonProService(null);
PdfMetadata md =
PdfMetadata.builder()
.author("Bob")
.title("Doc Title")
.subject("Doc Subject")
.keywords("a, b, c")
.creationDate(
ZonedDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
.modificationDate(
ZonedDateTime.of(2021, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
.build();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
service.setMetadataToPdf(doc, md);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals(LABEL, info.getProducer());
assertEquals("Doc Title", info.getTitle());
assertEquals("Doc Subject", info.getSubject());
assertEquals("a, b, c", info.getKeywords());
// Non-pro: author is taken verbatim from the metadata.
assertEquals("Bob", info.getAuthor());
assertNotNull(info.getModificationDate());
}
}
@Test
@DisplayName("existing creation date is left untouched when not newly created")
void keepsExistingCreationDate() throws Exception {
PdfMetadataService service = nonProService(null);
ZonedDateTime creation = ZonedDateTime.of(2019, 5, 20, 8, 15, 0, 0, ZoneId.of("UTC"));
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md);
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
// creationDate is non-null and newlyCreated=false, so setNewDocumentMetadata
// is skipped and no creation date is written.
assertNull(creationCal);
}
}
@Test
@DisplayName("sets a fresh creation date when metadata has none")
void setsCreationDateWhenMissing() throws Exception {
PdfMetadataService service = nonProService(null);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md);
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
assertNotNull(creationCal);
// Non-pro path writes the Stirling label as the creator.
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
}
}
@Test
@DisplayName("newlyCreated=true forces a fresh creation date even if metadata has one")
void newlyCreatedForcesCreationDate() throws Exception {
PdfMetadataService service = nonProService(null);
ZonedDateTime creation = ZonedDateTime.of(2018, 3, 3, 3, 3, 3, 0, ZoneId.of("UTC"));
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
assertNotNull(creationCal);
// The supplied creation date must have been honoured (not "now").
assertEquals(creation.toInstant().toEpochMilli(), creationCal.getTimeInMillis());
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
}
}
@Test
@DisplayName(
"setDefaultMetadata round-trips existing document info through the producer label")
void setDefaultMetadataRewritesProducer() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
PDDocumentInformation info = doc.getDocumentInformation();
info.setTitle("Original Title");
info.setAuthor("Original Author");
info.setProducer("Some Other Producer");
service.setDefaultMetadata(doc);
// extract + re-apply keeps title/author but rewrites producer to the label.
assertEquals("Original Title", info.getTitle());
assertEquals("Original Author", info.getAuthor());
assertEquals(LABEL, info.getProducer());
}
}
@Test
@DisplayName("null string fields in metadata are written through without error")
void handlesNullStringFields() throws Exception {
PdfMetadataService service = nonProService(null);
PdfMetadata md = PdfMetadata.builder().build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals(LABEL, info.getProducer());
assertNull(info.getTitle());
assertNull(info.getSubject());
assertNull(info.getKeywords());
assertNull(info.getAuthor());
// newlyCreated=true always stamps a creation date.
assertNotNull(info.getCreationDate());
assertNotNull(info.getModificationDate());
}
}
}
@Nested
@DisplayName("setMetadataToPdf (pro path with custom metadata)")
class SetMetadataProTests {
private ApplicationProperties propsWithCustomMetadata(
boolean autoUpdate, String author, String creator) {
ApplicationProperties props = mock(ApplicationProperties.class);
Premium premium = mock(Premium.class);
ProFeatures proFeatures = mock(ProFeatures.class);
CustomMetadata customMetadata = mock(CustomMetadata.class);
lenient().when(props.getPremium()).thenReturn(premium);
lenient().when(premium.getProFeatures()).thenReturn(proFeatures);
lenient().when(proFeatures.getCustomMetadata()).thenReturn(customMetadata);
lenient().when(customMetadata.isAutoUpdateMetadata()).thenReturn(autoUpdate);
lenient().when(customMetadata.getAuthor()).thenReturn(author);
lenient().when(customMetadata.getCreator()).thenReturn(creator);
return props;
}
@Test
@DisplayName("uses custom author and creator when pro and auto-update enabled")
void appliesCustomAuthorAndCreator() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
PdfMetadata md = PdfMetadata.builder().author("Ignored").title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals("Custom Author", info.getAuthor());
assertEquals("Custom Creator", info.getCreator());
// Producer is set to the label by both setNewDocumentMetadata and
// setCommonMetadata.
assertEquals(LABEL, info.getProducer());
}
}
@Test
@DisplayName("replaces 'username' token with the current user when userService present")
void replacesUsernameToken() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Report by username", "Creator");
UserServiceInterface userService = mock(UserServiceInterface.class);
when(userService.getCurrentUsername()).thenReturn("alice");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
assertEquals("Report by alice", doc.getDocumentInformation().getAuthor());
}
}
@Test
@DisplayName("leaves 'username' token intact when current user is null")
void keepsTokenWhenUsernameNull() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Report by username", "Creator");
UserServiceInterface userService = mock(UserServiceInterface.class);
when(userService.getCurrentUsername()).thenReturn(null);
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
assertEquals("Report by username", doc.getDocumentInformation().getAuthor());
}
}
@Test
@DisplayName("custom author applied even without a userService")
void appliesCustomAuthorWithoutUserService() throws Exception {
ApplicationProperties props = propsWithCustomMetadata(true, "Static Author", "Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
assertEquals("Static Author", doc.getDocumentInformation().getAuthor());
}
}
@Test
@DisplayName("pro flag without auto-update keeps metadata author and label creator")
void proButAutoUpdateDisabledUsesMetadata() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(false, "Custom Author", "Custom Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals("Metadata Author", info.getAuthor());
assertEquals(LABEL, info.getCreator());
}
}
@Test
@DisplayName("auto-update enabled but not pro keeps metadata author and label creator")
void autoUpdateButNotProUsesMetadata() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, false, null);
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals("Metadata Author", info.getAuthor());
assertEquals(LABEL, info.getCreator());
}
}
}
}
@@ -0,0 +1,441 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.HashMap;
import java.util.Map;
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.mock.env.MockEnvironment;
import com.posthog.java.PostHog;
import stirling.software.common.model.ApplicationProperties;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PostHogServiceTest {
private static final String UUID = "test-uuid-1234";
private static final String APP_VERSION = "9.9.9";
@Mock PostHog postHog;
@Mock UserServiceInterface userService;
/** Build an ApplicationProperties with analytics/posthog toggled. */
private ApplicationProperties props(boolean analyticsEnabled) {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSystem().setEnableAnalytics(analyticsEnabled);
return appProps;
}
/** Construct the service under test. */
private PostHogService newService(
ApplicationProperties appProps,
UserServiceInterface user,
boolean configDirMounted,
MockEnvironment env) {
return new PostHogService(
postHog, UUID, configDirMounted, APP_VERSION, appProps, user, env);
}
private MockEnvironment env() {
return new MockEnvironment();
}
@Nested
@DisplayName("Constructor / captureSystemInfo")
class ConstructorBehavior {
@Test
@DisplayName("constructor captures system_info when posthog is enabled")
void constructorCapturesWhenEnabled() {
ApplicationProperties appProps = props(true);
newService(appProps, userService, false, env());
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
}
@Test
@DisplayName("constructor does not capture when analytics disabled")
void constructorNoCaptureWhenDisabled() {
ApplicationProperties appProps = props(false);
newService(appProps, userService, false, env());
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
}
@Test
@DisplayName("constructor does not capture when posthog explicitly disabled")
void constructorNoCaptureWhenPosthogOff() {
ApplicationProperties appProps = props(true);
appProps.getSystem().setEnablePosthog(false);
newService(appProps, userService, false, env());
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
}
@Test
@DisplayName("constructor swallows exceptions thrown by postHog.capture")
void constructorSwallowsCaptureException() {
ApplicationProperties appProps = props(true);
doThrow(new RuntimeException("boom"))
.when(postHog)
.capture(anyString(), anyString(), anyMap());
// Must not propagate; constructor wraps capture in try/catch.
assertDoesNotThrow(() -> newService(appProps, userService, false, env()));
}
@Test
@DisplayName("constructor works with null userService (optional dependency)")
void constructorWithNullUserService() {
ApplicationProperties appProps = props(true);
assertDoesNotThrow(() -> newService(appProps, null, false, env()));
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
}
}
@Nested
@DisplayName("captureEvent")
class CaptureEvent {
@Test
@DisplayName("captureEvent forwards to postHog when enabled and injects app_version")
void captureEventWhenEnabled() {
ApplicationProperties appProps = props(true);
PostHogService service = newService(appProps, userService, false, env());
// Reset the constructor's capture so we only assert on captureEvent.
clearInvocations(postHog);
Map<String, Object> properties = new HashMap<>();
properties.put("foo", "bar");
service.captureEvent("my_event", properties);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
verify(postHog).capture(eq(UUID), eq("my_event"), captor.capture());
Map<String, Object> sent = captor.getValue();
assertEquals("bar", sent.get("foo"));
assertEquals(APP_VERSION, sent.get("app_version"));
}
@Test
@DisplayName("captureEvent is a no-op when analytics disabled")
void captureEventWhenDisabled() {
ApplicationProperties appProps = props(false);
PostHogService service = newService(appProps, userService, false, env());
clearInvocations(postHog);
Map<String, Object> properties = new HashMap<>();
service.captureEvent("my_event", properties);
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
// app_version must not be added when disabled (early return).
assertFalse(properties.containsKey("app_version"));
}
@Test
@DisplayName("captureEvent adds app_version key to the provided map")
void captureEventMutatesMap() {
ApplicationProperties appProps = props(true);
PostHogService service = newService(appProps, userService, false, env());
clearInvocations(postHog);
Map<String, Object> properties = new HashMap<>();
service.captureEvent("evt", properties);
assertTrue(properties.containsKey("app_version"));
assertEquals(APP_VERSION, properties.get("app_version"));
}
}
@Nested
@DisplayName("captureServerMetrics")
class CaptureServerMetrics {
private PostHogService disabledService() {
// Keep posthog disabled so the constructor performs no capture; metrics
// methods are independent of the enabled flag.
return newService(props(false), userService, true, env());
}
@Test
@DisplayName("includes core application and system metrics")
void includesCoreMetrics() {
PostHogService service = disabledService();
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals(APP_VERSION, metrics.get("app_version"));
assertEquals(true, metrics.get("mounted_config_dir"));
assertNotNull(metrics.get("os_name"));
assertNotNull(metrics.get("java_version"));
assertTrue(metrics.containsKey("cpu_cores"));
assertTrue(metrics.containsKey("total_memory"));
assertTrue(metrics.containsKey("free_memory"));
assertTrue(metrics.containsKey("process_id"));
assertTrue(metrics.containsKey("jvm_uptime_ms"));
assertTrue(metrics.containsKey("thread_count"));
}
@Test
@DisplayName("deployment_type defaults to JAR when not docker/exe")
void deploymentTypeJar() {
PostHogService service = disabledService();
Map<String, Object> metrics = service.captureServerMetrics();
// In the unit-test environment there is no /.dockerenv and no BROWSER_OPEN.
assertEquals("JAR", metrics.get("deployment_type"));
}
@Test
@DisplayName("deployment_type becomes EXE when BROWSER_OPEN=true")
void deploymentTypeExe() {
MockEnvironment environment = env();
environment.setProperty("BROWSER_OPEN", "true");
PostHogService service = newService(props(false), userService, false, environment);
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals("EXE", metrics.get("deployment_type"));
}
@Test
@DisplayName("BROWSER_OPEN matching is case-insensitive")
void deploymentTypeExeCaseInsensitive() {
MockEnvironment environment = env();
environment.setProperty("BROWSER_OPEN", "TRUE");
PostHogService service = newService(props(false), userService, false, environment);
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals("EXE", metrics.get("deployment_type"));
}
@Test
@DisplayName("mounted_config_dir reflects the configDirMounted flag")
void mountedConfigDirFalse() {
PostHogService service = newService(props(false), userService, false, env());
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals(false, metrics.get("mounted_config_dir"));
}
@Test
@DisplayName("includes total_users_created when userService present")
void includesUserCountWhenUserServicePresent() {
when(userService.getTotalUsersCount()).thenReturn(42L);
PostHogService service = newService(props(false), userService, false, env());
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals(42L, metrics.get("total_users_created"));
}
@Test
@DisplayName("omits total_users_created when userService is null")
void omitsUserCountWhenUserServiceNull() {
PostHogService service = newService(props(false), null, false, env());
Map<String, Object> metrics = service.captureServerMetrics();
assertFalse(metrics.containsKey("total_users_created"));
}
@Test
@DisplayName("always embeds nested application_properties map")
void embedsApplicationProperties() {
PostHogService service = disabledService();
Map<String, Object> metrics = service.captureServerMetrics();
assertTrue(metrics.get("application_properties") instanceof Map);
}
}
@Nested
@DisplayName("captureApplicationProperties")
class CaptureApplicationProperties {
private PostHogService serviceWith(ApplicationProperties appProps) {
// Disable analytics to keep the constructor from capturing.
appProps.getSystem().setEnableAnalytics(false);
return newService(appProps, userService, false, env());
}
@Test
@DisplayName("includes blank-trimmed legal strings only when non-empty")
void legalPropertiesFiltered() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getLegal().setTermsAndConditions(" https://terms ");
appProps.getLegal().setPrivacyPolicy(""); // blank -> skipped
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
// String values are trimmed by addIfNotEmpty.
assertEquals("https://terms", p.get("legal_termsAndConditions"));
assertFalse(p.containsKey("legal_privacyPolicy"));
assertFalse(p.containsKey("legal_accessibilityStatement"));
}
@Test
@DisplayName("always reports csrfDisabled true and login booleans")
void securityProperties() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSecurity().setEnableLogin(true);
appProps.getSecurity().setLoginAttemptCount(5);
appProps.getSecurity().setLoginResetTimeMinutes(10);
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("security_csrfDisabled"));
assertEquals(true, p.get("security_enableLogin"));
assertEquals(5, p.get("security_loginAttemptCount"));
assertEquals(10L, p.get("security_loginResetTimeMinutes"));
assertEquals("all", p.get("security_loginMethod"));
}
@Test
@DisplayName("oauth2 nested fields are omitted when oauth2 disabled")
void oauth2DisabledOmitsNested() {
ApplicationProperties appProps = new ApplicationProperties();
// oauth2.enabled defaults to false.
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(false, p.get("security_oauth2_enabled"));
assertFalse(p.containsKey("security_oauth2_autoCreateUser"));
assertFalse(p.containsKey("security_oauth2_provider"));
}
@Test
@DisplayName("oauth2 nested fields are included when oauth2 enabled")
void oauth2EnabledIncludesNested() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSecurity().getOauth2().setEnabled(true);
appProps.getSecurity().getOauth2().setAutoCreateUser(true);
appProps.getSecurity().getOauth2().setBlockRegistration(false);
appProps.getSecurity().getOauth2().setUseAsUsername("email");
appProps.getSecurity().getOauth2().setProvider("google");
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("security_oauth2_enabled"));
assertEquals(true, p.get("security_oauth2_autoCreateUser"));
assertEquals(false, p.get("security_oauth2_blockRegistration"));
assertEquals("email", p.get("security_oauth2_useAsUsername"));
assertEquals("google", p.get("security_oauth2_provider"));
}
@Test
@DisplayName("system analytics/posthog/scarf booleans are reported")
void systemAnalyticsBooleans() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSystem().setEnableAnalytics(true);
appProps.getSystem().setEnablePosthog(true);
appProps.getSystem().setEnableScarf(false);
appProps.getSystem().setDefaultLocale("en-US");
PostHogService service = newService(appProps, userService, false, env());
// Constructor will capture once because analytics is enabled; that's fine.
clearInvocations(postHog);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals("en-US", p.get("system_defaultLocale"));
assertEquals(true, p.get("system_enableAnalytics"));
assertEquals(true, p.get("system_enablePosthog"));
// isScarfEnabled() is false because enableScarf is false.
assertEquals(false, p.get("system_enableScarf"));
}
@Test
@DisplayName("metrics_enabled and autoPipeline output folder included appropriately")
void metricsAndAutoPipeline() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getMetrics().setEnabled(true);
appProps.getAutoPipeline().setOutputFolder("/tmp/out");
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("metrics_enabled"));
assertEquals("/tmp/out", p.get("autoPipeline_outputFolder"));
}
@Test
@DisplayName("enterprise metadata flag omitted when premium disabled")
void premiumDisabledOmitsMetadata() {
ApplicationProperties appProps = new ApplicationProperties();
// premium.enabled defaults to false.
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(false, p.get("enterpriseEdition_enabled"));
assertFalse(p.containsKey("enterpriseEdition_customMetadata_autoUpdateMetadata"));
}
@Test
@DisplayName("enterprise metadata flag included when premium enabled")
void premiumEnabledIncludesMetadata() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getPremium().setEnabled(true);
appProps.getPremium().getProFeatures().getCustomMetadata().setAutoUpdateMetadata(true);
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("enterpriseEdition_enabled"));
assertEquals(true, p.get("enterpriseEdition_customMetadata_autoUpdateMetadata"));
}
@Test
@DisplayName("ui appNameNavbar omitted when blank, included when set")
void uiAppNameNavbar() {
ApplicationProperties blankProps = new ApplicationProperties();
// appNameNavbar getter returns null for blank/empty values.
PostHogService blankService = serviceWith(blankProps);
Map<String, Object> blank = blankService.captureApplicationProperties();
assertFalse(blank.containsKey("ui_appNameNavbar"));
ApplicationProperties namedProps = new ApplicationProperties();
namedProps.getUi().setAppNameNavbar("My App");
PostHogService namedService = serviceWith(namedProps);
Map<String, Object> named = namedService.captureApplicationProperties();
assertEquals("My App", named.get("ui_appNameNavbar"));
}
@Test
@DisplayName("returns a non-null map for a fresh ApplicationProperties")
void defaultsProduceNonNullMap() {
PostHogService service = serviceWith(new ApplicationProperties());
Map<String, Object> p = service.captureApplicationProperties();
assertNotNull(p);
// csrfDisabled is always added regardless of config, so map is never empty.
assertTrue(p.containsKey("security_csrfDisabled"));
}
}
}
@@ -0,0 +1,738 @@
package stirling.software.common.util;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import stirling.software.common.util.ExceptionUtils.BaseAppException;
import stirling.software.common.util.ExceptionUtils.CbrFormatException;
import stirling.software.common.util.ExceptionUtils.CbzFormatException;
import stirling.software.common.util.ExceptionUtils.EmlFormatException;
import stirling.software.common.util.ExceptionUtils.ErrorCode;
import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException;
import stirling.software.common.util.ExceptionUtils.GhostscriptException;
import stirling.software.common.util.ExceptionUtils.OutOfMemoryDpiException;
import stirling.software.common.util.ExceptionUtils.PdfCorruptedException;
/**
* Additional gap-filling unit tests for {@link ExceptionUtils}, covering areas not exercised by
* {@code ExceptionUtilsTest}: CBR/CBZ/EML factories, error-code hint/action lookups, rendering
* dimension validation, OOM rendering wrappers, Ghostscript output analysis, and wrapException.
*
* <p>The {@code messages} ResourceBundle is not on the common module test classpath, so {@link
* ExceptionUtils} falls back to the default messages baked into {@link ErrorCode}. Assertions here
* rely only on those default messages and on deterministic structural behavior.
*/
class ExceptionUtilsGapTest {
@Nested
@DisplayName("ErrorCode enum metadata")
class ErrorCodeMetadataTests {
@Test
@DisplayName("each error code exposes code, message key and default message")
void allErrorCodesHaveMetadata() {
for (ErrorCode code : ErrorCode.values()) {
assertNotNull(code.getCode(), "code for " + code);
assertTrue(code.getCode().startsWith("E"), "code prefix for " + code);
assertNotNull(code.getMessageKey(), "messageKey for " + code);
assertNotNull(code.getDefaultMessage(), "defaultMessage for " + code);
assertFalse(code.getDefaultMessage().isEmpty(), "defaultMessage empty for " + code);
}
}
@Test
@DisplayName("known error codes map to expected identifiers")
void knownErrorCodeIdentifiers() {
assertEquals("E001", ErrorCode.PDF_CORRUPTED.getCode());
assertEquals("E081", ErrorCode.OUT_OF_MEMORY_DPI.getCode());
assertEquals("error.pdfCorrupted", ErrorCode.PDF_CORRUPTED.getMessageKey());
}
}
@Nested
@DisplayName("Hints and action lookups via resource bundle")
class HintAndActionTests {
@Test
@DisplayName("getHintsForErrorCode returns empty list for null code")
void hintsNullCode() {
assertEquals(List.of(), ExceptionUtils.getHintsForErrorCode(null));
}
@Test
@DisplayName("getHintsForErrorCode returns empty list when no hints exist in bundle")
void hintsMissingFromBundle() {
// Fallback empty bundle has no hint keys, so the result is an empty list.
List<String> hints = ExceptionUtils.getHintsForErrorCode("E001");
assertNotNull(hints);
assertTrue(hints.isEmpty());
}
@Test
@DisplayName("getActionRequiredForErrorCode returns null for null code")
void actionNullCode() {
assertNull(ExceptionUtils.getActionRequiredForErrorCode(null));
}
@Test
@DisplayName("getActionRequiredForErrorCode returns null when key absent from bundle")
void actionMissingFromBundle() {
assertNull(ExceptionUtils.getActionRequiredForErrorCode("E001"));
}
}
@Nested
@DisplayName("CBR format exception factories")
class CbrFactoryTests {
@Test
@DisplayName("invalid format uses provided message when non-null")
void cbrInvalidFormatWithMessage() {
CbrFormatException ex =
ExceptionUtils.createCbrInvalidFormatException("custom cbr msg");
assertEquals("custom cbr msg", ex.getMessage());
assertEquals(ErrorCode.CBR_INVALID_FORMAT.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("invalid format falls back to default message when null")
void cbrInvalidFormatNullMessage() {
CbrFormatException ex = ExceptionUtils.createCbrInvalidFormatException(null);
assertTrue(ex.getMessage().contains("CBR/RAR archive"));
assertEquals("E010", ex.getErrorCode());
}
@Test
@DisplayName("encrypted CBR reuses invalid-format code")
void cbrEncrypted() {
CbrFormatException ex = ExceptionUtils.createCbrEncryptedException();
assertEquals(ErrorCode.CBR_INVALID_FORMAT.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("no images and corrupted images both map to CBR_NO_IMAGES")
void cbrNoImages() {
CbrFormatException noImages = ExceptionUtils.createCbrNoImagesException();
CbrFormatException corrupted = ExceptionUtils.createCbrCorruptedImagesException();
assertEquals(ErrorCode.CBR_NO_IMAGES.getCode(), noImages.getErrorCode());
assertEquals(ErrorCode.CBR_NO_IMAGES.getCode(), corrupted.getErrorCode());
assertTrue(noImages.getMessage().contains("No valid images"));
}
@Test
@DisplayName("not-a-CBR file uses CBR_NOT_CBR code")
void notCbr() {
CbrFormatException ex = ExceptionUtils.createNotCbrFileException();
assertEquals(ErrorCode.CBR_NOT_CBR.getCode(), ex.getErrorCode());
assertTrue(ex.getMessage().contains("CBR or RAR"));
}
@Test
@DisplayName(
"CbrFormatException is an IllegalArgumentException via BaseValidationException")
void cbrIsIllegalArgument() {
CbrFormatException ex = ExceptionUtils.createNotCbrFileException();
assertInstanceOf(IllegalArgumentException.class, ex);
}
}
@Nested
@DisplayName("CBZ format exception factories")
class CbzFactoryTests {
@Test
@DisplayName("invalid format wraps cause and uses CBZ_INVALID_FORMAT code")
void cbzInvalidFormat() {
Exception cause = new Exception("zip boom");
CbzFormatException ex = ExceptionUtils.createCbzInvalidFormatException(cause);
assertSame(cause, ex.getCause());
assertEquals(ErrorCode.CBZ_INVALID_FORMAT.getCode(), ex.getErrorCode());
assertTrue(ex.getMessage().contains("CBZ/ZIP archive"));
}
@Test
@DisplayName("empty CBZ reuses invalid-format code")
void cbzEmpty() {
CbzFormatException ex = ExceptionUtils.createCbzEmptyException();
assertEquals(ErrorCode.CBZ_INVALID_FORMAT.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("no images and corrupted images both map to CBZ_NO_IMAGES")
void cbzNoImages() {
CbzFormatException noImages = ExceptionUtils.createCbzNoImagesException();
CbzFormatException corrupted = ExceptionUtils.createCbzCorruptedImagesException();
assertEquals(ErrorCode.CBZ_NO_IMAGES.getCode(), noImages.getErrorCode());
assertEquals(ErrorCode.CBZ_NO_IMAGES.getCode(), corrupted.getErrorCode());
}
@Test
@DisplayName("not-a-CBZ file uses CBZ_NOT_CBZ code")
void notCbz() {
CbzFormatException ex = ExceptionUtils.createNotCbzFileException();
assertEquals(ErrorCode.CBZ_NOT_CBZ.getCode(), ex.getErrorCode());
assertTrue(ex.getMessage().contains("CBZ or ZIP"));
}
}
@Nested
@DisplayName("EML format exception factories")
class EmlFactoryTests {
@Test
@DisplayName("empty EML uses EML_EMPTY code")
void emlEmpty() {
EmlFormatException ex = ExceptionUtils.createEmlEmptyException();
assertEquals(ErrorCode.EML_EMPTY.getCode(), ex.getErrorCode());
assertTrue(ex.getMessage().contains("EML file is empty"));
}
@Test
@DisplayName("invalid EML uses EML_INVALID_FORMAT code")
void emlInvalid() {
EmlFormatException ex = ExceptionUtils.createEmlInvalidFormatException();
assertEquals(ErrorCode.EML_INVALID_FORMAT.getCode(), ex.getErrorCode());
assertTrue(ex.getMessage().contains("Invalid EML"));
}
}
@Nested
@DisplayName("Image, OCR and processing factories")
class ImageOcrProcessingTests {
@Test
@DisplayName("image read exception embeds filename and has no cause")
void imageRead() {
IOException ex = ExceptionUtils.createImageReadException("photo.png");
assertTrue(ex.getMessage().contains("photo.png"));
assertNull(ex.getCause());
}
@Test
@DisplayName("image read exception rejects null filename")
void imageReadNullFilename() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createImageReadException(null));
}
@Test
@DisplayName("ocr invalid render type uses default message")
void ocrInvalidRenderType() {
IOException ex = ExceptionUtils.createOcrInvalidRenderTypeException();
assertTrue(ex.getMessage().contains("hocr"));
}
@Test
@DisplayName("ocr processing failed includes return code")
void ocrProcessingFailed() {
IOException ex = ExceptionUtils.createOcrProcessingFailedException(7);
assertTrue(ex.getMessage().contains("7"));
}
@Test
@DisplayName("processing interrupted wraps the InterruptedException cause")
void processingInterrupted() {
InterruptedException cause = new InterruptedException("stop");
IOException ex =
ExceptionUtils.createProcessingInterruptedException("compression", cause);
assertSame(cause, ex.getCause());
assertTrue(ex.getMessage().contains("compression"));
}
@Test
@DisplayName("processing interrupted rejects null arguments")
void processingInterruptedNullArgs() {
assertThrows(
IllegalArgumentException.class,
() ->
ExceptionUtils.createProcessingInterruptedException(
null, new InterruptedException()));
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createProcessingInterruptedException("x", null));
}
@Test
@DisplayName("ghostscript conversion exception embeds output type")
void ghostscriptConversion() {
IOException ex = ExceptionUtils.createGhostscriptConversionException("png");
assertNotNull(ex.getMessage());
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createGhostscriptConversionException(null));
}
}
@Nested
@DisplayName("Validation factories: page size, file, ffmpeg")
class ValidationFactoryTests {
@Test
@DisplayName("invalid page size rejects null size")
void invalidPageSizeNull() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createInvalidPageSizeException(null));
}
@Test
@DisplayName("file null-or-empty uses FILE_NULL_OR_EMPTY default message")
void fileNullOrEmpty() {
IllegalArgumentException ex = ExceptionUtils.createFileNullOrEmptyException();
assertTrue(ex.getMessage().contains("null or empty"));
}
@Test
@DisplayName("file no-name uses FILE_NO_NAME default message")
void fileNoName() {
IllegalArgumentException ex = ExceptionUtils.createFileNoNameException();
assertTrue(ex.getMessage().contains("must have a name"));
}
@Test
@DisplayName("pdf no-pages uses PDF_NO_PAGES default message")
void pdfNoPages() {
IllegalArgumentException ex = ExceptionUtils.createPdfNoPages();
assertTrue(ex.getMessage().contains("no pages"));
}
@Test
@DisplayName("ffmpeg required exception exposes FFMPEG_REQUIRED code and null cause")
void ffmpegRequired() {
FfmpegRequiredException ex = ExceptionUtils.createFfmpegRequiredException();
assertEquals(ErrorCode.FFMPEG_REQUIRED.getCode(), ex.getErrorCode());
assertNull(ex.getCause());
assertTrue(ex.getMessage().contains("FFmpeg"));
}
}
@Nested
@DisplayName("ErrorCode-based argument and IO factories")
class ErrorCodeArgFactoryTests {
@Test
@DisplayName("createIllegalArgumentException(ErrorCode, args) formats default message")
void illegalArgumentFromErrorCode() {
IllegalArgumentException ex =
ExceptionUtils.createIllegalArgumentException(
ErrorCode.INVALID_PAGE_SIZE, "B7");
assertTrue(ex.getMessage().contains("B7"));
}
@Test
@DisplayName("createIllegalArgumentException rejects null ErrorCode")
void illegalArgumentFromNullErrorCode() {
ErrorCode nullCode = null;
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createIllegalArgumentException(nullCode));
}
@Test
@DisplayName("createFileProcessingException rejects null operation and cause")
void fileProcessingNullArgs() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createFileProcessingException(null, new Exception()));
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createFileProcessingException("op", null));
}
@Test
@DisplayName("createInvalidArgumentException rejects null name or value")
void invalidArgumentNullArgs() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createInvalidArgumentException(null, "v"));
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createInvalidArgumentException("n", null));
}
@Test
@DisplayName("createNullArgumentException rejects null argument name")
void nullArgumentNullName() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createNullArgumentException(null));
}
@Test
@DisplayName("createIOException without cause leaves cause null")
void ioExceptionWithoutCause() {
IOException ex = ExceptionUtils.createIOException("key", "msg {0}", null, "A");
assertEquals("msg A", ex.getMessage());
assertNull(ex.getCause());
}
@Test
@DisplayName("createRuntimeException without cause leaves cause null")
void runtimeExceptionWithoutCause() {
RuntimeException ex =
ExceptionUtils.createRuntimeException("key", "msg {0}", null, "B");
assertEquals("msg B", ex.getMessage());
assertNull(ex.getCause());
}
}
@Nested
@DisplayName("createPdfCorruptedException null-cause handling")
class PdfCorruptedCauseTests {
@Test
@DisplayName("rejects null cause")
void nullCause() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createPdfCorruptedException("ctx", null));
}
@Test
@DisplayName("empty context behaves like no context")
void emptyContext() {
PdfCorruptedException ex =
ExceptionUtils.createPdfCorruptedException("", new Exception("x"));
assertTrue(ex.getMessage().contains("PDF file appears to be corrupted"));
assertEquals(ErrorCode.PDF_CORRUPTED.getCode(), ex.getErrorCode());
}
}
@Nested
@DisplayName("validateRenderingDimensions")
class ValidateRenderingDimensionsTests {
@Test
@DisplayName("null page is a no-op")
void nullPage() {
// Should simply return without throwing.
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
() -> ExceptionUtils.validateRenderingDimensions(null, 1, 300));
}
@Test
@DisplayName("normal letter-size page at 150 DPI passes validation")
void normalPagePasses() {
PDPage page = new PDPage(PDRectangle.LETTER);
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
() -> ExceptionUtils.validateRenderingDimensions(page, 1, 150));
}
@Test
@DisplayName("page with zero DPI yields zero pixels and passes")
void zeroDpiPasses() {
PDPage page = new PDPage(PDRectangle.A4);
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
() -> ExceptionUtils.validateRenderingDimensions(page, 2, 0));
}
}
@Nested
@DisplayName("handleOomRendering wrappers")
class HandleOomRenderingTests {
@Test
@DisplayName("returns operation result on success (with page number)")
void successWithPage() throws IOException {
String result = ExceptionUtils.handleOomRendering(3, 300, () -> "ok");
assertEquals("ok", result);
}
@Test
@DisplayName("returns operation result on success (no page number)")
void successNoPage() throws IOException {
String result = ExceptionUtils.handleOomRendering(300, () -> "fine");
assertEquals("fine", result);
}
@Test
@DisplayName("propagates IOException from the operation unchanged")
void propagatesIoException() {
IOException boom = new IOException("io boom");
IOException thrown =
assertThrows(
IOException.class,
() ->
ExceptionUtils.handleOomRendering(
1,
300,
() -> {
throw boom;
}));
assertSame(boom, thrown);
}
@Test
@DisplayName("converts OutOfMemoryError to OutOfMemoryDpiException (with page)")
void oomToDpiExceptionWithPage() {
OutOfMemoryDpiException thrown =
assertThrows(
OutOfMemoryDpiException.class,
() ->
ExceptionUtils.handleOomRendering(
5,
300,
() -> {
throw new OutOfMemoryError("heap");
}));
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), thrown.getErrorCode());
assertInstanceOf(OutOfMemoryError.class, thrown.getCause());
}
@Test
@DisplayName("converts NegativeArraySizeException to OutOfMemoryDpiException (no page)")
void negativeArraySizeToDpiExceptionNoPage() {
OutOfMemoryDpiException thrown =
assertThrows(
OutOfMemoryDpiException.class,
() ->
ExceptionUtils.handleOomRendering(
300,
() -> {
throw new NegativeArraySizeException("-1");
}));
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), thrown.getErrorCode());
assertInstanceOf(NegativeArraySizeException.class, thrown.getCause());
}
}
@Nested
@DisplayName("createOutOfMemoryDpiException overloads")
class OutOfMemoryDpiFactoryTests {
@Test
@DisplayName("page + dpi + Throwable wraps cause and sets code")
void pageDpiThrowable() {
Throwable cause = new IllegalStateException("too big");
OutOfMemoryDpiException ex =
ExceptionUtils.createOutOfMemoryDpiException(4, 600, cause);
assertSame(cause, ex.getCause());
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("page + dpi + OutOfMemoryError overload wraps the error")
void pageDpiOomError() {
OutOfMemoryError cause = new OutOfMemoryError("oom");
OutOfMemoryDpiException ex =
ExceptionUtils.createOutOfMemoryDpiException(2, 300, cause);
assertSame(cause, ex.getCause());
}
@Test
@DisplayName("dpi + Throwable overload wraps cause")
void dpiThrowable() {
Throwable cause = new RuntimeException("x");
OutOfMemoryDpiException ex = ExceptionUtils.createOutOfMemoryDpiException(300, cause);
assertSame(cause, ex.getCause());
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("dpi + OutOfMemoryError overload wraps the error")
void dpiOomError() {
OutOfMemoryError cause = new OutOfMemoryError("oom");
OutOfMemoryDpiException ex = ExceptionUtils.createOutOfMemoryDpiException(300, cause);
assertSame(cause, ex.getCause());
}
@Test
@DisplayName("rejects null cause")
void nullCause() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createOutOfMemoryDpiException(1, 300, (Throwable) null));
}
}
@Nested
@DisplayName("Ghostscript output analysis")
class GhostscriptAnalysisTests {
@Test
@DisplayName("null/blank output produces generic compression exception")
void blankOutput() {
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(" ");
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("recognized page drawing error yields page-drawing error code")
void pageDrawingError() {
String output = "Page 3\nERROR: page drawing error encountered while processing";
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("non-page-drawing output falls back to compression error code")
void unrecognizedOutput() {
String output = "Some random ghostscript chatter that is not an error marker";
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("detectGhostscriptCriticalError returns exception only for critical output")
void detectCritical() {
GhostscriptException critical =
ExceptionUtils.detectGhostscriptCriticalError(
"Page 1\ncould not draw this page");
assertNotNull(critical);
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), critical.getErrorCode());
}
@Test
@DisplayName("detectGhostscriptCriticalError returns null for non-critical output")
void detectNonCritical() {
assertNull(ExceptionUtils.detectGhostscriptCriticalError("just informational output"));
assertNull(ExceptionUtils.detectGhostscriptCriticalError(null));
}
@Test
@DisplayName("compression exception derived from cause message")
void compressionFromCauseMessage() {
GhostscriptException ex =
ExceptionUtils.createGhostscriptCompressionException(
new Exception("Page 2\npage drawing error"));
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("createGhostscriptCompressionException rejects null cause overload")
void compressionNullCause() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.createGhostscriptCompressionException((Exception) null));
}
@Test
@DisplayName("multiple affected pages are summarized in the message")
void multiplePages() {
String output = "Page 1\npage drawing error\nPage 2\ncould not draw this page";
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
assertNotNull(ex.getMessage());
}
}
@Nested
@DisplayName("wrapException")
class WrapExceptionTests {
@Test
@DisplayName("RuntimeException is returned unchanged")
void runtimePassthrough() {
RuntimeException original = new IllegalStateException("boom");
RuntimeException wrapped = ExceptionUtils.wrapException(original, "merge");
assertSame(original, wrapped);
}
@Test
@DisplayName("BaseAppException (IOException subtype) is wrapped in a RuntimeException")
void baseAppExceptionWrapped() {
// A corrupted-pdf IOException triggers handlePdfException -> PdfCorruptedException.
IOException corrupted = new IOException("Invalid PDF");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(corrupted)).thenReturn(true);
RuntimeException wrapped = ExceptionUtils.wrapException(corrupted, "merge");
assertInstanceOf(BaseAppException.class, wrapped.getCause());
}
}
@Test
@DisplayName("plain IOException is wrapped via file-processing exception")
void plainIoExceptionWrapped() {
IOException io = new IOException("disk full");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(io)).thenReturn(false);
RuntimeException wrapped = ExceptionUtils.wrapException(io, "split");
assertInstanceOf(IOException.class, wrapped.getCause());
assertFalse(wrapped.getCause() instanceof BaseAppException);
}
}
@Test
@DisplayName("checked non-IO exception is wrapped with operation context")
void checkedExceptionWrapped() {
Exception checked = new Exception("oops");
RuntimeException wrapped = ExceptionUtils.wrapException(checked, "convert");
assertSame(checked, wrapped.getCause());
assertTrue(wrapped.getMessage().contains("convert"));
assertTrue(wrapped.getMessage().contains("oops"));
}
@Test
@DisplayName("rejects null exception or operation")
void wrapNullArgs() {
assertThrows(
IllegalArgumentException.class, () -> ExceptionUtils.wrapException(null, "op"));
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.wrapException(new Exception(), null));
}
}
@Nested
@DisplayName("logException return value and handlePdfException null guard")
class LogAndHandleTests {
@Test
@DisplayName("logException returns the same exception instance for fluent throw")
void logExceptionReturnsSame() {
Exception e = new RuntimeException("unexpected");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
Exception returned = ExceptionUtils.logException("op", e);
assertSame(e, returned);
}
}
@Test
@DisplayName("logException rejects null operation or exception")
void logExceptionNullArgs() {
assertThrows(
IllegalArgumentException.class,
() -> ExceptionUtils.logException(null, new Exception()));
assertThrows(
IllegalArgumentException.class, () -> ExceptionUtils.logException("op", null));
}
@Test
@DisplayName("handlePdfException rejects null exception")
void handlePdfNull() {
assertThrows(
IllegalArgumentException.class, () -> ExceptionUtils.handlePdfException(null));
}
@Test
@DisplayName("handlePdfException with context wraps corrupted PDF and includes context")
void handlePdfWithContext() {
IOException original = new IOException("damaged");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(true);
IOException result = ExceptionUtils.handlePdfException(original, "during merge");
assertInstanceOf(PdfCorruptedException.class, result);
assertTrue(result.getMessage().contains("during merge"));
}
}
}
}
@@ -0,0 +1,847 @@
package stirling.software.common.util;
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 java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
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.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Gap coverage for {@link FormUtils} methods not exercised by {@code FormUtilsTest} (disabled) or
* {@code FormUtilsAdditionalTest}. Focuses on coordinate extraction, the page-map / repair / prune
* / delete / modify lifecycle, and the package-private parsing helpers.
*/
class FormUtilsGapTest {
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
private static SetupDocument createBasicDocument(PDDocument document) {
PDPage page = new PDPage();
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
// Register a Helvetica font in the default resources and set a default appearance so
// PDFBox can write text-field values without throwing "/DA is a required entry".
PDResources dr = new PDResources();
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
acroForm.setDefaultResources(dr);
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachWidget(
SetupDocument setup, PDTerminalField field, PDRectangle rectangle) throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page());
// Start from an empty list: a fresh terminal field has no /Kids, so getWidgets() would
// return a synthetic widget wrapping the field dict itself. Re-adding that turns the field
// into a self-referential non-terminal field whose getWidgets() is empty.
List<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm().getFields().add(field);
setup.page().getAnnotations().add(widget);
}
// ----------------------------------------------------------------------
// Constants
// ----------------------------------------------------------------------
@Nested
@DisplayName("Field type constants")
class Constants {
@Test
void typeConstantsHaveExpectedValues() {
assertEquals("text", FormUtils.FIELD_TYPE_TEXT);
assertEquals("checkbox", FormUtils.FIELD_TYPE_CHECKBOX);
assertEquals("combobox", FormUtils.FIELD_TYPE_COMBOBOX);
assertEquals("listbox", FormUtils.FIELD_TYPE_LISTBOX);
assertEquals("radio", FormUtils.FIELD_TYPE_RADIO);
assertEquals("button", FormUtils.FIELD_TYPE_BUTTON);
assertEquals("signature", FormUtils.FIELD_TYPE_SIGNATURE);
}
@Test
void choiceFieldTypesContainsExpectedMembers() {
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("combobox"));
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("listbox"));
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("radio"));
assertFalse(FormUtils.CHOICE_FIELD_TYPES.contains("text"));
assertEquals(3, FormUtils.CHOICE_FIELD_TYPES.size());
}
}
// ----------------------------------------------------------------------
// detectFieldType (choice/radio/signature/button branches)
// ----------------------------------------------------------------------
@Nested
@DisplayName("detectFieldType")
class DetectFieldType {
@Test
void comboBoxDetected() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
assertEquals(
"combobox", FormUtils.detectFieldType(new PDComboBox(setup.acroForm())));
}
}
@Test
void listBoxDetected() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
assertEquals("listbox", FormUtils.detectFieldType(new PDListBox(setup.acroForm())));
}
}
@Test
void radioButtonDetected() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
assertEquals(
"radio", FormUtils.detectFieldType(new PDRadioButton(setup.acroForm())));
}
}
@Test
void signatureDetected() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
assertEquals(
"signature",
FormUtils.detectFieldType(new PDSignatureField(setup.acroForm())));
}
}
}
// ----------------------------------------------------------------------
// isChecked
// ----------------------------------------------------------------------
@Nested
@DisplayName("isChecked")
class IsChecked {
@Test
void nullIsFalse() {
assertFalse(FormUtils.isChecked(null));
}
@Test
void truthyValuesAreChecked() {
assertTrue(FormUtils.isChecked("true"));
assertTrue(FormUtils.isChecked("1"));
assertTrue(FormUtils.isChecked("yes"));
assertTrue(FormUtils.isChecked("on"));
assertTrue(FormUtils.isChecked("checked"));
}
@Test
void truthyValuesAreCaseInsensitiveAndTrimmed() {
assertTrue(FormUtils.isChecked(" TRUE "));
assertTrue(FormUtils.isChecked("Yes"));
assertTrue(FormUtils.isChecked("ON"));
}
@Test
void falsyValuesAreNotChecked() {
assertFalse(FormUtils.isChecked("false"));
assertFalse(FormUtils.isChecked("0"));
assertFalse(FormUtils.isChecked("off"));
assertFalse(FormUtils.isChecked(""));
assertFalse(FormUtils.isChecked("anything"));
}
}
// ----------------------------------------------------------------------
// safeValue
// ----------------------------------------------------------------------
@Test
void safeValueEmptyStringPassesThrough() {
assertEquals("", FormUtils.safeValue(""));
}
// ----------------------------------------------------------------------
// parseMultiChoiceSelections
// ----------------------------------------------------------------------
@Nested
@DisplayName("parseMultiChoiceSelections")
class ParseMultiChoiceSelections {
@Test
void nullReturnsEmpty() {
assertTrue(FormUtils.parseMultiChoiceSelections(null).isEmpty());
}
@Test
void blankReturnsEmpty() {
assertTrue(FormUtils.parseMultiChoiceSelections(" ").isEmpty());
}
@Test
void splitsAndTrims() {
List<String> result = FormUtils.parseMultiChoiceSelections(" a , b ,c ");
assertEquals(List.of("a", "b", "c"), result);
}
@Test
void dropsEmptySegments() {
List<String> result = FormUtils.parseMultiChoiceSelections("a,,b,");
assertEquals(List.of("a", "b"), result);
}
}
// ----------------------------------------------------------------------
// filterChoiceSelections
// ----------------------------------------------------------------------
@Nested
@DisplayName("filterChoiceSelections")
class FilterChoiceSelections {
@Test
void nullSelectionsReturnsEmpty() {
assertTrue(FormUtils.filterChoiceSelections(null, List.of("A"), "f").isEmpty());
}
@Test
void emptySelectionsReturnsEmpty() {
assertTrue(FormUtils.filterChoiceSelections(List.of(), List.of("A"), "f").isEmpty());
}
@Test
void selectionsOfOnlyBlanksReturnsEmpty() {
List<String> selections = new ArrayList<>();
selections.add(" ");
selections.add(null);
assertTrue(FormUtils.filterChoiceSelections(selections, List.of("A"), "f").isEmpty());
}
@Test
void matchingSelectionsAreKeptCaseInsensitively() {
List<String> result =
FormUtils.filterChoiceSelections(
List.of("apple", "BANANA"), List.of("Apple", "Banana", "Cherry"), "f");
// The resolved (canonical) allowed option is returned, not the input.
assertEquals(List.of("Apple", "Banana"), result);
}
@Test
void unsupportedSelectionsAreDropped() {
List<String> result =
FormUtils.filterChoiceSelections(
List.of("Apple", "Grape"), List.of("Apple", "Banana"), "f");
assertEquals(List.of("Apple"), result);
}
@Test
void missingAllowedOptionsThrows() {
org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() -> FormUtils.filterChoiceSelections(List.of("Apple"), List.of(), "fieldX"));
}
@Test
void nullAllowedOptionsThrows() {
org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() -> FormUtils.filterChoiceSelections(List.of("Apple"), null, "fieldX"));
}
}
// ----------------------------------------------------------------------
// resolveOptions / resolveDisplayOptions / collectChoiceAllowedValues
// ----------------------------------------------------------------------
@Nested
@DisplayName("option resolution")
class OptionResolution {
@Test
void resolveOptionsForComboBox() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setOptions(List.of("Red", "Green", "Blue"));
List<String> options = FormUtils.resolveOptions(combo);
assertTrue(options.contains("Red"));
assertTrue(options.contains("Green"));
assertTrue(options.contains("Blue"));
}
}
@Test
void resolveOptionsForTextFieldIsEmpty() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
assertTrue(FormUtils.resolveOptions(text).isEmpty());
}
}
@Test
void resolveOptionsForCheckBoxUsesExportValues() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDCheckBox checkBox = new PDCheckBox(setup.acroForm());
checkBox.setExportValues(List.of("Yes"));
assertEquals(List.of("Yes"), FormUtils.resolveOptions(checkBox));
}
}
@Test
void resolveDisplayOptionsEmptyForTextField() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
assertTrue(FormUtils.resolveDisplayOptions(text).isEmpty());
}
}
@Test
void collectChoiceAllowedValuesNullReturnsEmpty() {
assertTrue(FormUtils.collectChoiceAllowedValues(null).isEmpty());
}
@Test
void collectChoiceAllowedValuesReturnsOptions() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setOptions(List.of("One", "Two"));
List<String> allowed = FormUtils.collectChoiceAllowedValues(combo);
assertTrue(allowed.contains("One"));
assertTrue(allowed.contains("Two"));
}
}
}
// ----------------------------------------------------------------------
// setTextValue
// ----------------------------------------------------------------------
@Nested
@DisplayName("setTextValue")
class SetTextValue {
@Test
void writesValue() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("note");
text.setDefaultAppearance("/Helv 12 Tf 0 g");
attachWidget(setup, text, new PDRectangle(20, 600, 200, 20));
FormUtils.setTextValue(text, "hello world");
assertEquals("hello world", text.getValueAsString());
}
}
@Test
void nullValueWritesEmptyString() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("note");
text.setDefaultAppearance("/Helv 12 Tf 0 g");
attachWidget(setup, text, new PDRectangle(20, 600, 200, 20));
FormUtils.setTextValue(text, null);
assertEquals("", text.getValueAsString());
}
}
}
// ----------------------------------------------------------------------
// buildFillTemplateRecord (choice branches not covered elsewhere)
// ----------------------------------------------------------------------
@Nested
@DisplayName("buildFillTemplateRecord")
class BuildFillTemplateRecord {
@Test
void comboBoxUsesCurrentValue() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"color", "Color", "combobox", "Red", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals("Red", result.get("color"));
}
@Test
void singleSelectListBoxUsesValue() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"list", "List", "listbox", "Item1", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals("Item1", result.get("list"));
}
@Test
void multiSelectListBoxUsesEmptyArray() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"list", "List", "listbox", "Item1", null, false, 0, true, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
Object value = result.get("list");
assertTrue(value instanceof List<?>);
assertTrue(((List<?>) value).isEmpty());
}
@Test
void nullValueDefaultsToEmptyString() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"name", "Name", "text", null, null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals("", result.get("name"));
}
@Test
void entriesWithBlankNamesAreSkipped() {
FormUtils.FormFieldInfo blank =
new FormUtils.FormFieldInfo(
" ", "Blank", "text", "x", null, false, 0, false, null, 0);
FormUtils.FormFieldInfo good =
new FormUtils.FormFieldInfo(
"kept", "Kept", "text", "x", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(blank, good));
assertEquals(1, result.size());
assertTrue(result.containsKey("kept"));
}
}
// ----------------------------------------------------------------------
// buildAnnotationPageMap
// ----------------------------------------------------------------------
@Nested
@DisplayName("buildAnnotationPageMap")
class BuildAnnotationPageMap {
@Test
void nullDocumentReturnsEmpty() {
assertTrue(FormUtils.buildAnnotationPageMap(null).isEmpty());
}
@Test
void emptyDocumentReturnsEmpty() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertTrue(FormUtils.buildAnnotationPageMap(doc).isEmpty());
}
}
@Test
void mapsWidgetToItsPageIndex() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("a");
attachWidget(setup, text, new PDRectangle(10, 10, 100, 20));
Map<COSDictionary, Integer> map = FormUtils.buildAnnotationPageMap(doc);
assertEquals(1, map.size());
assertTrue(map.containsValue(0));
}
}
}
// ----------------------------------------------------------------------
// extractFormFieldsWithCoordinates
// ----------------------------------------------------------------------
@Nested
@DisplayName("extractFormFieldsWithCoordinates")
class ExtractFormFieldsWithCoordinates {
@Test
void nullDocumentReturnsEmpty() {
assertTrue(FormUtils.extractFormFieldsWithCoordinates(null).isEmpty());
}
@Test
void noAcroFormReturnsEmpty() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertTrue(FormUtils.extractFormFieldsWithCoordinates(doc).isEmpty());
}
}
@Test
void textFieldProducesWidgetCoordinates() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("firstName");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<stirling.software.common.model.FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
stirling.software.common.model.FormFieldWithCoordinates field = fields.get(0);
assertEquals("firstName", field.getName());
assertEquals("text", field.getType());
assertNotNull(field.getWidgets());
assertEquals(1, field.getWidgets().size());
stirling.software.common.model.FormFieldWithCoordinates.WidgetCoordinates wc =
field.getWidgets().get(0);
assertEquals(0, wc.getPageIndex());
// x is relative to crop-box origin (0 here), so it equals the lower-left x.
assertEquals(50f, wc.getX(), 0.01f);
assertEquals(200f, wc.getWidth(), 0.01f);
assertEquals(20f, wc.getHeight(), 0.01f);
}
}
@Test
void multipleFieldsAreSortedTopToBottom() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField lower = new PDTextField(setup.acroForm());
lower.setPartialName("lower");
attachWidget(setup, lower, new PDRectangle(50, 100, 200, 20));
PDTextField upper = new PDTextField(setup.acroForm());
upper.setPartialName("upper");
attachWidget(setup, upper, new PDRectangle(50, 700, 200, 20));
List<stirling.software.common.model.FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(2, fields.size());
// The widget higher on the page (smaller CSS-y after flip) sorts first.
assertEquals("upper", fields.get(0).getName());
assertEquals("lower", fields.get(1).getName());
}
}
}
// ----------------------------------------------------------------------
// repairMissingWidgetPageReferences
// ----------------------------------------------------------------------
@Nested
@DisplayName("repairMissingWidgetPageReferences")
class RepairMissingWidgetPageReferences {
@Test
void nullDocumentDoesNotThrow() {
FormUtils.repairMissingWidgetPageReferences(null);
}
@Test
void documentWithoutAcroFormDoesNotThrow() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
FormUtils.repairMissingWidgetPageReferences(doc);
}
}
@Test
void setsPageReferenceForOrphanWidget() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("orphan");
// Build a widget that is on the page's annotation list but has no /P page ref.
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(new PDRectangle(10, 10, 100, 20));
List<PDAnnotationWidget> widgets = new ArrayList<>(text.getWidgets());
widgets.add(widget);
text.setWidgets(widgets);
setup.acroForm().getFields().add(text);
setup.page().getAnnotations().add(widget);
assertNull(widget.getPage());
FormUtils.repairMissingWidgetPageReferences(doc);
assertNotNull(widget.getPage());
}
}
}
// ----------------------------------------------------------------------
// deleteFormFields
// ----------------------------------------------------------------------
@Nested
@DisplayName("deleteFormFields")
class DeleteFormFields {
@Test
void nullDocumentIsNoOp() {
FormUtils.deleteFormFields(null, List.of("a"));
}
@Test
void nullNamesIsNoOp() throws IOException {
try (PDDocument doc = new PDDocument()) {
createBasicDocument(doc);
FormUtils.deleteFormFields(doc, null);
}
}
@Test
void emptyNamesIsNoOp() throws IOException {
try (PDDocument doc = new PDDocument()) {
createBasicDocument(doc);
FormUtils.deleteFormFields(doc, List.of());
}
}
@Test
void removesNamedField() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField keep = new PDTextField(setup.acroForm());
keep.setPartialName("keep");
attachWidget(setup, keep, new PDRectangle(50, 700, 200, 20));
PDTextField remove = new PDTextField(setup.acroForm());
remove.setPartialName("remove");
attachWidget(setup, remove, new PDRectangle(50, 660, 200, 20));
FormUtils.deleteFormFields(doc, List.of("remove"));
List<FormUtils.FormFieldInfo> remaining = FormUtils.extractFormFields(doc);
assertEquals(1, remaining.size());
assertEquals("keep", remaining.get(0).name());
}
}
@Test
void unknownFieldNameIsIgnored() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField keep = new PDTextField(setup.acroForm());
keep.setPartialName("keep");
attachWidget(setup, keep, new PDRectangle(50, 700, 200, 20));
FormUtils.deleteFormFields(doc, List.of("doesNotExist", " ", "keep"));
assertTrue(FormUtils.extractFormFields(doc).isEmpty());
}
}
}
// ----------------------------------------------------------------------
// modifyFormFields
// ----------------------------------------------------------------------
@Nested
@DisplayName("modifyFormFields")
class ModifyFormFields {
@Test
void nullDocumentIsNoOp() {
FormUtils.modifyFormFields(null, List.of());
}
@Test
void nullModificationsIsNoOp() throws IOException {
try (PDDocument doc = new PDDocument()) {
createBasicDocument(doc);
FormUtils.modifyFormFields(doc, null);
}
}
@Test
void emptyModificationsIsNoOp() throws IOException {
try (PDDocument doc = new PDDocument()) {
createBasicDocument(doc);
FormUtils.modifyFormFields(doc, List.of());
}
}
@Test
void inPlaceRenameAndLabelUpdate() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("oldName");
text.setDefaultAppearance("/Helv 12 Tf 0 g");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"oldName",
"newName",
"New Label",
null, // keep type (text) -> in-place path
Boolean.TRUE,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("newName", fields.get(0).name());
assertEquals("New Label", fields.get(0).label());
assertTrue(fields.get(0).required());
}
}
@Test
void unknownTargetIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("present");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"missing", null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
// Untouched field remains.
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("present", fields.get(0).name());
}
}
@Test
void nullEntriesAndBlankTargetsAreSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("present");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormUtils.ModifyFormFieldDefinition> mods = new ArrayList<>();
mods.add(null);
mods.add(
new FormUtils.ModifyFormFieldDefinition(
" ", null, null, null, null, null, null, null, null));
FormUtils.modifyFormFields(doc, mods);
assertEquals(1, FormUtils.extractFormFields(doc).size());
}
}
}
// ----------------------------------------------------------------------
// pruneOrphanedFormFields
// ----------------------------------------------------------------------
@Nested
@DisplayName("pruneOrphanedFormFields")
class PruneOrphanedFormFields {
@Test
void nullDocumentIsNoOp() {
FormUtils.pruneOrphanedFormFields(null);
}
@Test
void documentWithoutAcroFormIsNoOp() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
FormUtils.pruneOrphanedFormFields(doc);
assertNull(doc.getDocumentCatalog().getAcroForm(null));
}
}
@Test
void keepsFieldsWithLiveWidgets() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("live");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.pruneOrphanedFormFields(doc);
PDAcroForm form = doc.getDocumentCatalog().getAcroForm(null);
assertNotNull(form);
assertEquals(1, form.getFields().size());
}
}
@Test
void dropsAcroFormWhenAllWidgetsOrphaned() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("orphan");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
// Remove the widget from the page so it is no longer "live".
setup.page().getAnnotations().clear();
FormUtils.pruneOrphanedFormFields(doc);
assertNull(doc.getDocumentCatalog().getAcroForm(null));
}
}
}
// ----------------------------------------------------------------------
// hasAnyRotatedPage (rotated branch)
// ----------------------------------------------------------------------
@Nested
@DisplayName("hasAnyRotatedPage")
class HasAnyRotatedPage {
@Test
void rotatedPageDetected() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
page.setRotation(90);
doc.addPage(page);
assertTrue(FormUtils.hasAnyRotatedPage(doc));
}
}
@Test
void unrotatedPageNotDetected() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertFalse(FormUtils.hasAnyRotatedPage(doc));
}
}
}
}
@@ -0,0 +1,333 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
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.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
/**
* Gap-coverage tests for {@link GeneralUtils}. Targets the public methods NOT already exercised by
* {@code GeneralUtilsAdditionalTest} (size/url/version/uuid) or {@code GeneralUtilsTest} (filename
* helpers, parsePageList basics, saveKeyToSettings): namely {@code generateFilename}, {@code
* convertToFileName}, {@code evaluateNFunc}, the n-function {@code parsePageList} path, {@code
* createDir}/{@code deleteDirectory}, multipart conversion, the {@code updateSettingsTransactional}
* early-return guards, {@code getResourcesFromLocationPattern}, and the environment helpers {@code
* generateMachineFingerprint}/{@code getLocalNetworkIp}.
*/
class GeneralUtilsGapTest {
@Nested
@DisplayName("generateFilename")
class GenerateFilenameTests {
@Test
@DisplayName("removes extension then appends suffix")
void removesAndAppends() {
assertEquals(
"report_out.pdf", GeneralUtils.generateFilename("report.docx", "_out.pdf"));
}
@Test
@DisplayName("null filename uses default base")
void nullFilename() {
assertEquals("default_out.pdf", GeneralUtils.generateFilename(null, "_out.pdf"));
}
@Test
@DisplayName("filename without extension is preserved")
void noExtension() {
assertEquals("README_x", GeneralUtils.generateFilename("README", "_x"));
}
}
@Nested
@DisplayName("convertToFileName")
class ConvertToFileNameTests {
@Test
@DisplayName("null returns underscore")
void nullReturnsUnderscore() {
assertEquals("_", GeneralUtils.convertToFileName(null));
}
@Test
@DisplayName("keeps letters and digits, replaces others with underscore")
void replacesUnsafeChars() {
assertEquals("my_file_2024_", GeneralUtils.convertToFileName("my file/2024!"));
}
@Test
@DisplayName("alphanumeric input is unchanged")
void alphanumericUnchanged() {
assertEquals("File123", GeneralUtils.convertToFileName("File123"));
}
@Test
@DisplayName("truncates to 50 characters")
void truncatesToFifty() {
String input = "a".repeat(100);
assertEquals(50, GeneralUtils.convertToFileName(input).length());
}
}
@Nested
@DisplayName("evaluateNFunc")
class EvaluateNFuncTests {
@Test
@DisplayName("null expression throws")
void nullThrows() {
assertThrows(
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc(null, 10));
}
@Test
@DisplayName("blank expression throws")
void blankThrows() {
assertThrows(
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc(" ", 10));
}
@Test
@DisplayName("maxValue below 1 throws")
void maxValueTooLow() {
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc("n", 0));
}
@Test
@DisplayName("maxValue above 10000 throws")
void maxValueTooHigh() {
assertThrows(
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc("n", 10001));
}
@Test
@DisplayName("invalid characters throw")
void invalidCharsThrow() {
assertThrows(
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc("n$", 10));
}
@Test
@DisplayName("identity 'n' yields all pages up to maxValue")
void identity() {
assertEquals(List.of(1, 2, 3, 4, 5), GeneralUtils.evaluateNFunc("n", 5));
}
@Test
@DisplayName("2n yields even values within bounds")
void doubling() {
assertEquals(List.of(2, 4, 6), GeneralUtils.evaluateNFunc("2n", 6));
}
@Test
@DisplayName("implicit multiplication 'n(n-1)' is handled")
void implicitMultiplication() {
// n*(n-1): n=1->0(excluded), n=2->2, n=3->6 ; capped at maxValue 6
assertEquals(List.of(2, 6), GeneralUtils.evaluateNFunc("n(n-1)", 6));
}
@Test
@DisplayName("results outside (0, maxValue] are excluded")
void boundsExcluded() {
// n+10 always exceeds maxValue 5 -> empty
assertTrue(GeneralUtils.evaluateNFunc("n+10", 5).isEmpty());
}
}
@Nested
@DisplayName("parsePageList n-function path")
class ParsePageListNFuncTests {
@Test
@DisplayName("n-function token expands to matching one-based pages")
void nFunctionOneBased() {
// 2n for total 6 (one-based) -> values 2,4,6 mapped to (v-1+1)=v
assertEquals(List.of(2, 4, 6), GeneralUtils.parsePageList("2n", 6, true));
}
@Test
@DisplayName("n-function token zero-based subtracts one")
void nFunctionZeroBased() {
// 2n for total 6 zero-based -> values 2,4,6 mapped to (v-1+0)=v-1
assertEquals(List.of(1, 3, 5), GeneralUtils.parsePageList("2n", 6, false));
}
}
@Nested
@DisplayName("createDir and deleteDirectory")
class DirectoryTests {
@Test
@DisplayName("createDir makes a nested directory and returns true")
void createNested(@TempDir Path tempDir) {
Path nested = tempDir.resolve("a").resolve("b").resolve("c");
assertTrue(GeneralUtils.createDir(nested.toString()));
assertTrue(Files.isDirectory(nested));
}
@Test
@DisplayName("createDir returns true when directory already exists")
void createExisting(@TempDir Path tempDir) {
assertTrue(GeneralUtils.createDir(tempDir.toString()));
}
@Test
@DisplayName("deleteDirectory removes a populated tree without touching siblings")
void deletePopulatedTree(@TempDir Path tempDir) throws IOException {
Path sibling = tempDir.resolve("sibling");
Files.createDirectories(sibling);
Files.writeString(sibling.resolve("keep.txt"), "data");
Path root = tempDir.resolve("root");
Files.createDirectories(root.resolve("nested"));
Files.writeString(root.resolve("a.txt"), "x");
Files.writeString(root.resolve("nested").resolve("b.txt"), "y");
GeneralUtils.deleteDirectory(root);
assertFalse(Files.exists(root));
assertTrue(Files.exists(sibling.resolve("keep.txt")));
}
}
@Nested
@DisplayName("multipart conversion")
class MultipartTests {
@Test
@DisplayName("convertMultipartFileToFile writes content to a temp file")
void convertWritesContent() throws IOException {
byte[] content = "hello world".getBytes(StandardCharsets.UTF_8);
MultipartFile mf =
new MockMultipartFile("file", "input.bin", "application/octet-stream", content);
File out = GeneralUtils.convertMultipartFileToFile(mf);
try {
assertTrue(out.exists());
assertArrayEquals(content, Files.readAllBytes(out.toPath()));
} finally {
Files.deleteIfExists(out.toPath());
}
}
@Test
@DisplayName("convertMultipartFileToFile handles empty input")
void convertEmpty() throws IOException {
MultipartFile mf =
new MockMultipartFile(
"file", "empty.bin", "application/octet-stream", new byte[0]);
File out = GeneralUtils.convertMultipartFileToFile(mf);
try {
assertTrue(out.exists());
assertEquals(0, out.length());
} finally {
Files.deleteIfExists(out.toPath());
}
}
@Test
@DisplayName("multipartToFile writes content to a .pdf temp file")
void multipartToFileWritesContent() throws IOException {
byte[] content = "%PDF-1.7 minimal".getBytes(StandardCharsets.UTF_8);
MultipartFile mf = new MockMultipartFile("file", "doc.pdf", "application/pdf", content);
File out = GeneralUtils.multipartToFile(mf);
try {
assertTrue(out.exists());
assertTrue(out.getName().endsWith(".pdf"));
assertArrayEquals(content, Files.readAllBytes(out.toPath()));
} finally {
Files.deleteIfExists(out.toPath());
}
}
}
@Nested
@DisplayName("getResourcesFromLocationPattern")
class ResourcePatternTests {
@Test
@DisplayName("file: pattern resolves matching files in a directory")
void filePatternResolves(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("one.txt"), "1");
Files.writeString(tempDir.resolve("two.txt"), "2");
String pattern = "file:" + tempDir.toString().replace("\\", "/") + "/*";
Resource[] resources =
GeneralUtils.getResourcesFromLocationPattern(
pattern, new DefaultResourceLoader());
assertNotNull(resources);
assertEquals(2, resources.length);
}
@Test
@DisplayName("classpath pattern with no matches returns an empty array")
void classpathNoMatches() throws Exception {
Resource[] resources =
GeneralUtils.getResourcesFromLocationPattern(
"classpath*:this/path/does/not/exist/**/*.nope",
new DefaultResourceLoader());
assertNotNull(resources);
assertEquals(0, resources.length);
}
}
@Nested
@DisplayName("updateSettingsTransactional early-return guards")
class SettingsGuardTests {
@Test
@DisplayName("null map returns without throwing")
void nullMapNoOp() {
assertDoesNotThrow(() -> GeneralUtils.updateSettingsTransactional(null));
}
@Test
@DisplayName("empty map returns without throwing")
void emptyMapNoOp() {
assertDoesNotThrow(() -> GeneralUtils.updateSettingsTransactional(Map.of()));
}
}
@Nested
@DisplayName("environment-dependent helpers")
class EnvironmentHelperTests {
@Test
@DisplayName("generateMachineFingerprint returns a non-blank, deterministic value")
void fingerprintStable() {
String first = GeneralUtils.generateMachineFingerprint();
assertNotNull(first);
assertFalse(first.isBlank());
// Deterministic within the same JVM/host
assertEquals(first, GeneralUtils.generateMachineFingerprint());
}
@Test
@DisplayName("getLocalNetworkIp returns null or a dotted IPv4 string")
void localIpFormat() {
String ip = GeneralUtils.getLocalNetworkIp();
if (ip != null) {
assertTrue(ip.matches("\\d{1,3}(\\.\\d{1,3}){3}"), "unexpected IP form: " + ip);
}
}
}
}
@@ -0,0 +1,446 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
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 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.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PdfAttachmentHandlerGapTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
// ----- helpers -------------------------------------------------------
/** Builds a tiny one-page PDF whose page renders the given text lines, each on its own line. */
private static byte[] pdfWithLines(String... lines) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
float y = 720f;
for (String line : lines) {
cs.beginText();
cs.newLineAtOffset(72f, y);
cs.showText(line);
cs.endText();
y -= 20f;
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
/** Builds a blank one-page PDF with no text. */
private static byte[] blankPdf() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static EmlParser.EmailAttachment attachment(String filename, byte[] data) {
EmlParser.EmailAttachment a = new EmlParser.EmailAttachment();
a.setFilename(filename);
a.setData(data);
a.setContentType("application/pdf");
return a;
}
private static List<String> embeddedFileNames(byte[] pdfBytes) throws Exception {
List<String> names = new ArrayList<>();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDDocumentNameDictionary docNames = doc.getDocumentCatalog().getNames();
if (docNames == null) {
return names;
}
PDEmbeddedFilesNameTreeNode tree = docNames.getEmbeddedFiles();
if (tree == null) {
return names;
}
Map<String, PDComplexFileSpecification> map = tree.getNames();
if (map != null) {
names.addAll(map.keySet());
}
}
return names;
}
// ----- attachFilesToPdf: short-circuit branches ----------------------
@Nested
@DisplayName("attachFilesToPdf short-circuit handling")
class ShortCircuitTests {
@Test
@DisplayName("null attachment list returns the original bytes untouched")
void nullAttachments_returnsOriginalBytes() throws Exception {
byte[] original = {1, 2, 3, 4};
byte[] result =
PdfAttachmentHandler.attachFilesToPdf(original, null, pdfDocumentFactory);
assertSame(original, result);
verifyNoInteractions(pdfDocumentFactory);
}
@Test
@DisplayName("empty attachment list returns the original bytes untouched")
void emptyAttachments_returnsOriginalBytes() throws Exception {
byte[] original = {9, 8, 7};
byte[] result =
PdfAttachmentHandler.attachFilesToPdf(
original, new ArrayList<>(), pdfDocumentFactory);
assertSame(original, result);
verifyNoInteractions(pdfDocumentFactory);
}
@Test
@DisplayName("attachments with no usable data are skipped and a clean PDF is returned")
void attachmentsWithoutData_produceNoEmbeddedFiles() throws Exception {
byte[] pdfBytes = blankPdf();
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
attachments.add(attachment("empty.pdf", new byte[0]));
attachments.add(attachment("alsoEmpty.pdf", null));
byte[] result =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, attachments, pdfDocumentFactory);
assertNotNull(result);
assertTrue(result.length > 0);
assertTrue(embeddedFileNames(result).isEmpty());
}
}
// ----- attachFilesToPdf: embedding happy paths -----------------------
@Nested
@DisplayName("attachFilesToPdf embedding behaviour")
class EmbeddingTests {
@Test
@DisplayName("embeds attachment data even when no '@' marker exists in the PDF text")
void embedsAttachment_withoutMarker() throws Exception {
byte[] pdfBytes = pdfWithLines("Just a plain document with no attachment markers");
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
attachments.add(attachment("report.pdf", "hello".getBytes(StandardCharsets.UTF_8)));
byte[] result =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, attachments, pdfDocumentFactory);
List<String> embedded = embeddedFileNames(result);
assertEquals(1, embedded.size());
assertTrue(embedded.contains("report.pdf"));
}
@Test
@DisplayName("embeds attachment and adds an annotation when an '@' marker matches")
void embedsAttachment_withMatchingMarker() throws Exception {
byte[] pdfBytes =
pdfWithLines("Email body text here", "Attachments (1)", "@report.pdf (5 KB)");
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
attachments.add(attachment("report.pdf", "PDFDATA".getBytes(StandardCharsets.UTF_8)));
byte[] result =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, attachments, pdfDocumentFactory);
List<String> embedded = embeddedFileNames(result);
assertTrue(embedded.contains("report.pdf"));
// The annotation pass should have run and produced at least one annotation on the
// page that contains the marker (a blank source page has none).
try (PDDocument doc = Loader.loadPDF(result)) {
assertFalse(doc.getPage(0).getAnnotations().isEmpty());
}
}
@Test
@DisplayName("attachment without a filename falls back to a generated embedded name")
void embedsAttachment_withGeneratedName() throws Exception {
byte[] pdfBytes = blankPdf();
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
EmlParser.EmailAttachment a = new EmlParser.EmailAttachment();
a.setFilename(null);
a.setData("x".getBytes(StandardCharsets.UTF_8));
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
attachments.add(a);
byte[] result =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, attachments, pdfDocumentFactory);
// A single embedded file should exist with a non-blank generated name.
List<String> embedded = embeddedFileNames(result);
assertEquals(1, embedded.size());
assertFalse(embedded.get(0).isBlank());
}
@Test
@DisplayName("duplicate attachment filenames produce uniquely named embedded files")
void embedsAttachments_withDuplicateNames() throws Exception {
byte[] pdfBytes = blankPdf();
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
attachments.add(attachment("dup.pdf", "a".getBytes(StandardCharsets.UTF_8)));
attachments.add(attachment("dup.pdf", "b".getBytes(StandardCharsets.UTF_8)));
byte[] result =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, attachments, pdfDocumentFactory);
List<String> embedded = embeddedFileNames(result);
assertEquals(2, embedded.size());
assertTrue(embedded.contains("dup.pdf"));
// The second one must have been disambiguated, not overwritten.
assertTrue(embedded.stream().anyMatch(n -> !"dup.pdf".equals(n)));
}
}
// ----- attachFilesToPdf: error wrapping ------------------------------
@Nested
@DisplayName("attachFilesToPdf error handling")
class ErrorHandlingTests {
@Test
@DisplayName("IOException from the factory load propagates to the caller")
void factoryIOException_propagates() throws Exception {
byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
when(pdfDocumentFactory.load(pdfBytes))
.thenThrow(new java.io.IOException("boom from factory"));
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
attachments.add(attachment("a.pdf", "data".getBytes(StandardCharsets.UTF_8)));
java.io.IOException ex =
assertThrows(
java.io.IOException.class,
() ->
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, attachments, pdfDocumentFactory));
assertTrue(ex.getMessage().contains("boom from factory"));
}
}
// ----- AttachmentMarkerPositionFinder --------------------------------
@Nested
@DisplayName("AttachmentMarkerPositionFinder")
class MarkerFinderTests {
@Test
@DisplayName("finds marker positions inside an attachments section")
void findsMarkerPositions() throws Exception {
byte[] pdfBytes =
pdfWithLines(
"Some intro text",
"Attachments (2)",
"@invoice.pdf (10 KB)",
"@photo.png (4 KB)");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PdfAttachmentHandler.AttachmentMarkerPositionFinder finder =
new PdfAttachmentHandler.AttachmentMarkerPositionFinder();
finder.setSortByPosition(false);
String returned = finder.getText(doc);
// getText is overridden to return an empty string (positions are the payload).
assertEquals("", returned);
List<PdfAttachmentHandler.MarkerPosition> positions = finder.getPositions();
assertEquals(2, positions.size());
List<String> filenames =
positions.stream()
.map(PdfAttachmentHandler.MarkerPosition::getFilename)
.toList();
assertTrue(filenames.contains("invoice.pdf"));
assertTrue(filenames.contains("photo.png"));
for (PdfAttachmentHandler.MarkerPosition p : positions) {
assertEquals("@", p.getCharacter());
assertEquals(0, p.getPageIndex());
}
}
}
@Test
@DisplayName("collects no positions when there is no attachments section")
void noAttachmentSection_noPositions() throws Exception {
byte[] pdfBytes =
pdfWithLines("Plain email body", "Contact us @ support address", "Goodbye");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PdfAttachmentHandler.AttachmentMarkerPositionFinder finder =
new PdfAttachmentHandler.AttachmentMarkerPositionFinder();
finder.getText(doc);
assertTrue(finder.getPositions().isEmpty());
}
}
@Test
@DisplayName("sortByPosition reorders collected positions deterministically")
void sortByPosition_sortsPositions() throws Exception {
byte[] pdfBytes =
pdfWithLines("Attachments (2)", "@first.pdf (1 KB)", "@second.pdf (2 KB)");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PdfAttachmentHandler.AttachmentMarkerPositionFinder finder =
new PdfAttachmentHandler.AttachmentMarkerPositionFinder();
finder.setSortByPosition(true);
finder.getText(doc);
List<PdfAttachmentHandler.MarkerPosition> positions = finder.getPositions();
assertEquals(2, positions.size());
// With descending-Y sorting and same page, the higher-on-page marker comes first.
assertTrue(positions.get(0).getY() >= positions.get(1).getY());
}
}
}
// ----- processInlineImages -------------------------------------------
@Nested
@DisplayName("processInlineImages")
class ProcessInlineImagesTests {
@Test
@DisplayName("replaces a cid: reference with an inline base64 data URI")
void replacesCidWithDataUri() {
byte[] imageData = {(byte) 0x89, 'P', 'N', 'G'};
EmlParser.EmailAttachment img = new EmlParser.EmailAttachment();
img.setEmbedded(true);
img.setContentId("img001");
img.setFilename("pic.png");
img.setContentType("image/png");
img.setData(imageData);
EmlParser.EmailContent content = new EmlParser.EmailContent();
List<EmlParser.EmailAttachment> list = new ArrayList<>();
list.add(img);
content.setAttachments(list);
String html = "<html><body><img src=\"cid:img001\"/></body></html>";
String result = PdfAttachmentHandler.processInlineImages(html, content);
String expectedB64 = Base64.getEncoder().encodeToString(imageData);
assertTrue(result.contains("data:image/png;base64," + expectedB64));
assertFalse(result.contains("cid:img001"));
}
@Test
@DisplayName("leaves a cid: reference untouched when no attachment matches it")
void unmatchedCid_isUnchanged() {
EmlParser.EmailAttachment img = new EmlParser.EmailAttachment();
img.setEmbedded(true);
img.setContentId("known");
img.setFilename("known.png");
img.setContentType("image/png");
img.setData(new byte[] {1, 2, 3});
EmlParser.EmailContent content = new EmlParser.EmailContent();
List<EmlParser.EmailAttachment> list = new ArrayList<>();
list.add(img);
content.setAttachments(list);
String html = "<img src=\"cid:unknown\"/>";
String result = PdfAttachmentHandler.processInlineImages(html, content);
// The unknown cid reference is preserved verbatim.
assertTrue(result.contains("cid:unknown"));
}
@Test
@DisplayName("returns original html when there are no embedded images to map")
void noEmbeddedImages_returnsOriginal() {
EmlParser.EmailAttachment nonEmbedded = new EmlParser.EmailAttachment();
nonEmbedded.setEmbedded(false);
nonEmbedded.setContentId("x");
nonEmbedded.setData(new byte[] {1});
EmlParser.EmailContent content = new EmlParser.EmailContent();
List<EmlParser.EmailAttachment> list = new ArrayList<>();
list.add(nonEmbedded);
content.setAttachments(list);
String html = "<img src=\"cid:x\"/>";
assertEquals(html, PdfAttachmentHandler.processInlineImages(html, content));
}
}
// ----- formatEmailDate (deterministic UTC) ---------------------------
@Nested
@DisplayName("formatEmailDate determinism")
class FormatEmailDateTests {
@Test
@DisplayName("a known instant formats to a stable UTC string regardless of input zone")
void zonedDateTime_formatsToUtc() {
// 2024-06-15 12:00 in Tokyo is 03:00 UTC the same day.
ZonedDateTime tokyo =
ZonedDateTime.of(2024, 6, 15, 12, 0, 0, 0, ZoneId.of("Asia/Tokyo"));
String result = PdfAttachmentHandler.formatEmailDate(tokyo);
assertEquals("Sat, Jun 15, 2024 at 3:00 AM UTC", result);
}
@Test
@DisplayName("Date overload converts a fixed epoch instant to the expected UTC string")
void date_formatsToUtc() {
// Epoch milli 0 == 1970-01-01T00:00:00Z.
String result = PdfAttachmentHandler.formatEmailDate(new Date(0L));
assertEquals("Thu, Jan 1, 1970 at 12:00 AM UTC", result);
}
@Test
@DisplayName("null inputs yield an empty string for both overloads")
void nullInputs_returnEmpty() {
assertEquals("", PdfAttachmentHandler.formatEmailDate((Date) null));
assertEquals("", PdfAttachmentHandler.formatEmailDate((ZonedDateTime) null));
}
}
}
@@ -0,0 +1,551 @@
package stirling.software.common.util;
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.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
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.graphics.image.PDImageXObject;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Additional unit tests for {@link PdfUtils} targeting methods not exercised by {@code
* PdfUtilsTest}: convertFromPdf, convertPdfToPdfImage, imageToPdf, addImageToDocument,
* overlayImage, containsTextInFile and the error branch of pageSize.
*/
@ExtendWith(MockitoExtension.class)
class PdfUtilsGapTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
// ---- helpers ------------------------------------------------------------
/** Builds a tiny single-page PDF and returns it serialized to bytes. */
private static byte[] simplePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
/** Builds a PDF with the given number of empty A4 pages. */
private static PDDocument docWithPages(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
/**
* Builds a PDF with the given number of tiny pages. convertPdfToPdfImage rasterises every page
* at 300 DPI, so page area drives the cost; tiny pages keep render work minimal while still
* exercising the per-page loop. Page size is non-square so dimension preservation stays
* verifiable.
*/
private static PDDocument docWithTinyPages(int pages, float width, float height) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(new PDRectangle(width, height)));
}
return doc;
}
/** Builds a PDF whose pages each contain the given text phrase. */
private static PDDocument docWithText(String... pageTexts) throws IOException {
PDDocument doc = new PDDocument();
for (String text : pageTexts) {
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(100, 700);
cs.showText(text);
cs.endText();
}
}
return doc;
}
/** Encodes a small solid-color image to bytes in the requested format. */
private static byte[] imageBytes(String format, Color color) throws IOException {
BufferedImage img = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(color);
g.fillRect(0, 0, 20, 20);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, format, baos);
return baos.toByteArray();
}
// ---- convertFromPdf -----------------------------------------------------
@Nested
@DisplayName("convertFromPdf")
class ConvertFromPdf {
@Test
@DisplayName("single PNG image is produced from a one-page PDF")
void singlePng() throws Exception {
byte[] bytes = simplePdfBytes();
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(1));
byte[] out =
PdfUtils.convertFromPdf(
pdfDocumentFactory, bytes, "png", ImageType.RGB, true, 72, "doc", true);
assertNotNull(out);
assertTrue(out.length > 0);
// A valid PNG starts with the 8-byte PNG signature.
assertEquals((byte) 0x89, out[0]);
assertEquals('P', out[1]);
assertEquals('N', out[2]);
assertEquals('G', out[3]);
}
@Test
@DisplayName("single combined JPEG image is produced for multi-page PDF")
void singleJpegMultiPage() throws Exception {
byte[] bytes = simplePdfBytes();
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(2));
byte[] out =
PdfUtils.convertFromPdf(
pdfDocumentFactory,
bytes,
"jpg",
ImageType.RGB,
true,
72,
"doc",
false);
assertNotNull(out);
assertTrue(out.length > 0);
// JPEG magic bytes.
assertEquals((byte) 0xFF, out[0]);
assertEquals((byte) 0xD8, out[1]);
}
@Test
@DisplayName("single TIFF image sequence is produced for multi-page PDF")
void singleTiffMultiPage() throws Exception {
byte[] bytes = simplePdfBytes();
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(2));
byte[] out =
PdfUtils.convertFromPdf(
pdfDocumentFactory,
bytes,
"tiff",
ImageType.RGB,
true,
72,
"doc",
true);
assertNotNull(out);
assertTrue(out.length > 0);
}
@Test
@DisplayName("non-single image mode returns a non-empty zip of per-page images")
void zipOfImages() throws Exception {
byte[] bytes = simplePdfBytes();
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(2));
byte[] out =
PdfUtils.convertFromPdf(
pdfDocumentFactory,
bytes,
"png",
ImageType.RGB,
false,
72,
"myfile",
true);
assertNotNull(out);
assertTrue(out.length > 0);
// ZIP local-file-header magic "PK\003\004".
assertEquals('P', out[0]);
assertEquals('K', out[1]);
}
@Test
@DisplayName("DPI above the safe limit throws IllegalArgumentException")
void dpiTooHighThrows() {
byte[] bytes = new byte[] {1, 2, 3};
// The DPI check happens before the document is loaded.
assertThrows(
IllegalArgumentException.class,
() ->
PdfUtils.convertFromPdf(
pdfDocumentFactory,
bytes,
"png",
ImageType.RGB,
true,
9999,
"doc",
true));
}
@Test
@DisplayName("annotations excluded path still renders successfully")
void withoutAnnotations() throws Exception {
byte[] bytes = simplePdfBytes();
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(1));
byte[] out =
PdfUtils.convertFromPdf(
pdfDocumentFactory,
bytes,
"png",
ImageType.RGB,
true,
72,
"doc",
false);
assertNotNull(out);
assertTrue(out.length > 0);
}
}
// ---- convertPdfToPdfImage -----------------------------------------------
@Nested
@DisplayName("convertPdfToPdfImage")
class ConvertPdfToPdfImage {
@Test
@DisplayName("returns a new document with the same page count")
void preservesPageCount() throws IOException {
// Page size is irrelevant to the count assertion; tiny pages avoid a 300 DPI A4 raster.
try (PDDocument source = docWithTinyPages(2, 6f, 9f);
PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
assertNotNull(result);
assertEquals(2, result.getNumberOfPages());
}
}
@Test
@DisplayName("preserves page dimensions of the source")
void preservesPageSize() throws IOException {
// A small non-square page still proves width/height are carried through (and not
// swapped) without rastering a full LETTER page at 300 DPI.
float width = 60f;
float height = 90f;
try (PDDocument source = new PDDocument()) {
source.addPage(new PDPage(new PDRectangle(width, height)));
try (PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
PDRectangle box = result.getPage(0).getMediaBox();
assertEquals(width, box.getWidth(), 0.5f);
assertEquals(height, box.getHeight(), 0.5f);
}
}
}
@Test
@DisplayName("empty document yields an empty document")
void emptyDocument() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
assertEquals(0, result.getNumberOfPages());
}
}
}
// ---- imageToPdf ---------------------------------------------------------
@Nested
@DisplayName("imageToPdf")
class ImageToPdf {
private byte[] runImageToPdf(MultipartFile[] files, String fitOption, boolean autoRotate)
throws IOException {
when(pdfDocumentFactory.createNewDocument()).thenReturn(new PDDocument());
return PdfUtils.imageToPdf(files, fitOption, autoRotate, "color", pdfDocumentFactory);
}
@Test
@DisplayName("single PNG image becomes a one-page PDF")
void singlePngImage() throws IOException {
MockMultipartFile file =
new MockMultipartFile(
"file",
"image.png",
MediaType.IMAGE_PNG_VALUE,
imageBytes("png", Color.RED));
byte[] pdfOut = runImageToPdf(new MultipartFile[] {file}, "fillPage", false);
assertNotNull(pdfOut);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("JPEG image uses the lossy factory path and produces a PDF")
void jpegImage() throws IOException {
MockMultipartFile file =
new MockMultipartFile(
"file",
"image.jpg",
MediaType.IMAGE_JPEG_VALUE,
imageBytes("jpg", Color.BLUE));
byte[] pdfOut = runImageToPdf(new MultipartFile[] {file}, "maintainAspectRatio", false);
assertNotNull(pdfOut);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("fitDocumentToImage sizes the page to the image")
void fitDocumentToImage() throws IOException {
MockMultipartFile file =
new MockMultipartFile(
"file",
"image.png",
MediaType.IMAGE_PNG_VALUE,
imageBytes("png", Color.GREEN));
byte[] pdfOut = runImageToPdf(new MultipartFile[] {file}, "fitDocumentToImage", false);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
PDRectangle box = doc.getPage(0).getMediaBox();
assertEquals(20f, box.getWidth(), 0.5f);
assertEquals(20f, box.getHeight(), 0.5f);
}
}
@Test
@DisplayName("multiple images become multiple pages")
void multipleImages() throws IOException {
MockMultipartFile a =
new MockMultipartFile(
"file",
"a.png",
MediaType.IMAGE_PNG_VALUE,
imageBytes("png", Color.RED));
MockMultipartFile b =
new MockMultipartFile(
"file",
"b.png",
MediaType.IMAGE_PNG_VALUE,
imageBytes("png", Color.BLUE));
byte[] pdfOut = runImageToPdf(new MultipartFile[] {a, b}, "fillPage", true);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
assertEquals(2, doc.getNumberOfPages());
}
}
}
// ---- addImageToDocument -------------------------------------------------
@Nested
@DisplayName("addImageToDocument")
class AddImageToDocument {
private PDImageXObject portraitImage(PDDocument doc) throws IOException {
BufferedImage img = new BufferedImage(40, 80, BufferedImage.TYPE_INT_RGB);
return org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory.createFromImage(
doc, img);
}
private PDImageXObject landscapeImage(PDDocument doc) throws IOException {
BufferedImage img = new BufferedImage(80, 40, BufferedImage.TYPE_INT_RGB);
return org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory.createFromImage(
doc, img);
}
@Test
@DisplayName("fillPage adds an A4 page")
void fillPage() throws IOException {
try (PDDocument doc = new PDDocument()) {
PdfUtils.addImageToDocument(doc, portraitImage(doc), "fillPage", false);
assertEquals(1, doc.getNumberOfPages());
PDRectangle box = doc.getPage(0).getMediaBox();
assertEquals(PDRectangle.A4.getWidth(), box.getWidth(), 0.5f);
}
}
@Test
@DisplayName("maintainAspectRatio adds an A4 page and centers the image")
void maintainAspectRatio() throws IOException {
try (PDDocument doc = new PDDocument()) {
PdfUtils.addImageToDocument(doc, portraitImage(doc), "maintainAspectRatio", false);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("fitDocumentToImage sizes the page to the image bounds")
void fitDocumentToImage() throws IOException {
try (PDDocument doc = new PDDocument()) {
PdfUtils.addImageToDocument(doc, portraitImage(doc), "fitDocumentToImage", false);
PDRectangle box = doc.getPage(0).getMediaBox();
assertEquals(40f, box.getWidth(), 0.5f);
assertEquals(80f, box.getHeight(), 0.5f);
}
}
@Test
@DisplayName("autoRotate with a landscape image swaps to landscape A4")
void autoRotateLandscape() throws IOException {
try (PDDocument doc = new PDDocument()) {
PdfUtils.addImageToDocument(doc, landscapeImage(doc), "maintainAspectRatio", true);
PDRectangle box = doc.getPage(0).getMediaBox();
// Landscape: width should now exceed height.
assertTrue(box.getWidth() > box.getHeight());
}
}
@Test
@DisplayName("unknown fit option still adds a page without drawing")
void unknownFitOption() throws IOException {
try (PDDocument doc = new PDDocument()) {
PdfUtils.addImageToDocument(doc, portraitImage(doc), "unknownOption", false);
assertEquals(1, doc.getNumberOfPages());
}
}
}
// ---- overlayImage -------------------------------------------------------
@Nested
@DisplayName("overlayImage")
class OverlayImage {
@Test
@DisplayName("overlays only the first page when everyPage is false")
void firstPageOnly() throws IOException {
byte[] pdf = simplePdfBytes();
when(pdfDocumentFactory.load(pdf)).thenReturn(docWithPages(3));
byte[] image = imageBytes("png", Color.RED);
byte[] out = PdfUtils.overlayImage(pdfDocumentFactory, pdf, image, 10f, 10f, false);
assertNotNull(out);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
assertEquals(3, doc.getNumberOfPages());
}
}
@Test
@DisplayName("overlays every page when everyPage is true")
void everyPage() throws IOException {
byte[] pdf = simplePdfBytes();
when(pdfDocumentFactory.load(pdf)).thenReturn(docWithPages(2));
byte[] image = imageBytes("png", Color.BLUE);
byte[] out = PdfUtils.overlayImage(pdfDocumentFactory, pdf, image, 0f, 0f, true);
assertNotNull(out);
assertTrue(out.length > 0);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
assertEquals(2, doc.getNumberOfPages());
}
}
}
// ---- containsTextInFile -------------------------------------------------
@Nested
@DisplayName("containsTextInFile")
class ContainsTextInFile {
@Test
@DisplayName("finds text when searching all pages")
void allPagesMatch() throws IOException {
PDDocument doc = docWithText("HelloWorld");
assertTrue(PdfUtils.containsTextInFile(doc, "HelloWorld", "all"));
}
@Test
@DisplayName("null pagesToCheck is treated as all pages")
void nullPagesTreatedAsAll() throws IOException {
PDDocument doc = docWithText("FindThis");
assertTrue(PdfUtils.containsTextInFile(doc, "FindThis", null));
}
@Test
@DisplayName("returns false when text is absent")
void noMatch() throws IOException {
PDDocument doc = docWithText("SomeText");
assertFalse(PdfUtils.containsTextInFile(doc, "Missing", "all"));
}
@Test
@DisplayName("matches text on an individual page number")
void individualPage() throws IOException {
PDDocument doc = docWithText("PageOne", "PageTwo");
assertTrue(PdfUtils.containsTextInFile(doc, "PageTwo", "2"));
}
@Test
@DisplayName("matches text within a page range")
void pageRange() throws IOException {
PDDocument doc = docWithText("Alpha", "Beta", "Gamma");
assertTrue(PdfUtils.containsTextInFile(doc, "Gamma", "1-3"));
}
@Test
@DisplayName("whitespace in the page spec is stripped before parsing")
void whitespaceStripped() throws IOException {
PDDocument doc = docWithText("One", "Two");
assertTrue(PdfUtils.containsTextInFile(doc, "Two", " 1 , 2 "));
}
}
// ---- pageSize error branch ---------------------------------------------
@Nested
@DisplayName("pageSize parsing")
class PageSizeParsing {
@Test
@DisplayName("non-numeric expected size throws NumberFormatException")
void nonNumericThrows() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
assertThrows(
NumberFormatException.class, () -> PdfUtils.pageSize(doc, "widthxheight"));
}
}
}
}
@@ -0,0 +1,634 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
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.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.model.ApplicationProperties;
/**
* Gap-filling unit tests for {@link ProcessExecutor}. Focused on the pure logic that can be
* exercised without launching any real OS process: command validation branches, the unoserver
* endpoint helper methods (via reflection), the {@link ProcessExecutor.Processes} enum, the
* singleton/getInstance behaviour, the static unoserver pool setter, and the nested {@link
* ProcessExecutor.ProcessExecutorResult} value type.
*/
class ProcessExecutorGapTest {
// ----- reflection helpers -------------------------------------------------
private void invokeValidateCommand(ProcessExecutor executor, List<String> command)
throws Exception {
Method method = ProcessExecutor.class.getDeclaredMethod("validateCommand", List.class);
method.setAccessible(true);
try {
method.invoke(executor, command);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@SuppressWarnings("unchecked")
private List<String> invokeStripUnoEndpointArgs(ProcessExecutor executor, List<String> command)
throws Exception {
Method method = ProcessExecutor.class.getDeclaredMethod("stripUnoEndpointArgs", List.class);
method.setAccessible(true);
return (List<String>) method.invoke(executor, command);
}
@SuppressWarnings("unchecked")
private List<String> invokeApplyUnoServerEndpoint(
ProcessExecutor executor,
List<String> command,
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint)
throws Exception {
Method method =
ProcessExecutor.class.getDeclaredMethod(
"applyUnoServerEndpoint",
List.class,
ApplicationProperties.ProcessExecutor.UnoServerEndpoint.class);
method.setAccessible(true);
return (List<String>) method.invoke(executor, command, endpoint);
}
private boolean invokeShouldUseUnoServerPool(ProcessExecutor executor, List<String> command)
throws Exception {
Method method =
ProcessExecutor.class.getDeclaredMethod("shouldUseUnoServerPool", List.class);
method.setAccessible(true);
return (boolean) method.invoke(executor, command);
}
private ProcessExecutor qpdfExecutor() {
return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
}
private ProcessExecutor libreOfficeExecutor() {
return ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
}
/** The static unoserver pool is global state; clear it after every test that touches it. */
@AfterEach
void resetUnoServerPool() {
ProcessExecutor.setUnoServerPool(null);
}
// ----- validateCommand deeper branches -----------------------------------
@Nested
@DisplayName("validateCommand path/executable branches")
class ValidateCommandPathTests {
@Test
@DisplayName("absolute path executable that does not exist is rejected")
void absolutePathExecutableMissing() {
String bogus =
System.getProperty("os.name").toLowerCase().contains("win")
? "C:\\definitely\\does\\not\\exist\\tool.exe"
: "/definitely/does/not/exist/tool";
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(qpdfExecutor(), List.of(bogus)));
assertTrue(ex.getMessage().contains("does not exist"));
}
@Test
@DisplayName("path that exists but is a directory is rejected (not a regular file)")
void directoryPathExecutableRejected(@TempDir Path tempDir) {
String dirPath = tempDir.toString();
// Ensure the path contains a separator so the path-based validation branch is taken.
assertTrue(dirPath.contains("/") || dirPath.contains("\\"));
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(qpdfExecutor(), List.of(dirPath)));
assertTrue(ex.getMessage().contains("not a regular file"));
}
@Test
@DisplayName("absolute path to an existing regular file passes validation")
void existingRegularFileExecutablePasses(@TempDir Path tempDir) throws Exception {
Path file = tempDir.resolve("fakebinary");
Files.writeString(file, "#!/bin/sh\n");
assertTrue(Files.exists(file));
// Should not throw.
invokeValidateCommand(qpdfExecutor(), List.of(file.toString(), "--version"));
}
@Test
@DisplayName("path traversal anywhere in the executable is rejected")
void pathTraversalInExecutable() {
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() ->
invokeValidateCommand(
qpdfExecutor(), List.of("/usr/bin/../bin/tool")));
assertTrue(ex.getMessage().contains("path traversal"));
}
@Test
@DisplayName("null byte / newline checks run across every argument, not just the first")
void invalidCharactersInLaterArgument() {
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "ok", "bad\0arg")));
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "ok", "bad\narg")));
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "ok", "bad\rarg")));
}
@Test
@DisplayName("relative simple command (no separators) is trusted and passes")
void relativeSimpleCommandPasses() throws Exception {
invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "--help"));
}
@Test
@DisplayName("null first-argument executable is rejected")
void nullExecutableRejected() {
List<String> command = new ArrayList<>();
command.add(null);
// null arg is caught by the per-arg null check before the executable check.
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(qpdfExecutor(), command));
}
}
// ----- stripUnoEndpointArgs ----------------------------------------------
@Nested
@DisplayName("stripUnoEndpointArgs")
class StripUnoEndpointArgsTests {
@Test
@DisplayName("removes space-separated --host/--port/--host-location/--protocol pairs")
void stripsSpaceSeparatedArgs() throws Exception {
List<String> input =
List.of(
"unoconvert",
"--host",
"1.2.3.4",
"--port",
"9999",
"--host-location",
"remote",
"--protocol",
"https",
"in.docx",
"out.pdf");
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
assertEquals(List.of("unoconvert", "in.docx", "out.pdf"), result);
}
@Test
@DisplayName("removes equals-form --host=.../--port=... arguments")
void stripsEqualsFormArgs() throws Exception {
List<String> input =
List.of(
"unoconvert",
"--host=5.6.7.8",
"--port=4002",
"--host-location=local",
"--protocol=http",
"doc.odt");
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
assertEquals(List.of("unoconvert", "doc.odt"), result);
}
@Test
@DisplayName("leaves a command without endpoint args unchanged")
void leavesPlainCommandUntouched() throws Exception {
List<String> input = List.of("unoconvert", "in.docx", "out.pdf");
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
assertEquals(input, result);
}
@Test
@DisplayName("returns a fresh list, not the same instance")
void returnsNewList() throws Exception {
List<String> input = new ArrayList<>(List.of("unoconvert", "a", "b"));
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
assertNotSame(input, result);
}
}
// ----- applyUnoServerEndpoint --------------------------------------------
private ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint(
String host, int port, String hostLocation, String protocol) {
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
ep.setHost(host);
ep.setPort(port);
ep.setHostLocation(hostLocation);
ep.setProtocol(protocol);
return ep;
}
@Nested
@DisplayName("applyUnoServerEndpoint")
class ApplyUnoServerEndpointTests {
@Test
@DisplayName(
"injects --host/--port after the executable, defaults omit host-location and protocol")
void injectsHostAndPortWithDefaults() throws Exception {
List<String> command = List.of("unoconvert", "in.docx", "out.pdf");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
endpoint("9.9.9.9", 7777, "auto", "http");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
assertEquals(
List.of(
"unoconvert",
"--host",
"9.9.9.9",
"--port",
"7777",
"in.docx",
"out.pdf"),
result);
}
@Test
@DisplayName("non-default host-location and protocol are injected")
void injectsHostLocationAndProtocolWhenNonDefault() throws Exception {
List<String> command = List.of("unoconvert", "in.docx");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
endpoint("10.0.0.5", 2200, "remote", "https");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
assertEquals(
List.of(
"unoconvert",
"--host",
"10.0.0.5",
"--port",
"2200",
"--host-location",
"remote",
"--protocol",
"https",
"in.docx"),
result);
}
@Test
@DisplayName("blank host falls back to 127.0.0.1 and non-positive port falls back to 2003")
void appliesHostAndPortFallbacks() throws Exception {
List<String> command = List.of("unoconvert", "in.docx");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
endpoint(" ", 0, "auto", "http");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
assertEquals(
List.of("unoconvert", "--host", "127.0.0.1", "--port", "2003", "in.docx"),
result);
}
@Test
@DisplayName("invalid host-location and protocol values are normalised to defaults")
void invalidHostLocationAndProtocolNormalised() throws Exception {
List<String> command = List.of("unoconvert", "in.docx");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
endpoint("1.1.1.1", 3000, "sideways", "gopher");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
// Both invalid -> normalised to defaults (auto/http) -> neither injected.
assertEquals(
List.of("unoconvert", "--host", "1.1.1.1", "--port", "3000", "in.docx"),
result);
}
@Test
@DisplayName("host-location and protocol matching is case-insensitive and trimmed")
void hostLocationAndProtocolCaseInsensitive() throws Exception {
List<String> command = List.of("unoconvert", "in.docx");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
endpoint("1.1.1.1", 3000, " REMOTE ", " HTTPS ");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
assertEquals(
List.of(
"unoconvert",
"--host",
"1.1.1.1",
"--port",
"3000",
"--host-location",
"remote",
"--protocol",
"https",
"in.docx"),
result);
}
@Test
@DisplayName("existing endpoint args are stripped before re-injection")
void stripsExistingEndpointArgsBeforeInjecting() throws Exception {
List<String> command = List.of("unoconvert", "--host", "old", "--port", "1", "in.docx");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
endpoint("2.2.2.2", 2222, "auto", "http");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
assertEquals(
List.of("unoconvert", "--host", "2.2.2.2", "--port", "2222", "in.docx"),
result);
}
@Test
@DisplayName("null endpoint returns the command unchanged")
void nullEndpointReturnsCommandUnchanged() throws Exception {
List<String> command = List.of("unoconvert", "in.docx");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, null);
assertEquals(command, result);
}
@Test
@DisplayName("empty command returns the command unchanged")
void emptyCommandReturnedUnchanged() throws Exception {
List<String> command = List.of();
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
endpoint("1.1.1.1", 2003, "auto", "http");
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
assertEquals(command, result);
}
}
// ----- shouldUseUnoServerPool --------------------------------------------
@Nested
@DisplayName("shouldUseUnoServerPool")
class ShouldUseUnoServerPoolTests {
private UnoServerPool nonEmptyPool() {
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
return new UnoServerPool(List.of(ep));
}
@Test
@DisplayName(
"false for non-LIBRE_OFFICE process type even with a pool and unoconvert command")
void falseForNonLibreOfficeProcessType() throws Exception {
ProcessExecutor.setUnoServerPool(nonEmptyPool());
assertFalse(
invokeShouldUseUnoServerPool(qpdfExecutor(), List.of("unoconvert", "in.docx")));
}
@Test
@DisplayName("false when no pool is configured")
void falseWhenPoolNull() throws Exception {
ProcessExecutor.setUnoServerPool(null);
assertFalse(
invokeShouldUseUnoServerPool(
libreOfficeExecutor(), List.of("unoconvert", "in.docx")));
}
@Test
@DisplayName("false when the configured pool is empty")
void falseWhenPoolEmpty() throws Exception {
ProcessExecutor.setUnoServerPool(new UnoServerPool(List.of()));
assertFalse(
invokeShouldUseUnoServerPool(
libreOfficeExecutor(), List.of("unoconvert", "in.docx")));
}
@Test
@DisplayName("false for null or empty command")
void falseForNullOrEmptyCommand() throws Exception {
ProcessExecutor.setUnoServerPool(nonEmptyPool());
assertFalse(invokeShouldUseUnoServerPool(libreOfficeExecutor(), null));
assertFalse(invokeShouldUseUnoServerPool(libreOfficeExecutor(), List.of()));
}
@Test
@DisplayName("true for a plain unoconvert command with a non-empty pool")
void trueForUnoconvertCommand() throws Exception {
ProcessExecutor.setUnoServerPool(nonEmptyPool());
assertTrue(
invokeShouldUseUnoServerPool(
libreOfficeExecutor(), List.of("unoconvert", "in.docx", "out.pdf")));
}
@Test
@DisplayName("true for a unoconvert path with directories and a .exe extension")
void trueForUnoconvertWithPathAndExeExtension() throws Exception {
ProcessExecutor.setUnoServerPool(nonEmptyPool());
assertTrue(
invokeShouldUseUnoServerPool(
libreOfficeExecutor(),
List.of("C:\\tools\\bin\\unoconvert.exe", "in.docx")));
assertTrue(
invokeShouldUseUnoServerPool(
libreOfficeExecutor(),
List.of("/usr/local/bin/unoconvert", "in.docx")));
}
@Test
@DisplayName("true for the legacy 'unoconv' executable name")
void trueForLegacyUnoconv() throws Exception {
ProcessExecutor.setUnoServerPool(nonEmptyPool());
assertTrue(
invokeShouldUseUnoServerPool(
libreOfficeExecutor(), List.of("unoconv", "in.docx")));
}
@Test
@DisplayName("false for soffice, which must not be routed through the pool")
void falseForSoffice() throws Exception {
ProcessExecutor.setUnoServerPool(nonEmptyPool());
assertFalse(
invokeShouldUseUnoServerPool(
libreOfficeExecutor(),
List.of("/usr/bin/soffice", "--headless", "in.docx")));
}
}
// ----- Processes enum -----------------------------------------------------
@Nested
@DisplayName("Processes enum")
class ProcessesEnumTests {
@Test
@DisplayName("contains all expected process types")
void containsExpectedValues() {
ProcessExecutor.Processes[] values = ProcessExecutor.Processes.values();
assertEquals(13, values.length);
assertEquals(
ProcessExecutor.Processes.LIBRE_OFFICE,
ProcessExecutor.Processes.valueOf("LIBRE_OFFICE"));
assertEquals(
ProcessExecutor.Processes.CFF_CONVERTER,
ProcessExecutor.Processes.valueOf("CFF_CONVERTER"));
assertEquals(
ProcessExecutor.Processes.FFMPEG, ProcessExecutor.Processes.valueOf("FFMPEG"));
}
@Test
@DisplayName("valueOf rejects an unknown name")
void valueOfRejectsUnknown() {
assertThrows(
IllegalArgumentException.class,
() -> ProcessExecutor.Processes.valueOf("NOT_A_PROCESS"));
}
@Test
@DisplayName("getInstance resolves a non-null singleton for every enum value")
void getInstanceForEveryProcessType() {
for (ProcessExecutor.Processes p : ProcessExecutor.Processes.values()) {
ProcessExecutor instance = ProcessExecutor.getInstance(p);
assertNotNull(instance, "instance should not be null for " + p);
// Same key returns the cached singleton.
assertSame(instance, ProcessExecutor.getInstance(p));
}
}
}
// ----- getInstance / liveUpdates -----------------------------------------
@Nested
@DisplayName("getInstance behaviour")
class GetInstanceTests {
@Test
@DisplayName("single-arg getInstance delegates to liveUpdates=true and is cached")
void singleArgDelegatesAndCaches() {
ProcessExecutor a = ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
ProcessExecutor b =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT, true);
assertSame(a, b);
}
@Test
@DisplayName("the liveUpdates flag of the first call wins because the instance is cached")
void firstCallWinsForCachedInstance() {
// First resolution for this type fixes its configuration.
ProcessExecutor first =
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF, false);
ProcessExecutor second =
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF, true);
assertSame(first, second);
}
}
// ----- setUnoServerPool ---------------------------------------------------
@Nested
@DisplayName("setUnoServerPool")
class SetUnoServerPoolTests {
@Test
@DisplayName("setting then clearing the pool flips shouldUseUnoServerPool")
void poolSetterAffectsRouting() throws Exception {
ProcessExecutor exec = libreOfficeExecutor();
List<String> command = List.of("unoconvert", "in.docx");
ProcessExecutor.setUnoServerPool(null);
assertFalse(invokeShouldUseUnoServerPool(exec, command));
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
ProcessExecutor.setUnoServerPool(new UnoServerPool(List.of(ep)));
assertTrue(invokeShouldUseUnoServerPool(exec, command));
ProcessExecutor.setUnoServerPool(null);
assertFalse(invokeShouldUseUnoServerPool(exec, command));
}
}
// ----- ProcessExecutorResult ---------------------------------------------
@Nested
@DisplayName("ProcessExecutorResult value type")
class ProcessExecutorResultTests {
@Test
@DisplayName("constructor stores rc and messages; setters mutate them")
void constructorAndSetters() {
ProcessExecutor exec = qpdfExecutor();
ProcessExecutor.ProcessExecutorResult result = exec.new ProcessExecutorResult(0, "ok");
assertEquals(0, result.getRc());
assertEquals("ok", result.getMessages());
result.setRc(42);
result.setMessages("boom");
assertEquals(42, result.getRc());
assertEquals("boom", result.getMessages());
}
@Test
@DisplayName("messages may be null")
void allowsNullMessages() {
ProcessExecutor exec = qpdfExecutor();
ProcessExecutor.ProcessExecutorResult result = exec.new ProcessExecutorResult(3, null);
assertEquals(3, result.getRc());
assertNull(result.getMessages());
}
}
// ----- runCommandWithOutputHandling validation entry point ---------------
@Nested
@DisplayName("runCommandWithOutputHandling validation (no process launched)")
class RunCommandValidationTests {
@Test
@DisplayName("empty command is rejected before any process is started")
void emptyCommandRejected() {
ProcessExecutor exec = qpdfExecutor();
assertThrows(
IllegalArgumentException.class,
() -> exec.runCommandWithOutputHandling(List.of()));
}
@Test
@DisplayName("command containing a null byte is rejected before any process is started")
void nullByteCommandRejected() {
ProcessExecutor exec = qpdfExecutor();
assertThrows(
IllegalArgumentException.class,
() -> exec.runCommandWithOutputHandling(List.of("qpdf", "bad\0arg")));
}
@Test
@DisplayName("absolute non-existent executable is rejected before any process is started")
void missingAbsoluteExecutableRejected() {
ProcessExecutor exec = qpdfExecutor();
String bogus =
System.getProperty("os.name").toLowerCase().contains("win")
? "C:\\no\\such\\tool.exe"
: "/no/such/tool";
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> exec.runCommandWithOutputHandling(List.of(bogus)));
assertTrue(ex.getMessage().contains("does not exist"));
}
@Test
@DisplayName("validation exception type is not an IOException for bad input")
void validationThrowsIllegalArgumentNotIOException() {
ProcessExecutor exec = qpdfExecutor();
Exception thrown =
assertThrows(
Exception.class,
() -> exec.runCommandWithOutputHandling(List.of("qpdf", "x\ny")));
assertInstanceOf(IllegalArgumentException.class, thrown);
assertFalse(thrown instanceof IOException);
}
}
}
@@ -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());
}
}
}
}