From f33f4f8f75efd4b959a9d767403f5b756f850b20 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:23:34 +0100 Subject: [PATCH] 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. --- .../config/EndpointConfigurationGapTest.java | 481 +++++++++ .../pdf/parser/TabulaTableParserGapTest.java | 345 +++++++ .../configuration/RuntimePathConfigTest.java | 520 ++++++++++ .../service/MobileScannerServiceTest.java | 471 +++++++++ .../service/PdfMetadataServiceTest.java | 416 ++++++++ .../common/service/PostHogServiceTest.java | 441 +++++++++ .../common/util/ExceptionUtilsGapTest.java | 738 ++++++++++++++ .../common/util/FormUtilsGapTest.java | 847 ++++++++++++++++ .../common/util/GeneralUtilsGapTest.java | 333 +++++++ .../util/PdfAttachmentHandlerGapTest.java | 446 +++++++++ .../software/common/util/PdfUtilsGapTest.java | 551 +++++++++++ .../common/util/ProcessExecutorGapTest.java | 634 ++++++++++++ .../api/MergeControllerGapTest.java | 493 +++++++++ .../api/PosterPdfControllerTest.java | 446 +++++++++ .../api/SettingsControllerTest.java | 186 ++++ .../api/ToSinglePageControllerTest.java | 325 ++++++ .../api/UIDataControllerGapTest.java | 423 ++++++++ .../ConvertImgPDFControllerGapTest.java | 826 ++++++++++++++++ .../converters/ConvertPDFToPDFAGapTest.java | 869 ++++++++++++++++ .../ConvertPdfToVideoControllerTest.java | 450 +++++++++ .../api/misc/AutoSplitPdfControllerTest.java | 637 ++++++++++++ .../api/misc/CompressControllerTest.java | 469 +++++++++ .../misc/ExtractImageScansControllerTest.java | 413 ++++++++ .../api/misc/OCRControllerTest.java | 290 ++++++ .../api/misc/PageNumbersControllerTest.java | 553 +++++++++++ .../api/misc/RemoveImagesControllerTest.java | 447 +++++++++ .../api/misc/RepairControllerTest.java | 318 ++++++ .../api/misc/ScannerEffectControllerTest.java | 932 ++++++++++++++++++ .../pipeline/PipelineControllerGapTest.java | 402 ++++++++ .../security/ManualRedactionServiceTest.java | 669 +++++++++++++ .../security/TextRedactionServiceTest.java | 566 +++++++++++ .../PdfJsonConversionServiceGapTest.java | 654 ++++++++++++ .../SPDF/service/PdfJsonCosMapperTest.java | 780 +++++++++++++++ .../PdfJsonFallbackFontServiceTest.java | 571 +++++++++++ 34 files changed, 17942 insertions(+) create mode 100644 app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java create mode 100644 app/common/src/test/java/stirling/software/SPDF/pdf/parser/TabulaTableParserGapTest.java create mode 100644 app/common/src/test/java/stirling/software/common/configuration/RuntimePathConfigTest.java create mode 100644 app/common/src/test/java/stirling/software/common/service/MobileScannerServiceTest.java create mode 100644 app/common/src/test/java/stirling/software/common/service/PdfMetadataServiceTest.java create mode 100644 app/common/src/test/java/stirling/software/common/service/PostHogServiceTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/ExceptionUtilsGapTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/GeneralUtilsGapTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/PdfAttachmentHandlerGapTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/PdfUtilsGapTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/ProcessExecutorGapTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerGapTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/PosterPdfControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/ToSinglePageControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/UIDataControllerGapTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerGapTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/OCRControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/PageNumbersControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ScannerEffectControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineControllerGapTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/security/ManualRedactionServiceTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceGapTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/service/PdfJsonCosMapperTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/service/PdfJsonFallbackFontServiceTest.java diff --git a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java new file mode 100644 index 000000000..6275b4934 --- /dev/null +++ b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java @@ -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// uri") + void plainEndpoint() { + assertEquals( + "remove-pages", + EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages")); + } + + @Test + @DisplayName("builds a -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 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 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 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 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(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/SPDF/pdf/parser/TabulaTableParserGapTest.java b/app/common/src/test/java/stirling/software/SPDF/pdf/parser/TabulaTableParserGapTest.java new file mode 100644 index 000000000..8e92c2fe5 --- /dev/null +++ b/app/common/src/test/java/stirling/software/SPDF/pdf/parser/TabulaTableParserGapTest.java @@ -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 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 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 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 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 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 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 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 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 fragments = + parser.parse(doc, new RawPage(1, 0f, 0f, List.of())); + assertFalse(fragments.isEmpty()); + for (List 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(); + } +} diff --git a/app/common/src/test/java/stirling/software/common/configuration/RuntimePathConfigTest.java b/app/common/src/test/java/stirling/software/common/configuration/RuntimePathConfigTest.java new file mode 100644 index 000000000..f3e0490b1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/configuration/RuntimePathConfigTest.java @@ -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 /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 /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 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 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 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 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 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 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 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 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 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); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/MobileScannerServiceTest.java b/app/common/src/test/java/stirling/software/common/service/MobileScannerServiceTest.java new file mode 100644 index 000000000..1bfc16667 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/MobileScannerServiceTest.java @@ -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 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 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 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 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 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 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 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 sessions = + (java.util.Map) + ReflectionTestUtils.getField(service, "activeSessions"); + assertNotNull(sessions); + Object sessionData = sessions.get(sessionId); + assertNotNull(sessionData, "session not found: " + sessionId); + ReflectionTestUtils.setField(sessionData, "lastAccessTime", lastAccessTime); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/PdfMetadataServiceTest.java b/app/common/src/test/java/stirling/software/common/service/PdfMetadataServiceTest.java new file mode 100644 index 000000000..6519d667a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/PdfMetadataServiceTest.java @@ -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()); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/PostHogServiceTest.java b/app/common/src/test/java/stirling/software/common/service/PostHogServiceTest.java new file mode 100644 index 000000000..025ef71a6 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/PostHogServiceTest.java @@ -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 properties = new HashMap<>(); + properties.put("foo", "bar"); + service.captureEvent("my_event", properties); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(postHog).capture(eq(UUID), eq("my_event"), captor.capture()); + Map 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 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 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 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 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 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 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 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 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 metrics = service.captureServerMetrics(); + + assertFalse(metrics.containsKey("total_users_created")); + } + + @Test + @DisplayName("always embeds nested application_properties map") + void embedsApplicationProperties() { + PostHogService service = disabledService(); + + Map 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 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 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 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 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 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 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 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 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 blank = blankService.captureApplicationProperties(); + assertFalse(blank.containsKey("ui_appNameNavbar")); + + ApplicationProperties namedProps = new ApplicationProperties(); + namedProps.getUi().setAppNameNavbar("My App"); + PostHogService namedService = serviceWith(namedProps); + Map 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 p = service.captureApplicationProperties(); + + assertNotNull(p); + // csrfDisabled is always added regardless of config, so map is never empty. + assertTrue(p.containsKey("security_csrfDisabled")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsGapTest.java new file mode 100644 index 000000000..826802748 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsGapTest.java @@ -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. + * + *

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 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 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 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 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 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")); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java new file mode 100644 index 000000000..dd828327b --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java @@ -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 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 result = FormUtils.parseMultiChoiceSelections(" a , b ,c "); + assertEquals(List.of("a", "b", "c"), result); + } + + @Test + void dropsEmptySegments() { + List 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 selections = new ArrayList<>(); + selections.add(" "); + selections.add(null); + assertTrue(FormUtils.filterChoiceSelections(selections, List.of("A"), "f").isEmpty()); + } + + @Test + void matchingSelectionsAreKeptCaseInsensitively() { + List 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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)); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/GeneralUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsGapTest.java new file mode 100644 index 000000000..51a08ee9a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsGapTest.java @@ -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); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/PdfAttachmentHandlerGapTest.java b/app/common/src/test/java/stirling/software/common/util/PdfAttachmentHandlerGapTest.java new file mode 100644 index 000000000..01d9128f2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/PdfAttachmentHandlerGapTest.java @@ -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 embeddedFileNames(byte[] pdfBytes) throws Exception { + List 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 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 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 attachments = new ArrayList<>(); + attachments.add(attachment("report.pdf", "hello".getBytes(StandardCharsets.UTF_8))); + + byte[] result = + PdfAttachmentHandler.attachFilesToPdf( + pdfBytes, attachments, pdfDocumentFactory); + + List 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 attachments = new ArrayList<>(); + attachments.add(attachment("report.pdf", "PDFDATA".getBytes(StandardCharsets.UTF_8))); + + byte[] result = + PdfAttachmentHandler.attachFilesToPdf( + pdfBytes, attachments, pdfDocumentFactory); + + List 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 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 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 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 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 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 positions = finder.getPositions(); + assertEquals(2, positions.size()); + + List 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 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 list = new ArrayList<>(); + list.add(img); + content.setAttachments(list); + + String 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 list = new ArrayList<>(); + list.add(img); + content.setAttachments(list); + + String html = ""; + 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 list = new ArrayList<>(); + list.add(nonEmbedded); + content.setAttachments(list); + + String html = ""; + 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)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/PdfUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/PdfUtilsGapTest.java new file mode 100644 index 000000000..b0f5c750a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/PdfUtilsGapTest.java @@ -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")); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/ProcessExecutorGapTest.java b/app/common/src/test/java/stirling/software/common/util/ProcessExecutorGapTest.java new file mode 100644 index 000000000..961ae0b6c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ProcessExecutorGapTest.java @@ -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 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 invokeStripUnoEndpointArgs(ProcessExecutor executor, List command) + throws Exception { + Method method = ProcessExecutor.class.getDeclaredMethod("stripUnoEndpointArgs", List.class); + method.setAccessible(true); + return (List) method.invoke(executor, command); + } + + @SuppressWarnings("unchecked") + private List invokeApplyUnoServerEndpoint( + ProcessExecutor executor, + List command, + ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint) + throws Exception { + Method method = + ProcessExecutor.class.getDeclaredMethod( + "applyUnoServerEndpoint", + List.class, + ApplicationProperties.ProcessExecutor.UnoServerEndpoint.class); + method.setAccessible(true); + return (List) method.invoke(executor, command, endpoint); + } + + private boolean invokeShouldUseUnoServerPool(ProcessExecutor executor, List 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 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 input = + List.of( + "unoconvert", + "--host", + "1.2.3.4", + "--port", + "9999", + "--host-location", + "remote", + "--protocol", + "https", + "in.docx", + "out.pdf"); + List 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 input = + List.of( + "unoconvert", + "--host=5.6.7.8", + "--port=4002", + "--host-location=local", + "--protocol=http", + "doc.odt"); + List 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 input = List.of("unoconvert", "in.docx", "out.pdf"); + List result = invokeStripUnoEndpointArgs(qpdfExecutor(), input); + assertEquals(input, result); + } + + @Test + @DisplayName("returns a fresh list, not the same instance") + void returnsNewList() throws Exception { + List input = new ArrayList<>(List.of("unoconvert", "a", "b")); + List 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 command = List.of("unoconvert", "in.docx", "out.pdf"); + ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep = + endpoint("9.9.9.9", 7777, "auto", "http"); + List 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 command = List.of("unoconvert", "in.docx"); + ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep = + endpoint("10.0.0.5", 2200, "remote", "https"); + List 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 command = List.of("unoconvert", "in.docx"); + ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep = + endpoint(" ", 0, "auto", "http"); + List 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 command = List.of("unoconvert", "in.docx"); + ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep = + endpoint("1.1.1.1", 3000, "sideways", "gopher"); + List 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 command = List.of("unoconvert", "in.docx"); + ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep = + endpoint("1.1.1.1", 3000, " REMOTE ", " HTTPS "); + List 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 command = List.of("unoconvert", "--host", "old", "--port", "1", "in.docx"); + ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep = + endpoint("2.2.2.2", 2222, "auto", "http"); + List 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 command = List.of("unoconvert", "in.docx"); + List result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, null); + assertEquals(command, result); + } + + @Test + @DisplayName("empty command returns the command unchanged") + void emptyCommandReturnedUnchanged() throws Exception { + List command = List.of(); + ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep = + endpoint("1.1.1.1", 2003, "auto", "http"); + List 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 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); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerGapTest.java new file mode 100644 index 000000000..f5543bcdc --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerGapTest.java @@ -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 sortComparator(String sortType) throws Exception { + Method m = MergeController.class.getDeclaredMethod("getSortComparator", String.class); + m.setAccessible(true); + return (java.util.Comparator) 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 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 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 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 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(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/PosterPdfControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/PosterPdfControllerTest.java new file mode 100644 index 000000000..37647caac --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/PosterPdfControllerTest.java @@ -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 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 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 _poster.pdf") + void zipEntryNamedAfterBase() throws Exception { + MockMultipartFile file = createRealPdf(1, "report.pdf"); + PosterPdfRequest request = createRequest(file); + stubFactory(file); + + ResponseEntity 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 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 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 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 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 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 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 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)); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java new file mode 100644 index 000000000..11d4c4140 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java @@ -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 = mockStatic(GeneralUtils.class)) { + ResponseEntity> 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 = mockStatic(GeneralUtils.class)) { + ResponseEntity> 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 = mockStatic(GeneralUtils.class)) { + ResponseEntity> 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 = mockStatic(GeneralUtils.class)) { + ResponseEntity> 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 statuses = new ConcurrentHashMap<>(); + statuses.put("merge-pdfs", Boolean.TRUE); + statuses.put("remove-blanks", Boolean.FALSE); + when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses); + + ResponseEntity> 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 statuses = new HashMap<>(); + when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses); + + ResponseEntity> response = + settingsController.getDisabledEndpoints(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isEmpty()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/ToSinglePageControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/ToSinglePageControllerTest.java new file mode 100644 index 000000000..49dc4fb75 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/ToSinglePageControllerTest.java @@ -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.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 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 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 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 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 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 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 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()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/UIDataControllerGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/UIDataControllerGapTest.java new file mode 100644 index 000000000..df934461f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/UIDataControllerGapTest.java @@ -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 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 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 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 response = + controller(userService).getLicensesData(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + UIDataController.LicensesData body = response.getBody(); + assertNotNull(body); + List 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 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 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 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 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 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 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 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 sigs = List.of(new SignatureFile("sig.png", "Personal")); + when(signatureService.getAvailableSignatures("alice")).thenReturn(sigs); + + ResponseEntity 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 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 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 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 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(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerGapTest.java new file mode 100644 index 000000000..4f87942f2 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerGapTest.java @@ -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 expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic cbz = Mockito.mockStatic(CbzUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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 expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic cbz = Mockito.mockStatic(CbzUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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 expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic cbz = Mockito.mockStatic(CbzUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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 expected = ResponseEntity.ok(pdfBytes); + + try (MockedStatic cbr = Mockito.mockStatic(CbrUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 expected = ResponseEntity.ok(pdfBytes); + + try (MockedStatic cbr = Mockito.mockStatic(CbrUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic p2c = Mockito.mockStatic(PdfToCbzUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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 expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic p2c = Mockito.mockStatic(PdfToCbzUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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 expected = ResponseEntity.ok(cbrBytes); + + try (MockedStatic p2c = Mockito.mockStatic(PdfToCbrUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 expected = ResponseEntity.ok(cbrBytes); + + try (MockedStatic p2c = Mockito.mockStatic(PdfToCbrUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 expected = ResponseEntity.ok(imageBytes); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic 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 expected = ResponseEntity.ok(zipBytes); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic 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 expected = ResponseEntity.ok(imageBytes); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic 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 expected = ResponseEntity.ok(imageBytes); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic 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 pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic 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 expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic cbz = Mockito.mockStatic(CbzUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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 expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic cbz = Mockito.mockStatic(CbzUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java new file mode 100644 index 000000000..b64776665 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java @@ -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 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 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 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 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 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 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 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 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(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java new file mode 100644 index 000000000..7a4661144 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoControllerTest.java @@ -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}. + * + *

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 buildFfmpegCommand( + String format, String resolution, String frameRate, TempFile outputVideo) + throws Exception { + return (List) + 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 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 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 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 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 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)); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfControllerTest.java new file mode 100644 index 000000000..09ff35463 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfControllerTest.java @@ -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 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 zipEntryNames(byte[] zipBytes) throws Exception { + List 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 response = controller.autoSplitPdf(req); + + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + + byte[] zip = readResource(response.getBody()); + List 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 response = controller.autoSplitPdf(request(pdf, null)); + + byte[] zip = readResource(response.getBody()); + List 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 response = controller.autoSplitPdf(req); + byte[] zip = readResource(response.getBody()); + List 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 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 response = controller.autoSplitPdf(request(pdf, null)); + assertEquals(HttpStatus.OK, response.getStatusCode()); + List 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 valid = new HashSet<>((Set) 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")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerTest.java new file mode 100644 index 000000000..d5a5c6a30 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerTest.java @@ -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 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.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 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 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 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 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 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); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansControllerTest.java new file mode 100644 index 000000000..7e1e7da29 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansControllerTest.java @@ -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}. + * + *

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 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 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 check = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic 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 check = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic general = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 check = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic general = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 check = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic general = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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.getArgument(0) + inv.getArgument(1)); + + ProcessExecutor exec = execWritingOutputs(1); + pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)) + .thenReturn(exec); + + ResponseEntity 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 check = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic general = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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.getArgument(0) + inv.getArgument(1)); + + // Three output files -> zip path. + ProcessExecutor exec = execWritingOutputs(3); + pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)) + .thenReturn(exec); + + ResponseEntity 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> cmdCaptor = ArgumentCaptor.forClass(List.class); + when(exec.runCommandWithOutputHandling(cmdCaptor.capture())) + .thenAnswer(invocation -> mock(ProcessExecutorResult.class)); + + try (MockedStatic check = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic general = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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 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 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 check = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic general = Mockito.mockStatic(GeneralUtils.class); + MockedStatic 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; + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/OCRControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/OCRControllerTest.java new file mode 100644 index 000000000..83d036fa6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/OCRControllerTest.java @@ -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 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 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()); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/PageNumbersControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/PageNumbersControllerTest.java new file mode 100644 index 000000000..69f1d4cf7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/PageNumbersControllerTest.java @@ -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.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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java new file mode 100644 index 000000000..1a97cc1a5 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RemoveImagesControllerTest.java @@ -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 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.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 response; + try (MockedStatic 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 web = mockStatic(WebResponseUtils.class)) { + web.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(okResponse()); + ResponseEntity 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 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 web = mockStatic(WebResponseUtils.class)) { + web.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(okResponse()); + ResponseEntity 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 web = mockStatic(WebResponseUtils.class)) { + web.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(okResponse()); + ResponseEntity 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 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 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 web = mockStatic(WebResponseUtils.class)) { + web.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(okResponse()); + + ResponseEntity 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 okResponse() { + return ResponseEntity.ok(new ByteArrayResource("ok".getBytes())); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerTest.java new file mode 100644 index 000000000..71fdf5ffc --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerTest.java @@ -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}. + * + *

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 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 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 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 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 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()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ScannerEffectControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ScannerEffectControllerTest.java new file mode 100644 index 000000000..691992684 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ScannerEffectControllerTest.java @@ -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.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 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 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 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 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 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 within(float tol) { + return org.assertj.core.data.Offset.offset(tol); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineControllerGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineControllerGapTest.java new file mode 100644 index 000000000..edcfbc72e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineControllerGapTest.java @@ -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 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 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 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 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 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 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 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 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> propsCaptor = ArgumentCaptor.forClass(Map.class); + verify(postHogService).captureEvent(eq("pipeline_api_event"), propsCaptor.capture()); + + Map 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 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 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 response = controller.handleData(request(files, VALID_JSON)); + + assertNotNull(response); + Map entries = readZip(response.getBody()); + // Two distinct zip entry names despite the same source filename. + assertEquals(2, entries.size()); + } + + private Map readZip(Resource zipResource) throws IOException { + Map 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 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 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> 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()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/ManualRedactionServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/ManualRedactionServiceTest.java new file mode 100644 index 000000000..40d142e60 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/ManualRedactionServiceTest.java @@ -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 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.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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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> 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> 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> 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> 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> 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(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java new file mode 100644 index 000000000..d487848e0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java @@ -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> 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> 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> result = + service.findTextToRedact(doc, new String[] {"SECRET"}, false, false); + + assertFalse(result.isEmpty()); + assertTrue(result.containsKey(0), "match should be on page index 0"); + List 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> 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> 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> 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> 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> 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> 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> 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> found = + service.findTextToRedact(doc, new String[] {"SECRET"}, false, false); + + service.performTextReplacement(doc, found, new String[] {"SECRET"}, false, false); + + Map> 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 originalTokens = parseTokens(page); + + List 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 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 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 originalTokens = parseTokens(page); + + List tokens = + service.createTokensWithoutTargetText( + doc, page, Collections.emptySet(), false, false); + + assertEquals(originalTokens.size(), tokens.size()); + } + } + + private List parseTokens(PDPage page) throws IOException { + PDFStreamParser parser = new PDFStreamParser(page); + List 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 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 matches = + (List) + 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 matches = + (List) + 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")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceGapTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceGapTest.java new file mode 100644 index 000000000..5178b779f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceGapTest.java @@ -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. + * + *

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 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 = + ""; + 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")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonCosMapperTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonCosMapperTest.java new file mode 100644 index 000000000..a8172bf91 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonCosMapperTest.java @@ -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 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 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 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 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 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 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 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); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonFallbackFontServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonFallbackFontServiceTest.java new file mode 100644 index 000000000..c39f79ce2 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonFallbackFontServiceTest.java @@ -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()); + } + } + } +}