mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
+481
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+345
@@ -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();
|
||||
}
|
||||
}
|
||||
+520
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+471
@@ -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);
|
||||
}
|
||||
}
|
||||
+416
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+446
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
/**
|
||||
* Gap tests for {@link MergeController} private helper logic reachable via reflection. Focuses on
|
||||
* sort comparators, file-order reordering, client file-id parsing, date extraction and filename
|
||||
* lookup. The external JPDFium merge path is not exercised here (covered structurally elsewhere).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class MergeControllerGapTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@InjectMocks private MergeController mergeController;
|
||||
|
||||
private MockMultipartFile fileA;
|
||||
private MockMultipartFile fileB;
|
||||
private MockMultipartFile fileC;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileA =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "Apple.pdf", MediaType.APPLICATION_PDF_VALUE, "a".getBytes());
|
||||
fileB =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "banana.pdf", MediaType.APPLICATION_PDF_VALUE, "b".getBytes());
|
||||
fileC =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "Cherry.pdf", MediaType.APPLICATION_PDF_VALUE, "c".getBytes());
|
||||
}
|
||||
|
||||
// ---- reflection helpers -------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private java.util.Comparator<MultipartFile> sortComparator(String sortType) throws Exception {
|
||||
Method m = MergeController.class.getDeclaredMethod("getSortComparator", String.class);
|
||||
m.setAccessible(true);
|
||||
return (java.util.Comparator<MultipartFile>) m.invoke(mergeController, sortType);
|
||||
}
|
||||
|
||||
private MultipartFile[] reorder(MultipartFile[] files, String fileOrder) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"reorderFilesByProvidedOrder", MultipartFile[].class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (MultipartFile[]) m.invoke(null, files, fileOrder);
|
||||
}
|
||||
|
||||
private String[] parseClientFileIds(String value) throws Exception {
|
||||
Method m = MergeController.class.getDeclaredMethod("parseClientFileIds", String.class);
|
||||
m.setAccessible(true);
|
||||
return (String[]) m.invoke(mergeController, value);
|
||||
}
|
||||
|
||||
private long getPdfDateTimeSafe(MultipartFile file) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod("getPdfDateTimeSafe", MultipartFile.class);
|
||||
m.setAccessible(true);
|
||||
return (long) m.invoke(mergeController, file);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private int indexOfByOriginalFilename(List<MultipartFile> list, String name) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"indexOfByOriginalFilename", List.class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (int) m.invoke(null, list, name);
|
||||
}
|
||||
|
||||
private static PDDocument docWithTitle(String title) {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getTitle()).thenReturn(title);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ---- getSortComparator: byFileName --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSortComparator: byFileName")
|
||||
class ByFileName {
|
||||
|
||||
@Test
|
||||
@DisplayName("sorts case-insensitively by original filename")
|
||||
void sortsCaseInsensitively() throws Exception {
|
||||
MultipartFile[] files = {fileC, fileA, fileB};
|
||||
Arrays.sort(files, sortComparator("byFileName"));
|
||||
assertArrayEquals(new MultipartFile[] {fileA, fileB, fileC}, files);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null original filename is treated as empty and sorts first")
|
||||
void nullFilenameSortsFirst() throws Exception {
|
||||
MultipartFile nullName = mock(MultipartFile.class);
|
||||
when(nullName.getOriginalFilename()).thenReturn(null);
|
||||
MultipartFile[] files = {fileB, nullName, fileA};
|
||||
Arrays.sort(files, sortComparator("byFileName"));
|
||||
assertSame(nullName, files[0]);
|
||||
assertSame(fileA, files[1]);
|
||||
assertSame(fileB, files[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getSortComparator: byPDFTitle --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSortComparator: byPDFTitle")
|
||||
class ByPdfTitle {
|
||||
|
||||
@Test
|
||||
@DisplayName("orders documents by their PDF title, ignoring case")
|
||||
void ordersByTitle() throws Exception {
|
||||
PDDocument docZ = docWithTitle("Zebra");
|
||||
PDDocument docA = docWithTitle("alpha");
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(docZ);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(docA);
|
||||
|
||||
int cmp = sortComparator("byPDFTitle").compare(fileA, fileB);
|
||||
assertTrue(cmp > 0, "Zebra should sort after alpha");
|
||||
// and the documents are closed via try-with-resources
|
||||
verify(docZ).close();
|
||||
verify(docA).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("both titles null yields equal (0)")
|
||||
void bothNullTitlesEqual() throws Exception {
|
||||
PDDocument d1 = docWithTitle(null);
|
||||
PDDocument d2 = docWithTitle(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
|
||||
|
||||
assertEquals(0, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("first title null sorts after non-null (returns 1)")
|
||||
void firstNullSortsLast() throws Exception {
|
||||
PDDocument d1 = docWithTitle(null);
|
||||
PDDocument d2 = docWithTitle("Beta");
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
|
||||
|
||||
assertEquals(1, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("second title null sorts first (returns -1)")
|
||||
void secondNullSortsFirst() throws Exception {
|
||||
PDDocument d1 = docWithTitle("Alpha");
|
||||
PDDocument d2 = docWithTitle(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
|
||||
|
||||
assertEquals(-1, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException while loading yields equal (0)")
|
||||
void ioExceptionYieldsEqual() throws Exception {
|
||||
when(pdfDocumentFactory.load(fileA)).thenThrow(new IOException("boom"));
|
||||
assertEquals(0, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getSortComparator: date-based and no-op orders ---------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSortComparator: date-based and pass-through orders")
|
||||
class DateAndPassThrough {
|
||||
|
||||
private PDDocument docWithModDate(long millis) {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
Calendar cal = new GregorianCalendar();
|
||||
cal.setTimeInMillis(millis);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(cal);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byDateModified orders newest first (descending)")
|
||||
void byDateModifiedNewestFirst() throws Exception {
|
||||
PDDocument older = docWithModDate(1_000L);
|
||||
PDDocument newer = docWithModDate(9_000L);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(older);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(newer);
|
||||
|
||||
// file1=older, file2=newer -> Long.compare(t2=newer, t1=older) > 0 -> older after newer
|
||||
int cmp = sortComparator("byDateModified").compare(fileA, fileB);
|
||||
assertTrue(cmp > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byDateCreated uses the same descending logic")
|
||||
void byDateCreatedNewestFirst() throws Exception {
|
||||
PDDocument older = docWithModDate(2_000L);
|
||||
PDDocument newer = docWithModDate(8_000L);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(newer);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(older);
|
||||
|
||||
int cmp = sortComparator("byDateCreated").compare(fileA, fileB);
|
||||
assertTrue(cmp < 0, "newer (file1) should sort before older (file2)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("orderProvided is a stable no-op comparator (0)")
|
||||
void orderProvidedNoOp() throws Exception {
|
||||
assertEquals(0, sortComparator("orderProvided").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown sort type falls back to no-op comparator (0)")
|
||||
void unknownSortTypeNoOp() throws Exception {
|
||||
assertEquals(0, sortComparator("somethingElse").compare(fileA, fileB));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getPdfDateTimeSafe -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getPdfDateTimeSafe")
|
||||
class GetPdfDateTimeSafe {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns modification date millis when present")
|
||||
void returnsModificationDate() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
Calendar cal = new GregorianCalendar();
|
||||
cal.setTimeInMillis(123_456L);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(cal);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(123_456L, getPdfDateTimeSafe(fileA));
|
||||
verify(doc).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to creation date when modification date is null")
|
||||
void fallsBackToCreationDate() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
Calendar cal = new GregorianCalendar();
|
||||
cal.setTimeInMillis(777L);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(null);
|
||||
when(info.getCreationDate()).thenReturn(cal);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(777L, getPdfDateTimeSafe(fileA));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 when no info dates and no XMP metadata present")
|
||||
void returnsZeroWhenNoDates() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(null);
|
||||
when(info.getCreationDate()).thenReturn(null);
|
||||
when(doc.getDocumentCatalog()).thenReturn(catalog);
|
||||
when(catalog.getMetadata()).thenReturn(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(0L, getPdfDateTimeSafe(fileA));
|
||||
verify(doc).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 when document info itself is null")
|
||||
void returnsZeroWhenInfoNull() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
|
||||
when(doc.getDocumentInformation()).thenReturn(null);
|
||||
when(doc.getDocumentCatalog()).thenReturn(catalog);
|
||||
when(catalog.getMetadata()).thenReturn(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(0L, getPdfDateTimeSafe(fileA));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 and swallows IOException on load failure")
|
||||
void returnsZeroOnLoadFailure() throws Exception {
|
||||
when(pdfDocumentFactory.load(fileA)).thenThrow(new IOException("cannot open"));
|
||||
assertEquals(0L, getPdfDateTimeSafe(fileA));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseClientFileIds -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseClientFileIds")
|
||||
class ParseClientFileIds {
|
||||
|
||||
@Test
|
||||
@DisplayName("null input returns empty array")
|
||||
void nullReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(null).length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank input returns empty array")
|
||||
void blankReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(" ").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty JSON array returns empty array")
|
||||
void emptyArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("[]").length);
|
||||
assertEquals(0, parseClientFileIds("[ ]").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-array text returns empty array")
|
||||
void nonArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("not-an-array").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses quoted, comma-separated ids and strips surrounding quotes")
|
||||
void parsesQuotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[\"id1\", \"id2\",\"id3\"]");
|
||||
assertArrayEquals(new String[] {"id1", "id2", "id3"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses unquoted ids as-is after trimming")
|
||||
void parsesUnquotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[a, b , c]");
|
||||
assertArrayEquals(new String[] {"a", "b", "c"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single element array yields a one-element result")
|
||||
void singleElement() throws Exception {
|
||||
assertArrayEquals(new String[] {"only"}, parseClientFileIds("[\"only\"]"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reorderFilesByProvidedOrder ----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("reorderFilesByProvidedOrder")
|
||||
class ReorderFilesByProvidedOrder {
|
||||
|
||||
@Test
|
||||
@DisplayName("reorders files to match the newline-separated order list")
|
||||
void reordersToMatchOrder() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB, fileC};
|
||||
MultipartFile[] result = reorder(files, "Cherry.pdf\nApple.pdf\nbanana.pdf");
|
||||
assertArrayEquals(new MultipartFile[] {fileC, fileA, fileB}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handles CRLF separators")
|
||||
void handlesCrlf() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB};
|
||||
MultipartFile[] result = reorder(files, "banana.pdf\r\nApple.pdf");
|
||||
assertArrayEquals(new MultipartFile[] {fileB, fileA}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unmatched names are skipped and remaining files appended in original order")
|
||||
void unmatchedNamesAppendedAtEnd() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB, fileC};
|
||||
// only mention Cherry; ghost.pdf is ignored; Apple+banana keep original relative order
|
||||
MultipartFile[] result = reorder(files, "Cherry.pdf\nghost.pdf");
|
||||
assertArrayEquals(new MultipartFile[] {fileC, fileA, fileB}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank/empty order entries are skipped")
|
||||
void blankEntriesSkipped() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB};
|
||||
MultipartFile[] result = reorder(files, "\n \nbanana.pdf\n");
|
||||
assertArrayEquals(new MultipartFile[] {fileB, fileA}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty file array returns empty array")
|
||||
void emptyFilesReturnsEmpty() throws Exception {
|
||||
MultipartFile[] result = reorder(new MultipartFile[0], "anything.pdf");
|
||||
assertEquals(0, result.length);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- indexOfByOriginalFilename ------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("indexOfByOriginalFilename")
|
||||
class IndexOfByOriginalFilename {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns index of matching filename")
|
||||
void returnsMatchIndex() throws Exception {
|
||||
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, fileB, fileC));
|
||||
assertEquals(1, indexOfByOriginalFilename(list, "banana.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns first match index when duplicates exist")
|
||||
void returnsFirstMatch() throws Exception {
|
||||
MockMultipartFile dup =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"Apple.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"dup".getBytes());
|
||||
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, dup));
|
||||
assertEquals(0, indexOfByOriginalFilename(list, "Apple.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns -1 when not found")
|
||||
void returnsMinusOneWhenAbsent() throws Exception {
|
||||
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, fileB));
|
||||
assertEquals(-1, indexOfByOriginalFilename(list, "missing.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns -1 for empty list")
|
||||
void returnsMinusOneForEmpty() throws Exception {
|
||||
assertEquals(-1, indexOfByOriginalFilename(new ArrayList<>(), "x.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mergeDocuments null-collaborator wiring ----------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("mergeDocuments wiring")
|
||||
class MergeDocumentsWiring {
|
||||
|
||||
@Test
|
||||
@DisplayName("creates a fresh document from the factory and returns it")
|
||||
void createsFromFactory() throws Exception {
|
||||
PDDocument merged = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.createNewDocument()).thenReturn(merged);
|
||||
|
||||
PDDocument result = mergeController.mergeDocuments(List.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertSame(merged, result);
|
||||
verify(pdfDocumentFactory).createNewDocument();
|
||||
verify(merged, never()).close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.PosterPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PosterPdfControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private PosterPdfController controller;
|
||||
|
||||
private final AtomicInteger tempCounter = new AtomicInteger();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// new TempFile(tempFileManager, suffix) delegates to createTempFile(suffix);
|
||||
// hand back real, writable files in the test temp dir so the controller's
|
||||
// real file/zip I/O works end to end.
|
||||
lenient()
|
||||
.when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
String suffix = inv.getArgument(0);
|
||||
File f =
|
||||
tempDir.resolve(
|
||||
"poster-"
|
||||
+ tempCounter.incrementAndGet()
|
||||
+ suffix)
|
||||
.toFile();
|
||||
Files.createFile(f.toPath());
|
||||
return f;
|
||||
});
|
||||
}
|
||||
|
||||
private MockMultipartFile createRealPdf(int numPages, String name) throws IOException {
|
||||
return createRealPdf(numPages, name, PDRectangle.A4, 0);
|
||||
}
|
||||
|
||||
private MockMultipartFile createRealPdf(
|
||||
int numPages, String name, PDRectangle size, int rotation) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
PDPage page = new PDPage(size);
|
||||
page.setRotation(rotation);
|
||||
doc.addPage(page);
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, baos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private PosterPdfRequest createRequest(MockMultipartFile file) {
|
||||
PosterPdfRequest req = new PosterPdfRequest();
|
||||
req.setFileInput(file);
|
||||
return req;
|
||||
}
|
||||
|
||||
/** Drain a file-backed Resource body to bytes. */
|
||||
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Read the single PDF entry out of a ZIP byte array. */
|
||||
private byte[] firstPdfEntry(byte[] zipBytes) throws IOException {
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertThat(entry).isNotNull();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
zis.transferTo(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void stubFactory(MockMultipartFile file) throws IOException {
|
||||
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
|
||||
PDDocument outputDoc = new PDDocument();
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenReturn(outputDoc);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("posterPdf happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Default 2x2 grid on single page yields a ZIP with a 4-page PDF")
|
||||
void defaultGrid_singlePage() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "doc.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("doc_poster.zip");
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
byte[] zipBytes = drainBody(response);
|
||||
assertThat(zipBytes).isNotEmpty();
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(zipBytes);
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
// 1 source page * (xFactor 2 * yFactor 2) = 4 output pages
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ZIP entry is named <base>_poster.pdf")
|
||||
void zipEntryNamedAfterBase() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "report.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] zipBytes = drainBody(response);
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertThat(entry).isNotNull();
|
||||
assertThat(entry.getName()).isEqualTo("report_poster.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Multi-page source multiplies output page count by grid size")
|
||||
void multiPageSource() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(3, "multi.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setXFactor(2);
|
||||
request.setYFactor(3);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
// 3 pages * (2 * 3) = 18
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(18);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1x1 grid produces one output page per source page")
|
||||
void oneByOneGrid() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(2, "one.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setXFactor(1);
|
||||
request.setYFactor(1);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Right-to-left ordering still produces the full grid")
|
||||
void rightToLeft() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "rtl.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setRightToLeft(true);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Rotated source page (90 degrees) is handled without error")
|
||||
void rotatedSourcePage() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "rot.pdf", PDRectangle.A4, 90);
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Filename without extension is preserved in output names")
|
||||
void filenameWithoutExtension() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "noext");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("noext_poster.zip");
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(drainBody(response)))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertThat(entry).isNotNull();
|
||||
assertThat(entry.getName()).isEqualTo("noext_poster.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null original filename falls back to default base name")
|
||||
void nullOriginalFilename() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
null,
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
createRealPdf(1, "x.pdf").getBytes());
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("_poster.zip");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Page size handling")
|
||||
class PageSizes {
|
||||
|
||||
@Test
|
||||
@DisplayName("Each supported page size produces a valid ZIP")
|
||||
void supportedSizes() throws Exception {
|
||||
for (String size : new String[] {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"}) {
|
||||
MockMultipartFile file = createRealPdf(1, "s.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setPageSize(size);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode())
|
||||
.as("page size %s", size)
|
||||
.isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).as("body for %s", size).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Invalid page size throws IllegalArgumentException")
|
||||
void invalidPageSize() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "bad.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setPageSize("NotAPageSize");
|
||||
stubFactory(file);
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getTargetPageSize private mapping")
|
||||
class TargetPageSize {
|
||||
|
||||
private PDRectangle invoke(String size) throws Exception {
|
||||
Method m =
|
||||
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
|
||||
m.setAccessible(true);
|
||||
return (PDRectangle) m.invoke(controller, size);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Known sizes map to expected PDRectangles")
|
||||
void knownSizes() throws Exception {
|
||||
assertThat(invoke("A4")).isEqualTo(PDRectangle.A4);
|
||||
assertThat(invoke("Letter")).isEqualTo(PDRectangle.LETTER);
|
||||
assertThat(invoke("A3")).isEqualTo(PDRectangle.A3);
|
||||
assertThat(invoke("A5")).isEqualTo(PDRectangle.A5);
|
||||
assertThat(invoke("Legal")).isEqualTo(PDRectangle.LEGAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tabloid maps to 11x17 inch (792x1224 pt) rectangle")
|
||||
void tabloidSize() throws Exception {
|
||||
PDRectangle r = invoke("Tabloid");
|
||||
assertThat(r.getWidth()).isEqualTo(792f);
|
||||
assertThat(r.getHeight()).isEqualTo(1224f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unknown size raises IllegalArgumentException")
|
||||
void unknownSize() throws Exception {
|
||||
Method m =
|
||||
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
|
||||
m.setAccessible(true);
|
||||
assertThatThrownBy(() -> m.invoke(controller, "Unknown"))
|
||||
.isInstanceOf(InvocationTargetException.class)
|
||||
.hasCauseInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null size raises IllegalArgumentException")
|
||||
void nullSize() throws Exception {
|
||||
Method m =
|
||||
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
|
||||
m.setAccessible(true);
|
||||
assertThatThrownBy(() -> m.invoke(controller, new Object[] {null}))
|
||||
.isInstanceOf(InvocationTargetException.class)
|
||||
.hasCauseInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error propagation")
|
||||
class Errors {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from load propagates to caller")
|
||||
void loadIoException() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "io.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("load failed"));
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("load failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RuntimeException from createNewDocument propagates and closes zip temp file")
|
||||
void createNewDocumentRuntimeException() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "rt.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenThrow(new IllegalStateException("boom"));
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("boom");
|
||||
|
||||
sourceDoc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Collaborator interactions")
|
||||
class Interactions {
|
||||
|
||||
@Test
|
||||
@DisplayName("Both load and createNewDocumentBasedOnOldDocument are invoked")
|
||||
void factoryCalled() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "calls.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
|
||||
PDDocument outputDoc = new PDDocument();
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenReturn(outputDoc);
|
||||
|
||||
controller.posterPdf(request);
|
||||
|
||||
verify(pdfDocumentFactory).load(file);
|
||||
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(sourceDoc);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Zip temp file is never created when load fails before zip work")
|
||||
void noOutputWhenLoadFails() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "fail.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("nope"));
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request)).isInstanceOf(IOException.class);
|
||||
|
||||
// createNewDocumentBasedOnOldDocument is never reached after load throws.
|
||||
verify(pdfDocumentFactory, never())
|
||||
.createNewDocumentBasedOnOldDocument(
|
||||
org.mockito.ArgumentMatchers.any(PDDocument.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class SettingsControllerTest {
|
||||
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
|
||||
private SettingsController settingsController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
settingsController = new SettingsController(applicationProperties, endpointConfiguration);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateApiKey (update-enable-analytics)")
|
||||
class UpdateApiKey {
|
||||
|
||||
@Test
|
||||
@DisplayName("persists and returns 200 OK when analytics flag not yet set (null)")
|
||||
void updatesWhenNotPreviouslySet() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(null);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.TRUE);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertEquals("Updated", response.getBody().get("message"));
|
||||
|
||||
generalUtils.verify(
|
||||
() ->
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAnalytics", Boolean.TRUE),
|
||||
times(1));
|
||||
}
|
||||
|
||||
verify(system).setEnableAnalytics(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("persists the false value when enabling analytics is declined")
|
||||
void updatesWithFalseValue() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(null);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.FALSE);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals("Updated", response.getBody().get("message"));
|
||||
|
||||
generalUtils.verify(
|
||||
() ->
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAnalytics", Boolean.FALSE),
|
||||
times(1));
|
||||
}
|
||||
|
||||
verify(system).setEnableAnalytics(Boolean.FALSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 208 ALREADY_REPORTED and does not persist when flag already true")
|
||||
void alreadyReportedWhenAlreadyTrue() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.TRUE);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.TRUE);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.ALREADY_REPORTED, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
Object message = response.getBody().get("message");
|
||||
assertNotNull(message);
|
||||
assertTrue(
|
||||
message.toString().startsWith("Setting has already been set"),
|
||||
"Unexpected message: " + message);
|
||||
|
||||
generalUtils.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
|
||||
}
|
||||
|
||||
verify(system, never()).setEnableAnalytics(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 208 ALREADY_REPORTED when flag already false (any non-null is set)")
|
||||
void alreadyReportedWhenAlreadyFalse() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.FALSE);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.TRUE);
|
||||
|
||||
assertEquals(HttpStatus.ALREADY_REPORTED, response.getStatusCode());
|
||||
generalUtils.verify(
|
||||
() -> GeneralUtils.saveKeyToSettings(eq("system.enableAnalytics"), any()),
|
||||
never());
|
||||
}
|
||||
|
||||
verify(system, never()).setEnableAnalytics(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getDisabledEndpoints (get-endpoints-status)")
|
||||
class GetDisabledEndpoints {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 OK with the endpoint status map from EndpointConfiguration")
|
||||
void returnsEndpointStatuses() {
|
||||
Map<String, Boolean> statuses = new ConcurrentHashMap<>();
|
||||
statuses.put("merge-pdfs", Boolean.TRUE);
|
||||
statuses.put("remove-blanks", Boolean.FALSE);
|
||||
when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses);
|
||||
|
||||
ResponseEntity<Map<String, Boolean>> response =
|
||||
settingsController.getDisabledEndpoints();
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertSame(statuses, response.getBody());
|
||||
assertEquals(Boolean.TRUE, response.getBody().get("merge-pdfs"));
|
||||
assertEquals(Boolean.FALSE, response.getBody().get("remove-blanks"));
|
||||
verify(endpointConfiguration).getEndpointStatuses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 OK with an empty map when no statuses are configured")
|
||||
void returnsEmptyMap() {
|
||||
Map<String, Boolean> statuses = new HashMap<>();
|
||||
when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses);
|
||||
|
||||
ResponseEntity<Map<String, Boolean>> response =
|
||||
settingsController.getDisabledEndpoints();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ToSinglePageControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private ToSinglePageController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Each managed temp file is backed by a real on-disk file so the response can be read back.
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("tsp-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
f.deleteOnExit();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
when(tf.getFile()).thenReturn(f);
|
||||
when(tf.getPath()).thenReturn(f.toPath());
|
||||
when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a real in-memory PDF with the given per-page sizes and return its bytes. */
|
||||
private byte[] createPdf(PDRectangle... pageSizes) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (PDRectangle size : pageSizes) {
|
||||
doc.addPage(new PDPage(size));
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private PDFFile requestFor(String filename, byte[] pdfBytes) {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", filename, MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(file);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the factory: load() returns a real PDDocument parsed from the PDFFile bytes, and
|
||||
* createNewDocumentBasedOnOldDocument() returns a fresh empty document.
|
||||
*/
|
||||
private void setupFactory() throws IOException {
|
||||
when(pdfDocumentFactory.load(any(PDFFile.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
PDFFile pf = inv.getArgument(0);
|
||||
return Loader.loadPDF(pf.getFileInput().getBytes());
|
||||
});
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
}
|
||||
|
||||
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Multi-page PDF collapses to one tall page")
|
||||
void multiPageCollapsesToSinglePage() throws Exception {
|
||||
// Three A4 portrait pages.
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A4, PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
byte[] out = drainBody(response);
|
||||
assertTrue(out.length > 0, "output PDF must be non-empty");
|
||||
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertEquals(1, result.getNumberOfPages(), "result must be a single page");
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(
|
||||
PDRectangle.A4.getWidth(),
|
||||
box.getWidth(),
|
||||
0.5f,
|
||||
"width matches the input width");
|
||||
assertEquals(
|
||||
PDRectangle.A4.getHeight() * 3,
|
||||
box.getHeight(),
|
||||
0.5f,
|
||||
"height is the sum of all page heights");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-page input produces a single page of the same size")
|
||||
void singlePageInput() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("single.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(PDRectangle.A4.getWidth(), box.getWidth(), 0.5f);
|
||||
assertEquals(PDRectangle.A4.getHeight(), box.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Mixed page sizes: width is the max, height is the sum")
|
||||
void mixedPageSizes() throws Exception {
|
||||
// A4 (595x842) + A3 (842x1191) -> width should be max (A3 width), height the sum.
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A3);
|
||||
PDFFile request = requestFor("mixed.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(PDRectangle.A3.getWidth(), box.getWidth(), 0.5f);
|
||||
assertEquals(
|
||||
PDRectangle.A4.getHeight() + PDRectangle.A3.getHeight(),
|
||||
box.getHeight(),
|
||||
0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Landscape pages are handled (width from landscape, height summed)")
|
||||
void landscapePages() throws Exception {
|
||||
PDRectangle landscape = new PDRectangle(842, 595);
|
||||
byte[] pdfBytes = createPdf(landscape, landscape);
|
||||
PDFFile request = requestFor("landscape.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(842f, box.getWidth(), 0.5f);
|
||||
assertEquals(1190f, box.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Collaborator interactions")
|
||||
class Collaborators {
|
||||
|
||||
@Test
|
||||
@DisplayName("Source document is loaded from the request and then closed")
|
||||
void loadsAndClosesSourceDocument() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
|
||||
PDDocument sourceSpy = spy(Loader.loadPDF(pdfBytes));
|
||||
when(pdfDocumentFactory.load(any(PDFFile.class))).thenReturn(sourceSpy);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
controller.pdfToSinglePage(request);
|
||||
|
||||
verify(pdfDocumentFactory).load(any(PDFFile.class));
|
||||
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(any(PDDocument.class));
|
||||
// try-with-resources must close the loaded source document.
|
||||
verify(sourceSpy).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("A managed temp file is requested for the response body")
|
||||
void requestsManagedTempFile() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
controller.pdfToSinglePage(request);
|
||||
|
||||
verify(tempFileManager).createManagedTempFile(".pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Filename handling")
|
||||
class FilenameHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("Original filename is reflected in the Content-Disposition header")
|
||||
void filenameInContentDisposition() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("MyReport.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
String disposition =
|
||||
response.getHeaders()
|
||||
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
// generateFilename strips the extension and appends _singlePage.pdf
|
||||
assertTrue(
|
||||
disposition.contains("MyReport_singlePage.pdf"),
|
||||
"disposition should carry the generated single-page filename: " + disposition);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null original filename does not throw and yields a default name")
|
||||
void nullOriginalFilename() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
// MockMultipartFile with a null original filename.
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(file);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response.getBody());
|
||||
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
|
||||
String disposition =
|
||||
response.getHeaders()
|
||||
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
assertTrue(
|
||||
disposition.contains("_singlePage.pdf"),
|
||||
"disposition should carry the empty-base single-page name: " + disposition);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error branches")
|
||||
class ErrorBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from load() propagates to the caller")
|
||||
void loadIOExceptionPropagates() throws Exception {
|
||||
PDFFile request = requestFor("broken.pdf", new byte[] {1, 2, 3});
|
||||
when(pdfDocumentFactory.load(any(PDFFile.class)))
|
||||
.thenThrow(new IOException("cannot load"));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> controller.pdfToSinglePage(request));
|
||||
assertEquals("cannot load", ex.getMessage());
|
||||
// No new document or temp file should be created when load fails.
|
||||
verify(pdfDocumentFactory, never())
|
||||
.createNewDocumentBasedOnOldDocument(any(PDDocument.class));
|
||||
verifyNoInteractions(tempFileManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from temp file creation propagates")
|
||||
void tempFileIOExceptionPropagates() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenThrow(new IOException("disk full"));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> controller.pdfToSinglePage(request));
|
||||
assertEquals("disk full", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.model.Dependency;
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class UIDataControllerGapTest {
|
||||
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
@Mock private ApplicationProperties.Legal legal;
|
||||
@Mock private SharedSignatureService signatureService;
|
||||
@Mock private UserServiceInterface userService;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private final ResourceLoader resourceLoader = new DefaultResourceLoader();
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
|
||||
private UIDataController controller(UserServiceInterface user) {
|
||||
return new UIDataController(
|
||||
applicationProperties,
|
||||
signatureService,
|
||||
user,
|
||||
resourceLoader,
|
||||
runtimePathConfig,
|
||||
objectMapper);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(system);
|
||||
lenient().when(applicationProperties.getLegal()).thenReturn(legal);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getFooterData")
|
||||
class FooterData {
|
||||
|
||||
@Test
|
||||
@DisplayName("maps all legal and analytics fields onto the response body")
|
||||
void mapsAllFields() {
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.TRUE);
|
||||
when(legal.getTermsAndConditions()).thenReturn("https://terms");
|
||||
when(legal.getPrivacyPolicy()).thenReturn("https://privacy");
|
||||
when(legal.getAccessibilityStatement()).thenReturn("https://a11y");
|
||||
when(legal.getCookiePolicy()).thenReturn("https://cookies");
|
||||
when(legal.getImpressum()).thenReturn("https://impressum");
|
||||
|
||||
ResponseEntity<UIDataController.FooterData> response =
|
||||
controller(userService).getFooterData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.FooterData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(Boolean.TRUE, body.getAnalyticsEnabled());
|
||||
assertEquals("https://terms", body.getTermsAndConditions());
|
||||
assertEquals("https://privacy", body.getPrivacyPolicy());
|
||||
assertEquals("https://a11y", body.getAccessibilityStatement());
|
||||
assertEquals("https://cookies", body.getCookiePolicy());
|
||||
assertEquals("https://impressum", body.getImpressum());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates null/false values from configuration")
|
||||
void handlesNulls() {
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.FALSE);
|
||||
when(legal.getTermsAndConditions()).thenReturn(null);
|
||||
when(legal.getPrivacyPolicy()).thenReturn(null);
|
||||
when(legal.getAccessibilityStatement()).thenReturn(null);
|
||||
when(legal.getCookiePolicy()).thenReturn(null);
|
||||
when(legal.getImpressum()).thenReturn(null);
|
||||
|
||||
ResponseEntity<UIDataController.FooterData> response =
|
||||
controller(userService).getFooterData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.FooterData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(Boolean.FALSE, body.getAnalyticsEnabled());
|
||||
assertNull(body.getTermsAndConditions());
|
||||
assertNull(body.getPrivacyPolicy());
|
||||
assertNull(body.getAccessibilityStatement());
|
||||
assertNull(body.getCookiePolicy());
|
||||
assertNull(body.getImpressum());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getHomeData")
|
||||
class HomeData {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns OK with a populated body regardless of survey env var")
|
||||
void returnsOk() {
|
||||
ResponseEntity<UIDataController.HomeData> response =
|
||||
controller(userService).getHomeData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
// SHOW_SURVEY is unset in the test JVM, so the default (true) applies.
|
||||
assertTrue(response.getBody().isShowSurveyFromDocker());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getLicensesData")
|
||||
class LicensesData {
|
||||
|
||||
@Test
|
||||
@DisplayName("loads the bundled 3rdPartyLicenses.json from the classpath")
|
||||
void loadsDependencies() {
|
||||
ResponseEntity<UIDataController.LicensesData> response =
|
||||
controller(userService).getLicensesData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.LicensesData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
List<Dependency> deps = body.getDependencies();
|
||||
assertNotNull(deps);
|
||||
assertFalse(deps.isEmpty());
|
||||
// Each parsed dependency should at least carry a module name.
|
||||
assertNotNull(deps.get(0).getModuleName());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getPipelineData")
|
||||
class PipelineData {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the placeholder entry when the config directory is missing")
|
||||
void missingDirectoryYieldsPlaceholder() {
|
||||
String missing = Path.of("nonexistent-pipeline-dir-" + UUID.randomUUID()).toString();
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(missing);
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertTrue(body.getPipelineConfigs().isEmpty());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
Map<String, String> placeholder = body.getPipelineConfigsWithNames().get(0);
|
||||
assertEquals("", placeholder.get("json"));
|
||||
assertEquals("No preloaded configs found", placeholder.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the embedded name field when present")
|
||||
void readsConfigWithName(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(
|
||||
dir.resolve("config1.json"),
|
||||
"{\"name\":\"My Pipeline\",\"operations\":[]}",
|
||||
StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(1, body.getPipelineConfigs().size());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals("My Pipeline", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
assertTrue(
|
||||
body.getPipelineConfigsWithNames().get(0).get("json").contains("My Pipeline"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the filename (sans extension) when name is missing")
|
||||
void fallsBackToFilenameWhenNameMissing(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(
|
||||
dir.resolve("fallback-name.json"),
|
||||
"{\"operations\":[]}",
|
||||
StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals("fallback-name", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the filename when name is blank")
|
||||
void fallsBackToFilenameWhenNameBlank(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(
|
||||
dir.resolve("blank-name.json"),
|
||||
"{\"name\":\"\",\"operations\":[]}",
|
||||
StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals("blank-name", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ignores non-json files in the config directory")
|
||||
void ignoresNonJsonFiles(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("notes.txt"), "ignore me", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
dir.resolve("real.json"), "{\"name\":\"Real\"}", StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(1, body.getPipelineConfigs().size());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals("Real", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the placeholder when the directory exists but holds no json")
|
||||
void emptyDirectoryYieldsPlaceholder(@TempDir Path dir) {
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertTrue(body.getPipelineConfigs().isEmpty());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals(
|
||||
"No preloaded configs found",
|
||||
body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSignData")
|
||||
class SignData {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the current username from the user service to fetch signatures")
|
||||
void usesUsernameFromUserService() {
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
List<SignatureFile> sigs = List.of(new SignatureFile("sig.png", "Personal"));
|
||||
when(signatureService.getAvailableSignatures("alice")).thenReturn(sigs);
|
||||
|
||||
ResponseEntity<UIDataController.SignData> response =
|
||||
controller(userService).getSignData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.SignData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(sigs, body.getSignatures());
|
||||
// Fonts come from the real resource loader; the list is never null.
|
||||
assertNotNull(body.getFonts());
|
||||
verify(userService).getCurrentUsername();
|
||||
verify(signatureService).getAvailableSignatures("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to an empty username when no user service is wired")
|
||||
void nullUserServiceUsesEmptyUsername() {
|
||||
when(signatureService.getAvailableSignatures("")).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<UIDataController.SignData> response = controller(null).getSignData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.SignData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertNotNull(body.getSignatures());
|
||||
assertNotNull(body.getFonts());
|
||||
verify(signatureService).getAvailableSignatures("");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getOcrPdfData")
|
||||
class OcrData {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns an empty language list when the tessdata directory is absent")
|
||||
void absentTessdataDirYieldsEmptyList() {
|
||||
when(runtimePathConfig.getTessDataPath())
|
||||
.thenReturn(Path.of("nonexistent-tessdata-" + UUID.randomUUID()).toString());
|
||||
|
||||
ResponseEntity<UIDataController.OcrData> response =
|
||||
controller(userService).getOcrPdfData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.OcrData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertNotNull(body.getLanguages());
|
||||
assertTrue(body.getLanguages().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lists trained languages, excludes osd, and sorts alphabetically")
|
||||
void listsAndSortsTrainedLanguages(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("eng.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
Files.writeString(dir.resolve("deu.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
Files.writeString(dir.resolve("osd.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
// Non-traineddata files must be ignored.
|
||||
Files.writeString(dir.resolve("readme.txt"), "x", StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.OcrData> response =
|
||||
controller(userService).getOcrPdfData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.OcrData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
// osd filtered out; remaining sorted alphabetically.
|
||||
assertEquals(List.of("deu", "eng"), body.getLanguages());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("excludes osd case-insensitively")
|
||||
void excludesOsdCaseInsensitively(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("OSD.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
Files.writeString(dir.resolve("fra.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.OcrData> response =
|
||||
controller(userService).getOcrPdfData();
|
||||
|
||||
UIDataController.OcrData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(List.of("fra"), body.getLanguages());
|
||||
assertFalse(body.getLanguages().contains("OSD"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("FontResource format mapping")
|
||||
class FontResourceMapping {
|
||||
|
||||
@Test
|
||||
@DisplayName("maps known extensions to their CSS font-format strings")
|
||||
void mapsKnownExtensions() {
|
||||
assertEquals("truetype", new UIDataController.FontResource("Arial", "ttf").getType());
|
||||
assertEquals("woff", new UIDataController.FontResource("Arial", "woff").getType());
|
||||
assertEquals("woff2", new UIDataController.FontResource("Arial", "woff2").getType());
|
||||
assertEquals(
|
||||
"embedded-opentype",
|
||||
new UIDataController.FontResource("Arial", "eot").getType());
|
||||
assertEquals("svg", new UIDataController.FontResource("Arial", "svg").getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps unknown extensions to an empty type and preserves name/extension")
|
||||
void mapsUnknownExtensionToEmpty() {
|
||||
UIDataController.FontResource resource =
|
||||
new UIDataController.FontResource("Arial", "otf");
|
||||
assertEquals("", resource.getType());
|
||||
assertEquals("Arial", resource.getName());
|
||||
assertEquals("otf", resource.getExtension());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Cross-cutting behaviour")
|
||||
class CrossCutting {
|
||||
|
||||
@Test
|
||||
@DisplayName("getFooterData never touches the signature or user services")
|
||||
void footerDataIsIndependentOfUserState() {
|
||||
when(system.getEnableAnalytics()).thenReturn(null);
|
||||
|
||||
controller(userService).getFooterData();
|
||||
|
||||
verify(signatureService, never())
|
||||
.getAvailableSignatures(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(userService, never()).getCurrentUsername();
|
||||
}
|
||||
}
|
||||
}
|
||||
+826
@@ -0,0 +1,826 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertCbrToPdfRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertCbzToPdfRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbrRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbzRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CbrUtils;
|
||||
import stirling.software.common.util.CbzUtils;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfToCbrUtils;
|
||||
import stirling.software.common.util.PdfToCbzUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Gap coverage for {@link ConvertImgPDFController}, exercising the comic-book and image converters
|
||||
* left untested by ConvertImgPDFControllerTest. External binaries (Ghostscript, Python, RAR) are
|
||||
* never invoked: the utility boundaries are mocked statically.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertImgPDFControllerGapTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@InjectMocks private ConvertImgPDFController controller;
|
||||
|
||||
/** Builds a tiny, valid single-page A4 PDF as bytes. */
|
||||
private static byte[] tinyPdfBytes(int pages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a fresh in-memory PDDocument (the factory must hand back a real, open document). */
|
||||
private static PDDocument tinyDocument(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdfFile(String name, byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", name, "application/pdf", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf")
|
||||
class ConvertCbzToPdf {
|
||||
|
||||
@Test
|
||||
@DisplayName("disables ebook optimization when Ghostscript is not enabled")
|
||||
void disablesOptimizationWhenGhostscriptMissing() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "book.cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
|
||||
.thenReturn("book_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(tempFile, "book_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
cbz.verify(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps ebook optimization when Ghostscript is enabled")
|
||||
void keepsOptimizationWhenGhostscriptEnabled() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "book.cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(true)))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
|
||||
.thenReturn("book_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(tempFile, "book_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
cbz.verify(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(true)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the default comic name when the original filename is null")
|
||||
void usesDefaultNameWhenFilenameNull() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", null, "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"))
|
||||
.thenReturn("comic_converted.pdf");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
tempFile, "comic_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
// Default comic name is resolved when the upload carries no filename.
|
||||
gu.verify(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf")
|
||||
class ConvertCbrToPdf {
|
||||
|
||||
@Test
|
||||
@DisplayName("disables ebook optimization when Ghostscript is not enabled")
|
||||
void disablesOptimizationWhenGhostscriptMissing() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "book.cbr", "application/x-rar", new byte[] {1});
|
||||
ConvertCbrToPdfRequest request = new ConvertCbrToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
|
||||
byte[] pdfBytes = "converted-pdf".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
|
||||
|
||||
try (MockedStatic<CbrUtils> cbr = Mockito.mockStatic(CbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbr.when(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)))
|
||||
.thenReturn(pdfBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
|
||||
.thenReturn("book_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "book_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertCbrToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
cbr.verify(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps ebook optimization when Ghostscript is enabled")
|
||||
void keepsOptimizationWhenGhostscriptEnabled() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "story.cbr", "application/x-rar", new byte[] {1});
|
||||
ConvertCbrToPdfRequest request = new ConvertCbrToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
|
||||
byte[] pdfBytes = "converted-pdf".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
|
||||
|
||||
try (MockedStatic<CbrUtils> cbr = Mockito.mockStatic(CbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbr.when(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(true)))
|
||||
.thenReturn(pdfBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("story", "_converted.pdf"))
|
||||
.thenReturn("story_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "story_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertCbrToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToCbz")
|
||||
class ConvertPdfToCbz {
|
||||
|
||||
@Test
|
||||
@DisplayName("passes the requested DPI through and returns a zip response")
|
||||
void passesThroughRequestedDpi() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbzRequest request = new ConvertPdfToCbzRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(200);
|
||||
|
||||
TempFile cbzFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<PdfToCbzUtils> p2c = Mockito.mockStatic(PdfToCbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
eq(file),
|
||||
eq(200),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager)))
|
||||
.thenReturn(cbzFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbz"))
|
||||
.thenReturn("doc_converted.cbz");
|
||||
wr.when(() -> WebResponseUtils.zipFileToWebResponse(cbzFile, "doc_converted.cbz"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToCbz(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("defaults DPI to 300 when a non-positive value is supplied")
|
||||
void defaultsDpiWhenNonPositive() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbzRequest request = new ConvertPdfToCbzRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(0);
|
||||
|
||||
TempFile cbzFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<PdfToCbzUtils> p2c = Mockito.mockStatic(PdfToCbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
eq(file),
|
||||
eq(300),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager)))
|
||||
.thenReturn(cbzFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbz"))
|
||||
.thenReturn("doc_converted.cbz");
|
||||
wr.when(() -> WebResponseUtils.zipFileToWebResponse(cbzFile, "doc_converted.cbz"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToCbz(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
// Negative/zero DPI is replaced by the 300 default before delegating.
|
||||
p2c.verify(
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
eq(file),
|
||||
eq(300),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToCbr")
|
||||
class ConvertPdfToCbr {
|
||||
|
||||
@Test
|
||||
@DisplayName("passes the requested DPI and wraps bytes as an octet-stream response")
|
||||
void passesThroughRequestedDpi() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbrRequest request = new ConvertPdfToCbrRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(150);
|
||||
|
||||
byte[] cbrBytes = "cbr-archive".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(cbrBytes);
|
||||
|
||||
try (MockedStatic<PdfToCbrUtils> p2c = Mockito.mockStatic(PdfToCbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
eq(file), eq(150), eq(pdfDocumentFactory)))
|
||||
.thenReturn(cbrBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbr"))
|
||||
.thenReturn("doc_converted.cbr");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(cbrBytes),
|
||||
eq("doc_converted.cbr"),
|
||||
eq(MediaType.APPLICATION_OCTET_STREAM)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertPdfToCbr(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("defaults DPI to 300 when a non-positive value is supplied")
|
||||
void defaultsDpiWhenNonPositive() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbrRequest request = new ConvertPdfToCbrRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(-5);
|
||||
|
||||
byte[] cbrBytes = "cbr-archive".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(cbrBytes);
|
||||
|
||||
try (MockedStatic<PdfToCbrUtils> p2c = Mockito.mockStatic(PdfToCbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
eq(file), eq(300), eq(pdfDocumentFactory)))
|
||||
.thenReturn(cbrBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbr"))
|
||||
.thenReturn("doc_converted.cbr");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(cbrBytes),
|
||||
eq("doc_converted.cbr"),
|
||||
eq(MediaType.APPLICATION_OCTET_STREAM)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertPdfToCbr(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
p2c.verify(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
eq(file), eq(300), eq(pdfDocumentFactory)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertToImage")
|
||||
class ConvertToImage {
|
||||
|
||||
private MockMultipartFile imagePdf(byte[] bytes) {
|
||||
return pdfFile("source.pdf", bytes);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-image PNG path returns the rendered bytes")
|
||||
void singleImagePng() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("png");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(false);
|
||||
|
||||
// rearrangePdfPages loads a real document; convertFromPdf is the boundary we stub.
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
byte[] imageBytes = "png-image".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("PNG"),
|
||||
eq(ImageType.RGB),
|
||||
eq(true),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(false)))
|
||||
.thenReturn(imageBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(imageBytes),
|
||||
any(String.class),
|
||||
any(MediaType.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple-image path zips the rendered output with octet-stream type")
|
||||
void multipleImagesZip() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(2);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("jpg");
|
||||
request.setSingleOrMultiple("multiple");
|
||||
request.setColorType("greyscale");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(true);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(2));
|
||||
|
||||
byte[] zipBytes = "zip-bytes".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(zipBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
// greyscale -> ImageType.GRAY, multiple -> singleImage=false
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("JPG"),
|
||||
eq(ImageType.GRAY),
|
||||
eq(false),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(true)))
|
||||
.thenReturn(zipBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(zipBytes),
|
||||
any(String.class),
|
||||
eq(MediaType.APPLICATION_OCTET_STREAM)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blackwhite color type maps to BINARY image type")
|
||||
void blackwhiteMapsToBinary() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("png");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("blackwhite");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(false);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
byte[] imageBytes = "bw-image".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("PNG"),
|
||||
eq(ImageType.BINARY),
|
||||
eq(true),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(false)))
|
||||
.thenReturn(imageBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(imageBytes),
|
||||
any(String.class),
|
||||
any(MediaType.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null page numbers fall back to all pages")
|
||||
void nullPageNumbersDefaultsToAll() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("png");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers(null);
|
||||
request.setIncludeAnnotations(null);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
byte[] imageBytes = "png-image".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
// includeAnnotations null -> false
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("PNG"),
|
||||
eq(ImageType.RGB),
|
||||
eq(true),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(false)))
|
||||
.thenReturn(imageBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(imageBytes),
|
||||
any(String.class),
|
||||
any(MediaType.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("webp requested without Python throws the python-required IOException")
|
||||
void webpWithoutPythonThrows() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("webp");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(false);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<CheckProgramInstall> cpi =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
|
||||
// webp renders to PNG first, then requires Python for the final conversion.
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("png"),
|
||||
any(ImageType.class),
|
||||
anyBoolean(),
|
||||
anyInt(),
|
||||
any(String.class),
|
||||
anyBoolean()))
|
||||
.thenReturn("png-image".getBytes());
|
||||
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> controller.convertToImage(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createConvertedFilename (via the comic converters)")
|
||||
class CreateConvertedFilename {
|
||||
|
||||
@Test
|
||||
@DisplayName("strips only the trailing extension from a multi-dot filename")
|
||||
void stripsTrailingExtensionOnly() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "my.archive.cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("my.archive", "_converted.pdf"))
|
||||
.thenReturn("my.archive_converted.pdf");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
tempFile, "my.archive_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
// Only the final ".cbz" is removed, the inner dot is preserved.
|
||||
gu.verify(() -> GeneralUtils.generateFilename("my.archive", "_converted.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the default comic name when stripping leaves a blank base name")
|
||||
void fallsBackToComicWhenBaseNameBlank() throws Exception {
|
||||
// ".cbz" strips to an empty base, which the controller replaces with "comic".
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", ".cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"))
|
||||
.thenReturn("comic_converted.pdf");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
tempFile, "comic_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
gu.verify(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("controller is constructed with its injected collaborators")
|
||||
void controllerIsConstructed() {
|
||||
assertNotNull(controller);
|
||||
assertEquals(0, 0);
|
||||
}
|
||||
}
|
||||
+869
@@ -0,0 +1,869 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Gap-filling unit tests for {@link ConvertPDFToPDFA}. Focuses on validation/option/error branches
|
||||
* and the small pure helpers, mocking the collaborators so that no external binary (ghostscript,
|
||||
* qpdf, libreoffice) or network is invoked.
|
||||
*/
|
||||
@DisplayName("ConvertPDFToPDFA gap tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertPDFToPDFAGapTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private VeraPDFService veraPDFService;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ConvertPDFToPDFA newController() {
|
||||
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
|
||||
}
|
||||
|
||||
// ---- reflection helpers ----------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T invokeStatic(String methodName, Object... args) throws Exception {
|
||||
Method method = findMethod(methodName, args.length);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (T) method.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T invokeInstance(Object target, String methodName, Object... args)
|
||||
throws Exception {
|
||||
Method method = findMethod(methodName, args.length);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (T) method.invoke(target, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Method findMethod(String methodName, int argCount) {
|
||||
for (Method method : ConvertPDFToPDFA.class.getDeclaredMethods()) {
|
||||
if (method.getName().equals(methodName) && method.getParameterCount() == argCount) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"No method named " + methodName + " with " + argCount + " params");
|
||||
}
|
||||
|
||||
private static Exception unwrap(InvocationTargetException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof Exception ex) {
|
||||
return ex;
|
||||
}
|
||||
return new RuntimeException(cause);
|
||||
}
|
||||
|
||||
// ---- pdf builders ----------------------------------------------------------------------
|
||||
|
||||
private PDDocument simplePdf() throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(100, 700);
|
||||
cs.showText("hello world");
|
||||
cs.endText();
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private byte[] simplePdfBytes() throws IOException {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("PdfaProfile.fromRequest token resolution")
|
||||
class PdfaProfileResolution {
|
||||
|
||||
// invokes the enum's static fromRequest via reflection, then reads getPart()/displayName
|
||||
private Object resolveProfile(String token) throws Exception {
|
||||
Class<?> enumClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("PdfaProfile")) {
|
||||
enumClass = inner;
|
||||
}
|
||||
}
|
||||
assertThat(enumClass).isNotNull();
|
||||
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, token);
|
||||
}
|
||||
|
||||
private int partOf(Object profile) throws Exception {
|
||||
Method getPart = profile.getClass().getDeclaredMethod("getPart");
|
||||
getPart.setAccessible(true);
|
||||
return (int) getPart.invoke(profile);
|
||||
}
|
||||
|
||||
private String suffixOf(Object profile) throws Exception {
|
||||
Method m = profile.getClass().getDeclaredMethod("outputSuffix");
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(profile);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null token defaults to PDF/A-2b")
|
||||
void nullDefaultsToPdfA2() throws Exception {
|
||||
Object profile = resolveProfile(null);
|
||||
assertThat(partOf(profile)).isEqualTo(2);
|
||||
assertThat(suffixOf(profile)).isEqualTo("_PDFA-2b.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfa-1' resolves to PDF/A-1b")
|
||||
void pdfa1Resolves() throws Exception {
|
||||
assertThat(partOf(resolveProfile("pdfa-1"))).isEqualTo(1);
|
||||
assertThat(suffixOf(resolveProfile("pdfa-1"))).isEqualTo("_PDFA-1b.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfa' and 'pdfa-2b' resolve to PDF/A-2b")
|
||||
void pdfa2Resolves() throws Exception {
|
||||
assertThat(partOf(resolveProfile("pdfa"))).isEqualTo(2);
|
||||
assertThat(partOf(resolveProfile("pdfa-2b"))).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfa-3' and 'pdfa-3b' resolve to PDF/A-3b")
|
||||
void pdfa3Resolves() throws Exception {
|
||||
assertThat(partOf(resolveProfile("pdfa-3"))).isEqualTo(3);
|
||||
assertThat(partOf(resolveProfile("pdfa-3b"))).isEqualTo(3);
|
||||
assertThat(suffixOf(resolveProfile("pdfa-3"))).isEqualTo("_PDFA-3b.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("token is trimmed and case-insensitive")
|
||||
void caseInsensitiveAndTrimmed() throws Exception {
|
||||
assertThat(partOf(resolveProfile(" PDFA-1 "))).isEqualTo(1);
|
||||
assertThat(partOf(resolveProfile("PDFA-3B"))).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown token falls back to PDF/A-2b")
|
||||
void unknownFallsBack() throws Exception {
|
||||
assertThat(partOf(resolveProfile("not-a-real-format"))).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("PdfXProfile.fromRequest token resolution")
|
||||
class PdfXProfileResolution {
|
||||
|
||||
private Object resolveProfile(String token) throws Exception {
|
||||
Class<?> enumClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("PdfXProfile")) {
|
||||
enumClass = inner;
|
||||
}
|
||||
}
|
||||
assertThat(enumClass).isNotNull();
|
||||
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, token);
|
||||
}
|
||||
|
||||
private String suffixOf(Object profile) throws Exception {
|
||||
Method m = profile.getClass().getDeclaredMethod("outputSuffix");
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(profile);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null token defaults to PDF/X")
|
||||
void nullDefaultsToPdfX() throws Exception {
|
||||
assertThat(suffixOf(resolveProfile(null))).isEqualTo("_PDFX.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfx' resolves to the PDF/X profile")
|
||||
void pdfxResolves() throws Exception {
|
||||
assertThat(suffixOf(resolveProfile("pdfx"))).isEqualTo("_PDFX.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown token falls back to PDF/X")
|
||||
void unknownFallsBack() throws Exception {
|
||||
assertThat(suffixOf(resolveProfile("garbage"))).isEqualTo("_PDFX.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("detectMimeTypeFromFilename")
|
||||
class MimeTypeDetection {
|
||||
|
||||
private String detect(String fileName) throws Exception {
|
||||
return invokeInstance(newController(), "detectMimeTypeFromFilename", fileName);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("known extensions map to their MIME type")
|
||||
void knownExtensions() throws Exception {
|
||||
assertThat(detect("data.xml")).isEqualTo("application/xml");
|
||||
assertThat(detect("data.json")).isEqualTo("application/json");
|
||||
assertThat(detect("notes.txt")).isEqualTo("text/plain");
|
||||
assertThat(detect("image.png")).isEqualTo("image/png");
|
||||
assertThat(detect("photo.JPEG")).isEqualTo("image/jpeg");
|
||||
assertThat(detect("archive.zip")).isEqualTo("application/zip");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown extension yields octet-stream default")
|
||||
void unknownExtension() throws Exception {
|
||||
assertThat(detect("file.unknownext")).isEqualTo("application/octet-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null and empty file names yield octet-stream default")
|
||||
void nullAndEmpty() throws Exception {
|
||||
assertThat(detect(null)).isEqualTo("application/octet-stream");
|
||||
assertThat(detect("")).isEqualTo("application/octet-stream");
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("countGlyphs / buildStandardType1GlyphSet")
|
||||
class GlyphHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("countGlyphs counts forward slashes")
|
||||
void countsSlashes() throws Exception {
|
||||
assertThat((int) invokeStatic("countGlyphs", "/a/b/c")).isEqualTo(3);
|
||||
assertThat((int) invokeStatic("countGlyphs", "no-slashes")).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("countGlyphs handles null and empty")
|
||||
void countsNullEmpty() throws Exception {
|
||||
assertThat((int) invokeStatic("countGlyphs", (Object) null)).isEqualTo(0);
|
||||
assertThat((int) invokeStatic("countGlyphs", "")).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("standard glyph set is space-separated and contains core glyphs")
|
||||
void standardGlyphSet() throws Exception {
|
||||
String glyphs = invokeStatic("buildStandardType1GlyphSet");
|
||||
assertThat(glyphs).isNotBlank();
|
||||
assertThat(glyphs).contains(".notdef", "space", "A", "z", "zero", "period");
|
||||
// space-separated; no leading slash format here
|
||||
assertThat(glyphs.split(" ").length).isGreaterThan(100);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("isType1Font / quad and rect validation")
|
||||
class TypeAndGeometryHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("isType1Font true for Standard14 Type1 font")
|
||||
void isType1True() throws Exception {
|
||||
PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
assertThat((boolean) invokeStatic("isType1Font", font)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isValidQuadPoints accepts multiples of 8, rejects otherwise")
|
||||
void quadValidation() throws Exception {
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
assertThat(
|
||||
(boolean)
|
||||
invokeInstance(
|
||||
controller, "isValidQuadPoints", (Object) new float[8]))
|
||||
.isTrue();
|
||||
assertThat(
|
||||
(boolean)
|
||||
invokeInstance(
|
||||
controller,
|
||||
"isValidQuadPoints",
|
||||
(Object) new float[16]))
|
||||
.isTrue();
|
||||
assertThat(
|
||||
(boolean)
|
||||
invokeInstance(
|
||||
controller, "isValidQuadPoints", (Object) new float[5]))
|
||||
.isFalse();
|
||||
assertThat((boolean) invokeInstance(controller, "isValidQuadPoints", (Object) null))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isZeroSizeRect distinguishes collapsed and real rectangles")
|
||||
void zeroSizeRect() throws Exception {
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
PDRectangle zero = new PDRectangle(10f, 10f, 0f, 0f);
|
||||
PDRectangle real = new PDRectangle(0f, 0f, 100f, 50f);
|
||||
assertThat((boolean) invokeInstance(controller, "isZeroSizeRect", zero)).isTrue();
|
||||
assertThat((boolean) invokeInstance(controller, "isZeroSizeRect", real)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("findUnembeddedFontNames")
|
||||
class UnembeddedFontDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("standard 14 fonts (not embedded) are reported as missing")
|
||||
void detectsStandardFontAsUnembedded() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
Set<String> missing = invokeStatic("findUnembeddedFontNames", document);
|
||||
assertThat(missing).isNotNull();
|
||||
assertThat(missing).anyMatch(name -> name.contains("Helvetica"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no resources reports no missing fonts")
|
||||
void pageWithoutResources() throws Exception {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
page.setResources(new PDResources());
|
||||
document.addPage(page);
|
||||
Set<String> missing = invokeStatic("findUnembeddedFontNames", document);
|
||||
assertThat(missing).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("detectTransparentXObjects")
|
||||
class TransparentXObjectDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no resources returns empty set")
|
||||
void noResources() throws Exception {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
Set<COSName> result = invokeStatic("detectTransparentXObjects", page);
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with empty resources returns empty set")
|
||||
void emptyResources() throws Exception {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
page.setResources(new PDResources());
|
||||
Set<COSName> result = invokeStatic("detectTransparentXObjects", page);
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("sanitizePdfA part-specific behaviour")
|
||||
class SanitizePdfAExtras {
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/A-3 preserves embedded-file structures (FILESPEC type)")
|
||||
void pdfA3PreservesFilespec() throws Exception {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setItem(COSName.TYPE, COSName.FILESPEC);
|
||||
dict.setItem(COSName.EF, new COSDictionary());
|
||||
dict.setItem(COSName.URI, COSName.A);
|
||||
|
||||
invokeStatic("sanitizePdfA", dict, 3);
|
||||
|
||||
// For part 3, filespec dictionaries are skipped entirely, so URI survives.
|
||||
assertThat(dict.containsKey(COSName.EF)).isTrue();
|
||||
assertThat(dict.containsKey(COSName.URI)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/A-3 keeps URI on a normal dictionary but still strips JavaScript")
|
||||
void pdfA3KeepsUriStripsJs() throws Exception {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setString(COSName.URI, "http://example.com");
|
||||
dict.setString(COSName.JAVA_SCRIPT, "app.alert('x');");
|
||||
|
||||
invokeStatic("sanitizePdfA", dict, 3);
|
||||
|
||||
assertThat(dict.containsKey(COSName.URI)).isTrue();
|
||||
assertThat(dict.containsKey(COSName.JAVA_SCRIPT)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recurses into nested arrays and dictionaries")
|
||||
void recursesIntoNested() throws Exception {
|
||||
COSDictionary child = new COSDictionary();
|
||||
child.setString(COSName.JAVA_SCRIPT, "code");
|
||||
COSArray array = new COSArray();
|
||||
array.add(child);
|
||||
COSDictionary parent = new COSDictionary();
|
||||
parent.setItem(COSName.getPDFName("Kids"), array);
|
||||
|
||||
invokeStatic("sanitizePdfA", parent, 2);
|
||||
|
||||
assertThat(child.containsKey(COSName.JAVA_SCRIPT)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/A-2 does NOT remove SMask/CA (those are only stripped for part 1)")
|
||||
void pdfA2KeepsTransparencyEntries() throws Exception {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setItem(COSName.SMASK, new COSArray());
|
||||
dict.setFloat(COSName.CA, 0.5f);
|
||||
|
||||
invokeStatic("sanitizePdfA", dict, 2);
|
||||
|
||||
assertThat(dict.containsKey(COSName.SMASK)).isTrue();
|
||||
assertThat(dict.containsKey(COSName.CA)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("sanitizeMetadata / removeForbiddenActions")
|
||||
class MetadataAndActions {
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizeMetadata strips non-printable chars and sets producer")
|
||||
void sanitizeMetadataCleans() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setCustomMetadataValue("Custom", "cleanvalue");
|
||||
document.setDocumentInformation(info);
|
||||
|
||||
invokeInstance(newController(), "sanitizeMetadata", document);
|
||||
|
||||
PDDocumentInformation result = document.getDocumentInformation();
|
||||
assertThat(result.getProducer()).isEqualTo("Stirling-PDF Sanitizer");
|
||||
assertThat(result.getCustomMetadataValue("Custom")).isEqualTo("cleanvalue");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizeMetadata always overwrites producer to the sanitizer marker")
|
||||
void sanitizeMetadataOverwritesProducer() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setProducer("Some Other Producer");
|
||||
document.setDocumentInformation(info);
|
||||
|
||||
invokeInstance(newController(), "sanitizeMetadata", document);
|
||||
|
||||
assertThat(document.getDocumentInformation().getProducer())
|
||||
.isEqualTo("Stirling-PDF Sanitizer");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removeForbiddenActions clears open action and JavaScript")
|
||||
void removeForbiddenActions() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
catalog.getCOSObject().setItem(COSName.JAVA_SCRIPT, new COSDictionary());
|
||||
|
||||
invokeInstance(newController(), "removeForbiddenActions", document);
|
||||
|
||||
assertThat(catalog.getCOSObject().containsKey(COSName.JAVA_SCRIPT)).isFalse();
|
||||
assertThat(catalog.getOpenAction()).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("fixOptionalContentGroups")
|
||||
class OptionalContentGroups {
|
||||
|
||||
@Test
|
||||
@DisplayName("no OCProperties is a no-op (does not throw)")
|
||||
void noOcProperties() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThatCode(() -> invokeStatic("fixOptionalContentGroups", document))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("addWhiteBackground")
|
||||
class WhiteBackground {
|
||||
|
||||
@Test
|
||||
@DisplayName("adds a prepended content stream without changing page count")
|
||||
void addsBackground() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
int pagesBefore = document.getNumberOfPages();
|
||||
assertThatCode(
|
||||
() ->
|
||||
invokeInstance(
|
||||
newController(), "addWhiteBackground", document))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(document.getNumberOfPages()).isEqualTo(pagesBefore);
|
||||
// page still has content streams after prepending background
|
||||
assertThat(document.getPage(0).hasContents()).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("ensureAnnotationAppearances")
|
||||
class AnnotationAppearances {
|
||||
|
||||
@Test
|
||||
@DisplayName("Link annotations are skipped (kept) by appearance enforcement")
|
||||
void linkAnnotationsKept() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDPage page = document.getPage(0);
|
||||
PDAnnotationLink link = new PDAnnotationLink();
|
||||
link.setRectangle(new PDRectangle(0, 0, 50, 50));
|
||||
List<PDAnnotation> annotations = new ArrayList<>();
|
||||
annotations.add(link);
|
||||
page.setAnnotations(annotations);
|
||||
|
||||
invokeInstance(newController(), "ensureAnnotationAppearances", document);
|
||||
|
||||
assertThat(page.getAnnotations()).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("ensureEmbeddedFileCompliance / addICCProfileIfNotPresent")
|
||||
class EmbeddedAndIcc {
|
||||
|
||||
@Test
|
||||
@DisplayName("ensureEmbeddedFileCompliance returns quietly with no names dictionary")
|
||||
void noNamesDictionary() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThatCode(
|
||||
() ->
|
||||
invokeInstance(
|
||||
newController(),
|
||||
"ensureEmbeddedFileCompliance",
|
||||
document))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addICCProfileIfNotPresent adds an sRGB output intent")
|
||||
void addsIccOutputIntent() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents()).isEmpty();
|
||||
|
||||
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
|
||||
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents()).hasSize(1);
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents().get(0).getInfo())
|
||||
.contains("sRGB");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addICCProfileIfNotPresent does not add a second intent when one exists")
|
||||
void doesNotDuplicateIntent() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
|
||||
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents()).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("performBasicPdfAValidation / buildComprehensiveValidationMessage")
|
||||
class ValidationHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("basic validation flags missing XMP and output intent for a plain PDF")
|
||||
void basicValidationFlagsMissing() throws Exception {
|
||||
Path pdf = tempDir.resolve("plain.pdf");
|
||||
Files.write(pdf, simplePdfBytes());
|
||||
|
||||
// PdfaProfile.PDF_A_2B has no preflight format -> basic validation path.
|
||||
Object profile = resolvePdfaProfile("pdfa-2b");
|
||||
org.apache.pdfbox.preflight.ValidationResult result =
|
||||
invokeStaticWithTypes(
|
||||
"performBasicPdfAValidation",
|
||||
new Class<?>[] {Path.class, profile.getClass()},
|
||||
pdf,
|
||||
profile);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.isValid()).isFalse();
|
||||
assertThat(result.getErrorsList()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("comprehensive message summarises error count and codes")
|
||||
void comprehensiveMessage() throws Exception {
|
||||
Object profile = resolvePdfaProfile("pdfa-1");
|
||||
org.apache.pdfbox.preflight.ValidationResult result =
|
||||
new org.apache.pdfbox.preflight.ValidationResult(false);
|
||||
result.addError(
|
||||
new org.apache.pdfbox.preflight.ValidationResult.ValidationError(
|
||||
"CODE_A", "first problem"));
|
||||
result.addError(
|
||||
new org.apache.pdfbox.preflight.ValidationResult.ValidationError(
|
||||
"CODE_B", "second problem"));
|
||||
|
||||
String message =
|
||||
invokeStaticWithTypes(
|
||||
"buildComprehensiveValidationMessage",
|
||||
new Class<?>[] {
|
||||
org.apache.pdfbox.preflight.ValidationResult.class,
|
||||
profile.getClass()
|
||||
},
|
||||
result,
|
||||
profile);
|
||||
|
||||
assertThat(message).contains("PDF/A-1b");
|
||||
assertThat(message).contains("2 errors");
|
||||
assertThat(message).contains("CODE_A");
|
||||
}
|
||||
|
||||
// helper: resolve enum constant + call typed static method
|
||||
private Object resolvePdfaProfile(String token) throws Exception {
|
||||
Class<?> enumClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("PdfaProfile")) {
|
||||
enumClass = inner;
|
||||
}
|
||||
}
|
||||
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, token);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T invokeStaticWithTypes(String name, Class<?>[] types, Object... args)
|
||||
throws Exception {
|
||||
Method m = ConvertPDFToPDFA.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
return (T) m.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw unwrap(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("verifyStrictCompliance (VeraPDFService mocked)")
|
||||
class StrictCompliance {
|
||||
|
||||
@Test
|
||||
@DisplayName("compliant result passes without throwing")
|
||||
void compliantPasses() throws Exception {
|
||||
PDFVerificationResult ok = new PDFVerificationResult();
|
||||
ok.setCompliant(true);
|
||||
ok.setStandard("1b");
|
||||
ok.setComplianceSummary("PDF/A-1b compliant");
|
||||
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
assertThatCode(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-compliant result throws 400 ResponseStatusException with details")
|
||||
void nonCompliantThrowsBadRequest() throws Exception {
|
||||
PDFVerificationResult bad = new PDFVerificationResult();
|
||||
bad.setCompliant(false);
|
||||
bad.setStandard("1b");
|
||||
bad.setComplianceSummary("PDF/A-1b with errors");
|
||||
when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad));
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
ResponseStatusException ex =
|
||||
(ResponseStatusException)
|
||||
catchThrowable(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(ex.getReason()).contains("PDF/A-1b with errors");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty result list is treated as non-compliant -> 400")
|
||||
void emptyResultsTreatedNonCompliant() throws Exception {
|
||||
when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList());
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
ResponseStatusException ex =
|
||||
(ResponseStatusException)
|
||||
catchThrowable(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("service error is wrapped as 500 ResponseStatusException")
|
||||
void serviceErrorWrappedAs500() throws Exception {
|
||||
when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom"));
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
ResponseStatusException ex =
|
||||
(ResponseStatusException)
|
||||
catchThrowable(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("pdfToPdfA controller validation branch")
|
||||
class ControllerValidation {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-PDF content type throws PDF-required exception before any conversion")
|
||||
void nonPdfContentTypeRejected() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.txt",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
"not a pdf".getBytes());
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(file);
|
||||
request.setOutputFormat("pdfa-2b");
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
|
||||
assertThatThrownBy(() -> controller.pdfToPdfA(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
// collaborators must not be touched on the validation-failure path
|
||||
verifyNoInteractions(tempFileManager, veraPDFService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null content type is also rejected as not-a-PDF")
|
||||
void nullContentTypeRejected() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "input.bin", null, "data".getBytes());
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(file);
|
||||
request.setOutputFormat("pdfa");
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
|
||||
assertThatThrownBy(() -> controller.pdfToPdfA(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("ensureEmbeddedFilesAFRelationship / isTransparencyGroup")
|
||||
class StaticEdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("ensureEmbeddedFilesAFRelationship is a no-op when no names dictionary")
|
||||
void afRelationshipNoNames() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThatCode(() -> invokeStatic("ensureEmbeddedFilesAFRelationship", document))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isTransparencyGroup true only for /S /Transparency group dictionaries")
|
||||
void transparencyGroup() throws Exception {
|
||||
COSDictionary withGroup = new COSDictionary();
|
||||
COSDictionary groupDict = new COSDictionary();
|
||||
groupDict.setItem(COSName.S, COSName.TRANSPARENCY);
|
||||
withGroup.setItem(COSName.GROUP, groupDict);
|
||||
assertThat((boolean) invokeStatic("isTransparencyGroup", withGroup)).isTrue();
|
||||
|
||||
COSDictionary withoutGroup = new COSDictionary();
|
||||
assertThat((boolean) invokeStatic("isTransparencyGroup", withoutGroup)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ConvertPdfToVideoController}.
|
||||
*
|
||||
* <p>The public {@code convertPdfToVideo} endpoint is commented out in production (ffmpeg disabled
|
||||
* due to CVEs), so these tests exercise the remaining private helper methods directly via
|
||||
* reflection. No external processes (ffmpeg) are ever spawned: {@code buildFfmpegCommand} only
|
||||
* constructs the argument list, and {@code generateFrames} renders PDF pages to PNG files on disk.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertPdfToVideoControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ConvertPdfToVideoController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new ConvertPdfToVideoController(pdfDocumentFactory, tempFileManager);
|
||||
}
|
||||
|
||||
// ---- reflection helpers -------------------------------------------------
|
||||
|
||||
private Object invokePrivate(String name, Class<?>[] types, Object... args) throws Exception {
|
||||
Method method = ConvertPdfToVideoController.class.getDeclaredMethod(name, types);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return method.invoke(controller, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeFormat(String requested) throws Exception {
|
||||
return (String) invokePrivate("normalizeFormat", new Class<?>[] {String.class}, requested);
|
||||
}
|
||||
|
||||
private MediaType getMediaType(String format) throws Exception {
|
||||
return (MediaType) invokePrivate("getMediaType", new Class<?>[] {String.class}, format);
|
||||
}
|
||||
|
||||
private int getMaxDpi() throws Exception {
|
||||
return (int) invokePrivate("getMaxDpi", new Class<?>[] {});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> buildFfmpegCommand(
|
||||
String format, String resolution, String frameRate, TempFile outputVideo)
|
||||
throws Exception {
|
||||
return (List<String>)
|
||||
invokePrivate(
|
||||
"buildFfmpegCommand",
|
||||
new Class<?>[] {String.class, String.class, String.class, TempFile.class},
|
||||
format,
|
||||
resolution,
|
||||
frameRate,
|
||||
outputVideo);
|
||||
}
|
||||
|
||||
private void applyWatermark(BufferedImage image, float opacity, String text) throws Exception {
|
||||
invokePrivate(
|
||||
"applyWatermark",
|
||||
new Class<?>[] {BufferedImage.class, float.class, String.class},
|
||||
image,
|
||||
opacity,
|
||||
text);
|
||||
}
|
||||
|
||||
private void generateFrames(
|
||||
Path inputPdf,
|
||||
Path outputDir,
|
||||
int dpi,
|
||||
float opacity,
|
||||
String watermarkText,
|
||||
boolean watermarkEnabled)
|
||||
throws Exception {
|
||||
invokePrivate(
|
||||
"generateFrames",
|
||||
new Class<?>[] {
|
||||
Path.class, Path.class, int.class, float.class, String.class, boolean.class
|
||||
},
|
||||
inputPdf,
|
||||
outputDir,
|
||||
dpi,
|
||||
opacity,
|
||||
watermarkText,
|
||||
watermarkEnabled);
|
||||
}
|
||||
|
||||
// ---- PDF builder helper -------------------------------------------------
|
||||
|
||||
private byte[] buildPdf(int pageCount) throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- normalizeFormat ----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("normalizeFormat")
|
||||
class NormalizeFormat {
|
||||
|
||||
@Test
|
||||
@DisplayName("null defaults to mp4")
|
||||
void nullDefaultsToMp4() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mp4 passes through")
|
||||
void mp4PassesThrough() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat("mp4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("webm passes through")
|
||||
void webmPassesThrough() throws Exception {
|
||||
assertEquals("webm", normalizeFormat("webm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uppercase is lowercased")
|
||||
void uppercaseIsLowercased() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat("MP4"));
|
||||
assertEquals("webm", normalizeFormat("WEBM"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mixed case is normalized")
|
||||
void mixedCaseIsNormalized() throws Exception {
|
||||
assertEquals("webm", normalizeFormat("WeBm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unsupported format falls back to mp4")
|
||||
void unsupportedFallsBackToMp4() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat("avi"));
|
||||
assertEquals("mp4", normalizeFormat("gif"));
|
||||
assertEquals("mp4", normalizeFormat(""));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getMediaType -------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getMediaType")
|
||||
class GetMediaType {
|
||||
|
||||
@Test
|
||||
@DisplayName("webm maps to video/webm")
|
||||
void webmMapsToVideoWebm() throws Exception {
|
||||
assertEquals(MediaType.valueOf("video/webm"), getMediaType("webm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mp4 maps to video/mp4")
|
||||
void mp4MapsToVideoMp4() throws Exception {
|
||||
assertEquals(MediaType.valueOf("video/mp4"), getMediaType("mp4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown format defaults to video/mp4")
|
||||
void unknownDefaultsToVideoMp4() throws Exception {
|
||||
assertEquals(MediaType.valueOf("video/mp4"), getMediaType("avi"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getMaxDpi ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getMaxDpi")
|
||||
class GetMaxDpi {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 500 fallback when no Spring context is available")
|
||||
void returnsFallbackWithoutContext() throws Exception {
|
||||
// No ApplicationContext is set in this unit test, so getBean returns null and the
|
||||
// method falls back to the hardcoded default of 500.
|
||||
assertEquals(500, getMaxDpi());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- buildFfmpegCommand -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildFfmpegCommand")
|
||||
class BuildFfmpegCommand {
|
||||
|
||||
private TempFile newTempFile(File backing) throws IOException {
|
||||
when(tempFileManager.createTempFile(any())).thenReturn(backing);
|
||||
return new TempFile(tempFileManager, ".mp4");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mp4 command includes libx264 and faststart flags")
|
||||
void mp4Command(@TempDir Path dir) throws Exception {
|
||||
File backing = dir.resolve("out.mp4").toFile();
|
||||
TempFile outputVideo = newTempFile(backing);
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "ORIGINAL", "0.333333", outputVideo);
|
||||
|
||||
assertEquals("ffmpeg", command.get(0));
|
||||
assertTrue(command.contains("-y"));
|
||||
assertTrue(command.contains("-framerate"));
|
||||
assertTrue(command.contains("0.333333"));
|
||||
assertTrue(command.contains("frame_%05d.png"));
|
||||
assertTrue(command.contains("-vf"));
|
||||
assertTrue(command.contains("libx264"));
|
||||
assertTrue(command.contains("yuv420p"));
|
||||
assertTrue(command.contains("+faststart"));
|
||||
assertFalse(command.contains("libvpx-vp9"));
|
||||
// Output path is always the last argument.
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("webm command includes libvpx-vp9 and crf flags")
|
||||
void webmCommand(@TempDir Path dir) throws Exception {
|
||||
File backing = dir.resolve("out.webm").toFile();
|
||||
TempFile outputVideo = newTempFile(backing);
|
||||
|
||||
List<String> command = buildFfmpegCommand("webm", "720P", "0.5", outputVideo);
|
||||
|
||||
assertTrue(command.contains("libvpx-vp9"));
|
||||
assertTrue(command.contains("-crf"));
|
||||
assertTrue(command.contains("30"));
|
||||
assertFalse(command.contains("libx264"));
|
||||
assertFalse(command.contains("+faststart"));
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("framerate value is placed right after -framerate")
|
||||
void framerateOrdering(@TempDir Path dir) throws Exception {
|
||||
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "ORIGINAL", "0.25", outputVideo);
|
||||
|
||||
int idx = command.indexOf("-framerate");
|
||||
assertTrue(idx >= 0);
|
||||
assertEquals("0.25", command.get(idx + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("known resolution applies the matching scale filter")
|
||||
void knownResolutionFilter(@TempDir Path dir) throws Exception {
|
||||
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "1080P", "0.5", outputVideo);
|
||||
|
||||
int idx = command.indexOf("-vf");
|
||||
assertEquals("scale=-2:1080,setsar=1", command.get(idx + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown resolution falls back to the ORIGINAL filter")
|
||||
void unknownResolutionFallsBackToOriginal(@TempDir Path dir) throws Exception {
|
||||
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "NONSENSE", "0.5", outputVideo);
|
||||
|
||||
int idx = command.indexOf("-vf");
|
||||
assertEquals("scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1", command.get(idx + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- applyWatermark -----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyWatermark")
|
||||
class ApplyWatermark {
|
||||
|
||||
private BufferedImage solidImage(int w, int h, Color color) {
|
||||
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("modifies pixels at full opacity without throwing")
|
||||
void modifiesPixels() throws Exception {
|
||||
// The watermark is drawn through the image centre, so a small image still gets pixels
|
||||
// painted; the smaller buffer keeps the two getRGB snapshots cheap.
|
||||
BufferedImage image = solidImage(100, 80, Color.RED);
|
||||
int[] before =
|
||||
image.getRGB(
|
||||
0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
|
||||
|
||||
applyWatermark(image, 1.0f, "CONFIDENTIAL");
|
||||
|
||||
int[] after =
|
||||
image.getRGB(
|
||||
0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
|
||||
boolean changed = false;
|
||||
for (int i = 0; i < before.length; i++) {
|
||||
if (before[i] != after[i]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertTrue(changed, "watermark should have altered at least one pixel");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw on a small square image")
|
||||
void handlesSmallImage() throws Exception {
|
||||
BufferedImage image = solidImage(50, 50, Color.BLUE);
|
||||
applyWatermark(image, 0.5f, "X");
|
||||
assertNotNull(image);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw at zero opacity")
|
||||
void handlesZeroOpacity() throws Exception {
|
||||
BufferedImage image = solidImage(120, 80, Color.GREEN);
|
||||
applyWatermark(image, 0.0f, "WM");
|
||||
assertNotNull(image);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- generateFrames -----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("generateFrames")
|
||||
class GenerateFrames {
|
||||
|
||||
@Test
|
||||
@DisplayName("renders one PNG frame per page")
|
||||
void rendersOneFramePerPage(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("input.pdf");
|
||||
Files.write(inputPdf, buildPdf(3));
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
// The factory must return a fresh, real document that the controller can render.
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(Loader.loadPDF(buildPdf(3)));
|
||||
|
||||
generateFrames(inputPdf, outputDir, 72, 1.0f, null, false);
|
||||
|
||||
assertTrue(Files.exists(outputDir.resolve("frame_00001.png")));
|
||||
assertTrue(Files.exists(outputDir.resolve("frame_00002.png")));
|
||||
assertTrue(Files.exists(outputDir.resolve("frame_00003.png")));
|
||||
try (var stream = Files.list(outputDir)) {
|
||||
assertEquals(3, stream.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applies watermark when enabled and still produces frames")
|
||||
void appliesWatermarkWhenEnabled(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("input.pdf");
|
||||
Files.write(inputPdf, buildPdf(1));
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(Loader.loadPDF(buildPdf(1)));
|
||||
|
||||
generateFrames(inputPdf, outputDir, 72, 0.8f, "DRAFT", true);
|
||||
|
||||
Path frame = outputDir.resolve("frame_00001.png");
|
||||
assertTrue(Files.exists(frame));
|
||||
assertTrue(Files.size(frame) > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero-page document throws IllegalArgumentException")
|
||||
void zeroPageThrows(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("empty.pdf");
|
||||
// An empty PDDocument (no pages) cannot be saved/loaded, so feed an empty doc directly.
|
||||
Files.write(inputPdf, new byte[] {0});
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(new PDDocument());
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> generateFrames(inputPdf, outputDir, 72, 1.0f, null, false));
|
||||
try (var stream = Files.list(outputDir)) {
|
||||
assertEquals(0, stream.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates IOException from the document factory")
|
||||
void propagatesLoadFailure(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("input.pdf");
|
||||
Files.write(inputPdf, buildPdf(1));
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenThrow(new IOException("boom"));
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> generateFrames(inputPdf, outputDir, 72, 1.0f, null, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AutoSplitPdfControllerTest {
|
||||
|
||||
private static final String VALID_QR = "https://stirlingpdf.com";
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AutoSplitPdfController controller;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
// Keep maxDPI at the QR detection DPI so the high-DPI retry path is skipped (fast tests).
|
||||
applicationProperties.getSystem().setMaxDPI(150);
|
||||
controller =
|
||||
new AutoSplitPdfController(
|
||||
pdfDocumentFactory, tempFileManager, applicationProperties);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Build a tiny single-colour BufferedImage. */
|
||||
private static BufferedImage solidImage(int w, int h, Color color) {
|
||||
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/** Generate a real, decodable QR code as a BufferedImage using zxing core only. */
|
||||
private static BufferedImage qrImage(String text, int size) throws Exception {
|
||||
QRCodeWriter writer = new QRCodeWriter();
|
||||
java.util.Map<EncodeHintType, Object> hints = new java.util.EnumMap<>(EncodeHintType.class);
|
||||
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
|
||||
hints.put(EncodeHintType.MARGIN, 4);
|
||||
BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints);
|
||||
int width = matrix.getWidth();
|
||||
int height = matrix.getHeight();
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
image.setRGB(x, y, matrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB());
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
/** Build an in-memory PDF document with the given number of plain pages. */
|
||||
private static PDDocument simpleDoc(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(new PDRectangle(200, 200)));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* A PDF where every page draws the supplied image (used so embedded-image extraction works).
|
||||
*/
|
||||
private static PDDocument docWithImageOnEachPage(BufferedImage img, int pages)
|
||||
throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = new PDPage(new PDRectangle(200, 200));
|
||||
doc.addPage(page);
|
||||
PDImageXObject xobj = LosslessFactory.createFromImage(doc, img);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(xobj, 20, 20, 160, 160);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Load a PDF from the InputStream the controller hands to pdfDocumentFactory.load(...). */
|
||||
private static PDDocument loadFromStream(InputStream in) throws Exception {
|
||||
return Loader.loadPDF(in.readAllBytes());
|
||||
}
|
||||
|
||||
private static byte[] docToBytes(PDDocument doc) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
doc.close();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static AutoSplitPdfRequest request(byte[] pdfBytes, Boolean duplex) {
|
||||
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
|
||||
req.setFileInput(
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"fileInput", "input.pdf", "application/pdf", pdfBytes));
|
||||
req.setDuplexMode(duplex);
|
||||
return req;
|
||||
}
|
||||
|
||||
/** Make tempFileManager.createTempFile(".zip") create a real file inside the JUnit temp dir. */
|
||||
private void wireRealTempFile() throws Exception {
|
||||
when(tempFileManager.createTempFile(".zip"))
|
||||
.thenAnswer(
|
||||
invocation ->
|
||||
Files.createTempFile(tempDir, "stirling-test", ".zip").toFile());
|
||||
}
|
||||
|
||||
private static List<String> zipEntryNames(byte[] zipBytes) throws Exception {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
names.add(entry.getName());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static byte[] readResource(Resource resource) throws Exception {
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
return in.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
// reflection invokers for the private (static) helpers ------------------
|
||||
|
||||
private static Object invokeStatic(String name, Class<?>[] types, Object... args)
|
||||
throws Exception {
|
||||
Method m = AutoSplitPdfController.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
return m.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
if (e.getCause() instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private Object invokeInstance(String name, Class<?>[] types, Object... args) throws Exception {
|
||||
Method m = AutoSplitPdfController.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
return m.invoke(controller, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
if (e.getCause() instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// isBlankImage(int[])
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("isBlankImage")
|
||||
class IsBlankImage {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty array is treated as blank")
|
||||
void emptyArrayIsBlank() throws Exception {
|
||||
Object result =
|
||||
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) new int[0]);
|
||||
assertEquals(Boolean.TRUE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uniform pixels are blank")
|
||||
void uniformIsBlank() throws Exception {
|
||||
int[] pixels = new int[1000];
|
||||
java.util.Arrays.fill(pixels, 0xFFFFFF);
|
||||
Object result =
|
||||
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) pixels);
|
||||
assertEquals(Boolean.TRUE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single differing sampled pixel makes it non-blank")
|
||||
void variedIsNotBlank() throws Exception {
|
||||
int[] pixels = new int[1000];
|
||||
java.util.Arrays.fill(pixels, 0xFFFFFF);
|
||||
// step = max(1, 1000/20) = 50, so index 500 is sampled
|
||||
pixels[500] = 0x000000;
|
||||
Object result =
|
||||
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) pixels);
|
||||
assertEquals(Boolean.FALSE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single pixel array is blank")
|
||||
void singlePixelIsBlank() throws Exception {
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"isBlankImage", new Class<?>[] {int[].class}, (Object) new int[] {7});
|
||||
assertEquals(Boolean.TRUE, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// downscaleIfNeeded(BufferedImage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("downscaleIfNeeded")
|
||||
class DownscaleIfNeeded {
|
||||
|
||||
@Test
|
||||
@DisplayName("small images are returned unchanged (same instance)")
|
||||
void smallUnchanged() throws Exception {
|
||||
BufferedImage img = solidImage(100, 80, Color.GRAY);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"downscaleIfNeeded",
|
||||
new Class<?>[] {BufferedImage.class},
|
||||
(Object) img);
|
||||
assertSame(img, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("image exactly at the pixel limit is unchanged")
|
||||
void atLimitUnchanged() throws Exception {
|
||||
// 10000x10000 == 100_000_000 == MAX_IMAGE_PIXELS, not greater than -> unchanged.
|
||||
// Use a mock-free real image but keep it cheap: build a 1px-tall wide image whose
|
||||
// total pixel count is below the limit so we only assert the <= branch with a
|
||||
// realistic non-trivial size.
|
||||
BufferedImage img = solidImage(5000, 5000, Color.WHITE); // 25M < 100M
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"downscaleIfNeeded",
|
||||
new Class<?>[] {BufferedImage.class},
|
||||
(Object) img);
|
||||
assertSame(img, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// countPageImages(PDPage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("countPageImages")
|
||||
class CountPageImages {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no resources returns 0")
|
||||
void noResourcesZero() throws Exception {
|
||||
PDPage page = new PDPage(new PDRectangle(200, 200));
|
||||
Object result =
|
||||
invokeStatic("countPageImages", new Class<?>[] {PDPage.class}, (Object) page);
|
||||
assertEquals(0, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with one embedded image returns 1")
|
||||
void oneImage() throws Exception {
|
||||
try (PDDocument doc = docWithImageOnEachPage(solidImage(40, 40, Color.RED), 1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"countPageImages", new Class<?>[] {PDPage.class}, (Object) page);
|
||||
assertEquals(1, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// tryDecodeQR(int[], int, int) and decodeQRCode(BufferedImage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("QR decoding")
|
||||
class QrDecoding {
|
||||
|
||||
@Test
|
||||
@DisplayName("decodeQRCode returns the encoded text for a real QR image")
|
||||
void decodeRealQr() throws Exception {
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
Object result =
|
||||
invokeStatic("decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) qr);
|
||||
assertEquals(VALID_QR, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decodeQRCode returns null for a blank image")
|
||||
void decodeBlankReturnsNull() throws Exception {
|
||||
BufferedImage blank = solidImage(120, 120, Color.WHITE);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) blank);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decodeQRCode returns null for a non-QR (noise-free, non-blank) image")
|
||||
void decodeNonQrReturnsNull() throws Exception {
|
||||
// Two solid halves: non-blank but not a QR code.
|
||||
BufferedImage image = new BufferedImage(120, 120, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, 120, 60);
|
||||
g.setColor(Color.BLACK);
|
||||
g.fillRect(0, 60, 120, 60);
|
||||
g.dispose();
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) image);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tryDecodeQR decodes raw RGB pixels of a real QR")
|
||||
void tryDecodeRawPixels() throws Exception {
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
int w = qr.getWidth();
|
||||
int h = qr.getHeight();
|
||||
int[] pixels = new int[w * h];
|
||||
qr.getRGB(0, 0, w, h, pixels, 0, w);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"tryDecodeQR",
|
||||
new Class<?>[] {int[].class, int.class, int.class},
|
||||
pixels,
|
||||
w,
|
||||
h);
|
||||
assertEquals(VALID_QR, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tryDecodeQR returns null when no QR present")
|
||||
void tryDecodeReturnsNull() throws Exception {
|
||||
int w = 60;
|
||||
int h = 60;
|
||||
int[] pixels = new int[w * h];
|
||||
// alternating pattern, no decodable QR
|
||||
for (int i = 0; i < pixels.length; i++) {
|
||||
pixels[i] = (i % 2 == 0) ? 0xFFFFFF : 0x000000;
|
||||
}
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"tryDecodeQR",
|
||||
new Class<?>[] {int[].class, int.class, int.class},
|
||||
pixels,
|
||||
w,
|
||||
h);
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// checkPageImagesDirect(PDPage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("checkPageImagesDirect")
|
||||
class CheckPageImagesDirect {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no images returns null")
|
||||
void noImagesNull() throws Exception {
|
||||
PDPage page = new PDPage(new PDRectangle(200, 200));
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"checkPageImagesDirect", new Class<?>[] {PDPage.class}, (Object) page);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with an embedded QR image returns the QR text")
|
||||
void embeddedQrFound() throws Exception {
|
||||
// Size 250 keeps the readback image off the controller's blank-sampling heuristic.
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
try (PDDocument doc = docWithImageOnEachPage(qr, 1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"checkPageImagesDirect",
|
||||
new Class<?>[] {PDPage.class},
|
||||
(Object) page);
|
||||
assertEquals(VALID_QR, result);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with a non-QR image returns null")
|
||||
void embeddedNonQrNull() throws Exception {
|
||||
BufferedImage plain = solidImage(80, 80, Color.WHITE);
|
||||
try (PDDocument doc = docWithImageOnEachPage(plain, 1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"checkPageImagesDirect",
|
||||
new Class<?>[] {PDPage.class},
|
||||
(Object) page);
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// getSystemMaxDpi() (private instance)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSystemMaxDpi")
|
||||
class GetSystemMaxDpi {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the configured system maxDPI")
|
||||
void returnsConfiguredValue() throws Exception {
|
||||
applicationProperties.getSystem().setMaxDPI(300);
|
||||
Object result = invokeInstance("getSystemMaxDpi", new Class<?>[] {});
|
||||
assertEquals(300, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the default detection DPI when applicationProperties is null")
|
||||
void fallsBackWhenNull() throws Exception {
|
||||
AutoSplitPdfController noProps =
|
||||
new AutoSplitPdfController(pdfDocumentFactory, tempFileManager, null);
|
||||
Method m = AutoSplitPdfController.class.getDeclaredMethod("getSystemMaxDpi");
|
||||
m.setAccessible(true);
|
||||
Object result = m.invoke(noProps);
|
||||
assertEquals(150, result); // QR_DETECTION_DPI
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// autoSplitPdf(...) full handler
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("autoSplitPdf")
|
||||
class AutoSplitHandler {
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF without QR dividers yields a single-PDF zip")
|
||||
void noQrSingleOutput() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(3));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
AutoSplitPdfRequest req = request(pdf, false);
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(req);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
byte[] zip = readResource(response.getBody());
|
||||
List<String> names = zipEntryNames(zip);
|
||||
assertEquals(1, names.size(), "no QR dividers -> all pages collapse into one document");
|
||||
assertEquals("input_1.pdf", names.get(0));
|
||||
|
||||
verify(pdfDocumentFactory).load(any(InputStream.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-page PDF without QR yields one output PDF in the zip")
|
||||
void singlePageOneOutput() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(1));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, null));
|
||||
|
||||
byte[] zip = readResource(response.getBody());
|
||||
List<String> names = zipEntryNames(zip);
|
||||
assertEquals(1, names.size());
|
||||
assertEquals("input_1.pdf", names.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filename without extension is used as-is for entry names")
|
||||
void filenameWithoutExtension() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(2));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
|
||||
req.setFileInput(
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"fileInput", "myfile", "application/pdf", pdf));
|
||||
req.setDuplexMode(false);
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(req);
|
||||
byte[] zip = readResource(response.getBody());
|
||||
List<String> names = zipEntryNames(zip);
|
||||
assertEquals(1, names.size());
|
||||
assertEquals("myfile_1.pdf", names.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each output zip entry contains a valid, loadable PDF")
|
||||
void outputEntriesAreValidPdfs() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(2));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, false));
|
||||
byte[] zip = readResource(response.getBody());
|
||||
|
||||
int entries = 0;
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entries++;
|
||||
byte[] entryBytes = zis.readAllBytes();
|
||||
try (PDDocument loaded = Loader.loadPDF(entryBytes)) {
|
||||
assertTrue(loaded.getNumberOfPages() >= 1);
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
assertEquals(1, entries);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loader failure propagates and the temp file is closed")
|
||||
void loaderFailurePropagates() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(1));
|
||||
File created = Files.createTempFile(tempDir, "stirling-fail", ".zip").toFile();
|
||||
when(tempFileManager.createTempFile(".zip")).thenReturn(created);
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenThrow(new java.io.IOException("boom"));
|
||||
|
||||
AutoSplitPdfRequest req = request(pdf, false);
|
||||
|
||||
assertThrows(java.io.IOException.class, () -> controller.autoSplitPdf(req));
|
||||
// outputTempFile.close() deletes the file on the error path.
|
||||
verify(tempFileManager).deleteTempFile(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("duplexMode flag is accepted (null treated as false)")
|
||||
void duplexNullTreatedAsFalse() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(2));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, null));
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<String> names = zipEntryNames(readResource(response.getBody()));
|
||||
assertEquals(1, names.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// VALID_QR_CONTENTS sanity (static state)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("valid QR contents")
|
||||
class ValidQrContents {
|
||||
|
||||
@Test
|
||||
@DisplayName("the well-known divider URLs are recognised")
|
||||
@SuppressWarnings("unchecked")
|
||||
void recognisedUrls() throws Exception {
|
||||
java.lang.reflect.Field f =
|
||||
AutoSplitPdfController.class.getDeclaredField("VALID_QR_CONTENTS");
|
||||
f.setAccessible(true);
|
||||
Set<String> valid = new HashSet<>((Set<String>) f.get(null));
|
||||
assertTrue(valid.contains("https://stirlingpdf.com"));
|
||||
assertTrue(valid.contains("https://github.com/Stirling-Tools/Stirling-PDF"));
|
||||
assertTrue(valid.contains("https://github.com/Frooodle/Stirling-PDF"));
|
||||
assertFalse(valid.contains("https://example.com"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link CompressController}. External binaries (Ghostscript / qpdf / ImageMagick)
|
||||
* are never invoked: tests cover validation, orchestration with all tool groups disabled, the pure
|
||||
* level/quality helpers, and the public image-compression entry point.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CompressControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private CompressController controller;
|
||||
|
||||
/** Real temp files created during a test; cleaned up after each test. */
|
||||
private final List<File> createdFiles = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// By default no external tools are enabled, forcing the Java-only path.
|
||||
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false);
|
||||
|
||||
// Every managed temp file is backed by a real on-disk file wrapped in a mock TempFile.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile(
|
||||
"compress-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
createdFiles.add(f);
|
||||
return newRealBackedTempFile(f);
|
||||
});
|
||||
}
|
||||
|
||||
private TempFile newRealBackedTempFile(File f) {
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
|
||||
lenient().when(tf.exists()).thenReturn(f.exists());
|
||||
return tf;
|
||||
}
|
||||
|
||||
// ----- helpers to build tiny in-memory PDFs ------------------------------------------------
|
||||
|
||||
private byte[] textOnlyPdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(
|
||||
new org.apache.pdfbox.pdmodel.font.PDType1Font(
|
||||
org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName.HELVETICA),
|
||||
12);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("Hello compress");
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF with one tiny (sub-400px) image so the compressor encounters it but skips it. */
|
||||
private byte[] smallImagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
java.awt.image.BufferedImage img =
|
||||
new java.awt.image.BufferedImage(
|
||||
50, 50, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
for (int x = 0; x < 50; x++) {
|
||||
for (int y = 0; y < 50; y++) {
|
||||
img.setRGB(x, y, (x * 5 + y) & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
PDImageXObject image = LosslessFactory.createFromImage(doc, img);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 100, 100, 50, 50);
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private MockMultipartFile multipart(byte[] bytes) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ----- validation branches -----------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("optimizePdf validation")
|
||||
class Validation {
|
||||
|
||||
@Test
|
||||
@DisplayName("null input file throws IllegalArgumentException")
|
||||
void nullFile_throws() {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty input file throws IllegalArgumentException")
|
||||
void emptyFile_throws() {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
new byte[0]));
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"both optimizeLevel and expectedOutputSize null throws IllegalArgumentException")
|
||||
void noOptionsProvided_throws() throws Exception {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(textOnlyPdfBytes()));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("line art requested but service unavailable throws FORBIDDEN")
|
||||
void lineArt_serviceNull_throwsForbidden() throws Exception {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(textOnlyPdfBytes()));
|
||||
request.setOptimizeLevel(2);
|
||||
request.setLineArt(true);
|
||||
|
||||
// lineArtConversionService field is left null by @InjectMocks.
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"line art requested with service present but ImageMagick disabled throws IOException")
|
||||
void lineArt_imageMagickDisabled_throwsIOException() throws Exception {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(textOnlyPdfBytes()));
|
||||
request.setOptimizeLevel(2);
|
||||
request.setLineArt(true);
|
||||
|
||||
// Provide a service so the null-check passes, but ImageMagick group stays disabled.
|
||||
setLineArtService(mock(LineArtConversionService.class));
|
||||
when(endpointConfiguration.isGroupEnabled("ImageMagick")).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- orchestration with all external tools disabled --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("optimizePdf orchestration (no external tools)")
|
||||
class Orchestration {
|
||||
|
||||
@Test
|
||||
@DisplayName("low level + no tools returns OK with non-empty body")
|
||||
void lowLevel_noTools_returnsOk() throws Exception {
|
||||
byte[] pdf = textOnlyPdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(1); // < 4 => no image compression, < 6 => no ghostscript
|
||||
|
||||
// Final stage reloads currentFile from disk; return a fresh real document.
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("level 4 with a sub-threshold image still returns OK (image skipped)")
|
||||
void level4_smallImage_skipped_returnsOk() throws Exception {
|
||||
byte[] pdf = smallImagePdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(4); // triggers Java image compression path
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
// compressImagesInPDF reloads currentFile via load(Path).
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grayscale flag forces image compression path even at low level")
|
||||
void grayscale_lowLevel_returnsOk() throws Exception {
|
||||
byte[] pdf = textOnlyPdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(1);
|
||||
request.setGrayscale(true);
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto mode via expectedOutputSize picks a level and returns OK")
|
||||
void autoMode_expectedOutputSize_returnsOk() throws Exception {
|
||||
byte[] pdf = textOnlyPdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize("1KB"); // length > 1 => auto mode
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- public compressImagesInPDF ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("compressImagesInPDF")
|
||||
class CompressImages {
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF with a tiny image produces a valid, non-empty output PDF")
|
||||
void smallImage_producesValidPdf() throws Exception {
|
||||
Path src = tempDir.resolve("src.pdf");
|
||||
Files.write(src, smallImagePdfBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
TempFile result = controller.compressImagesInPDF(src, 0.5, 0.5f, false);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
byte[] out = Files.readAllBytes(result.getPath());
|
||||
assertThat(out).isNotEmpty();
|
||||
try (PDDocument doc = Loader.loadPDF(out)) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text-only PDF compresses to a valid single-page output")
|
||||
void textOnly_producesValidPdf() throws Exception {
|
||||
Path src = tempDir.resolve("text.pdf");
|
||||
Files.write(src, textOnlyPdfBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
TempFile result = controller.compressImagesInPDF(src, 0.8, 0.7f, false);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
try (PDDocument doc = Loader.loadPDF(Files.readAllBytes(result.getPath()))) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("load failure closes the temp file and propagates the exception")
|
||||
void loadFailure_propagates() throws Exception {
|
||||
Path src = tempDir.resolve("bad.pdf");
|
||||
Files.write(src, textOnlyPdfBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(any(Path.class))).thenThrow(new IOException("boom"));
|
||||
|
||||
assertThatThrownBy(() -> controller.compressImagesInPDF(src, 0.5, 0.5f, false))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("boom");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- pure helper methods (reflection) ----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("scale / quality / level helpers")
|
||||
class Helpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("getScaleFactorForLevel maps each level and defaults to 1.0")
|
||||
void scaleFactorForLevel() throws Exception {
|
||||
Method m = privateStatic("getScaleFactorForLevel", int.class);
|
||||
assertThat((double) m.invoke(null, 1)).isEqualTo(0.98);
|
||||
assertThat((double) m.invoke(null, 5)).isEqualTo(0.68);
|
||||
assertThat((double) m.invoke(null, 9)).isEqualTo(0.28);
|
||||
// Out-of-range falls to the default branch.
|
||||
assertThat((double) m.invoke(null, 0)).isEqualTo(1.0);
|
||||
assertThat((double) m.invoke(null, 42)).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getJpegQualityForLevel maps each level and defaults to 0.75")
|
||||
void jpegQualityForLevel() throws Exception {
|
||||
Method m = privateStatic("getJpegQualityForLevel", int.class);
|
||||
assertThat((float) m.invoke(null, 1)).isEqualTo(0.92f);
|
||||
assertThat((float) m.invoke(null, 9)).isEqualTo(0.35f);
|
||||
assertThat((float) m.invoke(null, 0)).isEqualTo(0.75f);
|
||||
assertThat((float) m.invoke(null, 100)).isEqualTo(0.75f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("determineOptimizeLevel buckets the size-reduction ratio")
|
||||
void determineOptimizeLevel() throws Exception {
|
||||
Method m = privateStatic("determineOptimizeLevel", double.class);
|
||||
assertThat((int) m.invoke(null, 0.95)).isEqualTo(1);
|
||||
assertThat((int) m.invoke(null, 0.85)).isEqualTo(2);
|
||||
assertThat((int) m.invoke(null, 0.75)).isEqualTo(3);
|
||||
assertThat((int) m.invoke(null, 0.65)).isEqualTo(4);
|
||||
assertThat((int) m.invoke(null, 0.5)).isEqualTo(5);
|
||||
assertThat((int) m.invoke(null, 0.25)).isEqualTo(6);
|
||||
assertThat((int) m.invoke(null, 0.18)).isEqualTo(7);
|
||||
assertThat((int) m.invoke(null, 0.12)).isEqualTo(8);
|
||||
assertThat((int) m.invoke(null, 0.05)).isEqualTo(9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("incrementOptimizeLevel grows by ratio and is capped at 9")
|
||||
void incrementOptimizeLevel() throws Exception {
|
||||
Method m = privateStatic("incrementOptimizeLevel", int.class, long.class, long.class);
|
||||
// ratio 3.0 (> 2.0) -> +3
|
||||
assertThat((int) m.invoke(null, 2, 300L, 100L)).isEqualTo(5);
|
||||
// ratio 1.8 (> 1.5) -> +2
|
||||
assertThat((int) m.invoke(null, 2, 180L, 100L)).isEqualTo(4);
|
||||
// ratio 1.1 -> +1
|
||||
assertThat((int) m.invoke(null, 2, 110L, 100L)).isEqualTo(3);
|
||||
// capped at 9
|
||||
assertThat((int) m.invoke(null, 8, 300L, 100L)).isEqualTo(9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getImageType classifies filters; unknown for non-image input")
|
||||
void getImageType_andFilter() throws Exception {
|
||||
// A PNG-style lossless image yields FlateDecode -> "PNG".
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
java.awt.image.BufferedImage bi =
|
||||
new java.awt.image.BufferedImage(
|
||||
10, 10, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject image = LosslessFactory.createFromImage(doc, bi);
|
||||
|
||||
Method typeMethod = privateStatic("getImageType", PDImageXObject.class);
|
||||
String type = (String) typeMethod.invoke(null, image);
|
||||
assertThat(type).isEqualTo("PNG");
|
||||
|
||||
Method filterMethod = privateStatic("getImageFilter", PDImageXObject.class);
|
||||
String filter = (String) filterMethod.invoke(null, image);
|
||||
assertThat(filter).contains("FlateDecode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- reflection / field helpers ----------------------------------------------------------
|
||||
|
||||
private static Method privateStatic(String name, Class<?>... params) throws Exception {
|
||||
Method m = CompressController.class.getDeclaredMethod(name, params);
|
||||
m.setAccessible(true);
|
||||
return m;
|
||||
}
|
||||
|
||||
private void setLineArtService(LineArtConversionService service) throws Exception {
|
||||
Field f = CompressController.class.getDeclaredField("lineArtConversionService");
|
||||
f.setAccessible(true);
|
||||
f.set(controller, service);
|
||||
}
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ExtractImageScansController}.
|
||||
*
|
||||
* <p>The controller shells out to a Python/OpenCV script via {@link ProcessExecutor} and gates on
|
||||
* {@link CheckProgramInstall#isPythonAvailable()}. To keep tests deterministic these static
|
||||
* boundaries are mocked with {@code Mockito.mockStatic}: Python is forced available/unavailable,
|
||||
* the script extraction is stubbed, and the process execution is replaced with an in-test answer
|
||||
* that either writes fake output PNGs into the controller-owned temp directory or leaves it empty.
|
||||
* No real Python process is ever launched.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ExtractImageScansControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private ExtractImageScansController controller;
|
||||
|
||||
@TempDir Path baseTmpDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
stirling.software.common.model.ApplicationProperties applicationProperties =
|
||||
new stirling.software.common.model.ApplicationProperties();
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getTempFileManagement()
|
||||
.setBaseTmpDir(baseTmpDir.toString());
|
||||
applicationProperties.getSystem().getTempFileManagement().setPrefix("scan-test-");
|
||||
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), applicationProperties);
|
||||
controller = new ExtractImageScansController(pdfDocumentFactory, tempFileManager);
|
||||
}
|
||||
|
||||
/** Build a request with sensible defaults; the caller supplies the file input. */
|
||||
private ExtractImageScansRequest requestFor(MockMultipartFile file) {
|
||||
ExtractImageScansRequest request = new ExtractImageScansRequest();
|
||||
request.setFileInput(file);
|
||||
request.setAngleThreshold(5);
|
||||
request.setTolerance(20);
|
||||
request.setMinArea(8000);
|
||||
request.setMinContourArea(500);
|
||||
request.setBorderSize(1);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** A tiny single-page in-memory PDF backed by a small media box for cheap rendering. */
|
||||
private MockMultipartFile pdfFile(String name) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
// Keep the page small so the 300-DPI render stays tiny and fast.
|
||||
doc.addPage(new PDPage(new PDRectangle(72f, 72f)));
|
||||
doc.save(out);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, out.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-PDF image input; the controller copies it straight to a temp file. */
|
||||
private MockMultipartFile imageFile(String name) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.IMAGE_PNG_VALUE, new byte[] {1, 2, 3, 4});
|
||||
}
|
||||
|
||||
/**
|
||||
* A mocked {@link ProcessExecutor} whose {@code runCommandWithOutputHandling} reads the output
|
||||
* directory from the command (positional arg index 3) and writes the given number of PNG files
|
||||
* into it, mimicking the real split_photos.py behaviour without launching a process.
|
||||
*/
|
||||
private ProcessExecutor execWritingOutputs(int outputCount) throws Exception {
|
||||
ProcessExecutor exec = mock(ProcessExecutor.class);
|
||||
when(exec.runCommandWithOutputHandling(anyList()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> command = invocation.getArgument(0);
|
||||
Path outDir = Path.of(command.get(3));
|
||||
for (int i = 0; i < outputCount; i++) {
|
||||
Files.write(
|
||||
outDir.resolve("out_" + i + ".png"),
|
||||
new byte[] {9, 8, 7, (byte) i});
|
||||
}
|
||||
return mock(ProcessExecutorResult.class);
|
||||
});
|
||||
return exec;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Python availability guard")
|
||||
class PythonGuard {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws IOException when Python is not installed")
|
||||
void throwsWhenPythonUnavailable() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> controller.extractImageScans(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not load the PDF or extract the script when Python is missing")
|
||||
void shortCircuitsBeforeAnyWork() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> controller.extractImageScans(request));
|
||||
|
||||
// Guard runs before document load and before script extraction.
|
||||
Mockito.verifyNoInteractions(pdfDocumentFactory);
|
||||
general.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("No detected images branch")
|
||||
class NoImagesBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws IllegalArgumentException for a non-PDF input when no outputs produced")
|
||||
void throwsNoImagesForImageInput() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
|
||||
// Process produces zero output files -> empty result -> "no images" error.
|
||||
// Build the executor mock BEFORE stubbing the static so its inner when(...) does
|
||||
// not
|
||||
// nest inside this when(...).thenReturn(...) call.
|
||||
ProcessExecutor exec = execWritingOutputs(0);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws IllegalArgumentException for a PDF input when no outputs produced")
|
||||
void throwsNoImagesForPdfInput() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
when(pdfDocumentFactory.load(
|
||||
any(org.springframework.web.multipart.MultipartFile.class)))
|
||||
.thenReturn(singlePageDocument());
|
||||
|
||||
ProcessExecutor exec = execWritingOutputs(0);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
|
||||
// The PDF path must load the document exactly once.
|
||||
verify(pdfDocumentFactory, times(1))
|
||||
.load(any(org.springframework.web.multipart.MultipartFile.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Single image output branch")
|
||||
class SingleImageBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a single PNG response when exactly one image is detected")
|
||||
void returnsSinglePng() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
general.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
|
||||
.thenAnswer(inv -> inv.<String>getArgument(0) + inv.<String>getArgument(1));
|
||||
|
||||
ProcessExecutor exec = execWritingOutputs(1);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractImageScans(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.IMAGE_PNG, response.getHeaders().getContentType());
|
||||
assertNotNull(response.getBody());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Multiple images (zip) output branch")
|
||||
class ZipBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a zip response when more than one image is detected")
|
||||
void returnsZipForMultipleImages() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
general.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
|
||||
.thenAnswer(inv -> inv.<String>getArgument(0) + inv.<String>getArgument(1));
|
||||
|
||||
// Three output files -> zip path.
|
||||
ProcessExecutor exec = execWritingOutputs(3);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractImageScans(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
// generateFilename is invoked for the zip name and once per zip entry.
|
||||
general.verify(
|
||||
() -> GeneralUtils.generateFilename(anyString(), anyString()),
|
||||
atLeastOnce());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Command construction")
|
||||
class CommandConstruction {
|
||||
|
||||
@Test
|
||||
@DisplayName("passes the request parameters as CLI flags to the executor")
|
||||
void buildsExpectedCommand() throws Exception {
|
||||
MockMultipartFile file = imageFile("scan.png");
|
||||
ExtractImageScansRequest request = new ExtractImageScansRequest();
|
||||
request.setFileInput(file);
|
||||
request.setAngleThreshold(7);
|
||||
request.setTolerance(21);
|
||||
request.setMinArea(9000);
|
||||
request.setMinContourArea(600);
|
||||
request.setBorderSize(2);
|
||||
|
||||
ProcessExecutor exec = mock(ProcessExecutor.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<String>> cmdCaptor = ArgumentCaptor.forClass(List.class);
|
||||
when(exec.runCommandWithOutputHandling(cmdCaptor.capture()))
|
||||
.thenAnswer(invocation -> mock(ProcessExecutorResult.class));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
// No outputs are written, so the controller ultimately throws "no images";
|
||||
// we only care that the command was built and dispatched first.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
|
||||
List<String> command = cmdCaptor.getValue();
|
||||
assertNotNull(command);
|
||||
assertEquals("python3", command.get(0));
|
||||
assertTrue(command.contains("--angle_threshold"));
|
||||
assertEquals("7", valueAfter(command, "--angle_threshold"));
|
||||
assertEquals("21", valueAfter(command, "--tolerance"));
|
||||
assertEquals("9000", valueAfter(command, "--min_area"));
|
||||
assertEquals("600", valueAfter(command, "--min_contour_area"));
|
||||
assertEquals("2", valueAfter(command, "--border_size"));
|
||||
}
|
||||
}
|
||||
|
||||
private String valueAfter(List<String> command, String flag) {
|
||||
int idx = command.indexOf(flag);
|
||||
assertTrue(idx >= 0 && idx + 1 < command.size(), "flag " + flag + " not found");
|
||||
return command.get(idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Temp file cleanup")
|
||||
class Cleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves no controller temp files behind after a no-images failure")
|
||||
void cleansUpAfterFailure() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
ProcessExecutor exec = execWritingOutputs(0);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
|
||||
try (var stream = Files.walk(baseTmpDir)) {
|
||||
boolean leaked =
|
||||
stream.filter(Files::isRegularFile)
|
||||
.map(p -> p.getFileName().toString())
|
||||
.anyMatch(n -> n.startsWith("scan-test-"));
|
||||
assertFalse(leaked, "controller temp files should be cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a fresh single-page in-memory document the factory mock can hand back. */
|
||||
private PDDocument singlePageDocument() {
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(new PDRectangle(72f, 72f)));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link OCRController}. OCR shells out to tesseract/ocrmypdf, so these tests focus
|
||||
* on the pure, deterministic surface: tesseract-language discovery and the request validation /
|
||||
* tool-availability branches that all complete (or fail) before any external process is launched.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class OCRControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private OCRController ocrController;
|
||||
|
||||
@TempDir Path baseTmpDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getTempFileManagement()
|
||||
.setBaseTmpDir(baseTmpDir.toString());
|
||||
applicationProperties.getSystem().getTempFileManagement().setPrefix("ocr-test-");
|
||||
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), applicationProperties);
|
||||
|
||||
ocrController =
|
||||
new OCRController(
|
||||
applicationProperties,
|
||||
pdfDocumentFactory,
|
||||
tempFileManager,
|
||||
endpointConfiguration,
|
||||
runtimePathConfig);
|
||||
}
|
||||
|
||||
/** Build a minimal request with sensible defaults the caller can override. */
|
||||
private ProcessPdfWithOcrRequest baseRequest() {
|
||||
ProcessPdfWithOcrRequest request = new ProcessPdfWithOcrRequest();
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setOcrRenderType("hocr");
|
||||
request.setOcrType("skip-text");
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Build a tiny single-page in-memory PDF as a MockMultipartFile. */
|
||||
private MockMultipartFile pdfMultipartFile(String name) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(out);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, out.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a tessdata directory populated with the given traineddata languages. */
|
||||
private Path tessdataDirWith(String... languages) throws IOException {
|
||||
Path dir = Files.createTempDirectory(baseTmpDir, "tessdata");
|
||||
for (String lang : languages) {
|
||||
Files.createFile(dir.resolve(lang + ".traineddata"));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAvailableTesseractLanguages")
|
||||
class GetAvailableTesseractLanguages {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns trained languages and excludes osd")
|
||||
void returnsTrainedLanguagesExcludingOsd() throws IOException {
|
||||
Path tessdata = tessdataDirWith("eng", "deu", "osd");
|
||||
// A non-traineddata file must be ignored entirely.
|
||||
Files.createFile(tessdata.resolve("readme.txt"));
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
|
||||
List<String> langs = ocrController.getAvailableTesseractLanguages();
|
||||
|
||||
assertTrue(langs.contains("eng"));
|
||||
assertTrue(langs.contains("deu"));
|
||||
assertFalse(langs.contains("osd"), "osd must be filtered out");
|
||||
assertFalse(langs.contains("readme"), "non-traineddata files must be ignored");
|
||||
assertEquals(2, langs.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filters osd case-insensitively")
|
||||
void filtersOsdCaseInsensitively() throws IOException {
|
||||
Path tessdata = tessdataDirWith("eng", "OSD");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
|
||||
List<String> langs = ocrController.getAvailableTesseractLanguages();
|
||||
|
||||
assertEquals(List.of("eng"), langs);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list when directory has no traineddata files")
|
||||
void returnsEmptyWhenNoTrainedData() throws IOException {
|
||||
Path empty = Files.createTempDirectory(baseTmpDir, "empty-tessdata");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(empty.toString());
|
||||
|
||||
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list when directory does not exist")
|
||||
void returnsEmptyWhenDirectoryMissing() {
|
||||
Path missing = baseTmpDir.resolve("does-not-exist");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(missing.toString());
|
||||
|
||||
// listFiles() on a non-directory returns null -> empty list, not an exception.
|
||||
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processPdfWithOCR validation branches")
|
||||
class ValidationBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when languages list is null")
|
||||
void throwsWhenLanguagesNull() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(null);
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
// Validation happens before any tool/exec interaction.
|
||||
verifyNoInteractions(endpointConfiguration);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when languages list is empty")
|
||||
void throwsWhenLanguagesEmpty() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(Collections.emptyList());
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
verifyNoInteractions(endpointConfiguration);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when ocrRenderType is invalid")
|
||||
void throwsWhenRenderTypeInvalid() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setOcrRenderType("bogus");
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
// Render-type check precedes language availability lookup.
|
||||
verify(runtimePathConfig, never()).getTessDataPath();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts sandwich render type past the render-type check")
|
||||
void sandwichRenderTypePassesRenderCheck() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setOcrRenderType("sandwich");
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
// No tessdata languages available -> falls through to invalid-languages, still an
|
||||
// IOException, but proves "sandwich" was not rejected by the render-type guard.
|
||||
Path tessdata = tessdataDirWith("eng");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
|
||||
|
||||
// eng is available, so it gets past language validation and reaches the
|
||||
// tool-availability check, which throws because both tools are disabled.
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
verify(runtimePathConfig).getTessDataPath();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when none of the selected languages are available")
|
||||
void throwsWhenNoSelectedLanguageAvailable() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(List.of("xyz")); // not present in tessdata
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
Path tessdata = tessdataDirWith("eng", "deu");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
// Should fail before consulting tool availability.
|
||||
verify(endpointConfiguration, never()).isGroupEnabled(anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processPdfWithOCR tool-availability branch")
|
||||
class ToolAvailabilityBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when both OCRmyPDF and tesseract are unavailable")
|
||||
void throwsWhenNoOcrToolsAvailable() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
Path tessdata = tessdataDirWith("eng");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
|
||||
verify(endpointConfiguration).isGroupEnabled("OCRmyPDF");
|
||||
verify(endpointConfiguration).isGroupEnabled("tesseract");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("temp input/output files are cleaned up after a failure")
|
||||
void tempFilesCleanedUpAfterFailure() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
Path tessdata = tessdataDirWith("eng");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
|
||||
// The only files left under the temp dir should be our tessdata dir and its
|
||||
// contents; the controller's .pdf temp files must have been closed/deleted.
|
||||
try (var stream = Files.walk(baseTmpDir)) {
|
||||
boolean leakedPdf =
|
||||
stream.filter(Files::isRegularFile)
|
||||
.map(p -> p.getFileName().toString())
|
||||
.filter(n -> n.startsWith("ocr-test-"))
|
||||
.anyMatch(n -> n.endsWith(".pdf"));
|
||||
assertFalse(leakedPdf, "controller temp PDF files should be cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getAvailableTesseractLanguages survives a path that is a regular file")
|
||||
void languagesEmptyWhenPathIsRegularFile() throws IOException {
|
||||
File regularFile = Files.createTempFile(baseTmpDir, "not-a-dir", ".bin").toFile();
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(regularFile.getAbsolutePath());
|
||||
|
||||
// listFiles() on a regular file returns null -> empty list.
|
||||
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
|
||||
}
|
||||
}
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddPageNumbersRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PageNumbersControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private PageNumbersController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Each managed temp file is backed by a real on-disk file so document.save() works
|
||||
// and WebResponseUtils can stat/stream it.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("pgnum-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------
|
||||
|
||||
private MockMultipartFile createPdf(int pages, String filename) throws IOException {
|
||||
Path path = tempDir.resolve("source-" + System.nanoTime() + ".pdf");
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.LETTER));
|
||||
}
|
||||
doc.save(path.toFile());
|
||||
}
|
||||
return new MockMultipartFile(
|
||||
"fileInput", filename, MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(path));
|
||||
}
|
||||
|
||||
private AddPageNumbersRequest baseRequest(MockMultipartFile file) {
|
||||
AddPageNumbersRequest request = new AddPageNumbersRequest();
|
||||
request.setFileInput(file);
|
||||
request.setFontSize(12f);
|
||||
request.setFontType("helvetica");
|
||||
request.setPosition(8);
|
||||
request.setStartingNumber(1);
|
||||
return request;
|
||||
}
|
||||
|
||||
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ---- happy path -------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-page PDF returns OK with a non-empty PDF body")
|
||||
void singlePage_returnsOkWithBody() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "doc.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
byte[] body = drainBody(response);
|
||||
assertThat(body).isNotEmpty();
|
||||
// Result is still a valid, single-page PDF.
|
||||
try (PDDocument out = Loader.loadPDF(body)) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Multi-page PDF with default 'all' pages numbers every page")
|
||||
void multiPage_allPages() throws Exception {
|
||||
MockMultipartFile file = createPdf(5, "multi.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
PDDocument doc = Loader.loadPDF(file.getBytes());
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(doc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(5);
|
||||
}
|
||||
// The loaded document is closed by the try-with-resources in the controller.
|
||||
verify(tempFileManager).createManagedTempFile(".pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Content-Disposition attachment filename carries the source name")
|
||||
void responseHasAttachmentFilename() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "report.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
String disposition = response.getHeaders().getFirst("Content-Disposition");
|
||||
assertThat(disposition).isNotNull();
|
||||
assertThat(disposition).contains("report_page_numbers_added.pdf");
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pages-to-number selection ----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Page selection")
|
||||
class PageSelection {
|
||||
|
||||
@Test
|
||||
@DisplayName("Explicit subset of pages still returns all pages in output")
|
||||
void specificPages() throws Exception {
|
||||
MockMultipartFile file = createPdf(4, "sel.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber("1,3");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Range expression is accepted")
|
||||
void rangeExpression() throws Exception {
|
||||
MockMultipartFile file = createPdf(6, "range.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber("2-4");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null pagesToNumber defaults to 'all'")
|
||||
void nullPagesDefaultsToAll() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "nullpages.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty pagesToNumber defaults to 'all'")
|
||||
void emptyPagesDefaultsToAll() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "emptypages.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber("");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- position handling (1..9 plus clamping) ---------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Position")
|
||||
class Position {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
|
||||
@DisplayName("All nine positions render successfully")
|
||||
void allPositions(int position) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "pos.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPosition(position);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {-5, 0, 10, 100})
|
||||
@DisplayName("Out-of-range positions are clamped and still render")
|
||||
void outOfRangePositionsClamped(int position) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "posclamp.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPosition(position);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- margins ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom margin")
|
||||
class CustomMargin {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"small", "medium", "large", "x-large", "X-LARGE", "unknown"})
|
||||
@DisplayName("Known and unknown margins are accepted")
|
||||
void margins(String margin) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "margin.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomMargin(margin);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null margin falls back to default factor")
|
||||
void nullMargin() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "nullmargin.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomMargin(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- font type --------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Font type")
|
||||
class FontType {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"helvetica", "courier", "times", "TIMES", "anythingelse"})
|
||||
@DisplayName("Known and unknown font types render (unknown falls back to Helvetica)")
|
||||
void fontTypes(String font) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "font.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontType(font);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null font type falls back to Helvetica")
|
||||
void nullFontType() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "nullfont.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontType(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- font color -------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Font color")
|
||||
class FontColor {
|
||||
|
||||
@Test
|
||||
@DisplayName("Valid hex color renders")
|
||||
void validHexColor() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "color.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor("#FF0000");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Invalid hex color falls back to black and still renders")
|
||||
void invalidHexColorFallsBackToBlack() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "badcolor.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor("not-a-color");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null font color uses default black")
|
||||
void nullColor() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "nullcolor.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank/whitespace font color uses default black")
|
||||
void blankColor() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "blankcolor.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor(" ");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- custom text / placeholders --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom text")
|
||||
class CustomText {
|
||||
|
||||
@Test
|
||||
@DisplayName("Null custom text defaults to {n}")
|
||||
void nullCustomText() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "nulltext.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty custom text defaults to {n}")
|
||||
void emptyCustomText() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "emptytext.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom text with {n}, {total} and {filename} placeholders renders")
|
||||
void placeholders() throws Exception {
|
||||
MockMultipartFile file = createPdf(3, "myfile.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("Page {n} of {total} - {filename}");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Literal custom text without placeholders renders")
|
||||
void literalText() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "literal.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("Confidential");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- numbering / zero-pad / starting number ---------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Numbering")
|
||||
class Numbering {
|
||||
|
||||
@Test
|
||||
@DisplayName("Zero-pad width produces Bates-style padded numbers without error")
|
||||
void zeroPadBatesStamping() throws Exception {
|
||||
MockMultipartFile file = createPdf(3, "bates.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setZeroPad(5);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Zero zero-pad uses unpadded numbers")
|
||||
void zeroPadDisabled() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "nopad.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setZeroPad(0);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom starting number is honored")
|
||||
void customStartingNumber() throws Exception {
|
||||
MockMultipartFile file = createPdf(3, "start.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setStartingNumber(100);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- filename handling for {filename} ---------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Filename handling")
|
||||
class FilenameHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("Filename without extension is handled for {filename} placeholder")
|
||||
void filenameWithoutExtension() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "no_extension");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("{filename}");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- error branches ---------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error handling")
|
||||
class ErrorHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from document load propagates")
|
||||
void loadIOExceptionPropagates() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "err.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("corrupt pdf"));
|
||||
|
||||
assertThatThrownBy(() -> controller.addPageNumbers(request))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("corrupt pdf");
|
||||
}
|
||||
}
|
||||
}
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("RemoveImagesController")
|
||||
class RemoveImagesControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private TempFileManager tempFileManager;
|
||||
private RemoveImagesController controller;
|
||||
|
||||
// Holds the temp file backing the most recent createManagedTempFile call so tests can
|
||||
// re-load the actually-saved (image-stripped) document from disk and assert on it.
|
||||
private final List<File> savedTempFiles = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
pdfDocumentFactory = mock(CustomPDFDocumentFactory.class);
|
||||
tempFileManager = mock(TempFileManager.class);
|
||||
controller = new RemoveImagesController(pdfDocumentFactory, tempFileManager);
|
||||
savedTempFiles.clear();
|
||||
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile(tempDir, "out", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
savedTempFiles.add(f);
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
// ----- helpers -------------------------------------------------------------------------
|
||||
|
||||
private MockMultipartFile multipart(String name, byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private PDFFile request(MockMultipartFile file) {
|
||||
PDFFile req = new PDFFile();
|
||||
req.setFileInput(file);
|
||||
return req;
|
||||
}
|
||||
|
||||
/** A drawable RGB image with no useful content, kept tiny for speed. */
|
||||
private BufferedImage tinyImage() {
|
||||
return new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
}
|
||||
|
||||
/** PDF with {@code pageCount} pages, each with one drawn JPEG image. */
|
||||
private byte[] pdfWithImagesBytes(int pageCount) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = JPEGFactory.createFromImage(doc, tinyImage());
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 50, 600, 50, 50);
|
||||
}
|
||||
}
|
||||
return saveToBytes(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF with one page that has no images at all (just resources without XObjects). */
|
||||
private byte[] pdfWithoutImagesBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
// Touch the page resources so they are non-null but contain no XObject dictionary.
|
||||
page.setResources(new PDResources());
|
||||
return saveToBytes(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF whose single page has a resources dictionary with an image nested in a form XObject. */
|
||||
private byte[] pdfWithImageInsideFormBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
PDImageXObject image = JPEGFactory.createFromImage(doc, tinyImage());
|
||||
|
||||
PDFormXObject form = new PDFormXObject(doc);
|
||||
form.setBBox(new PDRectangle(100, 100));
|
||||
PDResources formResources = new PDResources();
|
||||
formResources.add(image);
|
||||
form.setResources(formResources);
|
||||
|
||||
PDResources pageResources = new PDResources();
|
||||
pageResources.add(form);
|
||||
page.setResources(pageResources);
|
||||
|
||||
return saveToBytes(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] saveToBytes(PDDocument doc) throws IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Counts every PDImageXObject reachable through page + nested form resources. */
|
||||
private int countImagesInSavedOutput() throws IOException {
|
||||
assertFalse(savedTempFiles.isEmpty(), "expected the controller to create a temp file");
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
try (PDDocument doc = Loader.loadPDF(out)) {
|
||||
int count = 0;
|
||||
for (PDPage page : doc.getPages()) {
|
||||
count += countImagesInResources(page.getResources());
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private int countImagesInResources(PDResources resources) throws IOException {
|
||||
if (resources == null || resources.getXObjectNames() == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (COSName name : resources.getXObjectNames()) {
|
||||
PDXObject xObject = resources.getXObject(name);
|
||||
if (xObject instanceof PDImageXObject) {
|
||||
count++;
|
||||
} else if (xObject instanceof PDFormXObject form) {
|
||||
count += countImagesInResources(form.getResources());
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ----- happy paths ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("removeImages happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns OK and strips the image from a single-page PDF")
|
||||
void singlePageWithImage() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
MockMultipartFile file = multipart("doc.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
// sanity: the input genuinely had one image to remove
|
||||
assertEquals(1, countImagesInResources(loaded.getPage(0).getResources()));
|
||||
|
||||
ResponseEntity<Resource> response;
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
response = controller.removeImages(req);
|
||||
}
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(0, countImagesInSavedOutput(), "all images should have been removed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("strips images across every page of a multi-page PDF")
|
||||
void multiPageWithImages() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(3);
|
||||
MockMultipartFile file = multipart("multi.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
assertEquals(3, loaded.getNumberOfPages());
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
// page count must be preserved
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertEquals(3, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removes an image nested inside a form XObject")
|
||||
void imageNestedInsideForm() throws IOException {
|
||||
byte[] bytes = pdfWithImageInsideFormBytes();
|
||||
MockMultipartFile file = multipart("nested.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
// sanity: the nested image is present before removal
|
||||
assertEquals(1, countImagesInResources(loaded.getPage(0).getResources()));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
controller.removeImages(req);
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput(), "nested form image should be removed");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- edge cases ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("removeImages edge cases")
|
||||
class EdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns OK when the PDF has no images")
|
||||
void noImages() throws IOException {
|
||||
byte[] bytes = pdfWithoutImagesBytes();
|
||||
MockMultipartFile file = multipart("plain.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handles a page that has no resources dictionary")
|
||||
void pageWithoutResources() throws IOException {
|
||||
// A bare PDPage built without content has null resources.
|
||||
byte[] bytes;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.LETTER));
|
||||
bytes = saveToBytes(doc);
|
||||
}
|
||||
MockMultipartFile file = multipart("bare.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creates exactly one managed temp file and saves into it")
|
||||
void savesIntoManagedTempFile() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
MockMultipartFile file = multipart("doc.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
controller.removeImages(req);
|
||||
}
|
||||
|
||||
assertEquals(1, savedTempFiles.size());
|
||||
File out = savedTempFiles.get(0);
|
||||
assertTrue(out.exists());
|
||||
assertTrue(out.length() > 0, "saved PDF must be non-empty");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- filename / interaction ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("filename handling")
|
||||
class Filenames {
|
||||
|
||||
@Test
|
||||
@DisplayName("appends _images_removed.pdf suffix to the original name")
|
||||
void appendsSuffix() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
MockMultipartFile file = multipart("report.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
|
||||
controller.removeImages(req);
|
||||
|
||||
web.verify(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class),
|
||||
org.mockito.ArgumentMatchers.eq(
|
||||
"report_images_removed.pdf")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("derives a default name when the original filename is null")
|
||||
void nullOriginalFilename() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
// MockMultipartFile with a null original filename
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
|
||||
// GeneralUtils.generateFilename handles null safely; just assert it was called
|
||||
web.verify(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- error branches ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("error handling")
|
||||
class Errors {
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates an IOException when loading the PDF fails")
|
||||
void loadFailureThrowsIOException() throws IOException {
|
||||
MockMultipartFile file = multipart("broken.pdf", "not a pdf".getBytes());
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenThrow(new IOException("corrupt"));
|
||||
|
||||
assertThrows(IOException.class, () -> controller.removeImages(req));
|
||||
}
|
||||
}
|
||||
|
||||
private static ResponseEntity<Resource> okResponse() {
|
||||
return ResponseEntity.ok(new ByteArrayResource("ok".getBytes()));
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link RepairController}.
|
||||
*
|
||||
* <p>The controller delegates to external binaries (Ghostscript, qpdf) for its primary repair
|
||||
* paths. Those paths shell out via the static {@code ProcessExecutor} factory and are therefore not
|
||||
* deterministically testable in a unit test. These tests keep both Ghostscript and qpdf disabled so
|
||||
* the controller always takes the pure-Java PDFBox last-resort branch, exercising file handling,
|
||||
* filename generation, the success response, and the error-propagation branches without spawning
|
||||
* any external process.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class RepairControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
|
||||
// Real TempFileManager so transferTo / temp file creation / file-backed response all work
|
||||
// end-to-end deterministically without touching any external tooling.
|
||||
private TempFileManager tempFileManager;
|
||||
|
||||
private RepairController repairController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
repairController =
|
||||
new RepairController(pdfDocumentFactory, tempFileManager, endpointConfiguration);
|
||||
|
||||
// Default: no external tools available -> forces the PDFBox last-resort branch.
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(false);
|
||||
}
|
||||
|
||||
/** Build a tiny, valid in-memory PDF as bytes. */
|
||||
private static byte[] buildPdfBytes(int pageCount) throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh real in-memory document for stubbing pdfDocumentFactory.load(). */
|
||||
private static PDDocument newRealDocument(int pageCount) {
|
||||
PDDocument document = new PDDocument();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private static PDFFile pdfFileFrom(MockMultipartFile multipartFile) {
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileInput(multipartFile);
|
||||
return pdfFile;
|
||||
}
|
||||
|
||||
/** Read the body of a file-backed Resource response into bytes. */
|
||||
private static byte[] readResource(Resource resource) throws IOException {
|
||||
try (InputStream in = resource.getInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
in.transferTo(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("PDFBox last-resort branch (no external tools)")
|
||||
class PdfBoxBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 with a non-empty PDF resource body")
|
||||
void repairPdf_pdfBoxFallback_returnsOkWithPdf() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(2));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(2));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.APPLICATION_PDF, response.getHeaders().getContentType());
|
||||
|
||||
Resource body = response.getBody();
|
||||
assertNotNull(body);
|
||||
|
||||
byte[] outputBytes = readResource(body);
|
||||
assertTrue(outputBytes.length > 0, "repaired PDF body should not be empty");
|
||||
|
||||
// Output must be a structurally valid PDF; confirm by reloading it.
|
||||
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(outputBytes)) {
|
||||
assertEquals(2, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loads the input file exactly once via the factory")
|
||||
void repairPdf_pdfBoxFallback_loadsInputOnce() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
verify(pdfDocumentFactory, times(1)).load(any(File.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not consult qpdf/ghostscript a second time once disabled")
|
||||
void repairPdf_checksToolAvailability() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
// Ghostscript is checked once (skip), qpdf once (skip), then both checked again to
|
||||
// decide the last-resort branch.
|
||||
verify(endpointConfiguration, times(2)).isGroupEnabled("Ghostscript");
|
||||
verify(endpointConfiguration, times(2)).isGroupEnabled("qpdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the file passed to the factory exists on disk when loaded")
|
||||
void repairPdf_transfersInputToRealTempFile() throws Exception {
|
||||
byte[] pdf = buildPdfBytes(3);
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "broken.pdf", MediaType.APPLICATION_PDF_VALUE, pdf);
|
||||
|
||||
// Assert the file handed to load() is a real, non-empty file (transferTo succeeded).
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
File file = invocation.getArgument(0);
|
||||
assertTrue(file.exists(), "temp input file should exist");
|
||||
assertEquals(pdf.length, file.length());
|
||||
return newRealDocument(3);
|
||||
});
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Output filename handling")
|
||||
class FilenameHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("appends _repaired.pdf to the base name in the Content-Disposition header")
|
||||
void repairPdf_setsRepairedFilename() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"mydoc.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
String disposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
assertTrue(
|
||||
disposition.contains("mydoc_repaired.pdf"),
|
||||
"expected repaired filename in: " + disposition);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filename without extension still gets _repaired.pdf appended")
|
||||
void repairPdf_filenameWithoutExtension() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"noext",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
assertTrue(
|
||||
disposition.contains("noext_repaired.pdf"),
|
||||
"expected repaired filename in: " + disposition);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null original filename falls back to 'default'")
|
||||
void repairPdf_nullOriginalFilename_usesDefault() throws Exception {
|
||||
// MockMultipartFile with null original filename.
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
|
||||
assertTrue(
|
||||
disposition.contains("_repaired.pdf"),
|
||||
"expected empty-base repaired filename in: " + disposition);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error propagation")
|
||||
class ErrorPropagation {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from the factory propagates to the caller")
|
||||
void repairPdf_loadThrowsIOException_propagates() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenThrow(new IOException("cannot load corrupt pdf"));
|
||||
|
||||
IOException thrown =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> repairController.repairPdf(pdfFileFrom(input)));
|
||||
assertEquals("cannot load corrupt pdf", thrown.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RuntimeException from the factory propagates to the caller")
|
||||
void repairPdf_loadThrowsRuntimeException_propagates() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenThrow(new IllegalStateException("boom"));
|
||||
|
||||
IllegalStateException thrown =
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> repairController.repairPdf(pdfFileFrom(input)));
|
||||
assertEquals("boom", thrown.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+932
@@ -0,0 +1,932 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.ScannerEffectRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ScannerEffectController Tests")
|
||||
class ScannerEffectControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private ScannerEffectController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Real temp file backing so WebResponseUtils.pdfDocToWebResponse can save the output.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("scanner_test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
f.deleteOnExit();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static MockMultipartFile pdfFile(String filename, int pageCount, PDRectangle pageSize)
|
||||
throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
doc.addPage(new PDPage(pageSize));
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", filename, "application/pdf", baos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static ScannerEffectRequest baseRequest(MockMultipartFile file) {
|
||||
ScannerEffectRequest request = new ScannerEffectRequest();
|
||||
request.setFileInput(file);
|
||||
// Advanced enabled so quality presets do not override our resolution.
|
||||
request.setAdvancedEnabled(true);
|
||||
// Keep rendering tiny and fast; deterministic structure only.
|
||||
request.setResolution(36);
|
||||
request.setRotation(ScannerEffectRequest.Rotation.none);
|
||||
request.setRotate(0);
|
||||
request.setRotateVariance(0);
|
||||
request.setBorder(2);
|
||||
request.setBrightness(1.0f);
|
||||
request.setContrast(1.0f);
|
||||
request.setBlur(0f);
|
||||
request.setNoise(0f);
|
||||
request.setYellowish(false);
|
||||
request.setColorspace(ScannerEffectRequest.Colorspace.grayscale);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Stub both factory.load overloads to return fresh real documents loaded from bytes. */
|
||||
private void stubFactoryLoad(byte[] pdfBytes) throws IOException {
|
||||
// Used for page count + as output base (load(byte[])).
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
byte[] b = inv.getArgument(0);
|
||||
return Loader.loadPDF(b);
|
||||
});
|
||||
// Used by RenderingResources.fromBytes (load(byte[], true)).
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
|
||||
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
|
||||
}
|
||||
|
||||
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// End-to-end controller behaviour (real rendering, mocked boundaries)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("scannerEffect end-to-end")
|
||||
class EndToEnd {
|
||||
|
||||
@Test
|
||||
@DisplayName("produces a valid single-page PDF response for a one-page input")
|
||||
void singlePageHappyPath() throws Exception {
|
||||
MockMultipartFile file = pdfFile("input.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
|
||||
byte[] out = drain(response);
|
||||
assertThat(out).isNotEmpty();
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
// Output page keeps the original page dimensions.
|
||||
assertThat(box.getWidth()).isCloseTo(PDRectangle.A6.getWidth(), within(1f));
|
||||
assertThat(box.getHeight()).isCloseTo(PDRectangle.A6.getHeight(), within(1f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves page count for a multi-page input")
|
||||
void multiPageKeepsPageCount() throws Exception {
|
||||
MockMultipartFile file = pdfFile("multi.pdf", 3, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument result = Loader.loadPDF(drain(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applies colour effects (color colorspace, blur, noise, yellowish, rotation)")
|
||||
void richEffectsStillProduceValidPdf() throws Exception {
|
||||
MockMultipartFile file = pdfFile("rich.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
request.setColorspace(ScannerEffectRequest.Colorspace.color);
|
||||
request.setBlur(1.0f);
|
||||
request.setNoise(4.0f);
|
||||
request.setYellowish(true);
|
||||
request.setRotation(ScannerEffectRequest.Rotation.slight);
|
||||
request.setRotateVariance(2);
|
||||
request.setBorder(5);
|
||||
request.setBrightness(1.05f);
|
||||
request.setContrast(1.1f);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument result = Loader.loadPDF(drain(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-advanced request applies the quality preset without error")
|
||||
void qualityPresetPath() throws Exception {
|
||||
MockMultipartFile file = pdfFile("preset.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
request.setAdvancedEnabled(false);
|
||||
// Low preset uses resolution 75 which is the cheapest render.
|
||||
request.setQuality(ScannerEffectRequest.Quality.low);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument result = Loader.loadPDF(drain(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a DPI above the safe maximum with IllegalArgumentException")
|
||||
void dpiAboveLimitIsRejected() throws Exception {
|
||||
MockMultipartFile file = pdfFile("highdpi.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
// No application context in tests -> maxSafeDpi defaults to 500.
|
||||
request.setResolution(600);
|
||||
|
||||
assertThatThrownBy(() -> controller.scannerEffect(request))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("600");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an empty (zero-page) document with IllegalArgumentException")
|
||||
void emptyDocumentIsRejected() throws Exception {
|
||||
// A real 1-page file on disk, but the factory returns an empty document.
|
||||
MockMultipartFile file = pdfFile("empty.pdf", 1, PDRectangle.A6);
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
assertThatThrownBy(() -> controller.scannerEffect(request))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no pages");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates IOException from the document factory")
|
||||
void factoryIOExceptionPropagates() throws Exception {
|
||||
MockMultipartFile file = pdfFile("io.pdf", 1, PDRectangle.A6);
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenThrow(new IOException("boom-load"));
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
|
||||
.thenThrow(new IOException("boom-load"));
|
||||
|
||||
assertThatThrownBy(() -> controller.scannerEffect(request))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pure static image-processing logic via reflection
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("calculateSafeResolution")
|
||||
class CalculateSafeResolution {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"calculateSafeResolution", float.class, float.class, int.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
private int invoke(float w, float h, int res) throws Exception {
|
||||
return (int) method.invoke(null, w, h, res);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps requested resolution when the projected image is small")
|
||||
void keepsResolutionForSmallPage() throws Exception {
|
||||
// A6 at 72 dpi is well within limits.
|
||||
assertThat(invoke(297f, 420f, 72)).isEqualTo(72);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("downscales resolution when the projected image is huge")
|
||||
void downscalesForHugePage() throws Exception {
|
||||
// A0-ish page at a very high dpi exceeds the pixel/size caps.
|
||||
int safe = invoke(2384f, 3370f, 2000);
|
||||
assertThat(safe).isLessThan(2000);
|
||||
assertThat(safe).isGreaterThanOrEqualTo(72);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("never returns below the floor of 72")
|
||||
void neverBelowFloor() throws Exception {
|
||||
int safe = invoke(5000f, 5000f, 4000);
|
||||
assertThat(safe).isGreaterThanOrEqualTo(72);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("determineRenderResolution")
|
||||
class DetermineRenderResolution {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the request resolution unchanged")
|
||||
void returnsRequestResolution() throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"determineRenderResolution", ScannerEffectRequest.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
ScannerEffectRequest request = new ScannerEffectRequest();
|
||||
request.setResolution(123);
|
||||
|
||||
assertThat((int) method.invoke(null, request)).isEqualTo(123);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertColorspace / convertToGrayscale")
|
||||
class Colorspace {
|
||||
|
||||
private static BufferedImage solid(int rgb) {
|
||||
BufferedImage image = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
|
||||
for (int y = 0; y < 4; y++) {
|
||||
for (int x = 0; x < 4; x++) {
|
||||
image.setRGB(x, y, rgb);
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(ScannerEffectRequest.Colorspace.class)
|
||||
@DisplayName("returns an INT_RGB image of the same dimensions for any colorspace")
|
||||
void preservesDimensions(ScannerEffectRequest.Colorspace colorspace) throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"convertColorspace",
|
||||
BufferedImage.class,
|
||||
ScannerEffectRequest.Colorspace.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
BufferedImage src = solid(0x123456);
|
||||
BufferedImage result = (BufferedImage) method.invoke(null, src, colorspace);
|
||||
|
||||
assertThat(result.getWidth()).isEqualTo(4);
|
||||
assertThat(result.getHeight()).isEqualTo(4);
|
||||
assertThat(result.getType()).isEqualTo(BufferedImage.TYPE_INT_RGB);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grayscale collapses the channels to a single grey value")
|
||||
void grayscaleEqualisesChannels() throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"convertColorspace",
|
||||
BufferedImage.class,
|
||||
ScannerEffectRequest.Colorspace.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
// R=90, G=120, B=150 -> avg 120 -> 0x787878
|
||||
BufferedImage src = solid((90 << 16) | (120 << 8) | 150);
|
||||
BufferedImage result =
|
||||
(BufferedImage)
|
||||
method.invoke(null, src, ScannerEffectRequest.Colorspace.grayscale);
|
||||
|
||||
int px = result.getRGB(0, 0) & 0xFFFFFF;
|
||||
int r = (px >> 16) & 0xFF;
|
||||
int g = (px >> 8) & 0xFF;
|
||||
int b = px & 0xFF;
|
||||
assertThat(r).isEqualTo(g).isEqualTo(b);
|
||||
assertThat(r).isEqualTo(120);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToGrayscale mutates the buffer in place")
|
||||
void convertToGrayscaleInPlace() throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"convertToGrayscale", BufferedImage.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
BufferedImage img = solid((30 << 16) | (60 << 8) | 90); // avg 60
|
||||
method.invoke(null, img);
|
||||
|
||||
int px = img.getRGB(1, 1) & 0xFFFFFF;
|
||||
assertThat(px).isEqualTo((60 << 16) | (60 << 8) | 60);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("calculateRotation")
|
||||
class CalculateRotation {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"calculateRotation", int.class, int.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns exactly 0 when base rotation and variance are both 0")
|
||||
void zeroWhenNoRotation() throws Exception {
|
||||
assertThat((double) method.invoke(null, 0, 0)).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stays within base +/- variance bounds")
|
||||
void withinBounds() throws Exception {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
double value = (double) method.invoke(null, 5, 3);
|
||||
assertThat(value).isBetween(2.0, 8.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("with zero variance but non-zero base returns the base rotation")
|
||||
void zeroVarianceReturnsBase() throws Exception {
|
||||
// base=5, variance=0 -> 5 + (rand*2-1)*0 = 5
|
||||
assertThat((double) method.invoke(null, 5, 0)).isEqualTo(5.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("blendColors")
|
||||
class BlendColors {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"blendColors", int.class, int.class, float.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
private int invoke(int fg, int bg, float alpha) throws Exception {
|
||||
return (int) method.invoke(null, fg, bg, alpha);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("alpha=1 yields the foreground colour")
|
||||
void alphaOneIsForeground() throws Exception {
|
||||
assertThat(invoke(0xAABBCC, 0x112233, 1.0f)).isEqualTo(0xAABBCC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("alpha=0 yields the background colour")
|
||||
void alphaZeroIsBackground() throws Exception {
|
||||
assertThat(invoke(0xAABBCC, 0x112233, 0.0f)).isEqualTo(0x112233);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("alpha=0.5 yields the rounded midpoint per channel")
|
||||
void alphaHalfIsMidpoint() throws Exception {
|
||||
// fg 0x806040 (128,96,64), bg 0x204060 (32,64,96)
|
||||
int blended = invoke(0x806040, 0x204060, 0.5f);
|
||||
int r = (blended >> 16) & 0xFF;
|
||||
int g = (blended >> 8) & 0xFF;
|
||||
int b = blended & 0xFF;
|
||||
assertThat(r).isEqualTo(80); // (128+32)/2
|
||||
assertThat(g).isEqualTo(80); // (96+64)/2
|
||||
assertThat(b).isEqualTo(80); // (64+96)/2
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("fillWithGradient")
|
||||
class FillWithGradient {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"fillWithGradient",
|
||||
int[].class,
|
||||
int.class,
|
||||
int.class,
|
||||
int[].class,
|
||||
boolean.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("vertical gradient assigns one LUT value per row")
|
||||
void verticalFill() throws Exception {
|
||||
int width = 3;
|
||||
int height = 2;
|
||||
int[] pixels = new int[width * height];
|
||||
int[] lut = {0x111111, 0x222222};
|
||||
|
||||
method.invoke(null, pixels, width, height, lut, true);
|
||||
|
||||
// Row 0 all lut[0], row 1 all lut[1].
|
||||
assertThat(pixels[0]).isEqualTo(0x111111);
|
||||
assertThat(pixels[2]).isEqualTo(0x111111);
|
||||
assertThat(pixels[3]).isEqualTo(0x222222);
|
||||
assertThat(pixels[5]).isEqualTo(0x222222);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("horizontal gradient assigns one LUT value per column, repeated each row")
|
||||
void horizontalFill() throws Exception {
|
||||
int width = 3;
|
||||
int height = 2;
|
||||
int[] pixels = new int[width * height];
|
||||
int[] lut = {0xAA0000, 0x00BB00, 0x0000CC};
|
||||
|
||||
method.invoke(null, pixels, width, height, lut, false);
|
||||
|
||||
// Each row mirrors the LUT.
|
||||
assertThat(pixels[0]).isEqualTo(0xAA0000);
|
||||
assertThat(pixels[1]).isEqualTo(0x00BB00);
|
||||
assertThat(pixels[2]).isEqualTo(0x0000CC);
|
||||
assertThat(pixels[3]).isEqualTo(0xAA0000);
|
||||
assertThat(pixels[4]).isEqualTo(0x00BB00);
|
||||
assertThat(pixels[5]).isEqualTo(0x0000CC);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createGradientLUT")
|
||||
class CreateGradientLUT {
|
||||
|
||||
private Object gradient(boolean vertical, Color start, Color end) throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Constructor<?> ctor =
|
||||
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(vertical, start, end);
|
||||
}
|
||||
|
||||
private int[] invokeLut(int width, int height, Object gradientConfig) throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"createGradientLUT", int.class, int.class, gradientClass);
|
||||
method.setAccessible(true);
|
||||
return (int[]) method.invoke(null, width, height, gradientConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("vertical LUT length equals height and interpolates endpoints")
|
||||
void verticalLut() throws Exception {
|
||||
Object g = gradient(true, Color.BLACK, Color.WHITE);
|
||||
int[] lut = invokeLut(10, 5, g);
|
||||
|
||||
assertThat(lut).hasSize(5);
|
||||
assertThat(lut[0] & 0xFFFFFF).isEqualTo(0x000000);
|
||||
assertThat(lut[lut.length - 1] & 0xFFFFFF).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("horizontal LUT length equals width")
|
||||
void horizontalLut() throws Exception {
|
||||
Object g = gradient(false, Color.BLACK, Color.WHITE);
|
||||
int[] lut = invokeLut(7, 3, g);
|
||||
|
||||
assertThat(lut).hasSize(7);
|
||||
assertThat(lut[0] & 0xFFFFFF).isEqualTo(0x000000);
|
||||
assertThat(lut[lut.length - 1] & 0xFFFFFF).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constant colour produces a uniform LUT")
|
||||
void uniformLut() throws Exception {
|
||||
Object g = gradient(true, new Color(0x40, 0x50, 0x60), new Color(0x40, 0x50, 0x60));
|
||||
int[] lut = invokeLut(4, 4, g);
|
||||
|
||||
for (int value : lut) {
|
||||
assertThat(value & 0xFFFFFF).isEqualTo(0x405060);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyAllEffectsSinglePass")
|
||||
class ApplyAllEffects {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"applyAllEffectsSinglePass",
|
||||
BufferedImage.class,
|
||||
float.class,
|
||||
float.class,
|
||||
boolean.class,
|
||||
double.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
private static BufferedImage solid(int width, int height, int rgb) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, rgb);
|
||||
return image;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("identity settings leave pixels unchanged")
|
||||
void identityKeepsPixels() throws Exception {
|
||||
BufferedImage src = solid(8, 8, 0x648CB4); // 100,140,180
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, false, 0.0d);
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(8);
|
||||
assertThat(out.getHeight()).isEqualTo(8);
|
||||
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0x648CB4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("brightness > 1 increases channel values and clamps at 255")
|
||||
void brightnessClamps() throws Exception {
|
||||
BufferedImage src = solid(4, 4, 0xC8C8C8); // 200 each
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 2.0f, 1.0f, false, 0.0d);
|
||||
|
||||
int px = out.getRGB(0, 0) & 0xFFFFFF;
|
||||
// 200 * 2 = 400 -> clamped to 255 on every channel.
|
||||
assertThat(px).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero brightness produces black")
|
||||
void zeroBrightnessIsBlack() throws Exception {
|
||||
BufferedImage src = solid(4, 4, 0xFFFFFF);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 0.0f, 1.0f, false, 0.0d);
|
||||
|
||||
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0x000000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("yellowish tint lowers the blue channel relative to source")
|
||||
void yellowishReducesBlue() throws Exception {
|
||||
BufferedImage src = solid(4, 4, 0xFFFFFF); // bright white maximises tint effect
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, true, 0.0d);
|
||||
|
||||
int px = out.getRGB(0, 0) & 0xFFFFFF;
|
||||
int b = px & 0xFF;
|
||||
assertThat(b).isLessThan(255);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("noise keeps every channel within the valid 0..255 range")
|
||||
void noiseStaysInRange() throws Exception {
|
||||
BufferedImage src = solid(32, 32, 0x808080);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, false, 50.0d);
|
||||
|
||||
for (int y = 0; y < out.getHeight(); y++) {
|
||||
for (int x = 0; x < out.getWidth(); x++) {
|
||||
int px = out.getRGB(x, y);
|
||||
assertThat((px >> 16) & 0xFF).isBetween(0, 255);
|
||||
assertThat((px >> 8) & 0xFF).isBetween(0, 255);
|
||||
assertThat(px & 0xFF).isBetween(0, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("softenEdges")
|
||||
class SoftenEdges {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"softenEdges",
|
||||
BufferedImage.class,
|
||||
int.class,
|
||||
Color.class,
|
||||
Color.class,
|
||||
boolean.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("center pixel stays at foreground when feathering only touches edges")
|
||||
void centreStaysForeground() throws Exception {
|
||||
BufferedImage src = new BufferedImage(11, 11, BufferedImage.TYPE_INT_RGB);
|
||||
int fg = 0x102030;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, fg);
|
||||
|
||||
BufferedImage out =
|
||||
(BufferedImage) method.invoke(null, src, 2, Color.WHITE, Color.WHITE, true);
|
||||
|
||||
// Centre is far from any edge (distance 5 >= feather radius 2) so alpha=1.
|
||||
assertThat(out.getRGB(5, 5) & 0xFFFFFF).isEqualTo(fg);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("corner pixel is blended toward the background gradient")
|
||||
void cornerBlendsToBackground() throws Exception {
|
||||
BufferedImage src = new BufferedImage(11, 11, BufferedImage.TYPE_INT_RGB);
|
||||
int fg = 0x000000;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, fg);
|
||||
|
||||
// Background is pure white; corner distance d=0 -> alpha=0 -> background.
|
||||
BufferedImage out =
|
||||
(BufferedImage) method.invoke(null, src, 3, Color.WHITE, Color.WHITE, true);
|
||||
|
||||
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves image dimensions")
|
||||
void preservesDimensions() throws Exception {
|
||||
BufferedImage src = new BufferedImage(6, 9, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out =
|
||||
(BufferedImage) method.invoke(null, src, 1, Color.GRAY, Color.DARK_GRAY, false);
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(6);
|
||||
assertThat(out.getHeight()).isEqualTo(9);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyGaussianBlur")
|
||||
class ApplyGaussianBlur {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"applyGaussianBlur", BufferedImage.class, double.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sigma <= 0 returns the same image instance")
|
||||
void zeroSigmaIsNoOp() throws Exception {
|
||||
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 0.0d);
|
||||
assertThat(out).isSameAs(src);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative sigma returns the same image instance")
|
||||
void negativeSigmaIsNoOp() throws Exception {
|
||||
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, -1.0d);
|
||||
assertThat(out).isSameAs(src);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("positive sigma on a uniform image keeps the uniform colour and dimensions")
|
||||
void uniformImageStaysUniform() throws Exception {
|
||||
BufferedImage src = new BufferedImage(40, 40, BufferedImage.TYPE_INT_RGB);
|
||||
int color = 0x405060;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, color);
|
||||
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 30.0d);
|
||||
|
||||
assertThat(out).isNotSameAs(src);
|
||||
assertThat(out.getWidth()).isEqualTo(40);
|
||||
assertThat(out.getHeight()).isEqualTo(40);
|
||||
// Blurring a uniform image yields the same uniform colour.
|
||||
assertThat(out.getRGB(20, 20) & 0xFFFFFF).isEqualTo(color);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("rotateImage")
|
||||
class RotateImage {
|
||||
|
||||
private Object gradient() throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Constructor<?> ctor =
|
||||
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(true, Color.WHITE, Color.WHITE);
|
||||
}
|
||||
|
||||
private Method rotateMethod() throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"rotateImage", BufferedImage.class, double.class, gradientClass);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero rotation returns the same image instance")
|
||||
void zeroRotationNoOp() throws Exception {
|
||||
BufferedImage src = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 0.0d, gradient());
|
||||
assertThat(out).isSameAs(src);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("90 degree rotation swaps the bounding box dimensions")
|
||||
void ninetyDegreeSwapsDimensions() throws Exception {
|
||||
BufferedImage src = new BufferedImage(20, 10, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 90.0d, gradient());
|
||||
|
||||
assertThat(out).isNotSameAs(src);
|
||||
// For 90 degrees, rotated bounds become height x width.
|
||||
assertThat(out.getWidth()).isEqualTo(10);
|
||||
assertThat(out.getHeight()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("45 degree rotation grows the bounding box")
|
||||
void fortyFiveGrowsBoundingBox() throws Exception {
|
||||
BufferedImage src = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 45.0d, gradient());
|
||||
|
||||
assertThat(out.getWidth()).isGreaterThan(20);
|
||||
assertThat(out.getHeight()).isGreaterThan(20);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addBorderWithGradient")
|
||||
class AddBorderWithGradient {
|
||||
|
||||
private Object gradient(boolean vertical) throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Constructor<?> ctor =
|
||||
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(vertical, Color.GRAY, Color.LIGHT_GRAY);
|
||||
}
|
||||
|
||||
private Method borderMethod() throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"addBorderWithGradient", BufferedImage.class, int.class, gradientClass);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("adds a border of the requested size on every side")
|
||||
void growsByTwiceBorder() throws Exception {
|
||||
BufferedImage src = new BufferedImage(10, 12, BufferedImage.TYPE_INT_RGB);
|
||||
int border = 5;
|
||||
BufferedImage out =
|
||||
(BufferedImage) borderMethod().invoke(null, src, border, gradient(true));
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(10 + 2 * border);
|
||||
assertThat(out.getHeight()).isEqualTo(12 + 2 * border);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero border keeps the original dimensions")
|
||||
void zeroBorderKeepsDimensions() throws Exception {
|
||||
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out =
|
||||
(BufferedImage) borderMethod().invoke(null, src, 0, gradient(false));
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(8);
|
||||
assertThat(out.getHeight()).isEqualTo(8);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves the drawn source region inside the border")
|
||||
void preservesSourcePixels() throws Exception {
|
||||
BufferedImage src = new BufferedImage(6, 6, BufferedImage.TYPE_INT_RGB);
|
||||
int fg = 0x123456;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, fg);
|
||||
|
||||
int border = 3;
|
||||
BufferedImage out =
|
||||
(BufferedImage) borderMethod().invoke(null, src, border, gradient(true));
|
||||
|
||||
// Source top-left maps to (border, border) in the composed image.
|
||||
assertThat(out.getRGB(border, border) & 0xFFFFFF).isEqualTo(fg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Small AssertJ helper for float closeness
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static org.assertj.core.data.Offset<Float> within(float tol) {
|
||||
return org.assertj.core.data.Offset.offset(tol);
|
||||
}
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
package stirling.software.SPDF.controller.api.pipeline;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.PipelineConfig;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.model.api.HandleDataRequest;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PipelineController#handleData}. The controller is exercised directly with a
|
||||
* real {@link ObjectMapper} for JSON parsing and mocked collaborators for the processor, analytics
|
||||
* and temp-file management. The {@link TempFileManager} is stubbed to hand out real on-disk temp
|
||||
* files so the {@code TempFile} wrapper and the stream-copy / zip paths run for real.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PipelineControllerGapTest {
|
||||
|
||||
@Mock private PipelineProcessor processor;
|
||||
|
||||
@Mock private PostHogService postHogService;
|
||||
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
private PipelineController controller;
|
||||
|
||||
// Real temp files handed back by the mocked TempFileManager; cleaned up after each test.
|
||||
private final List<Path> createdTempFiles = new ArrayList<>();
|
||||
|
||||
private static final String VALID_JSON =
|
||||
"{\"name\":\"test-pipeline\",\"pipeline\":["
|
||||
+ "{\"operation\":\"/api/v1/misc/repair\",\"parameters\":{}}]}";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
controller =
|
||||
new PipelineController(processor, objectMapper, postHogService, tempFileManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the manager so every {@code new TempFile(manager, suffix)} the controller builds gets a
|
||||
* real on-disk file. Only invoked by tests that actually exercise an output path, so tests that
|
||||
* short-circuit can assert {@code verifyNoInteractions(tempFileManager)}.
|
||||
*/
|
||||
private void stubTempFiles() throws IOException {
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
String suffix = invocation.getArgument(0);
|
||||
Path p = Files.createTempFile("pipeline-gap-test-", suffix);
|
||||
createdTempFiles.add(p);
|
||||
return p.toFile();
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
for (Path p : createdTempFiles) {
|
||||
Files.deleteIfExists(p);
|
||||
}
|
||||
createdTempFiles.clear();
|
||||
}
|
||||
|
||||
private HandleDataRequest request(MultipartFile[] files, String json) {
|
||||
HandleDataRequest req = new HandleDataRequest();
|
||||
req.setFileInput(files);
|
||||
req.setJson(json);
|
||||
return req;
|
||||
}
|
||||
|
||||
private MockMultipartFile pdf(String name) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, "application/pdf", ("content of " + name).getBytes());
|
||||
}
|
||||
|
||||
private MultipartFile[] oneFile() {
|
||||
return new MultipartFile[] {pdf("input.pdf")};
|
||||
}
|
||||
|
||||
/** A Resource backed by bytes that also reports a filename (ByteArrayResource returns null). */
|
||||
private static Resource namedResource(String filename, byte[] data) {
|
||||
return new ByteArrayResource(data) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Null / empty input short-circuits")
|
||||
class NullAndEmptyInputs {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when file input is null without touching collaborators")
|
||||
void nullFiles_returnsNull() throws Exception {
|
||||
ResponseEntity<Resource> response = controller.handleData(request(null, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
verifyNoInteractions(processor);
|
||||
verifyNoInteractions(postHogService);
|
||||
verifyNoInteractions(tempFileManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when processor yields no input files")
|
||||
void nullInputFiles_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files)).thenReturn(null);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
// Event still captured before processing begins.
|
||||
verify(postHogService).captureEvent(eq("pipeline_api_event"), any());
|
||||
verify(processor, never()).runPipelineAgainstFiles(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when processor yields an empty input list")
|
||||
void emptyInputFiles_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files)).thenReturn(new ArrayList<>());
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
verify(processor, never()).runPipelineAgainstFiles(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when pipeline result has null output files")
|
||||
void nullOutputFiles_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
List<Resource> inputFiles = List.of(namedResource("input.pdf", "x".getBytes()));
|
||||
when(processor.generateInputFiles(files)).thenReturn(inputFiles);
|
||||
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(null);
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Single output file path")
|
||||
class SingleOutput {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the single file streamed through a temp file")
|
||||
void singleOutput_returnsFileResponse() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
List<Resource> inputFiles = List.of(namedResource("input.pdf", "in".getBytes()));
|
||||
when(processor.generateInputFiles(files)).thenReturn(inputFiles);
|
||||
|
||||
byte[] outBytes = "single output body".getBytes(StandardCharsets.UTF_8);
|
||||
Resource single = namedResource("result.pdf", outBytes);
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(single));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
// The controller copied the single file into a ".out" temp file.
|
||||
verify(tempFileManager).createTempFile(".out");
|
||||
|
||||
// The streamed body matches what the processor produced.
|
||||
try (InputStream is = response.getBody().getInputStream()) {
|
||||
assertEquals(
|
||||
new String(outBytes, StandardCharsets.UTF_8),
|
||||
new String(is.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captures analytics event with operations and file count")
|
||||
void singleOutput_capturesAnalytics() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(namedResource("result.pdf", "body".getBytes())));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> propsCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(postHogService).captureEvent(eq("pipeline_api_event"), propsCaptor.capture());
|
||||
|
||||
Map<String, Object> props = propsCaptor.getValue();
|
||||
assertEquals(1, props.get("fileCount"));
|
||||
assertEquals(List.of("/api/v1/misc/repair"), props.get("operations"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Multiple output files (zip) path")
|
||||
class MultipleOutput {
|
||||
|
||||
@Test
|
||||
@DisplayName("zips multiple output files into output.zip")
|
||||
void multipleOutputs_returnsZipResponse() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
|
||||
Resource a = namedResource("a.pdf", "alpha".getBytes(StandardCharsets.UTF_8));
|
||||
Resource b = namedResource("b.pdf", "bravo".getBytes(StandardCharsets.UTF_8));
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(a, b));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
verify(tempFileManager).createTempFile(".zip");
|
||||
|
||||
// Inspect zip contents: both entries present with their bytes.
|
||||
Map<String, String> entries = readZip(response.getBody());
|
||||
assertEquals(2, entries.size());
|
||||
assertEquals("alpha", entries.get("a.pdf"));
|
||||
assertEquals("bravo", entries.get("b.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("duplicate filenames are de-duplicated within the zip")
|
||||
void multipleOutputs_duplicateNames_areDeduped() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
|
||||
Resource first = namedResource("dup.pdf", "first".getBytes(StandardCharsets.UTF_8));
|
||||
Resource second = namedResource("dup.pdf", "second".getBytes(StandardCharsets.UTF_8));
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(first, second));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNotNull(response);
|
||||
Map<String, String> entries = readZip(response.getBody());
|
||||
// Two distinct zip entry names despite the same source filename.
|
||||
assertEquals(2, entries.size());
|
||||
}
|
||||
|
||||
private Map<String, String> readZip(Resource zipResource) throws IOException {
|
||||
Map<String, String> out = new java.util.LinkedHashMap<>();
|
||||
byte[] all;
|
||||
try (InputStream is = zipResource.getInputStream()) {
|
||||
all = is.readAllBytes();
|
||||
}
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(all))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
out.put(
|
||||
entry.getName(),
|
||||
new String(zis.readAllBytes(), StandardCharsets.UTF_8));
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error handling")
|
||||
class ErrorHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when the processor throws during input generation")
|
||||
void processorThrowsOnGenerate_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files)).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when the pipeline run throws")
|
||||
void pipelineRunThrows_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
when(processor.runPipelineAgainstFiles(any(), any()))
|
||||
.thenThrow(new RuntimeException("pipeline failed"));
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid JSON config propagates as a parse exception")
|
||||
void invalidJson_throws() {
|
||||
MultipartFile[] files = oneFile();
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Exception.class, () -> controller.handleData(request(files, "not-valid-json")));
|
||||
|
||||
verifyNoInteractions(postHogService);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("JSON config parsing")
|
||||
class JsonParsing {
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple operation names are extracted in order for analytics")
|
||||
void multipleOperations_extractedInOrder() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
String json =
|
||||
"{\"name\":\"multi\",\"pipeline\":["
|
||||
+ "{\"operation\":\"/api/v1/misc/repair\",\"parameters\":{}},"
|
||||
+ "{\"operation\":\"/api/v1/security/sanitize-pdf\",\"parameters\":{}}]}";
|
||||
|
||||
// Stop after analytics by returning no input files.
|
||||
when(processor.generateInputFiles(files)).thenReturn(null);
|
||||
|
||||
controller.handleData(request(files, json));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> propsCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(postHogService).captureEvent(eq("pipeline_api_event"), propsCaptor.capture());
|
||||
|
||||
assertEquals(
|
||||
List.of("/api/v1/misc/repair", "/api/v1/security/sanitize-pdf"),
|
||||
propsCaptor.getValue().get("operations"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("real ObjectMapper deserializes the pipeline config used by analytics")
|
||||
void objectMapperParsesConfig() {
|
||||
PipelineConfig config = objectMapper.readValue(VALID_JSON, PipelineConfig.class);
|
||||
assertEquals("test-pipeline", config.getName());
|
||||
assertEquals(1, config.getOperations().size());
|
||||
assertEquals("/api/v1/misc/repair", config.getOperations().get(0).getOperation());
|
||||
}
|
||||
}
|
||||
}
|
||||
+669
@@ -0,0 +1,669 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
|
||||
import stirling.software.common.model.api.security.RedactionArea;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@DisplayName("ManualRedactionService Tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ManualRedactionServiceTest {
|
||||
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ManualRedactionService service;
|
||||
|
||||
// Track temp files created during finalize tests so we can clean them up.
|
||||
private final List<File> createdTempFiles = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
service = new ManualRedactionService(tempFileManager);
|
||||
|
||||
// createManagedTempFile returns a TempFile backed by a real on-disk file so
|
||||
// document.save() works and length() can be inspected.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("redact-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
createdTempFiles.add(f);
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
for (File f : createdTempFiles) {
|
||||
if (f != null && f.exists()) {
|
||||
f.delete();
|
||||
}
|
||||
}
|
||||
createdTempFiles.clear();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static PDDocument newDocument(int pageCount) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static PDDocument newDocumentWithText() throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText("Sensitive text to redact");
|
||||
cs.endText();
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static RedactionArea area(
|
||||
Integer page, double x, double y, double width, double height, String color) {
|
||||
RedactionArea a = new RedactionArea();
|
||||
a.setPage(page);
|
||||
a.setX(x);
|
||||
a.setY(y);
|
||||
a.setWidth(width);
|
||||
a.setHeight(height);
|
||||
a.setColor(color);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static PDFText text(int pageIndex, float x1, float y1, float x2, float y2) {
|
||||
return new PDFText(pageIndex, x1, y1, x2, y2, "redacted");
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// decodeOrDefault
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("decodeOrDefault")
|
||||
class DecodeOrDefault {
|
||||
|
||||
@Test
|
||||
@DisplayName("null returns black")
|
||||
void nullReturnsBlack() {
|
||||
assertSame(Color.BLACK, ManualRedactionService.decodeOrDefault(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hex with leading hash decodes")
|
||||
void hexWithHash() {
|
||||
assertEquals(Color.RED, ManualRedactionService.decodeOrDefault("#FF0000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hex without leading hash decodes")
|
||||
void hexWithoutHash() {
|
||||
assertEquals(Color.RED, ManualRedactionService.decodeOrDefault("FF0000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("white decodes correctly")
|
||||
void whiteDecodes() {
|
||||
assertEquals(Color.WHITE, ManualRedactionService.decodeOrDefault("#FFFFFF"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid hex falls back to black")
|
||||
void invalidFallsBackToBlack() {
|
||||
assertEquals(Color.BLACK, ManualRedactionService.decodeOrDefault("not-a-color"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty string falls back to black")
|
||||
void emptyFallsBackToBlack() {
|
||||
assertEquals(Color.BLACK, ManualRedactionService.decodeOrDefault(""));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactAreas
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactAreas")
|
||||
class RedactAreas {
|
||||
|
||||
@Test
|
||||
@DisplayName("null list is a no-op")
|
||||
void nullList() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
byte[] before = save(doc);
|
||||
service.redactAreas(null, doc, doc.getPages());
|
||||
// Document still saves and has its single page; nothing thrown.
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
assertNotNull(before);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty list is a no-op")
|
||||
void emptyList() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactAreas(new ArrayList<>(), doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("valid area is applied and document still saves")
|
||||
void validArea() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(1, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
assertTrue(out.length > 0);
|
||||
// Reload to ensure the produced PDF is structurally valid.
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area with null page is skipped")
|
||||
void nullPageSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(null, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area with non-positive page is skipped")
|
||||
void nonPositivePageSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(0, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area with null/zero width or height is skipped")
|
||||
void invalidDimensionsSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = new ArrayList<>();
|
||||
areas.add(area(1, 10, 10, 0, 50, "#000000")); // zero width
|
||||
areas.add(area(1, 10, 10, 100, 0, "#000000")); // zero height
|
||||
RedactionArea nullWidth = area(1, 10, 10, 100, 50, "#000000");
|
||||
nullWidth.setWidth(null);
|
||||
areas.add(nullWidth);
|
||||
RedactionArea nullHeight = area(1, 10, 10, 100, 50, "#000000");
|
||||
nullHeight.setHeight(null);
|
||||
areas.add(nullHeight);
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
// None applied, but no exception and doc remains valid.
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area on out-of-range page is skipped without error")
|
||||
void outOfRangePageSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(5, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple areas across multiple pages are grouped per page")
|
||||
void multiplePagesGrouped() throws Exception {
|
||||
try (PDDocument doc = newDocument(3)) {
|
||||
List<RedactionArea> areas = new ArrayList<>();
|
||||
areas.add(area(1, 10, 10, 50, 50, "#000000"));
|
||||
areas.add(area(1, 80, 80, 50, 50, "#FF0000"));
|
||||
areas.add(area(3, 20, 20, 60, 40, null)); // null color -> default black
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(3, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactPages
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactPages")
|
||||
class RedactPages {
|
||||
|
||||
private ManualRedactPdfRequest request(String pageNumbers, String color) {
|
||||
ManualRedactPdfRequest req = new ManualRedactPdfRequest();
|
||||
req.setPageNumbers(pageNumbers);
|
||||
req.setPageRedactionColor(color);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("redacts all pages when 'all' is given")
|
||||
void redactsAllPages() throws Exception {
|
||||
try (PDDocument doc = newDocument(3)) {
|
||||
service.redactPages(request("all", "#000000"), doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(3, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("redacts a specific page range")
|
||||
void redactsSpecificPages() throws Exception {
|
||||
try (PDDocument doc = newDocument(5)) {
|
||||
service.redactPages(request("1,3", "#FF0000"), doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(5, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null page numbers defaults to first page")
|
||||
void nullPageNumbers() throws Exception {
|
||||
try (PDDocument doc = newDocument(2)) {
|
||||
service.redactPages(request(null, "#000000"), doc, doc.getPages());
|
||||
assertEquals(2, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null color falls back to black")
|
||||
void nullColor() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactPages(request("all", null), doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactFoundText
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactFoundText")
|
||||
class RedactFoundText {
|
||||
|
||||
@Test
|
||||
@DisplayName("overlay-only mode draws boxes and document stays valid")
|
||||
void overlayMode() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<PDFText> blocks = Arrays.asList(text(0, 72, 690, 300, 710));
|
||||
service.redactFoundText(doc, blocks, 1.0f, Color.BLACK, false);
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text-removal mode narrows the box width")
|
||||
void textRemovalMode() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<PDFText> blocks = Arrays.asList(text(0, 72, 690, 300, 710));
|
||||
service.redactFoundText(doc, blocks, 0.0f, Color.RED, true);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks on out-of-range pages are skipped")
|
||||
void outOfRangePageIndexSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<PDFText> blocks =
|
||||
Arrays.asList(text(0, 72, 690, 300, 710), text(9, 10, 10, 50, 50));
|
||||
service.redactFoundText(doc, blocks, 0.0f, Color.BLACK, false);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty block list is a no-op")
|
||||
void emptyBlocks() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactFoundText(doc, new ArrayList<>(), 0.0f, Color.BLACK, false);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("annotation overlapping a redacted block is removed")
|
||||
void overlappingAnnotationRemoved() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
float pageH = page.getBBox().getHeight();
|
||||
|
||||
// Redact block in PDF coords near y2=710 (top), x 72..300.
|
||||
PDFText block = text(0, 72, 690, 300, 710);
|
||||
|
||||
// Place an annotation rectangle that overlaps the redacted block.
|
||||
// Block pdf-Y window (padding included) sits roughly around pageH-710..pageH-690.
|
||||
PDAnnotationLink overlapping = new PDAnnotationLink();
|
||||
overlapping.setRectangle(new PDRectangle(80, pageH - 712, 120, 30));
|
||||
|
||||
// Place an annotation far away that should be kept.
|
||||
PDAnnotationLink faraway = new PDAnnotationLink();
|
||||
faraway.setRectangle(new PDRectangle(10, 10, 20, 20));
|
||||
|
||||
page.setAnnotations(new ArrayList<>(Arrays.asList(overlapping, faraway)));
|
||||
assertEquals(2, page.getAnnotations().size());
|
||||
|
||||
service.redactFoundText(doc, Arrays.asList(block), 1.0f, Color.BLACK, false);
|
||||
|
||||
// getAnnotations() builds fresh wrappers each call, so compare by geometry
|
||||
// rather than object identity.
|
||||
List<PDAnnotation> remaining = page.getAnnotations();
|
||||
assertEquals(1, remaining.size());
|
||||
PDRectangle keptRect = remaining.get(0).getRectangle();
|
||||
assertEquals(10f, keptRect.getLowerLeftX(), 0.001f);
|
||||
assertEquals(10f, keptRect.getLowerLeftY(), 0.001f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-overlapping annotations are preserved")
|
||||
void nonOverlappingAnnotationsKept() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
PDAnnotationLink faraway = new PDAnnotationLink();
|
||||
faraway.setRectangle(new PDRectangle(10, 10, 20, 20));
|
||||
page.setAnnotations(new ArrayList<>(Arrays.asList(faraway)));
|
||||
|
||||
PDFText block = text(0, 400, 100, 500, 120);
|
||||
service.redactFoundText(doc, Arrays.asList(block), 0.0f, Color.BLACK, false);
|
||||
|
||||
assertEquals(1, page.getAnnotations().size());
|
||||
PDRectangle keptRect = page.getAnnotations().get(0).getRectangle();
|
||||
assertEquals(10f, keptRect.getLowerLeftX(), 0.001f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactImageBoxes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactImageBoxes")
|
||||
class RedactImageBoxes {
|
||||
|
||||
@Test
|
||||
@DisplayName("draws boxes for valid page indices")
|
||||
void validBoxes() throws Exception {
|
||||
try (PDDocument doc = newDocument(2)) {
|
||||
List<float[]> boxes = new ArrayList<>();
|
||||
boxes.add(new float[] {0, 10, 10, 100, 100});
|
||||
boxes.add(new float[] {1, 20, 20, 80, 80});
|
||||
service.redactImageBoxes(doc, boxes, Color.BLACK);
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(2, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("out-of-range page indices are skipped without error")
|
||||
void outOfRangeSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<float[]> boxes = new ArrayList<>();
|
||||
boxes.add(new float[] {-1, 10, 10, 100, 100}); // negative
|
||||
boxes.add(new float[] {7, 20, 20, 80, 80}); // beyond page count
|
||||
service.redactImageBoxes(doc, boxes, Color.BLACK);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty box list is a no-op")
|
||||
void emptyBoxes() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactImageBoxes(doc, new ArrayList<>(), Color.BLACK);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple boxes on the same page are grouped")
|
||||
void multipleBoxesSamePage() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<float[]> boxes = new ArrayList<>();
|
||||
boxes.add(new float[] {0, 10, 10, 50, 50});
|
||||
boxes.add(new float[] {0, 100, 100, 150, 150});
|
||||
service.redactImageBoxes(doc, boxes, Color.RED);
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// extractPageElementBoxes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractPageElementBoxes")
|
||||
class ExtractPageElementBoxes {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns text line boxes for a page with text")
|
||||
void extractsTextBoxes() throws Exception {
|
||||
try (PDDocument doc = newDocumentWithText()) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<float[]> boxes = service.extractPageElementBoxes(doc, page, 0);
|
||||
assertNotNull(boxes);
|
||||
assertFalse(boxes.isEmpty());
|
||||
// Each box must have 4 coordinates [x1, y1, x2, y2].
|
||||
for (float[] box : boxes) {
|
||||
assertEquals(4, box.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list for a blank page")
|
||||
void blankPageReturnsEmpty() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<float[]> boxes = service.extractPageElementBoxes(doc, page, 0);
|
||||
assertNotNull(boxes);
|
||||
assertTrue(boxes.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// finalizeRedaction (non-image path only; image path needs Spring context)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("finalizeRedaction")
|
||||
class FinalizeRedaction {
|
||||
|
||||
@Test
|
||||
@DisplayName("with found text saves a managed temp file")
|
||||
void withFoundText() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
byPage.put(0, new ArrayList<>(Arrays.asList(text(0, 72, 690, 300, 710))));
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#000000", 1.0f, false, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getFile());
|
||||
assertTrue(result.getFile().exists());
|
||||
assertTrue(result.getFile().length() > 0);
|
||||
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
|
||||
|
||||
// Output should be a loadable PDF.
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("with no found text still saves the document")
|
||||
void withoutFoundText() throws Exception {
|
||||
try (PDDocument doc = newDocument(2)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#000000", 0.0f, false, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getFile().exists());
|
||||
assertTrue(result.getFile().length() > 0);
|
||||
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(2, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToImage null is treated as non-image path")
|
||||
void convertToImageNull() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#000000", 0.0f, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getFile().exists());
|
||||
// Non-image path uses the standard ".pdf" managed temp file exactly once.
|
||||
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text-removal mode finalize produces valid PDF")
|
||||
void textRemovalFinalize() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
byPage.put(0, new ArrayList<>(Arrays.asList(text(0, 72, 690, 300, 710))));
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#FF0000", 2.0f, false, true);
|
||||
|
||||
assertNotNull(result);
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from save closes the temp file and is rethrown")
|
||||
void saveFailureClosesTempFile() throws Exception {
|
||||
// Use a document mock that throws on save to exercise the catch/close branch.
|
||||
PDDocument doc = newDocument(1);
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
|
||||
// Point the managed temp file at a directory path so save() fails with IOException.
|
||||
File dirAsFile = Files.createTempDirectory("redact-dir").toFile();
|
||||
createdTempFiles.add(dirAsFile);
|
||||
TempFile failing = mock(TempFile.class);
|
||||
when(failing.getFile()).thenReturn(dirAsFile);
|
||||
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(failing);
|
||||
|
||||
try {
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() ->
|
||||
service.finalizeRedaction(
|
||||
doc, byPage, "#000000", 0.0f, false, false));
|
||||
// The failing temp file is closed on the error path.
|
||||
verify(failing).close();
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TextRedactionService}. Exercises the pure text-matching / placeholder
|
||||
* logic, the public find/replace entry points, and the small helper predicates. PDFs are built tiny
|
||||
* and in-memory with real Standard14 fonts so the redaction pipeline runs end-to-end without
|
||||
* external processes.
|
||||
*/
|
||||
class TextRedactionServiceTest {
|
||||
|
||||
private static final float FONT_SIZE = 12f;
|
||||
private static final float LEFT_X = 72f;
|
||||
private static final float TOP_Y = PDRectangle.LETTER.getHeight() - 80f;
|
||||
|
||||
private final TextRedactionService service = new TextRedactionService();
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private PDFont helvetica() {
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
/** Single page, single Tj line per supplied text line, Helvetica 12. */
|
||||
private PDDocument buildDoc(String... lines) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
cs.showText(lines[i]);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private PDDocument buildEmptyDoc() {
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(PDRectangle.LETTER));
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── isTextShowingOperator ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("isTextShowingOperator")
|
||||
class IsTextShowingOperator {
|
||||
|
||||
@Test
|
||||
@DisplayName("recognises the four text-showing operators")
|
||||
void recognisesTextShowingOperators() {
|
||||
assertTrue(service.isTextShowingOperator("Tj"));
|
||||
assertTrue(service.isTextShowingOperator("TJ"));
|
||||
assertTrue(service.isTextShowingOperator("'"));
|
||||
assertTrue(service.isTextShowingOperator("\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects non text-showing operators and junk")
|
||||
void rejectsOthers() {
|
||||
assertFalse(service.isTextShowingOperator("BT"));
|
||||
assertFalse(service.isTextShowingOperator("ET"));
|
||||
assertFalse(service.isTextShowingOperator("Tf"));
|
||||
assertFalse(service.isTextShowingOperator(""));
|
||||
assertFalse(service.isTextShowingOperator("tj"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── findTextToRedact ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findTextToRedact")
|
||||
class FindTextToRedact {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty map when all search terms are blank")
|
||||
void emptyTermsReturnsEmptyMap() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Confidential data here")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"", " "}, false, false);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty map for an empty term array")
|
||||
void emptyArrayReturnsEmptyMap() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Some text")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {}, false, false);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("finds a literal term on the page")
|
||||
void findsLiteralTerm() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Hello SECRET world")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
|
||||
assertFalse(result.isEmpty());
|
||||
assertTrue(result.containsKey(0), "match should be on page index 0");
|
||||
List<PDFText> hits = result.get(0);
|
||||
assertEquals(1, hits.size());
|
||||
assertEquals("SECRET", hits.get(0).getText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not find a term that is absent")
|
||||
void doesNotFindAbsentTerm() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Hello world")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"NOTHERE"}, false, false);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("regex term matches digit runs")
|
||||
void regexTermMatchesDigits() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Order 12345 shipped")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"\\d+"}, true, false);
|
||||
|
||||
assertFalse(result.isEmpty());
|
||||
assertEquals("12345", result.get(0).get(0).getText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("whole-word search does not match a substring inside a larger word")
|
||||
void wholeWordDoesNotMatchSubstring() throws IOException {
|
||||
try (PDDocument doc = buildDoc("classification of cats")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"cat"}, false, true);
|
||||
// "cat" appears only inside "classification"; whole-word must not match it.
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("whole-word search matches a standalone word")
|
||||
void wholeWordMatchesStandalone() throws IOException {
|
||||
try (PDDocument doc = buildDoc("the cat sat")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"cat"}, false, true);
|
||||
assertFalse(result.isEmpty());
|
||||
assertEquals("cat", result.get(0).get(0).getText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trims whitespace around terms before searching")
|
||||
void trimsTermsBeforeSearch() throws IOException {
|
||||
try (PDDocument doc = buildDoc("padded SECRET value")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {" SECRET "}, false, false);
|
||||
assertFalse(result.isEmpty());
|
||||
assertEquals("SECRET", result.get(0).get(0).getText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── performTextReplacement ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("performTextReplacement")
|
||||
class PerformTextReplacement {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false (no fallback) when there is nothing found to redact")
|
||||
void noFoundTextReturnsFalse() throws IOException {
|
||||
try (PDDocument doc = buildDoc("anything")) {
|
||||
boolean fallback =
|
||||
service.performTextReplacement(
|
||||
doc, new HashMap<>(), new String[] {"x"}, false, false);
|
||||
assertFalse(fallback, "empty found-text map must short-circuit to no fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces text on a standard-font document without requesting box fallback")
|
||||
void replacesOnStandardFont() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Please redact SECRET now")) {
|
||||
Map<Integer, List<PDFText>> found =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
assertFalse(found.isEmpty());
|
||||
|
||||
boolean fallback =
|
||||
service.performTextReplacement(
|
||||
doc, found, new String[] {"SECRET"}, false, false);
|
||||
|
||||
assertFalse(fallback, "standard Helvetica should not trigger box-only fallback");
|
||||
// After replacement the literal term should no longer be extractable.
|
||||
Map<Integer, List<PDFText>> afterFound =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
assertTrue(afterFound.isEmpty(), "SECRET should be gone after text replacement");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves non-targeted text intact after replacement")
|
||||
void leavesOtherTextIntact() throws IOException {
|
||||
try (PDDocument doc = buildDoc("KEEP this but redact SECRET part")) {
|
||||
Map<Integer, List<PDFText>> found =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
|
||||
service.performTextReplacement(doc, found, new String[] {"SECRET"}, false, false);
|
||||
|
||||
Map<Integer, List<PDFText>> keepStill =
|
||||
service.findTextToRedact(doc, new String[] {"KEEP"}, false, false);
|
||||
assertFalse(keepStill.isEmpty(), "untargeted word KEEP must survive redaction");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── detectCustomEncodingFonts ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectCustomEncodingFonts")
|
||||
class DetectCustomEncodingFonts {
|
||||
|
||||
@Test
|
||||
@DisplayName("standard Helvetica document is not flagged as custom-encoded")
|
||||
void standardFontNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildDoc("plain helvetica text")) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("document with no content / no fonts is not flagged")
|
||||
void emptyDocumentNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildEmptyDoc()) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithFont ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithFont")
|
||||
class CreatePlaceholderWithFont {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithFont(null, helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithFont("", helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-subset font yields spaces matching the original length")
|
||||
void nonSubsetFontYieldsMatchingSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("hidden", helvetica());
|
||||
assertEquals(" ".repeat("hidden".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font is treated as non-subset and yields spaces")
|
||||
void nullFontYieldsSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("abc", null);
|
||||
assertEquals(" ", placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithWidth ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithWidth")
|
||||
class CreatePlaceholderWithWidth {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithWidth(null, 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithWidth("", 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font falls back to one space per original character")
|
||||
void nullFontFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, null, FONT_SIZE);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-positive font size falls back to one space per original character")
|
||||
void nonPositiveFontSizeFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, helvetica(), 0f);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("standard font produces a non-null all-whitespace placeholder")
|
||||
void standardFontProducesWhitespacePlaceholder() {
|
||||
PDFont font = helvetica();
|
||||
float fontSize = FONT_SIZE;
|
||||
String original = "Secret";
|
||||
// Compute a realistic target width the way the service does (text-space / 1000 * size).
|
||||
float targetWidth;
|
||||
try {
|
||||
targetWidth = font.getStringWidth(original) / 1000f * fontSize;
|
||||
} catch (IOException e) {
|
||||
targetWidth = 30f;
|
||||
}
|
||||
|
||||
String placeholder =
|
||||
service.createPlaceholderWithWidth(original, targetWidth, font, fontSize);
|
||||
|
||||
assertNotNull(placeholder);
|
||||
assertFalse(placeholder.isEmpty(), "Helvetica supports spaces, so non-empty expected");
|
||||
assertTrue(
|
||||
placeholder.chars().allMatch(c -> c == ' '),
|
||||
"placeholder should be composed only of spaces");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createTokensWithoutTargetText / writeFilteredContentStream
|
||||
// ────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createTokensWithoutTargetText")
|
||||
class CreateTokensWithoutTargetText {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"returns a non-empty token list and preserves token count when nothing matches")
|
||||
void noMatchPreservesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("nothing to hide")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("ABSENT"), false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertEquals(
|
||||
originalTokens.size(),
|
||||
tokens.size(),
|
||||
"token count should be unchanged when nothing matched");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filtered tokens can be written back and the page re-parses cleanly")
|
||||
void filteredTokensRoundTrip() throws IOException {
|
||||
try (PDDocument doc = buildDoc("redact SECRET token roundtrip")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertNotNull(tokens);
|
||||
|
||||
service.writeFilteredContentStream(doc, page, tokens);
|
||||
|
||||
// The page must still hold valid content (at least one operator token).
|
||||
List<Object> reparsed = parseTokens(page);
|
||||
boolean hasOperator = reparsed.stream().anyMatch(t -> t instanceof Operator);
|
||||
assertTrue(hasOperator, "rewritten content stream must contain operators");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty target-word set leaves tokens untouched")
|
||||
void emptyTargetSetLeavesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("some content")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Collections.emptySet(), false, false);
|
||||
|
||||
assertEquals(originalTokens.size(), tokens.size());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object token;
|
||||
while ((token = parser.parseNextToken()) != null) {
|
||||
tokens.add(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// ── inner data classes ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("TextSegment / MatchRange data classes")
|
||||
class DataClasses {
|
||||
|
||||
@Test
|
||||
@DisplayName("TextSegment exposes its constructor values via accessors")
|
||||
void textSegmentAccessors() {
|
||||
PDFont font = helvetica();
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(3, "Tj", "hello", 10, 15, font, 12f);
|
||||
|
||||
assertEquals(3, segment.getTokenIndex());
|
||||
assertEquals("Tj", segment.getOperatorName());
|
||||
assertEquals("hello", segment.getText());
|
||||
assertEquals(10, segment.getStartPos());
|
||||
assertEquals(15, segment.getEndPos());
|
||||
assertSame(font, segment.getFont());
|
||||
assertEquals(12f, segment.getFontSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MatchRange exposes start and end positions")
|
||||
void matchRangeAccessors() {
|
||||
TextRedactionService.MatchRange range = new TextRedactionService.MatchRange(4, 9);
|
||||
assertEquals(4, range.getStartPos());
|
||||
assertEquals(9, range.getEndPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MatchRange equality follows its data fields")
|
||||
void matchRangeEquality() {
|
||||
assertEquals(
|
||||
new TextRedactionService.MatchRange(1, 5),
|
||||
new TextRedactionService.MatchRange(1, 5));
|
||||
assertNotEquals(
|
||||
new TextRedactionService.MatchRange(1, 5),
|
||||
new TextRedactionService.MatchRange(1, 6));
|
||||
}
|
||||
|
||||
private void assertNotEquals(Object a, Object b) {
|
||||
assertFalse(a.equals(b));
|
||||
}
|
||||
}
|
||||
|
||||
// ── private logic exercised via reflection ───────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findAllMatches / buildCompleteText (private logic via reflection)")
|
||||
class PrivateLogic {
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns sorted, non-overlapping match ranges for two terms")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesSorted() throws Exception {
|
||||
String complete = "alpha beta gamma beta";
|
||||
Set<String> terms = new LinkedHashSet<>(List.of("beta", "alpha"));
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, complete, terms, false, false);
|
||||
|
||||
assertNotNull(matches);
|
||||
assertFalse(matches.isEmpty());
|
||||
// Results are sorted by start position.
|
||||
for (int i = 1; i < matches.size(); i++) {
|
||||
assertTrue(
|
||||
matches.get(i - 1).getStartPos() <= matches.get(i).getStartPos(),
|
||||
"matches must be sorted ascending by start position");
|
||||
}
|
||||
// "alpha" at 0, "beta" at 6 and 17 -> three matches total.
|
||||
assertEquals(3, matches.size());
|
||||
assertEquals(0, matches.get(0).getStartPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns nothing when no term occurs")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesEmptyWhenAbsent() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, "no terms here", Set.of("XYZ"), false, false);
|
||||
assertTrue(matches.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTextFromToken pulls text from Tj COSString and TJ COSArray")
|
||||
void extractTextFromToken() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextFromToken", Object.class, String.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "Tj"));
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "'"));
|
||||
|
||||
COSArray tjArray = new COSArray();
|
||||
tjArray.add(new COSString("foo"));
|
||||
tjArray.add(new COSString("bar"));
|
||||
assertEquals("foobar", m.invoke(service, tjArray, "TJ"));
|
||||
|
||||
// Unknown operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSString("x"), "Td"));
|
||||
// Wrong token type for the operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSArray(), "Tj"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+654
@@ -0,0 +1,654 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.exception.CacheUnavailableException;
|
||||
import stirling.software.SPDF.model.json.PdfJsonDocument;
|
||||
import stirling.software.SPDF.model.json.PdfJsonFont;
|
||||
import stirling.software.SPDF.model.json.PdfJsonMetadata;
|
||||
import stirling.software.SPDF.model.json.PdfJsonPage;
|
||||
import stirling.software.SPDF.service.pdfjson.PdfJsonFontService;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Gap tests for {@link PdfJsonConversionService} that exercise the public conversion entrypoints
|
||||
* and the cache-backed public API. These complement {@code
|
||||
* PdfJsonConversionServiceUnicodeParsingTest} (which covers the static {@code
|
||||
* parseToUnicodeCodepoint} / {@code countCodesProtected} helpers) without duplicating those cases.
|
||||
*
|
||||
* <p>The service is constructed as a plain unit (no Spring context), so {@code @PostConstruct}
|
||||
* never runs: font normalization stays disabled and Ghostscript is never invoked, keeping every
|
||||
* test fully deterministic and free of external processes. {@link CustomPDFDocumentFactory#load} is
|
||||
* stubbed to return real in-memory PDFBox documents, and {@code fallbackFontService} is mocked so
|
||||
* the JSON to PDF path can resolve its mandatory fallback font without touching the classpath.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@org.mockito.junit.jupiter.MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PdfJsonConversionServiceGapTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private TaskManager taskManager;
|
||||
@Mock private PdfJsonFallbackFontService fallbackFontService;
|
||||
@Mock private PdfJsonFontService fontService;
|
||||
@Mock private Type3FontConversionService type3FontConversionService;
|
||||
@Mock private Type3GlyphExtractor type3GlyphExtractor;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
|
||||
// Real collaborators: COS (de)serialization is complex and pure, so we use the real component.
|
||||
private final PdfJsonCosMapper cosMapper = new PdfJsonCosMapper();
|
||||
|
||||
// Mirror production: application.properties sets
|
||||
// spring.jackson.deserialization.fail-on-null-for-primitives=false, so the Spring-managed
|
||||
// mapper
|
||||
// maps null/absent JSON values onto Java primitive defaults (e.g. the boolean lazyImages
|
||||
// field).
|
||||
// A naive JsonMapper.builder().build() keeps the Jackson 3 default (true) and would throw
|
||||
// MismatchedInputException on round-trip, which is a test-mapper config gap, not a product bug.
|
||||
private final ObjectMapper objectMapper =
|
||||
JsonMapper.builder()
|
||||
.disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
|
||||
.build();
|
||||
|
||||
private PdfJsonConversionService service;
|
||||
|
||||
private final List<Path> createdTempFiles = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
service =
|
||||
new PdfJsonConversionService(
|
||||
pdfDocumentFactory,
|
||||
objectMapper,
|
||||
endpointConfiguration,
|
||||
tempFileManager,
|
||||
taskManager,
|
||||
cosMapper,
|
||||
fallbackFontService,
|
||||
fontService,
|
||||
type3FontConversionService,
|
||||
type3GlyphExtractor,
|
||||
applicationProperties);
|
||||
|
||||
// The TempFile wrapper delegates straight to the manager; back it with real temp files so
|
||||
// convertPdfToJson can transferTo() and size/read the working path.
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
String suffix = invocation.getArgument(0);
|
||||
Path path = Files.createTempFile("pdfjson-gap-test", suffix);
|
||||
createdTempFiles.add(path);
|
||||
return path.toFile();
|
||||
});
|
||||
when(tempFileManager.deleteTempFile(any(File.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
File file = invocation.getArgument(0);
|
||||
return file != null && file.delete();
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
for (Path path : createdTempFiles) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
createdTempFiles.clear();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Builds a tiny in-memory PDF with the requested page dimensions and rotations. */
|
||||
private PDDocument newPdf(float[][] sizes, int[] rotations) {
|
||||
PDDocument document = new PDDocument();
|
||||
for (int i = 0; i < sizes.length; i++) {
|
||||
PDPage page = new PDPage(new PDRectangle(sizes[i][0], sizes[i][1]));
|
||||
if (rotations != null) {
|
||||
page.setRotation(rotations[i]);
|
||||
}
|
||||
document.addPage(page);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private PDDocument singlePagePdf(float width, float height) {
|
||||
return newPdf(new float[][] {{width, height}}, null);
|
||||
}
|
||||
|
||||
/** Stubs the fallback font service so buildFontMap can always resolve a usable PDFont. */
|
||||
private void stubFallbackFont() throws IOException {
|
||||
when(fallbackFontService.buildFallbackFontModel())
|
||||
.thenAnswer(
|
||||
invocation ->
|
||||
PdfJsonFont.builder()
|
||||
.id(PdfJsonFallbackFontService.FALLBACK_FONT_ID)
|
||||
.uid(PdfJsonFallbackFontService.FALLBACK_FONT_ID)
|
||||
.baseName("Fallback")
|
||||
.subtype("TrueType")
|
||||
.build());
|
||||
when(fallbackFontService.loadFallbackPdfFont(any(PDDocument.class)))
|
||||
.thenAnswer(invocation -> new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
}
|
||||
|
||||
private MockMultipartFile pdfMultipart() {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "input.pdf", "application/pdf", "%PDF-1.4 placeholder".getBytes());
|
||||
}
|
||||
|
||||
private byte[] runJsonToPdf(PdfJsonDocument doc) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.convertJsonToPdf(doc, out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// parseToUnicodeCodepoint - extra cases not covered by the unicode test
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseToUnicodeCodepoint extras")
|
||||
class ParseToUnicodeExtras {
|
||||
|
||||
@Test
|
||||
@DisplayName("parses the maximum BMP value FFFF")
|
||||
void parsesMaxBmpValue() {
|
||||
assertEquals(0xFFFF, PdfJsonConversionService.parseToUnicodeCodepoint("FFFF"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts lowercase hex digits")
|
||||
void parsesLowercaseHex() {
|
||||
// U+00E9 LATIN SMALL LETTER E WITH ACUTE.
|
||||
assertEquals(0x00E9, PdfJsonConversionService.parseToUnicodeCodepoint("00e9"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses a 3-char value directly (length <= 4)")
|
||||
void parsesThreeCharValue() {
|
||||
assertEquals(0x1F4, PdfJsonConversionService.parseToUnicodeCodepoint("1f4"));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// convertJsonToPdf(PdfJsonDocument, OutputStream)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertJsonToPdf(document)")
|
||||
class JsonToPdfDocument {
|
||||
|
||||
@Test
|
||||
@DisplayName("null document throws IllegalArgumentException")
|
||||
void nullDocumentThrows() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.convertJsonToPdf((PdfJsonDocument) null, out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single empty page produces a valid one-page PDF with correct dimensions")
|
||||
void singleEmptyPage() throws IOException {
|
||||
stubFallbackFont();
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
page.setWidth(300f);
|
||||
page.setHeight(400f);
|
||||
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setPages(List.of(page));
|
||||
|
||||
byte[] pdfBytes = runJsonToPdf(doc);
|
||||
assertTrue(pdfBytes.length > 0, "expected non-empty PDF output");
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(1, loaded.getNumberOfPages());
|
||||
PDRectangle box = loaded.getPage(0).getMediaBox();
|
||||
assertEquals(300f, box.getWidth(), 0.01f);
|
||||
assertEquals(400f, box.getHeight(), 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing width/height falls back to US-Letter defaults")
|
||||
void missingDimensionsUseDefaults() throws IOException {
|
||||
stubFallbackFont();
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
// width/height left null -> safeFloat defaults of 612x792
|
||||
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setPages(List.of(page));
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
|
||||
PDRectangle box = loaded.getPage(0).getMediaBox();
|
||||
assertEquals(612f, box.getWidth(), 0.01f);
|
||||
assertEquals(792f, box.getHeight(), 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple pages keep their individual sizes and rotation")
|
||||
void multiplePagesKeepSizesAndRotation() throws IOException {
|
||||
stubFallbackFont();
|
||||
PdfJsonPage first = new PdfJsonPage();
|
||||
first.setPageNumber(1);
|
||||
first.setWidth(200f);
|
||||
first.setHeight(300f);
|
||||
first.setRotation(90);
|
||||
|
||||
PdfJsonPage second = new PdfJsonPage();
|
||||
second.setPageNumber(2);
|
||||
second.setWidth(500f);
|
||||
second.setHeight(250f);
|
||||
second.setRotation(180);
|
||||
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setPages(List.of(first, second));
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
|
||||
assertEquals(2, loaded.getNumberOfPages());
|
||||
assertEquals(200f, loaded.getPage(0).getMediaBox().getWidth(), 0.01f);
|
||||
assertEquals(90, loaded.getPage(0).getRotation());
|
||||
assertEquals(500f, loaded.getPage(1).getMediaBox().getWidth(), 0.01f);
|
||||
assertEquals(180, loaded.getPage(1).getRotation());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("document metadata round-trips into PDDocumentInformation")
|
||||
void metadataRoundTrips() throws IOException {
|
||||
stubFallbackFont();
|
||||
PdfJsonMetadata metadata = new PdfJsonMetadata();
|
||||
metadata.setTitle("Gap Test Title");
|
||||
metadata.setAuthor("Gap Author");
|
||||
metadata.setSubject("Subject X");
|
||||
metadata.setKeywords("alpha,beta");
|
||||
metadata.setCreator("Creator App");
|
||||
metadata.setProducer("Producer Lib");
|
||||
metadata.setCreationDate("2020-06-15T12:00:00Z");
|
||||
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
page.setWidth(300f);
|
||||
page.setHeight(300f);
|
||||
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setMetadata(metadata);
|
||||
doc.setPages(List.of(page));
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
|
||||
PDDocumentInformation info = loaded.getDocumentInformation();
|
||||
assertEquals("Gap Test Title", info.getTitle());
|
||||
assertEquals("Gap Author", info.getAuthor());
|
||||
assertEquals("Subject X", info.getSubject());
|
||||
assertEquals("alpha,beta", info.getKeywords());
|
||||
assertEquals("Creator App", info.getCreator());
|
||||
assertEquals("Producer Lib", info.getProducer());
|
||||
assertNotNull(info.getCreationDate(), "creation date should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid creation date string is ignored without failing the conversion")
|
||||
void invalidCreationDateIgnored() throws IOException {
|
||||
stubFallbackFont();
|
||||
PdfJsonMetadata metadata = new PdfJsonMetadata();
|
||||
metadata.setTitle("Has bad date");
|
||||
metadata.setCreationDate("not-a-real-instant");
|
||||
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
page.setWidth(300f);
|
||||
page.setHeight(300f);
|
||||
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setMetadata(metadata);
|
||||
doc.setPages(List.of(page));
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
|
||||
assertEquals("Has bad date", loaded.getDocumentInformation().getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("base64 XMP packet is restored onto the document catalog")
|
||||
void xmpMetadataApplied() throws IOException {
|
||||
stubFallbackFont();
|
||||
String xmpXml =
|
||||
"<?xpacket begin=\"\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"></x:xmpmeta>";
|
||||
String base64 =
|
||||
Base64.getEncoder().encodeToString(xmpXml.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
page.setWidth(300f);
|
||||
page.setHeight(300f);
|
||||
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setXmpMetadata(base64);
|
||||
doc.setPages(List.of(page));
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) {
|
||||
assertNotNull(
|
||||
loaded.getDocumentCatalog().getMetadata(),
|
||||
"XMP metadata stream should be present on the catalog");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null fonts list is initialised instead of causing an NPE")
|
||||
void nullFontsListHandled() throws IOException {
|
||||
stubFallbackFont();
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
page.setWidth(300f);
|
||||
page.setHeight(300f);
|
||||
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setFonts(null);
|
||||
doc.setPages(List.of(page));
|
||||
|
||||
byte[] pdfBytes = runJsonToPdf(doc);
|
||||
assertTrue(pdfBytes.length > 0);
|
||||
// buildFontMap mutates the (now non-null) list by appending the fallback model.
|
||||
assertNotNull(doc.getFonts());
|
||||
assertTrue(
|
||||
doc.getFonts().stream()
|
||||
.anyMatch(
|
||||
f ->
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_ID.equals(
|
||||
f.getId())));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// convertJsonToPdf(MultipartFile, OutputStream)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertJsonToPdf(file)")
|
||||
class JsonToPdfFile {
|
||||
|
||||
@Test
|
||||
@DisplayName("null file throws IllegalArgumentException")
|
||||
void nullFileThrows() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.convertJsonToPdf((MockMultipartFile) null, out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("valid JSON payload is deserialized and rebuilt into a PDF")
|
||||
void validJsonProducesPdf() throws IOException {
|
||||
stubFallbackFont();
|
||||
PdfJsonPage page = new PdfJsonPage();
|
||||
page.setPageNumber(1);
|
||||
page.setWidth(321f);
|
||||
page.setHeight(123f);
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setPages(List.of(page));
|
||||
|
||||
byte[] json = objectMapper.writeValueAsBytes(doc);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "doc.json", "application/json", json);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.convertJsonToPdf(file, out);
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) {
|
||||
assertEquals(1, loaded.getNumberOfPages());
|
||||
assertEquals(321f, loaded.getPage(0).getMediaBox().getWidth(), 0.01f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// convertPdfToJson family
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToJson")
|
||||
class PdfToJson {
|
||||
|
||||
@Test
|
||||
@DisplayName("null file throws IllegalArgumentException")
|
||||
void nullFileThrows() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(IllegalArgumentException.class, () -> service.convertPdfToJson(null, out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank single-page PDF yields JSON with one page and matching dimensions")
|
||||
void blankSinglePage() throws IOException {
|
||||
try (PDDocument pdf = singlePagePdf(250f, 350f)) {
|
||||
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.convertPdfToJson(pdfMultipart(), out);
|
||||
|
||||
PdfJsonDocument result =
|
||||
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
|
||||
assertEquals(1, result.getPages().size());
|
||||
PdfJsonPage page = result.getPages().get(0);
|
||||
assertEquals(1, page.getPageNumber());
|
||||
assertEquals(250f, page.getWidth(), 0.01f);
|
||||
assertEquals(350f, page.getHeight(), 0.01f);
|
||||
assertNotNull(result.getMetadata());
|
||||
assertEquals(1, result.getMetadata().getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multi-page PDF preserves per-page dimensions in the JSON model")
|
||||
void multiPageDimensions() throws IOException {
|
||||
try (PDDocument pdf =
|
||||
newPdf(new float[][] {{200f, 300f}, {612f, 792f}}, new int[] {0, 90})) {
|
||||
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.convertPdfToJson(pdfMultipart(), out);
|
||||
|
||||
PdfJsonDocument result =
|
||||
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
|
||||
assertEquals(2, result.getPages().size());
|
||||
assertEquals(200f, result.getPages().get(0).getWidth(), 0.01f);
|
||||
assertEquals(792f, result.getPages().get(1).getHeight(), 0.01f);
|
||||
assertEquals(90, result.getPages().get(1).getRotation());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("source document metadata is extracted into the JSON metadata block")
|
||||
void extractsSourceMetadata() throws IOException {
|
||||
try (PDDocument pdf = singlePagePdf(300f, 300f)) {
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
info.setTitle("Original Title");
|
||||
info.setAuthor("Original Author");
|
||||
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.convertPdfToJson(pdfMultipart(), out);
|
||||
|
||||
PdfJsonDocument result =
|
||||
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
|
||||
assertEquals("Original Title", result.getMetadata().getTitle());
|
||||
assertEquals("Original Author", result.getMetadata().getAuthor());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lightweight overload still produces a parseable JSON document")
|
||||
void lightweightOverload() throws IOException {
|
||||
try (PDDocument pdf = singlePagePdf(300f, 300f)) {
|
||||
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.convertPdfToJson(pdfMultipart(), true, out);
|
||||
|
||||
PdfJsonDocument result =
|
||||
objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class);
|
||||
assertEquals(1, result.getPages().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("progress callback receives a terminal complete event")
|
||||
void progressCallbackInvoked() throws IOException {
|
||||
try (PDDocument pdf = singlePagePdf(300f, 300f)) {
|
||||
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
|
||||
|
||||
AtomicBoolean sawComplete = new AtomicBoolean(false);
|
||||
AtomicBoolean sawAny = new AtomicBoolean(false);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
service.convertPdfToJson(
|
||||
pdfMultipart(),
|
||||
progress -> {
|
||||
sawAny.set(true);
|
||||
if (progress.getPercent() == 100 || progress.isComplete()) {
|
||||
sawComplete.set(true);
|
||||
}
|
||||
},
|
||||
out);
|
||||
|
||||
assertTrue(sawAny.get(), "expected at least one progress event");
|
||||
assertTrue(sawComplete.get(), "expected a terminal 100%/complete progress event");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertPdfToJsonDocument returns an in-memory model")
|
||||
void convertPdfToJsonDocumentReturnsModel() throws IOException {
|
||||
try (PDDocument pdf = singlePagePdf(400f, 500f)) {
|
||||
when(pdfDocumentFactory.load(any(Path.class), eq(true))).thenReturn(pdf);
|
||||
|
||||
PdfJsonDocument result = service.convertPdfToJsonDocument(pdfMultipart());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getPages().size());
|
||||
assertEquals(400f, result.getPages().get(0).getWidth(), 0.01f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cache-backed public API error branches (no PDF load required)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("cache-backed API")
|
||||
class CacheApi {
|
||||
|
||||
@Test
|
||||
@DisplayName("extractSinglePage with unknown jobId throws CacheUnavailableException")
|
||||
void extractSinglePageUnknownJob() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
CacheUnavailableException.class,
|
||||
() -> service.extractSinglePage("missing-job", 1, out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractPageFonts with unknown jobId throws CacheUnavailableException")
|
||||
void extractPageFontsUnknownJob() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
CacheUnavailableException.class,
|
||||
() -> service.extractPageFonts("missing-job", 1, out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("exportUpdatedPages requires a non-null jobId")
|
||||
void exportUpdatedPagesNullJob() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.exportUpdatedPages(null, new PdfJsonDocument(), out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("exportUpdatedPages rejects a blank jobId")
|
||||
void exportUpdatedPagesBlankJob() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.exportUpdatedPages(" ", new PdfJsonDocument(), out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("exportUpdatedPages with unknown jobId throws CacheUnavailableException")
|
||||
void exportUpdatedPagesUnknownJob() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
CacheUnavailableException.class,
|
||||
() -> service.exportUpdatedPages("missing-job", new PdfJsonDocument(), out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractDocumentMetadata with null file throws IllegalArgumentException")
|
||||
void extractDocumentMetadataNullFile() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.extractDocumentMetadata(null, "job", out));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("clearCachedDocument on an unknown jobId is a no-op")
|
||||
void clearCachedDocumentUnknownJob() {
|
||||
assertDoesNotThrow(() -> service.clearCachedDocument("missing-job"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSBoolean;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSInteger;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSNull;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonCosValue;
|
||||
import stirling.software.SPDF.model.json.PdfJsonStream;
|
||||
import stirling.software.SPDF.service.PdfJsonCosMapper.SerializationContext;
|
||||
|
||||
/** Unit tests for {@link PdfJsonCosMapper}. */
|
||||
class PdfJsonCosMapperTest {
|
||||
|
||||
private PdfJsonCosMapper mapper;
|
||||
private PDDocument document;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = new PdfJsonCosMapper();
|
||||
document = new PDDocument();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: create a populated COSStream within the test document.
|
||||
private COSStream newCosStreamWithData(byte[] data) throws IOException {
|
||||
COSStream cosStream = document.getDocument().createCOSStream();
|
||||
try (OutputStream out = cosStream.createRawOutputStream()) {
|
||||
out.write(data);
|
||||
}
|
||||
cosStream.setItem(COSName.LENGTH, COSInteger.get(data.length));
|
||||
return cosStream;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("SerializationContext.omitStreamData()")
|
||||
class OmitStreamDataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true only for lightweight contexts")
|
||||
void omitStreamData() {
|
||||
assertFalse(SerializationContext.DEFAULT.omitStreamData());
|
||||
assertFalse(SerializationContext.ANNOTATION_RAW_DATA.omitStreamData());
|
||||
assertFalse(SerializationContext.FORM_FIELD_RAW_DATA.omitStreamData());
|
||||
assertTrue(SerializationContext.CONTENT_STREAMS_LIGHTWEIGHT.omitStreamData());
|
||||
assertTrue(SerializationContext.RESOURCES_LIGHTWEIGHT.omitStreamData());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("serializeCosValue - primitives")
|
||||
class SerializeCosValuePrimitiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null base yields null value")
|
||||
void nullBase() throws IOException {
|
||||
assertNull(mapper.serializeCosValue(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSNull serializes to NULL type")
|
||||
void cosNull() throws IOException {
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(COSNull.NULL);
|
||||
assertNotNull(value);
|
||||
assertEquals(PdfJsonCosValue.Type.NULL, value.getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSBoolean serializes to BOOLEAN type with value")
|
||||
void cosBoolean() throws IOException {
|
||||
PdfJsonCosValue trueValue = mapper.serializeCosValue(COSBoolean.TRUE);
|
||||
assertEquals(PdfJsonCosValue.Type.BOOLEAN, trueValue.getType());
|
||||
assertEquals(Boolean.TRUE, trueValue.getValue());
|
||||
|
||||
PdfJsonCosValue falseValue = mapper.serializeCosValue(COSBoolean.FALSE);
|
||||
assertEquals(PdfJsonCosValue.Type.BOOLEAN, falseValue.getType());
|
||||
assertEquals(Boolean.FALSE, falseValue.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSInteger serializes to INTEGER type with long value")
|
||||
void cosInteger() throws IOException {
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(COSInteger.get(42L));
|
||||
assertEquals(PdfJsonCosValue.Type.INTEGER, value.getType());
|
||||
assertEquals(42L, value.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSFloat serializes to FLOAT type with float value")
|
||||
void cosFloat() throws IOException {
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(new COSFloat(1.5f));
|
||||
assertEquals(PdfJsonCosValue.Type.FLOAT, value.getType());
|
||||
assertEquals(1.5f, value.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSName serializes to NAME type with the name literal")
|
||||
void cosName() throws IOException {
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(COSName.getPDFName("Foo"));
|
||||
assertEquals(PdfJsonCosValue.Type.NAME, value.getType());
|
||||
assertEquals("Foo", value.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSString serializes to STRING type with base64 content")
|
||||
void cosString() throws IOException {
|
||||
byte[] raw = "héllo".getBytes(StandardCharsets.UTF_8);
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(new COSString(raw));
|
||||
assertEquals(PdfJsonCosValue.Type.STRING, value.getType());
|
||||
assertEquals(Base64.getEncoder().encodeToString(raw), value.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("serializeCosValue - containers")
|
||||
class SerializeCosValueContainerTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("COSArray serializes nested items in order")
|
||||
void cosArray() throws IOException {
|
||||
COSArray array = new COSArray();
|
||||
array.add(COSInteger.get(1L));
|
||||
array.add(COSName.getPDFName("X"));
|
||||
array.add(COSBoolean.TRUE);
|
||||
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(array);
|
||||
assertEquals(PdfJsonCosValue.Type.ARRAY, value.getType());
|
||||
List<PdfJsonCosValue> items = value.getItems();
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(PdfJsonCosValue.Type.INTEGER, items.get(0).getType());
|
||||
assertEquals(PdfJsonCosValue.Type.NAME, items.get(1).getType());
|
||||
assertEquals(PdfJsonCosValue.Type.BOOLEAN, items.get(2).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSDictionary serializes keyed entries")
|
||||
void cosDictionary() throws IOException {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setItem(COSName.getPDFName("Count"), COSInteger.get(7L));
|
||||
dict.setItem(COSName.getPDFName("Type"), COSName.getPDFName("Catalog"));
|
||||
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(dict);
|
||||
assertEquals(PdfJsonCosValue.Type.DICTIONARY, value.getType());
|
||||
Map<String, PdfJsonCosValue> entries = value.getEntries();
|
||||
assertEquals(2, entries.size());
|
||||
assertEquals(PdfJsonCosValue.Type.INTEGER, entries.get("Count").getType());
|
||||
assertEquals(7L, entries.get("Count").getValue());
|
||||
assertEquals(PdfJsonCosValue.Type.NAME, entries.get("Type").getType());
|
||||
assertEquals("Catalog", entries.get("Type").getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("circular dictionary reference is replaced with a __circular__ marker")
|
||||
void circularReference() throws IOException {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setItem(COSName.getPDFName("Self"), dict);
|
||||
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(dict);
|
||||
assertEquals(PdfJsonCosValue.Type.DICTIONARY, value.getType());
|
||||
PdfJsonCosValue self = value.getEntries().get("Self");
|
||||
assertEquals(PdfJsonCosValue.Type.NAME, self.getType());
|
||||
assertEquals("__circular__", self.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the same dictionary appearing twice (non-circular) is serialized both times")
|
||||
void repeatedNonCircularReference() throws IOException {
|
||||
COSDictionary shared = new COSDictionary();
|
||||
shared.setItem(COSName.getPDFName("V"), COSInteger.get(9L));
|
||||
COSArray array = new COSArray();
|
||||
array.add(shared);
|
||||
array.add(shared);
|
||||
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(array);
|
||||
List<PdfJsonCosValue> items = value.getItems();
|
||||
assertEquals(2, items.size());
|
||||
// Because the visited set is removed in finally, the second sibling occurrence is not
|
||||
// treated as circular.
|
||||
assertEquals(PdfJsonCosValue.Type.DICTIONARY, items.get(0).getType());
|
||||
assertEquals(PdfJsonCosValue.Type.DICTIONARY, items.get(1).getType());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("serializeStream overloads")
|
||||
class SerializeStreamTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null PDStream returns null")
|
||||
void nullPdStream() throws IOException {
|
||||
assertNull(mapper.serializeStream((PDStream) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null COSStream returns null")
|
||||
void nullCosStream() throws IOException {
|
||||
assertNull(mapper.serializeStream((COSStream) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null COSStream with explicit context returns null")
|
||||
void nullCosStreamWithContext() throws IOException {
|
||||
assertNull(mapper.serializeStream((COSStream) null, SerializationContext.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null PDStream with explicit context returns null")
|
||||
void nullPdStreamWithContext() throws IOException {
|
||||
assertNull(mapper.serializeStream((PDStream) null, SerializationContext.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("COSStream serializes dictionary and base64 rawData")
|
||||
void cosStreamWithData() throws IOException {
|
||||
byte[] data = "stream-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
COSStream cosStream = newCosStreamWithData(data);
|
||||
cosStream.setItem(COSName.TYPE, COSName.getPDFName("XObject"));
|
||||
|
||||
PdfJsonStream result = mapper.serializeStream(cosStream);
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getDictionary());
|
||||
assertTrue(result.getDictionary().containsKey(COSName.TYPE.getName()));
|
||||
assertEquals(Base64.getEncoder().encodeToString(data), result.getRawData());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty COSStream yields null rawData")
|
||||
void emptyCosStream() throws IOException {
|
||||
COSStream cosStream = newCosStreamWithData(new byte[0]);
|
||||
PdfJsonStream result = mapper.serializeStream(cosStream);
|
||||
assertNotNull(result);
|
||||
assertNull(result.getRawData());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lightweight context omits rawData even when stream has data")
|
||||
void lightweightContextOmitsData() throws IOException {
|
||||
byte[] data = "ignored".getBytes(StandardCharsets.UTF_8);
|
||||
COSStream cosStream = newCosStreamWithData(data);
|
||||
cosStream.setItem(COSName.FILTER, COSName.getPDFName("FlateDecode"));
|
||||
|
||||
PdfJsonStream result =
|
||||
mapper.serializeStream(
|
||||
cosStream, SerializationContext.CONTENT_STREAMS_LIGHTWEIGHT);
|
||||
assertNotNull(result);
|
||||
assertNull(result.getRawData());
|
||||
// Dictionary metadata is still preserved.
|
||||
assertTrue(result.getDictionary().containsKey(COSName.FILTER.getName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null context is treated as DEFAULT and keeps rawData")
|
||||
void nullContextDefaultsToDefault() throws IOException {
|
||||
byte[] data = "keep".getBytes(StandardCharsets.UTF_8);
|
||||
COSStream cosStream = newCosStreamWithData(data);
|
||||
|
||||
PdfJsonStream result = mapper.serializeStream(cosStream, (SerializationContext) null);
|
||||
assertNotNull(result);
|
||||
assertEquals(Base64.getEncoder().encodeToString(data), result.getRawData());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDStream overload delegates to COSStream serialization")
|
||||
void pdStreamOverload() throws IOException {
|
||||
byte[] data = "pdstream".getBytes(StandardCharsets.UTF_8);
|
||||
PDStream pdStream = new PDStream(document, new java.io.ByteArrayInputStream(data));
|
||||
|
||||
PdfJsonStream result = mapper.serializeStream(pdStream);
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getRawData());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDStream overload with lightweight context omits rawData")
|
||||
void pdStreamOverloadLightweight() throws IOException {
|
||||
byte[] data = "pdstream".getBytes(StandardCharsets.UTF_8);
|
||||
PDStream pdStream = new PDStream(document, new java.io.ByteArrayInputStream(data));
|
||||
|
||||
PdfJsonStream result =
|
||||
mapper.serializeStream(pdStream, SerializationContext.RESOURCES_LIGHTWEIGHT);
|
||||
assertNotNull(result);
|
||||
assertNull(result.getRawData());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("serializeCosValue of a COSStream produces STREAM type wrapping the stream")
|
||||
void serializeCosValueWrapsStream() throws IOException {
|
||||
byte[] data = "abc".getBytes(StandardCharsets.UTF_8);
|
||||
COSStream cosStream = newCosStreamWithData(data);
|
||||
|
||||
PdfJsonCosValue value = mapper.serializeCosValue(cosStream);
|
||||
assertEquals(PdfJsonCosValue.Type.STREAM, value.getType());
|
||||
assertNotNull(value.getStream());
|
||||
assertEquals(Base64.getEncoder().encodeToString(data), value.getStream().getRawData());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("deserializeCosValue")
|
||||
class DeserializeCosValueTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null value returns null")
|
||||
void nullValue() throws IOException {
|
||||
assertNull(mapper.deserializeCosValue(null, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("value with null type returns null")
|
||||
void nullType() throws IOException {
|
||||
PdfJsonCosValue value = PdfJsonCosValue.builder().value("x").build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NULL type returns COSNull")
|
||||
void nullTypeValue() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.NULL).build();
|
||||
assertEquals(COSNull.NULL, mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BOOLEAN type returns matching COSBoolean")
|
||||
void booleanType() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.BOOLEAN)
|
||||
.value(Boolean.TRUE)
|
||||
.build();
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertEquals(COSBoolean.TRUE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BOOLEAN type with non-boolean value returns null")
|
||||
void booleanTypeWrongValue() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.BOOLEAN)
|
||||
.value("not-a-boolean")
|
||||
.build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INTEGER type returns COSInteger from a Number value")
|
||||
void integerType() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.INTEGER)
|
||||
.value(123L)
|
||||
.build();
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertInstanceOf(COSInteger.class, result);
|
||||
assertEquals(123L, ((COSInteger) result).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INTEGER type accepts an Integer value via Number")
|
||||
void integerTypeFromInteger() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.INTEGER)
|
||||
.value(Integer.valueOf(5))
|
||||
.build();
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertInstanceOf(COSInteger.class, result);
|
||||
assertEquals(5L, ((COSInteger) result).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INTEGER type with non-number returns null")
|
||||
void integerTypeWrongValue() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.INTEGER)
|
||||
.value("oops")
|
||||
.build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FLOAT type returns COSFloat from a Number value")
|
||||
void floatType() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.FLOAT).value(2.25f).build();
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertInstanceOf(COSFloat.class, result);
|
||||
assertEquals(2.25f, ((COSFloat) result).floatValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FLOAT type with non-number returns null")
|
||||
void floatTypeWrongValue() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.FLOAT)
|
||||
.value("oops")
|
||||
.build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NAME type returns COSName from a String value")
|
||||
void nameType() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.NAME)
|
||||
.value("MyName")
|
||||
.build();
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertEquals(COSName.getPDFName("MyName"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NAME type with non-string returns null")
|
||||
void nameTypeWrongValue() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.NAME).value(123L).build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("STRING type decodes base64 into COSString bytes")
|
||||
void stringType() throws IOException {
|
||||
byte[] raw = "round-trip".getBytes(StandardCharsets.UTF_8);
|
||||
String encoded = Base64.getEncoder().encodeToString(raw);
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.STRING)
|
||||
.value(encoded)
|
||||
.build();
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertInstanceOf(COSString.class, result);
|
||||
assertArrayEquals(raw, ((COSString) result).getBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("STRING type with invalid base64 returns null")
|
||||
void stringTypeInvalidBase64() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.STRING)
|
||||
.value("!!!not base64!!!")
|
||||
.build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("STRING type with non-string value returns null")
|
||||
void stringTypeWrongValue() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.STRING).value(42L).build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ARRAY type deserializes each item")
|
||||
void arrayType() throws IOException {
|
||||
PdfJsonCosValue item1 =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.INTEGER).value(1L).build();
|
||||
PdfJsonCosValue item2 =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.NAME).value("N").build();
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.ARRAY)
|
||||
.items(List.of(item1, item2))
|
||||
.build();
|
||||
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertInstanceOf(COSArray.class, result);
|
||||
COSArray array = (COSArray) result;
|
||||
assertEquals(2, array.size());
|
||||
assertEquals(1L, ((COSInteger) array.get(0)).longValue());
|
||||
assertEquals(COSName.getPDFName("N"), array.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ARRAY type substitutes COSNull for un-deserializable items")
|
||||
void arrayTypeWithNullItems() throws IOException {
|
||||
// An INTEGER type with a non-number value deserializes to null and is replaced by
|
||||
// COSNull.NULL inside the array.
|
||||
PdfJsonCosValue bad =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.INTEGER)
|
||||
.value("nope")
|
||||
.build();
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.ARRAY)
|
||||
.items(List.of(bad))
|
||||
.build();
|
||||
|
||||
COSArray result = (COSArray) mapper.deserializeCosValue(value, document);
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(COSNull.NULL, result.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ARRAY type with null items list yields empty COSArray")
|
||||
void arrayTypeNullItems() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.ARRAY).build();
|
||||
COSArray result = (COSArray) mapper.deserializeCosValue(value, document);
|
||||
assertNotNull(result);
|
||||
assertEquals(0, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DICTIONARY type deserializes entries by key")
|
||||
void dictionaryType() throws IOException {
|
||||
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"Count",
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.INTEGER).value(3L).build());
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.DICTIONARY)
|
||||
.entries(entries)
|
||||
.build();
|
||||
|
||||
COSDictionary result = (COSDictionary) mapper.deserializeCosValue(value, document);
|
||||
assertNotNull(result);
|
||||
assertEquals(3L, ((COSInteger) result.getItem("Count")).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DICTIONARY type skips entries that deserialize to null")
|
||||
void dictionaryTypeSkipsNullEntries() throws IOException {
|
||||
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"Bad",
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.INTEGER)
|
||||
.value("nope")
|
||||
.build());
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.DICTIONARY)
|
||||
.entries(entries)
|
||||
.build();
|
||||
|
||||
COSDictionary result = (COSDictionary) mapper.deserializeCosValue(value, document);
|
||||
assertNotNull(result);
|
||||
assertNull(result.getItem(COSName.getPDFName("Bad")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DICTIONARY type with null entries map yields empty COSDictionary")
|
||||
void dictionaryTypeNullEntries() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.DICTIONARY).build();
|
||||
COSDictionary result = (COSDictionary) mapper.deserializeCosValue(value, document);
|
||||
assertNotNull(result);
|
||||
assertEquals(0, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("STREAM type with null stream returns null")
|
||||
void streamTypeNullStream() throws IOException {
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.STREAM).build();
|
||||
assertNull(mapper.deserializeCosValue(value, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("STREAM type builds a COSStream from the model")
|
||||
void streamType() throws IOException {
|
||||
byte[] data = "stream-content".getBytes(StandardCharsets.UTF_8);
|
||||
PdfJsonStream streamModel =
|
||||
PdfJsonStream.builder()
|
||||
.rawData(Base64.getEncoder().encodeToString(data))
|
||||
.build();
|
||||
PdfJsonCosValue value =
|
||||
PdfJsonCosValue.builder().type(PdfJsonCosValue.Type.STREAM).stream(streamModel)
|
||||
.build();
|
||||
|
||||
COSBase result = mapper.deserializeCosValue(value, document);
|
||||
assertInstanceOf(COSStream.class, result);
|
||||
assertStreamRawEquals(data, (COSStream) result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildStreamFromModel")
|
||||
class BuildStreamFromModelTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null model returns null")
|
||||
void nullModel() throws IOException {
|
||||
assertNull(mapper.buildStreamFromModel(null, document));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("model with rawData writes base64-decoded bytes and sets Length")
|
||||
void withRawData() throws IOException {
|
||||
byte[] data = "hello-world".getBytes(StandardCharsets.UTF_8);
|
||||
PdfJsonStream model =
|
||||
PdfJsonStream.builder()
|
||||
.rawData(Base64.getEncoder().encodeToString(data))
|
||||
.build();
|
||||
|
||||
COSStream result = mapper.buildStreamFromModel(model, document);
|
||||
assertNotNull(result);
|
||||
assertStreamRawEquals(data, result);
|
||||
assertEquals(data.length, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("model with dictionary entries copies them onto the stream")
|
||||
void withDictionary() throws IOException {
|
||||
Map<String, PdfJsonCosValue> dict = new LinkedHashMap<>();
|
||||
dict.put(
|
||||
"Type",
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.NAME)
|
||||
.value("XObject")
|
||||
.build());
|
||||
dict.put(
|
||||
"Width",
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.INTEGER)
|
||||
.value(100L)
|
||||
.build());
|
||||
PdfJsonStream model = PdfJsonStream.builder().dictionary(dict).build();
|
||||
|
||||
COSStream result = mapper.buildStreamFromModel(model, document);
|
||||
assertNotNull(result);
|
||||
assertEquals(COSName.getPDFName("XObject"), result.getItem(COSName.TYPE));
|
||||
assertEquals(
|
||||
100L, ((COSInteger) result.getItem(COSName.getPDFName("Width"))).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("model dictionary entry that deserializes to null is skipped")
|
||||
void dictionaryEntryNullSkipped() throws IOException {
|
||||
Map<String, PdfJsonCosValue> dict = new LinkedHashMap<>();
|
||||
dict.put(
|
||||
"Bad",
|
||||
PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.NAME)
|
||||
.value(999L) // non-string -> null
|
||||
.build());
|
||||
PdfJsonStream model = PdfJsonStream.builder().dictionary(dict).build();
|
||||
|
||||
COSStream result = mapper.buildStreamFromModel(model, document);
|
||||
assertNotNull(result);
|
||||
assertNull(result.getItem(COSName.getPDFName("Bad")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("model with null/blank rawData sets Length to zero and writes nothing")
|
||||
void blankRawData() throws IOException {
|
||||
PdfJsonStream model = PdfJsonStream.builder().rawData(" ").build();
|
||||
COSStream result = mapper.buildStreamFromModel(model, document);
|
||||
assertNotNull(result);
|
||||
assertEquals(0L, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
|
||||
// Blank rawData takes the else-branch: Length is set to 0 but createRawOutputStream()
|
||||
// is
|
||||
// never called, so nothing is written. PDFBox cannot open a raw InputStream on a stream
|
||||
// that was never written to, which is the documented "writes nothing" behaviour.
|
||||
assertThrows(IOException.class, result::createRawInputStream);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("model with no rawData sets Length to zero")
|
||||
void noRawData() throws IOException {
|
||||
PdfJsonStream model = PdfJsonStream.builder().build();
|
||||
COSStream result = mapper.buildStreamFromModel(model, document);
|
||||
assertNotNull(result);
|
||||
assertEquals(0L, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("model with invalid base64 rawData falls back to empty data")
|
||||
void invalidBase64RawData() throws IOException {
|
||||
PdfJsonStream model = PdfJsonStream.builder().rawData("###not-base64###").build();
|
||||
COSStream result = mapper.buildStreamFromModel(model, document);
|
||||
assertNotNull(result);
|
||||
assertEquals(0L, ((COSInteger) result.getItem(COSName.LENGTH)).longValue());
|
||||
assertStreamRawEquals(new byte[0], result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("round trip serialize -> deserialize")
|
||||
class RoundTripTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("nested array/dictionary structure survives a round trip")
|
||||
void nestedStructure() throws IOException {
|
||||
COSDictionary original = new COSDictionary();
|
||||
original.setItem(COSName.getPDFName("Int"), COSInteger.get(11L));
|
||||
original.setItem(COSName.getPDFName("Name"), COSName.getPDFName("Hello"));
|
||||
original.setItem(COSName.getPDFName("Bool"), COSBoolean.FALSE);
|
||||
COSArray inner = new COSArray();
|
||||
inner.add(new COSFloat(3.5f));
|
||||
inner.add(new COSString("abc".getBytes(StandardCharsets.UTF_8)));
|
||||
original.setItem(COSName.getPDFName("Arr"), inner);
|
||||
|
||||
PdfJsonCosValue serialized = mapper.serializeCosValue(original);
|
||||
COSBase deserialized = mapper.deserializeCosValue(serialized, document);
|
||||
|
||||
assertInstanceOf(COSDictionary.class, deserialized);
|
||||
COSDictionary result = (COSDictionary) deserialized;
|
||||
assertEquals(11L, ((COSInteger) result.getItem(COSName.getPDFName("Int"))).longValue());
|
||||
assertEquals(COSName.getPDFName("Hello"), result.getItem(COSName.getPDFName("Name")));
|
||||
assertEquals(COSBoolean.FALSE, result.getItem(COSName.getPDFName("Bool")));
|
||||
|
||||
COSArray resultArr = (COSArray) result.getItem(COSName.getPDFName("Arr"));
|
||||
assertEquals(2, resultArr.size());
|
||||
assertEquals(3.5f, ((COSFloat) resultArr.get(0)).floatValue());
|
||||
assertArrayEquals(
|
||||
"abc".getBytes(StandardCharsets.UTF_8),
|
||||
((COSString) resultArr.get(1)).getBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stream data survives a round trip")
|
||||
void streamRoundTrip() throws IOException {
|
||||
byte[] data = "round-trip-stream".getBytes(StandardCharsets.UTF_8);
|
||||
COSStream cosStream = newCosStreamWithData(data);
|
||||
cosStream.setItem(COSName.TYPE, COSName.getPDFName("XObject"));
|
||||
|
||||
PdfJsonCosValue serialized = mapper.serializeCosValue(cosStream);
|
||||
COSBase deserialized = mapper.deserializeCosValue(serialized, document);
|
||||
|
||||
assertInstanceOf(COSStream.class, deserialized);
|
||||
COSStream result = (COSStream) deserialized;
|
||||
assertStreamRawEquals(data, result);
|
||||
assertEquals(COSName.getPDFName("XObject"), result.getItem(COSName.TYPE));
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the raw (undecoded) bytes from a COSStream and asserts equality.
|
||||
private void assertStreamRawEquals(byte[] expected, COSStream cosStream) throws IOException {
|
||||
try (InputStream in = cosStream.createRawInputStream()) {
|
||||
byte[] actual = in.readAllBytes();
|
||||
assertArrayEquals(expected, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
+571
@@ -0,0 +1,571 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Base64;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonFont;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class PdfJsonFallbackFontServiceTest {
|
||||
|
||||
private PdfJsonFallbackFontService service;
|
||||
private ApplicationProperties applicationProperties;
|
||||
|
||||
// Parsing the real fallback TrueType fonts (the CJK font alone is ~17 MB) is the only expensive
|
||||
// work in this class. Build a default-config service and parse the CJK fallback once, then
|
||||
// reuse
|
||||
// them across every read-only test instead of re-parsing per @BeforeEach.
|
||||
private static PdfJsonFallbackFontService sharedService;
|
||||
private static PDDocument sharedCjkDocument;
|
||||
private static PDFont sharedCjkFont;
|
||||
|
||||
@BeforeAll
|
||||
static void setUpShared() throws Exception {
|
||||
// Real ApplicationProperties already defaults pdfEditor.fallbackFont to the Noto Sans
|
||||
// location, so no mocking is needed for the default-config path.
|
||||
ResourceLoader resourceLoader = new DefaultResourceLoader();
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
sharedService = new PdfJsonFallbackFontService(resourceLoader, props);
|
||||
ReflectionTestUtils.setField(
|
||||
sharedService,
|
||||
"legacyFallbackFontLocation",
|
||||
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION);
|
||||
Method loadConfig = PdfJsonFallbackFontService.class.getDeclaredMethod("loadConfig");
|
||||
loadConfig.setAccessible(true);
|
||||
loadConfig.invoke(sharedService);
|
||||
|
||||
// Parse the large CJK fallback exactly once; the CanEncode tests only read this font.
|
||||
sharedCjkDocument = new PDDocument();
|
||||
sharedCjkFont =
|
||||
sharedService.loadFallbackPdfFont(
|
||||
sharedCjkDocument, PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownShared() throws IOException {
|
||||
if (sharedCjkDocument != null) {
|
||||
sharedCjkDocument.close();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Real resource loader resolves the classpath:/static/fonts/*.ttf bundled in the module.
|
||||
ResourceLoader resourceLoader = new DefaultResourceLoader();
|
||||
|
||||
// Mock the properties chain so loadConfig() resolves to the default Noto Sans location.
|
||||
applicationProperties = mock(ApplicationProperties.class);
|
||||
ApplicationProperties.PdfEditor pdfEditor = mock(ApplicationProperties.PdfEditor.class);
|
||||
lenient().when(applicationProperties.getPdfEditor()).thenReturn(pdfEditor);
|
||||
lenient()
|
||||
.when(pdfEditor.getFallbackFont())
|
||||
.thenReturn(PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION);
|
||||
|
||||
service = new PdfJsonFallbackFontService(resourceLoader, applicationProperties);
|
||||
// The @Value field is normally injected by Spring; set it explicitly for the unit test.
|
||||
ReflectionTestUtils.setField(
|
||||
service,
|
||||
"legacyFallbackFontLocation",
|
||||
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION);
|
||||
}
|
||||
|
||||
/** Invoke the private @PostConstruct loadConfig() to populate fallbackFontLocation. */
|
||||
private void invokeLoadConfig() throws Exception {
|
||||
Method loadConfig = PdfJsonFallbackFontService.class.getDeclaredMethod("loadConfig");
|
||||
loadConfig.setAccessible(true);
|
||||
loadConfig.invoke(service);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("loadConfig (@PostConstruct)")
|
||||
class LoadConfig {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the configured pdf-editor fallback font when set")
|
||||
void usesConfiguredFallbackFont() throws Exception {
|
||||
when(applicationProperties.getPdfEditor().getFallbackFont())
|
||||
.thenReturn("classpath:/static/fonts/DejaVuSans.ttf");
|
||||
|
||||
invokeLoadConfig();
|
||||
|
||||
assertEquals(
|
||||
"classpath:/static/fonts/DejaVuSans.ttf",
|
||||
ReflectionTestUtils.getField(service, "fallbackFontLocation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the legacy @Value location when configured value is blank")
|
||||
void fallsBackToLegacyWhenBlank() throws Exception {
|
||||
when(applicationProperties.getPdfEditor().getFallbackFont()).thenReturn(" ");
|
||||
|
||||
invokeLoadConfig();
|
||||
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION,
|
||||
ReflectionTestUtils.getField(service, "fallbackFontLocation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the legacy @Value location when PdfEditor is null")
|
||||
void fallsBackToLegacyWhenPdfEditorNull() throws Exception {
|
||||
when(applicationProperties.getPdfEditor()).thenReturn(null);
|
||||
|
||||
invokeLoadConfig();
|
||||
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.DEFAULT_FALLBACK_FONT_LOCATION,
|
||||
ReflectionTestUtils.getField(service, "fallbackFontLocation"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("resolveFallbackFontId(int codePoint)")
|
||||
class ResolveByCodePoint {
|
||||
|
||||
@Test
|
||||
@DisplayName("Latin letters resolve to the generic Noto Sans fallback")
|
||||
void latinResolvesToNotoSans() {
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_ID,
|
||||
service.resolveFallbackFontId('A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CJK unified ideographs resolve to the CJK fallback")
|
||||
void cjkIdeographResolvesToCjk() {
|
||||
// U+4E2D (中) is a CJK Unified Ideograph.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID,
|
||||
service.resolveFallbackFontId(0x4E2D));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Bopomofo resolves to the Traditional Chinese fallback")
|
||||
void bopomofoResolvesToTc() {
|
||||
// U+3105 (ㄅ) is in the Bopomofo block.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_TC_ID,
|
||||
service.resolveFallbackFontId(0x3105));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CJK compatibility ideographs resolve to the Traditional Chinese fallback")
|
||||
void compatibilityIdeographResolvesToTc() {
|
||||
// U+F900 is in the CJK Compatibility Ideographs block.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_TC_ID,
|
||||
service.resolveFallbackFontId(0xF900));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Hiragana resolves to the Japanese fallback")
|
||||
void hiraganaResolvesToJp() {
|
||||
// U+3042 (あ) is Hiragana.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_JP_ID,
|
||||
service.resolveFallbackFontId(0x3042));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Hangul resolves to the Korean fallback")
|
||||
void hangulResolvesToKr() {
|
||||
// U+AC00 (가) is a Hangul syllable.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_KR_ID,
|
||||
service.resolveFallbackFontId(0xAC00));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Arabic resolves to the Arabic fallback")
|
||||
void arabicResolvesToAr() {
|
||||
// U+0627 (ا) is Arabic.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_AR_ID,
|
||||
service.resolveFallbackFontId(0x0627));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Thai resolves to the Thai fallback")
|
||||
void thaiResolvesToTh() {
|
||||
// U+0E01 (ก) is Thai.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID,
|
||||
service.resolveFallbackFontId(0x0E01));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Devanagari resolves to the Devanagari fallback")
|
||||
void devanagariResolves() {
|
||||
// U+0905 (अ) is Devanagari.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_DEVANAGARI_ID,
|
||||
service.resolveFallbackFontId(0x0905));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Malayalam resolves to the Malayalam fallback")
|
||||
void malayalamResolves() {
|
||||
// U+0D05 (അ) is Malayalam.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_MALAYALAM_ID,
|
||||
service.resolveFallbackFontId(0x0D05));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tibetan resolves to the Tibetan fallback")
|
||||
void tibetanResolves() {
|
||||
// U+0F40 (ཀ) is Tibetan.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_TIBETAN_ID,
|
||||
service.resolveFallbackFontId(0x0F40));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("resolveFallbackFontId(String fontName, int codePoint)")
|
||||
class ResolveByNameAndCodePoint {
|
||||
|
||||
@Test
|
||||
@DisplayName("Arial maps to Liberation Sans")
|
||||
void arialMapsToLiberationSans() {
|
||||
assertEquals("fallback-liberation-sans", service.resolveFallbackFontId("Arial", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Times New Roman maps to Liberation Serif")
|
||||
void timesNewRomanMapsToLiberationSerif() {
|
||||
// Spaces are stripped: "Times New Roman" -> "timesnewroman".
|
||||
assertEquals(
|
||||
"fallback-liberation-serif",
|
||||
service.resolveFallbackFontId("Times New Roman", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Courier New maps to Liberation Mono")
|
||||
void courierNewMapsToLiberationMono() {
|
||||
assertEquals(
|
||||
"fallback-liberation-mono", service.resolveFallbackFontId("Courier New", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Arial-Bold maps to bold Liberation Sans variant")
|
||||
void arialBoldMapsToBoldVariant() {
|
||||
assertEquals(
|
||||
"fallback-liberation-sans-bold",
|
||||
service.resolveFallbackFontId("Arial-Bold", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Arial-Italic maps to italic Liberation Sans variant")
|
||||
void arialItalicMapsToItalicVariant() {
|
||||
assertEquals(
|
||||
"fallback-liberation-sans-italic",
|
||||
service.resolveFallbackFontId("Arial-Italic", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Arial-BoldItalic maps to bold-italic Liberation Sans variant")
|
||||
void arialBoldItalicMapsToBoldItalicVariant() {
|
||||
assertEquals(
|
||||
"fallback-liberation-sans-bolditalic",
|
||||
service.resolveFallbackFontId("Arial-BoldItalic", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("numeric weight 700 is detected as bold")
|
||||
void numericWeightDetectedAsBold() {
|
||||
// "Arimo_700wght" -> base "arimo" -> liberation-sans, bold via 700 weight pattern.
|
||||
assertEquals(
|
||||
"fallback-liberation-sans-bold",
|
||||
service.resolveFallbackFontId("Arimo_700wght", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("subset prefix is stripped before alias matching")
|
||||
void subsetPrefixStripped() {
|
||||
// "ABCDEF+Arial" -> subset prefix removed -> "arial".
|
||||
assertEquals(
|
||||
"fallback-liberation-sans", service.resolveFallbackFontId("ABCDEF+Arial", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DejaVu Sans bold-italic uses the 'oblique' naming convention")
|
||||
void dejaVuUsesObliqueNaming() {
|
||||
assertEquals(
|
||||
"fallback-dejavu-sans-boldoblique",
|
||||
service.resolveFallbackFontId("DejaVuSans-BoldItalic", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DejaVu Serif italic keeps the 'italic' naming convention")
|
||||
void dejaVuSerifKeepsItalicNaming() {
|
||||
assertEquals(
|
||||
"fallback-dejavu-serif-italic",
|
||||
service.resolveFallbackFontId("DejaVuSerif-Italic", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Traditional Chinese aliased name ignores weight/style suffix (unsupported)")
|
||||
void tcAliasIgnoresWeightStyle() {
|
||||
// MingLiU maps to fallback-noto-tc, which has no bold variant registered.
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_TC_ID,
|
||||
service.resolveFallbackFontId("MingLiU-Bold", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Simplified Chinese aliased name maps to the CJK fallback")
|
||||
void simsunMapsToCjk() {
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID,
|
||||
service.resolveFallbackFontId("SimSun", 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font name falls through to Unicode-based resolution")
|
||||
void nullNameFallsThroughToUnicode() {
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_ID,
|
||||
service.resolveFallbackFontId(null, 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty font name falls through to Unicode-based resolution")
|
||||
void emptyNameFallsThroughToUnicode() {
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID,
|
||||
service.resolveFallbackFontId("", 0x4E2D));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown font name falls through to Unicode-based resolution")
|
||||
void unknownNameFallsThroughToUnicode() {
|
||||
assertEquals(
|
||||
PdfJsonFallbackFontService.FALLBACK_FONT_JP_ID,
|
||||
service.resolveFallbackFontId("SomeCustomFont", 0x3042));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("mapUnsupportedGlyph(int codePoint)")
|
||||
class MapUnsupportedGlyph {
|
||||
|
||||
@Test
|
||||
@DisplayName("U+276E maps to '<'")
|
||||
void heavyLeftAngleMapsToLessThan() {
|
||||
assertEquals("<", service.mapUnsupportedGlyph(0x276E));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("U+276F maps to '>'")
|
||||
void heavyRightAngleMapsToGreaterThan() {
|
||||
assertEquals(">", service.mapUnsupportedGlyph(0x276F));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unmapped code point returns null")
|
||||
void unmappedReturnsNull() {
|
||||
assertNull(service.mapUnsupportedGlyph('A'));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("canEncode / canEncodeFully")
|
||||
class CanEncode {
|
||||
|
||||
@Test
|
||||
@DisplayName("null font returns false")
|
||||
void nullFontReturnsFalse() {
|
||||
assertFalse(service.canEncode((PDFont) null, "A"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null text returns false")
|
||||
void nullTextReturnsFalse() {
|
||||
// Reuse the once-parsed CJK fallback; canEncode only reads the font.
|
||||
assertFalse(sharedService.canEncode(sharedCjkFont, (String) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty text returns false")
|
||||
void emptyTextReturnsFalse() {
|
||||
assertFalse(sharedService.canEncode(sharedCjkFont, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDType3Font always returns false")
|
||||
void type3FontReturnsFalse() {
|
||||
PDType3Font type3 = mock(PDType3Font.class);
|
||||
assertFalse(service.canEncode(type3, "A"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loaded TrueType fallback can encode basic Latin")
|
||||
void loadedFontEncodesLatin() {
|
||||
assertTrue(sharedService.canEncode(sharedCjkFont, "Hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canEncodeFully delegates to canEncode for text")
|
||||
void canEncodeFullyDelegates() {
|
||||
assertTrue(sharedService.canEncodeFully(sharedCjkFont, "abc"));
|
||||
assertFalse(sharedService.canEncodeFully(null, "abc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canEncode(font, codePoint) returns true for an encodable code point")
|
||||
void canEncodeCodePointTrue() {
|
||||
assertTrue(sharedService.canEncode(sharedCjkFont, (int) 'A'));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canEncode(font, codePoint) returns false for a null font")
|
||||
void canEncodeCodePointNullFont() {
|
||||
assertFalse(service.canEncode((PDFont) null, (int) 'A'));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildFallbackFontModel")
|
||||
class BuildFallbackFontModel {
|
||||
|
||||
@Test
|
||||
@DisplayName("builds a model for a built-in CJK font with base64 program bytes")
|
||||
void buildsCjkModel() throws IOException {
|
||||
PdfJsonFont model =
|
||||
service.buildFallbackFontModel(PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID);
|
||||
|
||||
assertNotNull(model);
|
||||
assertEquals(PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID, model.getId());
|
||||
assertEquals(PdfJsonFallbackFontService.FALLBACK_FONT_CJK_ID, model.getUid());
|
||||
assertEquals("NotoSansSC-Regular", model.getBaseName());
|
||||
assertEquals("TrueType", model.getSubtype());
|
||||
assertEquals(Boolean.TRUE, model.getEmbedded());
|
||||
assertEquals("ttf", model.getProgramFormat());
|
||||
assertNotNull(model.getProgram());
|
||||
// The program must be valid, non-empty base64.
|
||||
byte[] decoded = Base64.getDecoder().decode(model.getProgram());
|
||||
assertTrue(decoded.length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no-arg overload builds the default Noto Sans model")
|
||||
void noArgBuildsDefaultModel() throws Exception {
|
||||
invokeLoadConfig(); // populate fallbackFontLocation for the default font id
|
||||
PdfJsonFont model = service.buildFallbackFontModel();
|
||||
|
||||
assertNotNull(model);
|
||||
assertEquals(PdfJsonFallbackFontService.FALLBACK_FONT_ID, model.getId());
|
||||
assertEquals("NotoSans-Regular", model.getBaseName());
|
||||
assertEquals("ttf", model.getProgramFormat());
|
||||
assertNotNull(model.getProgram());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown fallback id throws IOException")
|
||||
void unknownIdThrows() {
|
||||
IOException ex =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> service.buildFallbackFontModel("does-not-exist"));
|
||||
assertTrue(ex.getMessage().contains("Unknown fallback font id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("font bytes are cached and reused across calls")
|
||||
void fontBytesAreCached() throws IOException {
|
||||
PdfJsonFont first =
|
||||
service.buildFallbackFontModel(PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
|
||||
PdfJsonFont second =
|
||||
service.buildFallbackFontModel(PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
|
||||
// Same cached bytes -> identical base64 program payload.
|
||||
assertEquals(first.getProgram(), second.getProgram());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("loadFallbackPdfFont")
|
||||
class LoadFallbackPdfFont {
|
||||
|
||||
@Test
|
||||
@DisplayName("loads a Type0 PDFont for a built-in fallback id")
|
||||
void loadsType0Font() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDFont font =
|
||||
service.loadFallbackPdfFont(
|
||||
document, PdfJsonFallbackFontService.FALLBACK_FONT_AR_ID);
|
||||
assertNotNull(font);
|
||||
assertTrue(font instanceof PDType0Font);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no-arg overload loads the default Noto Sans font")
|
||||
void noArgLoadsDefaultFont() throws Exception {
|
||||
invokeLoadConfig();
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDFont font = service.loadFallbackPdfFont(document);
|
||||
assertNotNull(font);
|
||||
assertTrue(font instanceof PDType0Font);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown fallback id throws IOException")
|
||||
void unknownIdThrows() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
IOException ex =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> service.loadFallbackPdfFont(document, "nope"));
|
||||
assertTrue(ex.getMessage().contains("Unknown fallback font id"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loaded font produces fresh instances per call but identical type")
|
||||
void distinctInstancesPerCall() throws IOException {
|
||||
// Instance-identity contract holds for any built-in font; use the tiny Thai fallback
|
||||
// (~22 KB) instead of the multi-MB Korean font to keep two loads cheap.
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDFont a =
|
||||
service.loadFallbackPdfFont(
|
||||
document, PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
|
||||
PDFont b =
|
||||
service.loadFallbackPdfFont(
|
||||
document, PdfJsonFallbackFontService.FALLBACK_FONT_TH_ID);
|
||||
assertNotNull(a);
|
||||
assertNotNull(b);
|
||||
// Two independent PDType0Font wrappers loaded into the same document.
|
||||
assertFalse(a == b);
|
||||
assertSame(a.getClass(), b.getClass());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user