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