mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge main into migration/run-02 with full Spring removal for pulled-in code
This commit is contained in:
@@ -20,8 +20,8 @@ set -e
|
||||
# - To build the project, use:
|
||||
# ./gradlew build
|
||||
#
|
||||
# - For running pre-commit hooks (if configured), use:
|
||||
# pre-commit run --all-files
|
||||
# - To run the lint/format/secret checks, use:
|
||||
# task pre-commit
|
||||
#
|
||||
# Make sure you are in the project root directory after this script executes.
|
||||
# =============================================================================
|
||||
@@ -70,6 +70,6 @@ echo ""
|
||||
echo " To build the project: "
|
||||
echo -e "\e[34m gradle build\e[0m"
|
||||
echo ""
|
||||
echo " To run pre-commit hooks (if configured):"
|
||||
echo -e "\e[34m pre-commit run --all-files -c .pre-commit-config.yaml\e[0m"
|
||||
echo " To run the lint/format/secret checks:"
|
||||
echo -e "\e[34m task pre-commit\e[0m"
|
||||
echo "=================================================================="
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.12.0
|
||||
pkgver=2.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.12.0
|
||||
pkgver=2.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
|
||||
openapi: &openapi
|
||||
- *build
|
||||
@@ -38,6 +40,9 @@ project: &project
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- scripts/RestartHelper.java
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
- .taskfiles/docker.yml
|
||||
- scripts/db-migration/**
|
||||
- .github/workflows/db-migration-test.yml
|
||||
|
||||
@@ -55,6 +60,9 @@ frontend: &frontend
|
||||
- scripts/summarize_type3_signatures.py
|
||||
- scripts/type3_to_cff.py
|
||||
- scripts/update_type3_library.py
|
||||
- Taskfile.yml
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/e2e.yml
|
||||
|
||||
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
|
||||
# job on changes to any of these.
|
||||
@@ -66,6 +74,8 @@ tauri: &tauri
|
||||
- frontend/package-lock.json
|
||||
- frontend/editor/vite.config.ts
|
||||
- .github/workflows/tauri-build.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/desktop.yml
|
||||
|
||||
# Files that affect the AI engine (Python tool models, fixers, tests). Gate
|
||||
# the engine validation job on changes to engine sources or to the Java
|
||||
@@ -74,6 +84,8 @@ engine: &engine
|
||||
- engine/**
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
@@ -102,4 +114,4 @@ proprietary: &proprietary
|
||||
- configs/settings.yml.template
|
||||
- build.gradle
|
||||
- app/proprietary/build.gradle
|
||||
- .github/workflows/build-enterprise.yml
|
||||
- .github/workflows/build-enterprise.yml
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pre-commit
|
||||
@@ -1,121 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
|
||||
#
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.29.0 \
|
||||
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
|
||||
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
identify==2.6.19 \
|
||||
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
|
||||
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
|
||||
# via pre-commit
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
# via pre-commit
|
||||
platformdirs==4.9.6 \
|
||||
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
|
||||
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
|
||||
# via
|
||||
# python-discovery
|
||||
# virtualenv
|
||||
pre-commit==4.6.0 \
|
||||
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
|
||||
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
|
||||
# via -r .github/scripts/requirements_pre_commit.in
|
||||
python-discovery==1.2.2 \
|
||||
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
|
||||
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
|
||||
# via virtualenv
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
|
||||
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
|
||||
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
|
||||
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
|
||||
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
|
||||
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
|
||||
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
|
||||
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
|
||||
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
|
||||
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
|
||||
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
|
||||
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
|
||||
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
|
||||
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
|
||||
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
|
||||
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
|
||||
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
|
||||
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
|
||||
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
|
||||
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
|
||||
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
|
||||
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
|
||||
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
|
||||
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
|
||||
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
|
||||
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
|
||||
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
|
||||
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
|
||||
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
virtualenv==21.2.4 \
|
||||
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
|
||||
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
|
||||
# via pre-commit
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
|
||||
- name: Build and push V2 image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
|
||||
@@ -222,10 +222,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
|
||||
- name: Build and push PR-specific image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
|
||||
- name: Build and push engine image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ./engine
|
||||
|
||||
@@ -43,10 +43,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Regenerate tool models
|
||||
run: task engine:tool-models
|
||||
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
# Runs once per matrix combination - pick the cheapest leg
|
||||
# (core - no proprietary, no saas) so we don't wait for the
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (needed for playwright's vite preview webServer)
|
||||
|
||||
@@ -40,11 +40,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check licenses for compatibility
|
||||
run: task backend:licenses:check
|
||||
env:
|
||||
|
||||
@@ -45,11 +45,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: task backend:swagger
|
||||
env:
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
# No `-PnoSpotless` here yet because the upstream cache layer matches the
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Build and push frontend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
|
||||
- name: Build and push backend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
name: Docker Compose Cucumber tests (saas / PAYG)
|
||||
|
||||
# Self-contained CI job for the PAYG shadow-mode cucumber scenarios.
|
||||
# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR
|
||||
# that doesn't touch the saas flavour.
|
||||
#
|
||||
# Companion to `docker-compose-tests.yml` (which runs against the
|
||||
# proprietary-flavour stack and skips features/payg via behave.ini's
|
||||
# exclude_re). Kept as a separate workflow so the saas matrix can fail and
|
||||
# succeed independently without touching the main cucumber harness.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "app/saas/**"
|
||||
- "testing/cucumber/features/payg/**"
|
||||
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||
- "testing/cucumber/requirements.txt"
|
||||
- "testing/compose/docker-compose-saas.yml"
|
||||
- "testing/compose/payg/**"
|
||||
- "testing/test-payg.sh"
|
||||
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/saas/**"
|
||||
- "testing/cucumber/features/payg/**"
|
||||
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||
- "testing/cucumber/requirements.txt"
|
||||
- "testing/compose/docker-compose-saas.yml"
|
||||
- "testing/compose/payg/**"
|
||||
- "testing/test-payg.sh"
|
||||
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
docker-compose-tests-saas:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-saas-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
# No "Install Docker Compose" step: Ubuntu runners ship with `docker compose`
|
||||
# v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout
|
||||
# (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary
|
||||
# isn't needed. Avoids a `curl | sudo install` without checksum verification
|
||||
# (Aikido flagged this when copy-pasted from docker-compose-tests.yml).
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Run PAYG Cucumber Tests
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: |
|
||||
chmod +x ./testing/test-payg.sh
|
||||
./testing/test-payg.sh
|
||||
|
||||
- name: Dump saas container logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true
|
||||
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true
|
||||
|
||||
- name: Upload PAYG Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: payg-cucumber-report
|
||||
path: testing/cucumber/report-payg.html
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: PAYG Cucumber Test Report
|
||||
if: always()
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: PAYG Cucumber Tests
|
||||
path: testing/cucumber/junit-payg/*.xml
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
@@ -188,10 +188,30 @@ jobs:
|
||||
name: backend-log-live-${{ github.run_id }}
|
||||
path: .test-state/playwright/backend.log
|
||||
retention-days: 7
|
||||
- name: Upload Playwright report
|
||||
- name: List Playwright output locations (debug)
|
||||
if: always()
|
||||
run: |
|
||||
echo "::group::Playwright output dirs"
|
||||
# Playwright anchors its default outputDir + HTML report to the
|
||||
# nearest package.json, which is frontend/ (frontend/editor has
|
||||
# none), so artifacts land under frontend/, not frontend/editor/.
|
||||
ls -la frontend/playwright-report 2>/dev/null \
|
||||
|| echo "no playwright-report at frontend/"
|
||||
ls -la frontend/test-results 2>/dev/null \
|
||||
|| echo "no test-results at frontend/"
|
||||
find . -name node_modules -prune -o -name 'trace.zip' -print 2>/dev/null || true
|
||||
echo "::endgroup::"
|
||||
- name: Upload Playwright report + traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-live-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
# test-results/ holds the per-test trace.zip (with browser console
|
||||
# logs) + screenshots/video; playwright-report/ is the HTML report.
|
||||
# Both live under frontend/ (Playwright anchors them to the nearest
|
||||
# package.json, which is frontend/; frontend/editor has none).
|
||||
path: |
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
env:
|
||||
@@ -349,10 +349,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Quality-check frontend
|
||||
id: frontend-check
|
||||
run: task frontend:check:all
|
||||
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build JAR
|
||||
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -252,10 +252,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install all Playwright browsers
|
||||
run: task e2e:install
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
name: Pre-commit
|
||||
|
||||
# Runs `pre-commit run` for ruff / codespell / gitleaks / EOF / trailing-ws.
|
||||
# Called from build.yml on PRs and merge_group; also runnable on demand via
|
||||
# workflow_dispatch for manual local-equivalent linting.
|
||||
# Runs the repo-wide lint/format/secret checks via `task pre-commit`.
|
||||
# Called from build.yml on PRs and merge_group; also runnable on demand via workflow_dispatch.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
@@ -13,10 +12,6 @@ permissions:
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Prevents sdist builds → no tar extraction
|
||||
PIP_ONLY_BINARY: ":all:"
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -29,23 +24,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
|
||||
enable-cache: true
|
||||
|
||||
- name: Run Pre-Commit Hooks
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Run Pre-Commit
|
||||
run: |
|
||||
pre-commit run ruff --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run codespell --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
git diff --exit-code
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
|
||||
|
||||
@@ -78,14 +78,14 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
if: env.RUN_MAIN_APP == 'true'
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 30 days stale issues
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
SWAGGERHUB_USER: "Frooodle"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -58,15 +58,23 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
|
||||
|
||||
- name: pre-commit run
|
||||
- name: Sort translation TOML files
|
||||
run: |
|
||||
pre-commit run toml-sort-fix --all-files
|
||||
task pre-commit:toml-sort FIX=1
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
|
||||
@@ -136,10 +136,10 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
|
||||
@@ -106,11 +106,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Build application
|
||||
run: task backend:build
|
||||
env:
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }} (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 9.5.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Build and push test image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
|
||||
@@ -51,6 +51,11 @@ app/core/src/main/resources/static/index.html
|
||||
# Migration cleanup: earlier builds emitted the whole bundle into static/. Keep these ignored so
|
||||
# any stale generated assets left in static/ are not accidentally committed.
|
||||
app/core/src/main/resources/static/assets/
|
||||
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
|
||||
app/core/src/main/resources/static/*.html
|
||||
!app/core/src/main/resources/static/api-landing.html
|
||||
# Prerendered nested-route pages (e.g. settings/people.html)
|
||||
app/core/src/main/resources/static/settings/
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
@@ -58,6 +63,8 @@ app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/og-metadata.json
|
||||
app/core/src/main/resources/static/sw-folder-retry.js
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/pdfjs/
|
||||
|
||||
+8
-1
@@ -1,4 +1,4 @@
|
||||
# PostHog project-level key — phc_ prefix keys are public/client-side by design
|
||||
# PostHog project-level key - phc_ prefix keys are public/client-side by design
|
||||
# (PostHog client-side tracking embeds them in the browser bundle). Committed
|
||||
# intentionally in #6150 so engine/.env has a working default, with real
|
||||
# credentials overridden via engine/.env.local.
|
||||
@@ -12,3 +12,10 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
|
||||
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
|
||||
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:92
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:116
|
||||
|
||||
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
|
||||
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
|
||||
|
||||
+12
-50
@@ -1,52 +1,14 @@
|
||||
# The actual checks live in .taskfiles/pre-commit.yml (with helper scripts under
|
||||
# scripts/pre-commit/) and are driven by Task. This hook just delegates to `task
|
||||
# pre-commit` so the git pre-commit hook, CI and a manual `task pre-commit` all
|
||||
# run the exact same thing. Requires `task` and `uv` on PATH. To auto-fix instead
|
||||
# of only checking, run `task pre-commit:fix`.
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.14
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
- --fix
|
||||
- --line-length=127
|
||||
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
|
||||
exclude: (split_photos.py)
|
||||
- id: ruff-format
|
||||
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
|
||||
exclude: (split_photos.py)
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment,vertx
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/editor/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- id: trailing-whitespace
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- repo: https://github.com/pappasam/toml-sort
|
||||
rev: v0.24.4
|
||||
hooks:
|
||||
- id: toml-sort-fix
|
||||
files: frontend/editor/public/locales/.*\.toml$
|
||||
args: ['--in-place', '--all', '--ignore-case']
|
||||
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
|
||||
# rev: v16.21.1
|
||||
# hooks:
|
||||
# - id: stylelint
|
||||
# additional_dependencies:
|
||||
# - stylelint@16.21.1
|
||||
# - stylelint-config-standard@38.0.0
|
||||
# - "@stylistic/stylelint-plugin@3.1.3"
|
||||
# files: \.(css)$
|
||||
# args: [--fix]
|
||||
- id: task-pre-commit
|
||||
name: task pre-commit
|
||||
entry: task pre-commit
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
+19
-2
@@ -18,17 +18,28 @@ version: '3'
|
||||
tasks:
|
||||
dev:
|
||||
desc: "Start backend dev server"
|
||||
cmds:
|
||||
- task: dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -50,9 +61,15 @@ tasks:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
|
||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
STIRLING_FLAVOR: saas
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
cmds:
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
|
||||
platforms: [windows]
|
||||
|
||||
@@ -127,14 +127,15 @@ tasks:
|
||||
cmds:
|
||||
- rm -rf runtime/jre
|
||||
- mkdir -p runtime
|
||||
- >-
|
||||
jlink
|
||||
--add-modules {{.JLINK_MODULES}}
|
||||
--strip-debug
|
||||
--compress=zip-6
|
||||
--no-header-files
|
||||
--no-man-pages
|
||||
--output runtime/jre
|
||||
- |
|
||||
JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
jlink \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
# jlink emits its files mode 444 (read-only). Tauri's build-script
|
||||
# resource copier preserves source permissions when staging
|
||||
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
|
||||
|
||||
@@ -20,6 +20,11 @@ tasks:
|
||||
cmds:
|
||||
- docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite .
|
||||
|
||||
build:backend:
|
||||
desc: "Build backend-only Docker image (no embedded frontend)"
|
||||
cmds:
|
||||
- docker build -t stirling-pdf-backend -f docker/backend/Dockerfile .
|
||||
|
||||
build:frontend:
|
||||
desc: "Build frontend-only Docker image"
|
||||
cmds:
|
||||
|
||||
+3
-16
@@ -1,12 +1,5 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
# Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_*
|
||||
# vars (Task merges included-file vars into the global scope).
|
||||
# Paths are relative to the engine/ include dir.
|
||||
ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh"
|
||||
ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1"
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install engine dependencies"
|
||||
@@ -36,14 +29,11 @@ tasks:
|
||||
ignore_error: true
|
||||
dir: src
|
||||
vars:
|
||||
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||
# When running standalone, probe for a free port starting at 5001.
|
||||
PORT:
|
||||
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||
PORT: '{{.PORT | default "5001"}}'
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
|
||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
|
||||
|
||||
dev:
|
||||
desc: "Start engine dev server with hot reload"
|
||||
@@ -51,10 +41,7 @@ tasks:
|
||||
ignore_error: true
|
||||
dir: src
|
||||
vars:
|
||||
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||
# When running standalone, probe for a free port starting at 5001.
|
||||
PORT:
|
||||
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||
PORT: '{{.PORT | default "5001"}}'
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
cmds:
|
||||
|
||||
@@ -40,6 +40,21 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-icons.js
|
||||
|
||||
prepare:og:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate OG/social-preview metadata from the tool registry"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs
|
||||
sources:
|
||||
- editor/src/core/types/toolId.ts
|
||||
- editor/src/core/utils/urlMapping.ts
|
||||
- editor/src/core/data/useTranslatedToolRegistry.tsx
|
||||
- editor/public/og_images/*.png
|
||||
generates:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -49,6 +64,7 @@ tasks:
|
||||
- task: prepare:env
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -262,6 +278,12 @@ tasks:
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
|
||||
|
||||
typecheck:cloud:
|
||||
desc: "Typecheck cloud shared layer (standalone)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
|
||||
|
||||
typecheck:scripts:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
@@ -293,6 +315,7 @@ tasks:
|
||||
- task: typecheck:proprietary
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:cloud
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
@@ -310,9 +333,17 @@ tasks:
|
||||
- task: format:check
|
||||
- task: test
|
||||
|
||||
og:check:
|
||||
desc: "Fail if committed OG/social-preview metadata is out of date"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
cmds:
|
||||
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
|
||||
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
|
||||
- task: og:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
@@ -367,3 +398,15 @@ tasks:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/generate-licenses.js
|
||||
|
||||
# ============================================================
|
||||
# Clean
|
||||
# ============================================================
|
||||
|
||||
clean:
|
||||
desc: "Clean build artifacts and caches"
|
||||
cmds:
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
|
||||
platforms: [windows]
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
|
||||
platforms: [linux, darwin]
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
version: '3'
|
||||
|
||||
# Repo-wide lint/format/secret checks - the single source of truth that the git
|
||||
# pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call.
|
||||
|
||||
vars:
|
||||
GITLEAKS: '8.30.0'
|
||||
|
||||
# File selections as git pathspecs: git does the include/exclude matching, so
|
||||
# there is no grep/xargs and it behaves identically on every platform.
|
||||
PY_FILES: >-
|
||||
'scripts/*.py'
|
||||
'.github/scripts/*.py'
|
||||
'app/core/src/main/resources/static/python/*.py'
|
||||
':(exclude)*split_photos.py'
|
||||
SPELL_FILES: >-
|
||||
'*.html'
|
||||
'*.css'
|
||||
'*.js'
|
||||
'*.py'
|
||||
'*.md'
|
||||
':(exclude).vscode/*'
|
||||
':(exclude).devcontainer/*'
|
||||
':(exclude)app/core/src/main/resources/*'
|
||||
':(exclude)app/proprietary/src/main/resources/*'
|
||||
':(exclude)frontend/editor/public/vendor/*'
|
||||
':(exclude)*Dockerfile*'
|
||||
':(exclude)*pdfjs*'
|
||||
':(exclude)*thirdParty*'
|
||||
':(exclude)*bootstrap*'
|
||||
':(exclude)*.min.*'
|
||||
':(exclude)*diff.js'
|
||||
WS_FILES: >-
|
||||
'*.js'
|
||||
'*.java'
|
||||
'*.py'
|
||||
'*.yml'
|
||||
':(exclude)*pdfjs*'
|
||||
':(exclude)*thirdParty*'
|
||||
':(exclude)*bootstrap*'
|
||||
':(exclude)*.min.*'
|
||||
':(exclude)*diff.js'
|
||||
':(exclude).github/workflows/*'
|
||||
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
|
||||
|
||||
GITLEAKS_BIN: '.task/bin/gitleaks-{{.GITLEAKS}}{{if eq OS "windows"}}.exe{{end}}'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: "Check formatting, spelling, and secrets across the repo"
|
||||
cmds:
|
||||
- task: ruff
|
||||
- task: ruff-format
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
- task: whitespace
|
||||
- task: toml-sort
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
|
||||
cmds:
|
||||
# Auto-fixers first, then the report-only tools (codespell, gitleaks) so a
|
||||
# finding there does not stop the fixers from running.
|
||||
- task: ruff
|
||||
vars: { FIX: '1' }
|
||||
- task: ruff-format
|
||||
vars: { FIX: '1' }
|
||||
- task: whitespace
|
||||
vars: { FIX: '1' }
|
||||
- task: toml-sort
|
||||
vars: { FIX: '1' }
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
|
||||
run: once
|
||||
cmds:
|
||||
- uv sync --project scripts/pre-commit --locked
|
||||
sources:
|
||||
- scripts/pre-commit/uv.lock
|
||||
- scripts/pre-commit/pyproject.toml
|
||||
status:
|
||||
- test -d scripts/pre-commit/.venv
|
||||
|
||||
clean:
|
||||
desc: "Remove the cache/build artifacts"
|
||||
cmds:
|
||||
- task: '{{if eq OS "windows"}}clean-windows{{else}}clean-unix{{end}}'
|
||||
|
||||
clean-unix:
|
||||
internal: true
|
||||
cmds:
|
||||
- rm -rf scripts/pre-commit/.venv .task/bin/gitleaks-*
|
||||
|
||||
# On Windows, use PowerShell so it matches the same paths and tolerates absent
|
||||
# files without erroring.
|
||||
clean-windows:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- powershell -NoProfile -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue scripts/pre-commit/.venv, .task/bin/gitleaks-*"
|
||||
|
||||
# Individual checks (hidden from `task --list`, but callable, e.g.
|
||||
# `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported.
|
||||
ruff:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
ruff-format:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
|
||||
|
||||
codespell:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment,vertx --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
|
||||
|
||||
toml-sort:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}$(git ls-files {{.WS_FILES}})
|
||||
|
||||
gitleaks:
|
||||
deps: [gitleaks-bin]
|
||||
# Scan staged changes only, matching the old hook: the git-mode fingerprints
|
||||
# in .gitleaksignore (file:rule:line) still apply, and with nothing staged
|
||||
# this is a no-op. Secrets are never auto-fixed, so FIX has no effect.
|
||||
cmds:
|
||||
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
desc: "Ensure the pinned gitleaks binary is cached in .task/bin"
|
||||
status:
|
||||
- test -f {{.GITLEAKS_BIN}}
|
||||
vars:
|
||||
GL_ARCH: '{{if eq ARCH "amd64"}}x64{{else if eq ARCH "arm64"}}arm64{{else if eq ARCH "386"}}x32{{else}}{{ARCH}}{{end}}'
|
||||
GL_PLATFORM: '{{OS}}_{{.GL_ARCH}}'
|
||||
GL_URL: 'https://github.com/gitleaks/gitleaks/releases/download/v{{.GITLEAKS}}/gitleaks_{{.GITLEAKS}}_{{.GL_PLATFORM}}'
|
||||
# SHA-256 of each release asset, from gitleaks_{{.GITLEAKS}}_checksums.txt.
|
||||
GL_SHA: >-
|
||||
{{if eq .GL_PLATFORM "linux_x64"}}79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e
|
||||
{{- else if eq .GL_PLATFORM "linux_arm64"}}b4cbbb6ddf7d1b2a603088cd03a4e3f7ce48ee7fd449b51f7de6ee2906f5fa2f
|
||||
{{- else if eq .GL_PLATFORM "darwin_x64"}}ca221d012d247080c2f6f61f4b7a83bffa2453806b0c195c795bbe9a8c775ed5
|
||||
{{- else if eq .GL_PLATFORM "darwin_arm64"}}b251ab2bcd4cd8ba9e56ff37698c033ebf38582b477d21ebd86586d927cf87e7
|
||||
{{- else if eq .GL_PLATFORM "windows_x64"}}54fe94f644b832dd08e8c3a5915efb3bfa862386d59fb27ca0792cb687a83573
|
||||
{{- end}}
|
||||
cmds:
|
||||
- cmd: bash scripts/pre-commit/install-gitleaks.sh "{{.GL_URL}}.tar.gz" "{{.GL_SHA}}" "{{.GITLEAKS_BIN}}"
|
||||
platforms: [linux, darwin]
|
||||
- cmd: powershell -NoProfile -File scripts/pre-commit/install-gitleaks.ps1 -Url "{{.GL_URL}}.zip" -Sha "{{.GL_SHA}}" -Dest "{{.GITLEAKS_BIN}}"
|
||||
platforms: [windows]
|
||||
@@ -169,7 +169,31 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
|
||||
|
||||
#### Frontend `cloud/` Layer
|
||||
|
||||
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
|
||||
|
||||
- **core** → core
|
||||
- **proprietary** → proprietary → core
|
||||
- **saas** → saas → cloud → proprietary → core
|
||||
- **desktop** → desktop → cloud → proprietary → core
|
||||
- **cloud** → cloud → proprietary → core
|
||||
|
||||
What goes where:
|
||||
|
||||
- **core** — OSS base.
|
||||
- **proprietary** — licensed / offline features.
|
||||
- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
|
||||
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
|
||||
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
|
||||
|
||||
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
|
||||
|
||||
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
|
||||
|
||||
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
@@ -16,6 +16,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
|
||||
* All content that resides under the "frontend/portal/" directory of this repository,
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
|
||||
## 0. TL;DR status
|
||||
|
||||
- **2026-06-19 — merged `main` (136 commits) into `migration/run-02`.** Resolved 69 conflicts and
|
||||
did **full Spring removal + Quarkus migration of every newly pulled-in file** (36 Spring-bearing
|
||||
files: 21 proprietary + 15 saas). Net effect on main: legacy credits engine deleted (#6687,
|
||||
replaced by PAYG #6589); the proprietary **policy** subsystem and the new **PAYG** subsystem
|
||||
migrated to CDI/JAX-RS/Panache. Verified: 0 conflict markers, 0 `org.springframework` imports in
|
||||
any main source. **`core` + `proprietary` compile; `proprietary` Quarkus-augments + boots + serves
|
||||
real traffic; `saas` now compiles AND augments too** (previously ~28 CDI issues). Cross-file fix:
|
||||
`PolicyExecutor`/`DownstreamEntitlementError` now carry HTTP status+body via
|
||||
`jakarta.ws.rs.WebApplicationException` (was Spring `RestClientResponseException`);
|
||||
`AiWorkflowService.paygLimitResponseOrNull` rewired to it. Repo `save()` shim added to the Panache
|
||||
repos whose callers/tests expect Spring-Data `save()`.
|
||||
- **Branch:** `migration/run-01` (all work committed locally, **nothing pushed** — `origin` is the
|
||||
public `Stirling-Tools/Stirling-PDF` repo; do not push without the owner's say-so).
|
||||
- **Default flavor (`proprietary`):** compiles, Quarkus-augments, boots, and serves real traffic in
|
||||
|
||||
@@ -60,7 +60,7 @@ For full installation options (including desktop and Kubernetes), see our [Docum
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
|
||||
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
|
||||
|
||||
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
|
||||
|
||||
|
||||
+35
-16
@@ -25,8 +25,28 @@ includes:
|
||||
e2e:
|
||||
taskfile: .taskfiles/e2e.yml
|
||||
dir: .
|
||||
pre-commit:
|
||||
taskfile: .taskfiles/pre-commit.yml
|
||||
dir: .
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
# Help (shown when you run `task` with no arguments)
|
||||
# ============================================================
|
||||
|
||||
default:
|
||||
desc: "List the most common commands"
|
||||
silent: true
|
||||
cmds:
|
||||
- |
|
||||
echo "Common commands (run 'task --list' to see all):"
|
||||
echo ""
|
||||
echo " task dev Start backend & frontend on free ports"
|
||||
echo " task backend:dev Start backend on default port"
|
||||
echo " task frontend:dev Start frontend on default port"
|
||||
echo " task desktop:dev Start desktop app"
|
||||
echo " task check Quality gate (lint, typecheck, test, etc.)"
|
||||
|
||||
# ============================================================
|
||||
# Setup & Prerequisites
|
||||
# ============================================================
|
||||
@@ -60,24 +80,20 @@ tasks:
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
vars: { FRONTEND: saas, BACKEND: saas }
|
||||
|
||||
dev:all:
|
||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
|
||||
dev:_all:
|
||||
internal: true
|
||||
vars:
|
||||
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
|
||||
BACKEND: '{{.BACKEND | default "proprietary"}}'
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
@@ -87,11 +103,12 @@ tasks:
|
||||
- task: engine:dev
|
||||
vars:
|
||||
PORT: '{{.ENGINE_PORT}}'
|
||||
- task: backend:dev
|
||||
- task: 'backend:dev:{{.BACKEND}}'
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
- task: frontend:dev
|
||||
AIENGINE_ENABLED: "true"
|
||||
- task: 'frontend:dev:{{.FRONTEND}}'
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
@@ -175,4 +192,6 @@ tasks:
|
||||
desc: "Clean all build artifacts"
|
||||
cmds:
|
||||
- task: backend:clean
|
||||
- task: frontend:clean
|
||||
- task: engine:clean
|
||||
- task: pre-commit:clean
|
||||
|
||||
@@ -3,7 +3,6 @@ package stirling.software.common.configuration;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
@@ -185,18 +184,18 @@ public class AppConfig {
|
||||
@Produces
|
||||
@Named("RunningInDocker")
|
||||
public boolean runningInDocker() {
|
||||
return Files.exists(Paths.get("/.dockerenv"));
|
||||
return Files.exists(Path.of("/.dockerenv"));
|
||||
}
|
||||
|
||||
@Produces
|
||||
@Named("configDirMounted")
|
||||
public boolean isRunningInDockerWithConfig() {
|
||||
Path dockerEnv = Paths.get("/.dockerenv");
|
||||
Path dockerEnv = Path.of("/.dockerenv");
|
||||
// default to true if not docker
|
||||
if (!Files.exists(dockerEnv)) {
|
||||
return true;
|
||||
}
|
||||
Path mountInfo = Paths.get("/proc/1/mountinfo");
|
||||
Path mountInfo = Path.of("/proc/1/mountinfo");
|
||||
// this should always exist, if not some unknown usecase
|
||||
if (!Files.exists(mountInfo)) {
|
||||
return true;
|
||||
|
||||
+3
-4
@@ -7,7 +7,6 @@ import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
|
||||
@@ -27,7 +26,7 @@ public class ConfigInitializer {
|
||||
|
||||
public void ensureConfigExists() throws IOException, URISyntaxException {
|
||||
// 1) If settings file doesn't exist, create from template
|
||||
Path destPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
Path destPath = Path.of(InstallationPathConfig.getSettingsPath());
|
||||
|
||||
boolean settingsFileExists = Files.exists(destPath);
|
||||
|
||||
@@ -39,7 +38,7 @@ public class ConfigInitializer {
|
||||
if (settingsFileExists) {
|
||||
// move settings.yml to settings.yml.{timestamp}.bak
|
||||
Path backupPath =
|
||||
Paths.get(
|
||||
Path.of(
|
||||
InstallationPathConfig.getSettingsPath()
|
||||
+ "."
|
||||
+ System.currentTimeMillis()
|
||||
@@ -96,7 +95,7 @@ public class ConfigInitializer {
|
||||
}
|
||||
|
||||
// 3) Ensure custom settings file exists
|
||||
Path customSettingsPath = Paths.get(InstallationPathConfig.getCustomSettingsPath());
|
||||
Path customSettingsPath = Path.of(InstallationPathConfig.getCustomSettingsPath());
|
||||
if (Files.notExists(customSettingsPath)) {
|
||||
Files.createFile(customSettingsPath);
|
||||
log.info("Created custom_settings file: {}", customSettingsPath);
|
||||
|
||||
+5
-6
@@ -3,7 +3,6 @@ package stirling.software.common.configuration;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -202,7 +201,7 @@ public class RuntimePathConfig {
|
||||
|
||||
try {
|
||||
// Normalize to absolute path
|
||||
Path path = Paths.get(pathStr.trim()).toAbsolutePath().normalize();
|
||||
Path path = Path.of(pathStr.trim()).toAbsolutePath().normalize();
|
||||
String normalizedPath = path.toString();
|
||||
|
||||
// Check for duplicates
|
||||
@@ -225,9 +224,9 @@ public class RuntimePathConfig {
|
||||
|
||||
private void detectOverlappingPaths(List<String> paths) {
|
||||
for (int i = 0; i < paths.size(); i++) {
|
||||
Path path1 = Paths.get(paths.get(i));
|
||||
Path path1 = Path.of(paths.get(i));
|
||||
for (int j = i + 1; j < paths.size(); j++) {
|
||||
Path path2 = Paths.get(paths.get(j));
|
||||
Path path2 = Path.of(paths.get(j));
|
||||
|
||||
// Check if one path is a parent of the other
|
||||
if (path1.startsWith(path2)) {
|
||||
@@ -247,10 +246,10 @@ public class RuntimePathConfig {
|
||||
|
||||
private void validatePipelinePaths() {
|
||||
try {
|
||||
Path finishedPath = Paths.get(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
|
||||
Path finishedPath = Path.of(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
|
||||
|
||||
for (String watchedPathStr : pipelineWatchedFoldersPaths) {
|
||||
Path watchedPath = Paths.get(watchedPathStr).toAbsolutePath().normalize();
|
||||
Path watchedPath = Path.of(watchedPathStr).toAbsolutePath().normalize();
|
||||
|
||||
// Check if watched folder is same as finished folder
|
||||
if (watchedPath.equals(finishedPath)) {
|
||||
|
||||
@@ -339,6 +339,15 @@ public class ApplicationProperties {
|
||||
*/
|
||||
private String resourceId = "";
|
||||
|
||||
/**
|
||||
* Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}.
|
||||
* Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint
|
||||
* resource-specific audiences - e.g. Supabase's OAuth server always issues {@code
|
||||
* aud=authenticated} - so operators list the audience their IdP actually emits here
|
||||
* (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated).
|
||||
*/
|
||||
private List<String> acceptedAudiences = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
|
||||
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
|
||||
@@ -985,6 +994,10 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Signing {
|
||||
private boolean enabled = false;
|
||||
|
||||
// Signing user-picker scope: 'org' (default) = whole instance, anything else =
|
||||
// caller's team only (fail-closed). The saas profile pins 'team'.
|
||||
private String userListScope = "org";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
@@ -24,7 +23,7 @@ public class FileInfo {
|
||||
|
||||
// Converts the file path string to a Path object.
|
||||
public Path getFilePathAsPath() {
|
||||
return Paths.get(filePath);
|
||||
return Path.of(filePath);
|
||||
}
|
||||
|
||||
// Formats the file size into a human-readable string.
|
||||
|
||||
@@ -62,6 +62,16 @@ public class InternalApiClient {
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
/**
|
||||
* Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies
|
||||
* the call as {@code BillingCategory.AUTOMATION}. By construction every {@link
|
||||
* InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow,
|
||||
* policy runner) running a child tool inside a parent automation flow — see the saas {@code
|
||||
* PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any
|
||||
* per-tool {@code @RequiresFeature} annotation.
|
||||
*/
|
||||
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
private final TempFileManager tempFileManager;
|
||||
@@ -118,6 +128,11 @@ public class InternalApiClient {
|
||||
if (apiKey != null && !apiKey.isEmpty()) {
|
||||
requestBuilder.header("X-API-KEY", apiKey);
|
||||
}
|
||||
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
|
||||
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
|
||||
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
|
||||
// every caller of this dispatcher is an automation surface by design.
|
||||
requestBuilder.header(AUTOMATION_HEADER, "true");
|
||||
|
||||
try {
|
||||
HttpResponse<InputStream> response =
|
||||
|
||||
@@ -3,7 +3,6 @@ package stirling.software.common.service;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -37,7 +36,7 @@ public class MobileScannerService {
|
||||
public MobileScannerService() throws IOException {
|
||||
// Create temp directory for mobile scanner uploads
|
||||
this.tempDirectory =
|
||||
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Path.of(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Files.createDirectories(tempDirectory);
|
||||
log.info("Mobile scanner temp directory: {}", tempDirectory);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.lang.management.OperatingSystemMXBean;
|
||||
import java.lang.management.RuntimeMXBean;
|
||||
import java.lang.management.ThreadMXBean;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -167,7 +167,7 @@ public class PostHogService {
|
||||
}
|
||||
|
||||
private boolean isRunningInDocker() {
|
||||
return Files.exists(Paths.get("/.dockerenv"));
|
||||
return Files.exists(Path.of("/.dockerenv"));
|
||||
}
|
||||
|
||||
private Map<String, Object> getDockerMetrics() {
|
||||
|
||||
@@ -4,7 +4,6 @@ import java.io.File;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -20,7 +19,7 @@ public class JarPathUtil {
|
||||
public static Path currentJar() {
|
||||
try {
|
||||
Path jar =
|
||||
Paths.get(
|
||||
Path.of(
|
||||
JarPathUtil.class
|
||||
.getProtectionDomain()
|
||||
.getCodeSource()
|
||||
@@ -61,14 +60,14 @@ public class JarPathUtil {
|
||||
}
|
||||
|
||||
// Location 2: ./build/libs/ (development build)
|
||||
possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
possibleLocations[1] = Path.of("build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Location 3: app/common/build/libs/ (multi-module build)
|
||||
possibleLocations[2] =
|
||||
Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
Path.of("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Location 4: Current working directory
|
||||
possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath();
|
||||
possibleLocations[3] = Path.of("restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Check each location
|
||||
for (Path location : possibleLocations) {
|
||||
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in
|
||||
* {@code init()} during construction and then applies environment overrides. We build it with a
|
||||
* real {@link ApplicationProperties} (whose System/Endpoints sub-objects are non-null by default)
|
||||
* so the constructor runs cleanly without any mocking.
|
||||
*/
|
||||
class EndpointConfigurationGapTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Construct an EndpointConfiguration with the given pro flag and current applicationProperties.
|
||||
*/
|
||||
private EndpointConfiguration build(boolean runningProOrHigher) {
|
||||
return new EndpointConfiguration(applicationProperties, runningProOrHigher);
|
||||
}
|
||||
|
||||
/** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */
|
||||
private EndpointConfiguration buildDefault() {
|
||||
return build(false);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("endpointKeyForUri (static)")
|
||||
class EndpointKeyForUriTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for null uri")
|
||||
void nullUri() {
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when uri does not contain /api/v1")
|
||||
void notApiPath() {
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/foo/bar/baz"));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("https://example.com/home"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when uri has too few path segments")
|
||||
void tooFewSegments() {
|
||||
// "/api/v1/general" splits to ["", "api", "v1", "general"] -> length 4, not > 4
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extracts plain endpoint key from a standard /api/v1/<group>/<endpoint> uri")
|
||||
void plainEndpoint() {
|
||||
assertEquals(
|
||||
"remove-pages",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("builds a <from>-to-<to> key for convert endpoints")
|
||||
void convertEndpoint() {
|
||||
assertEquals(
|
||||
"pdf-to-img",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convert path without a target segment falls back to the segment after group")
|
||||
void convertWithoutTarget() {
|
||||
// "/api/v1/convert/pdf" -> length 5, the convert branch needs length > 5
|
||||
assertEquals("pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("enable / disable endpoint")
|
||||
class EnableDisableEndpointTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a freshly registered endpoint is enabled by default")
|
||||
void enabledByDefault() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isEndpointEnabled("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disableEndpoint marks the endpoint disabled")
|
||||
void disableEndpoint() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enableEndpoint re-enables a previously disabled endpoint")
|
||||
void reEnableEndpoint() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
config.enableEndpoint("merge-pdfs");
|
||||
assertTrue(config.isEndpointEnabled("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leading slash is normalized away on disable")
|
||||
void leadingSlashNormalizedOnDisable() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("/merge-pdfs");
|
||||
// both forms resolve to the same key
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
assertFalse(config.isEndpointEnabled("/merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isEndpointEnabled tolerates a leading slash on the query")
|
||||
void leadingSlashOnQuery() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isEndpointEnabled("/merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling clears with enable, removing the disable reason")
|
||||
void enableClearsReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("split-pages", DisableReason.DEPENDENCY);
|
||||
assertEquals(
|
||||
DisableReason.DEPENDENCY,
|
||||
config.getEndpointAvailability("split-pages").getReason());
|
||||
config.enableEndpoint("split-pages");
|
||||
EndpointAvailability availability = config.getEndpointAvailability("split-pages");
|
||||
assertTrue(availability.isEnabled());
|
||||
assertNull(availability.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isEndpointEnabledForUri")
|
||||
class IsEndpointEnabledForUriTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("translates a /api/v1 uri to a key and reports its status")
|
||||
void translatesUri() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertFalse(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to treating a non-api uri as a raw key")
|
||||
void fallsBackToRawKey() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
// non-api path: key resolution returns null, so the uri itself is used as the key
|
||||
assertFalse(config.isEndpointEnabledForUri("merge-pdfs"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("group enable / disable")
|
||||
class GroupTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a functional group with all endpoints enabled reports enabled")
|
||||
void functionalGroupEnabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isGroupEnabled("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling a functional group cascades to all its endpoints")
|
||||
void disableFunctionalGroupCascades() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
assertFalse(config.isGroupEnabled("PageOps"));
|
||||
assertFalse(config.isEndpointEnabled("remove-pages"));
|
||||
assertFalse(config.isEndpointEnabled("split-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("re-enabling a functional group re-enables its endpoints")
|
||||
void enableFunctionalGroupRestores() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
assertFalse(config.isEndpointEnabled("remove-pages"));
|
||||
config.enableGroup("PageOps");
|
||||
assertTrue(config.isEndpointEnabled("remove-pages"));
|
||||
assertTrue(config.isGroupEnabled("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a functional group with one disabled endpoint is not enabled")
|
||||
void functionalGroupWithDisabledEndpoint() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("remove-pages");
|
||||
assertFalse(config.isGroupEnabled("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabledGroups reflects disabled groups and getDisabledGroups returns a copy")
|
||||
void getDisabledGroupsReturnsCopy() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
Set<String> disabled = config.getDisabledGroups();
|
||||
assertTrue(disabled.contains("PageOps"));
|
||||
// mutating the returned set must not affect internal state
|
||||
disabled.clear();
|
||||
assertTrue(config.getDisabledGroups().contains("PageOps"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown group with no endpoints is not enabled")
|
||||
void unknownGroupNotEnabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertFalse(config.isGroupEnabled("NoSuchGroupXyz"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("tool group semantics")
|
||||
class ToolGroupTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a tool group is enabled until explicitly disabled")
|
||||
void toolGroupEnabledUntilDisabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertTrue(config.isGroupEnabled("qpdf"));
|
||||
config.disableGroup("qpdf");
|
||||
assertFalse(config.isGroupEnabled("qpdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling a tool group does NOT cascade to its endpoints directly")
|
||||
void toolGroupNoCascade() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
// repair has alternatives (qpdf, Ghostscript); disabling only qpdf keeps it enabled
|
||||
config.disableGroup("qpdf");
|
||||
assertTrue(config.isEndpointEnabled("repair"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoint with alternatives is disabled only when all tool groups are gone")
|
||||
void allAlternativesDisabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("qpdf");
|
||||
config.disableGroup("Ghostscript");
|
||||
// repair's only alternatives are qpdf and Ghostscript
|
||||
assertFalse(config.isEndpointEnabled("repair"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoint with a still-enabled alternative stays enabled")
|
||||
void oneAlternativeRemains() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
// compress-pdf alternatives: qpdf, Ghostscript, Java
|
||||
config.disableGroup("qpdf");
|
||||
config.disableGroup("Ghostscript");
|
||||
assertTrue(config.isEndpointEnabled("compress-pdf"));
|
||||
config.disableGroup("Java");
|
||||
assertFalse(config.isEndpointEnabled("compress-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-dependency endpoint (no alternatives) disabled when its tool group is")
|
||||
void singleDependencyDisabled() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
// pdf-to-epub depends on Calibre, no alternatives registered
|
||||
assertTrue(config.isEndpointEnabled("pdf-to-epub"));
|
||||
config.disableGroup("Calibre");
|
||||
assertFalse(config.isEndpointEnabled("pdf-to-epub"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addEndpointToGroup / addEndpointAlternative")
|
||||
class RegistrationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("addEndpointToGroup makes the endpoint part of the group")
|
||||
void addEndpointToGroup() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
|
||||
Set<String> endpoints = config.getEndpointsForGroup("CustomGroup");
|
||||
assertTrue(endpoints.contains("custom-endpoint"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabling a custom functional group disables its added endpoint")
|
||||
void customFunctionalGroupCascades() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
|
||||
assertTrue(config.isEndpointEnabled("custom-endpoint"));
|
||||
config.disableGroup("CustomGroup");
|
||||
assertFalse(config.isEndpointEnabled("custom-endpoint"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getEndpointsForGroup returns an empty set for unknown groups")
|
||||
void unknownGroupEmptySet() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
Set<String> endpoints = config.getEndpointsForGroup("NoSuchGroupXyz");
|
||||
assertNotNull(endpoints);
|
||||
assertTrue(endpoints.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getEndpointAvailability / determineDisableReason")
|
||||
class AvailabilityTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an enabled endpoint has a null disable reason")
|
||||
void enabledHasNullReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
|
||||
assertTrue(availability.isEnabled());
|
||||
assertNull(availability.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicit disable preserves the supplied reason")
|
||||
void explicitDisableReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs", DisableReason.DEPENDENCY);
|
||||
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
|
||||
assertFalse(availability.isEnabled());
|
||||
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default disableEndpoint reason is CONFIG")
|
||||
void defaultDisableReasonIsConfig() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertEquals(
|
||||
DisableReason.CONFIG, config.getEndpointAvailability("merge-pdfs").getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoint disabled via functional group reports the group's reason")
|
||||
void functionalGroupReason() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps", DisableReason.DEPENDENCY);
|
||||
EndpointAvailability availability = config.getEndpointAvailability("crop");
|
||||
assertFalse(availability.isEnabled());
|
||||
// crop is disabled both via group cascade and group membership; reason is DEPENDENCY
|
||||
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAllEndpoints")
|
||||
class GetAllEndpointsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("aggregates endpoints across all groups")
|
||||
void aggregatesAcrossGroups() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
Set<String> all = config.getAllEndpoints();
|
||||
assertTrue(all.contains("merge-pdfs"));
|
||||
assertTrue(all.contains("compress-pdf"));
|
||||
assertTrue(all.contains("ocr-pdf"));
|
||||
assertFalse(all.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("custom endpoints registered after init appear in getAllEndpoints")
|
||||
void includesCustomEndpoints() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.addEndpointToGroup("CustomGroup", "brand-new-endpoint");
|
||||
assertTrue(config.getAllEndpoints().contains("brand-new-endpoint"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("environment / constructor driven configuration")
|
||||
class EnvironmentConfigTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("url-to-pdf is disabled when enableUrlToPDF is false (default)")
|
||||
void urlToPdfDisabledByDefault() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
assertFalse(config.isEndpointEnabled("url-to-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("url-to-pdf stays enabled when enableUrlToPDF is true")
|
||||
void urlToPdfEnabledWhenFlagSet() {
|
||||
applicationProperties.getSystem().setEnableUrlToPDF(true);
|
||||
EndpointConfiguration config = build(false);
|
||||
assertTrue(config.isEndpointEnabled("url-to-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoints.toRemove disables the listed endpoints at construction")
|
||||
void endpointsToRemove() {
|
||||
applicationProperties
|
||||
.getEndpoints()
|
||||
.setToRemove(List.of(" merge-pdfs ", "split-pages"));
|
||||
EndpointConfiguration config = build(false);
|
||||
// values are trimmed before disabling
|
||||
assertFalse(config.isEndpointEnabled("merge-pdfs"));
|
||||
assertFalse(config.isEndpointEnabled("split-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("endpoints.groupsToRemove disables the listed groups at construction")
|
||||
void groupsToRemove() {
|
||||
applicationProperties.getEndpoints().setGroupsToRemove(List.of(" PageOps "));
|
||||
EndpointConfiguration config = build(false);
|
||||
assertTrue(config.getDisabledGroups().contains("PageOps"));
|
||||
assertFalse(config.isEndpointEnabled("remove-pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-pro build disables the enterprise group")
|
||||
void nonProDisablesEnterprise() {
|
||||
EndpointConfiguration config = build(false);
|
||||
assertTrue(config.getDisabledGroups().contains("enterprise"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pro build does not disable the enterprise group")
|
||||
void proDoesNotDisableEnterprise() {
|
||||
EndpointConfiguration config = build(true);
|
||||
assertFalse(config.getDisabledGroups().contains("enterprise"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getEndpointStatuses (Lombok getter) and logging summary")
|
||||
class MiscTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("getEndpointStatuses reflects explicit disable state")
|
||||
void endpointStatusesReflectDisable() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableEndpoint("merge-pdfs");
|
||||
assertEquals(Boolean.FALSE, config.getEndpointStatuses().get("merge-pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logDisabledEndpointsSummary runs without throwing")
|
||||
void logSummaryDoesNotThrow() {
|
||||
EndpointConfiguration config = buildDefault();
|
||||
config.disableGroup("PageOps");
|
||||
config.disableGroup("qpdf");
|
||||
// purely a smoke test of the logging branch coverage
|
||||
config.logDisabledEndpointsSummary();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logDisabledEndpointsSummary runs when nothing is disabled")
|
||||
void logSummaryNothingDisabled() {
|
||||
applicationProperties.getSystem().setEnableUrlToPDF(true);
|
||||
EndpointConfiguration config = build(true);
|
||||
config.logDisabledEndpointsSummary();
|
||||
}
|
||||
}
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.RawPage;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.TableCell;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.TableRow;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TabulaTableParser}. Tables are built in-memory with PDFBox so the tests are
|
||||
* deterministic and need no fixtures, network, or external processes.
|
||||
*/
|
||||
class TabulaTableParserGapTest {
|
||||
|
||||
private final TabulaTableParser parser = new TabulaTableParser();
|
||||
|
||||
// ── error / empty branches ───────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Empty and error branches")
|
||||
class EmptyAndErrorBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("page number 0 is out of Tabula's 1-based range -> empty list, no throw")
|
||||
void pageNumberZeroReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"hello"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> result = parser.parse(doc, 0);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page number beyond the document -> empty list, exception swallowed")
|
||||
void pageNumberOutOfRangeReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"hello"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> result = parser.parse(doc, 99);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative page number -> empty list")
|
||||
void negativePageNumberReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"hello"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertTrue(parser.parse(doc, -5).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lattice mode on a page with no ruled lines -> no tables")
|
||||
void latticeWithNoRulingsReturnsEmpty() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"just some prose", "no table here"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> result = parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertNotNull(result);
|
||||
assertTrue(
|
||||
result.isEmpty(), "borderless text must not be detected in lattice mode");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank page in lattice mode -> empty list")
|
||||
void blankPageLatticeReturnsEmpty() throws Exception {
|
||||
byte[] pdf = blankPdf();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertTrue(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── stream mode (BasicExtractionAlgorithm) ───────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Stream mode")
|
||||
class StreamMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with text yields at least one well-formed fragment")
|
||||
void streamOnTextProducesFragment() throws Exception {
|
||||
byte[] pdf =
|
||||
pdfWithText(new String[] {"Name Age City", "Alice 30 Paris", "Bob 25 Rome"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertNotNull(fragments);
|
||||
assertFalse(fragments.isEmpty(), "stream mode always builds a table from text");
|
||||
assertFragmentWellFormed(fragments.get(0), 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fragment ids encode page and index")
|
||||
void streamFragmentIdFormat() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"col1 col2", "a b"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertFalse(fragments.isEmpty());
|
||||
assertEquals("tbl-p1-0", fragments.get(0).tableId());
|
||||
assertEquals(1, fragments.get(0).pageNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rawRows and the parsed rows stay in lockstep")
|
||||
void streamRowsMatchRawRows() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"x y", "1 2", "3 4"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertFalse(fragments.isEmpty());
|
||||
TableFragment f = fragments.get(0);
|
||||
assertEquals(f.rawRows().size(), f.rows().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── lattice mode with a real bordered grid ───────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Lattice mode")
|
||||
class LatticeMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("bordered grid is detected and produces well-formed fragments")
|
||||
void latticeDetectsBorderedTable() throws Exception {
|
||||
byte[] pdf = pdfWithGrid();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertNotNull(fragments);
|
||||
assertFalse(
|
||||
fragments.isEmpty(), "a clean ruled grid must be detected in lattice mode");
|
||||
TableFragment f = fragments.get(0);
|
||||
assertFragmentWellFormed(f, 1, 0);
|
||||
assertTrue(f.columnCount() >= 1, "a detected grid must have at least one column");
|
||||
assertFalse(f.rawRows().isEmpty(), "a detected grid must have rows");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convenience overload with page number routes to lattice mode")
|
||||
void parseByPageNumberDetectsGrid() throws Exception {
|
||||
byte[] pdf = pdfWithGrid();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments = parser.parse(doc, 1);
|
||||
assertNotNull(fragments);
|
||||
assertFalse(fragments.isEmpty());
|
||||
assertEquals(1, fragments.get(0).pageNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cell text is normalised (trimmed, newlines collapsed)")
|
||||
void latticeCellTextIsNormalised() throws Exception {
|
||||
byte[] pdf = pdfWithGrid();
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TableFragment> fragments =
|
||||
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
assertFalse(fragments.isEmpty());
|
||||
for (List<String> row : fragments.get(0).rawRows()) {
|
||||
for (String cell : row) {
|
||||
assertNotNull(cell);
|
||||
assertFalse(cell.contains("\n"), "newlines must be collapsed");
|
||||
assertFalse(cell.contains("\r"), "carriage returns must be collapsed");
|
||||
assertEquals(cell.trim(), cell, "cell text must be trimmed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── contract invariants ──────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Contract invariants")
|
||||
class ContractInvariants {
|
||||
|
||||
@Test
|
||||
@DisplayName("parse never returns null")
|
||||
void parseNeverReturnsNull() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"abc"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertNotNull(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())));
|
||||
assertNotNull(parser.parse(doc, 1));
|
||||
assertNotNull(parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the document is not closed by the parser")
|
||||
void documentRemainsOpenAfterParse() throws Exception {
|
||||
byte[] pdf = pdfWithText(new String[] {"keep me open"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
|
||||
// ObjectExtractor.close() would close the underlying COSDocument; the parser must
|
||||
// not.
|
||||
assertFalse(
|
||||
doc.getDocument().isClosed(),
|
||||
"parser must not close the caller's document");
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Asserts every field of a fragment satisfies the documented contract. */
|
||||
private static void assertFragmentWellFormed(
|
||||
TableFragment f, int expectedPage, int expectedIndex) {
|
||||
assertNotNull(f);
|
||||
assertEquals(expectedPage, f.pageNumber());
|
||||
assertEquals("tbl-p" + expectedPage + "-" + expectedIndex, f.tableId());
|
||||
assertNotNull(f.bounds());
|
||||
assertNotNull(f.headers());
|
||||
assertTrue(f.headers().isEmpty(), "headers are deferred to v2 and must be empty");
|
||||
assertNotNull(f.rows());
|
||||
assertNotNull(f.rawRows());
|
||||
assertNotNull(f.warnings());
|
||||
assertSame(null, f.continuedFromPage(), "continuedFromPage is deferred to v2");
|
||||
assertTrue(f.columnCount() >= 0);
|
||||
assertTrue(f.confidence() >= 0f && f.confidence() <= 1f, "confidence must be within [0,1]");
|
||||
assertEquals(f.rawRows().size(), f.rows().size());
|
||||
|
||||
for (TableRow row : f.rows()) {
|
||||
assertNotNull(row.cells());
|
||||
for (TableCell cell : row.cells()) {
|
||||
assertNotNull(cell.text());
|
||||
assertNotNull(cell.bounds());
|
||||
assertEquals(1, cell.colSpan(), "colSpan is always 1 in v1");
|
||||
assertEquals(1, cell.rowSpan(), "rowSpan is always 1 in v1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pdfWithText(String[] lines) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
float y = 720f;
|
||||
for (String line : lines) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, y);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
y -= 20f;
|
||||
}
|
||||
}
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] blankPdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a small 3-row x 3-column ruled grid with text in each cell. The ruled lines make the
|
||||
* table detectable by lattice mode.
|
||||
*/
|
||||
private static byte[] pdfWithGrid() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
|
||||
float left = 100f;
|
||||
float right = 400f;
|
||||
float top = 700f;
|
||||
float bottom = 550f;
|
||||
int cols = 3;
|
||||
int rows = 3;
|
||||
float colStep = (right - left) / cols;
|
||||
float rowStep = (top - bottom) / rows;
|
||||
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setStrokingColor(Color.BLACK);
|
||||
cs.setLineWidth(1f);
|
||||
|
||||
// vertical lines
|
||||
for (int c = 0; c <= cols; c++) {
|
||||
float x = left + c * colStep;
|
||||
cs.moveTo(x, bottom);
|
||||
cs.lineTo(x, top);
|
||||
}
|
||||
// horizontal lines
|
||||
for (int r = 0; r <= rows; r++) {
|
||||
float yLine = bottom + r * rowStep;
|
||||
cs.moveTo(left, yLine);
|
||||
cs.lineTo(right, yLine);
|
||||
}
|
||||
cs.stroke();
|
||||
|
||||
// cell text
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(left + c * colStep + 5f, top - (r + 1) * rowStep + 6f);
|
||||
cs.showText("R" + r + "C" + c);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
}
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
|
||||
import stirling.software.common.model.ApplicationProperties.ProcessExecutor.UnoServerEndpoint;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link RuntimePathConfig}. All of the resolution logic lives in the constructor,
|
||||
* so each test builds a real {@link ApplicationProperties} (a plain @Data POJO with sensible
|
||||
* defaults), constructs the config, and asserts on the exposed getters.
|
||||
*/
|
||||
class RuntimePathConfigTest {
|
||||
|
||||
/** The base path the production code derives from {@link InstallationPathConfig#getPath()}. */
|
||||
private static final String BASE_PATH = InstallationPathConfig.getPath();
|
||||
|
||||
private static ApplicationProperties newProperties() {
|
||||
return new ApplicationProperties();
|
||||
}
|
||||
|
||||
private static RuntimePathConfig build(ApplicationProperties properties) {
|
||||
return new RuntimePathConfig(properties);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Pipeline directory resolution")
|
||||
class PipelinePaths {
|
||||
|
||||
@Test
|
||||
@DisplayName("Defaults to <basePath>/pipeline and derived sub-folders")
|
||||
void defaultPipelinePaths() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
String expectedPipeline = Path.of(BASE_PATH, "pipeline").toString();
|
||||
assertEquals(expectedPipeline, config.getPipelinePath());
|
||||
// Watched folders are resolved to an absolute, normalized path by the production code.
|
||||
assertEquals(
|
||||
Path.of(expectedPipeline, "watchedFolders")
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.toString(),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of(expectedPipeline, "finishedFolders").toString(),
|
||||
config.getPipelineFinishedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of(expectedPipeline, "defaultWebUIConfigs").toString(),
|
||||
config.getPipelineDefaultWebUiConfigs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom pipelineDir overrides the default pipeline path")
|
||||
void customPipelineDir() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
pipeline.setPipelineDir("/custom/pipeline");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/custom/pipeline", config.getPipelinePath());
|
||||
// Sub-folders are derived from the (already-resolved) custom pipeline path.
|
||||
assertEquals(
|
||||
Path.of("/custom/pipeline", "finishedFolders").toString(),
|
||||
config.getPipelineFinishedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of("/custom/pipeline", "defaultWebUIConfigs").toString(),
|
||||
config.getPipelineDefaultWebUiConfigs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank pipelineDir falls back to the default")
|
||||
void blankPipelineDirFallsBackToDefault() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getSystem().getCustomPaths().getPipeline().setPipelineDir(" ");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals(Path.of(BASE_PATH, "pipeline").toString(), config.getPipelinePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom finished and webUI configs dirs override defaults")
|
||||
void customFinishedAndWebUiDirs() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
pipeline.setFinishedFoldersDir("/custom/finished");
|
||||
pipeline.setWebUIConfigsDir("/custom/webui");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/custom/finished", config.getPipelineFinishedFoldersPath());
|
||||
assertEquals("/custom/webui", config.getPipelineDefaultWebUiConfigs());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Watched folder resolution")
|
||||
class WatchedFolders {
|
||||
|
||||
@Test
|
||||
@DisplayName("Default watched folder is <pipeline>/watchedFolders and list has one entry")
|
||||
void defaultWatchedFolder() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
// Watched folders are resolved to an absolute, normalized path by the production code.
|
||||
String expected =
|
||||
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.toString();
|
||||
assertEquals(expected, config.getPipelineWatchedFoldersPath());
|
||||
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
|
||||
assertEquals(expected, config.getPipelineWatchedFoldersPaths().get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Legacy single watchedFoldersDir is used when no list is provided")
|
||||
void legacyWatchedFolder() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDir("relativeWatched");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
// Legacy paths are normalized to absolute.
|
||||
String expected = Path.of("relativeWatched").toAbsolutePath().normalize().toString();
|
||||
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
|
||||
assertEquals(expected, config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("New list config takes precedence over the legacy single dir")
|
||||
void listTakesPrecedenceOverLegacy() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
pipeline.setWatchedFoldersDir("legacyDir");
|
||||
pipeline.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("listDirA", "listDirB")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<String> paths = config.getPipelineWatchedFoldersPaths();
|
||||
assertEquals(2, paths.size());
|
||||
assertEquals(Path.of("listDirA").toAbsolutePath().normalize().toString(), paths.get(0));
|
||||
assertEquals(Path.of("listDirB").toAbsolutePath().normalize().toString(), paths.get(1));
|
||||
// The legacy value must NOT appear when the list is present.
|
||||
assertFalse(
|
||||
paths.contains(Path.of("legacyDir").toAbsolutePath().normalize().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Duplicate paths in the list are de-duplicated after normalization")
|
||||
void duplicatePathsAreDeduplicated() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(
|
||||
new ArrayList<>(Arrays.asList("dupDir", "dupDir", "otherDir")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<String> paths = config.getPipelineWatchedFoldersPaths();
|
||||
assertEquals(2, paths.size());
|
||||
assertEquals(Path.of("dupDir").toAbsolutePath().normalize().toString(), paths.get(0));
|
||||
assertEquals(Path.of("otherDir").toAbsolutePath().normalize().toString(), paths.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank and whitespace-only list entries are sanitized out")
|
||||
void blankListEntriesAreFiltered() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(
|
||||
new ArrayList<>(Arrays.asList(" ", "", "validDir", " ")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<String> paths = config.getPipelineWatchedFoldersPaths();
|
||||
assertEquals(1, paths.size());
|
||||
assertEquals(Path.of("validDir").toAbsolutePath().normalize().toString(), paths.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("List entries are trimmed before resolution")
|
||||
void listEntriesAreTrimmed() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList(" spacedDir ")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals(
|
||||
Path.of("spacedDir").toAbsolutePath().normalize().toString(),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("An all-blank list falls back to the legacy dir, then default")
|
||||
void allBlankListFallsBackToDefault() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("", " ")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
// sanitizePathList strips everything -> empty -> falls through to default watched
|
||||
// folder.
|
||||
// The default is also resolved to an absolute, normalized path by the production code.
|
||||
String expectedDefault =
|
||||
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.toString();
|
||||
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
|
||||
assertEquals(expectedDefault, config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("First watched folder path is always exposed via the singular getter")
|
||||
void singularGetterReturnsFirstEntry() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getPipeline()
|
||||
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("firstDir", "secondDir")));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals(
|
||||
config.getPipelineWatchedFoldersPaths().get(0),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
assertEquals(
|
||||
Path.of("firstDir").toAbsolutePath().normalize().toString(),
|
||||
config.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Operation tool path resolution")
|
||||
class OperationPaths {
|
||||
|
||||
@Test
|
||||
@DisplayName("Defaults to bare command names when not running in Docker")
|
||||
void defaultOperationPaths() {
|
||||
// The test host has no /.dockerenv, so the non-docker defaults apply.
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
assertEquals("weasyprint", config.getWeasyPrintPath());
|
||||
assertEquals("unoconvert", config.getUnoConvertPath());
|
||||
assertEquals("ebook-convert", config.getCalibrePath());
|
||||
assertEquals("ocrmypdf", config.getOcrMyPdfPath());
|
||||
assertEquals("soffice", config.getSOfficePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom operation paths override the defaults")
|
||||
void customOperationPaths() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
Operations operations = properties.getSystem().getCustomPaths().getOperations();
|
||||
operations.setWeasyprint("/opt/custom/weasyprint");
|
||||
operations.setUnoconvert("/opt/custom/unoconvert");
|
||||
operations.setCalibre("/opt/custom/ebook-convert");
|
||||
operations.setOcrmypdf("/opt/custom/ocrmypdf");
|
||||
operations.setSoffice("/opt/custom/soffice");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/opt/custom/weasyprint", config.getWeasyPrintPath());
|
||||
assertEquals("/opt/custom/unoconvert", config.getUnoConvertPath());
|
||||
assertEquals("/opt/custom/ebook-convert", config.getCalibrePath());
|
||||
assertEquals("/opt/custom/ocrmypdf", config.getOcrMyPdfPath());
|
||||
assertEquals("/opt/custom/soffice", config.getSOfficePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank custom operation path falls back to the default")
|
||||
void blankOperationPathFallsBack() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getSystem().getCustomPaths().getOperations().setWeasyprint(" ");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("weasyprint", config.getWeasyPrintPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("A single custom path leaves the other operation paths at defaults")
|
||||
void partialOperationOverride() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties
|
||||
.getSystem()
|
||||
.getCustomPaths()
|
||||
.getOperations()
|
||||
.setSoffice("/usr/local/soffice");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertEquals("/usr/local/soffice", config.getSOfficePath());
|
||||
assertEquals("weasyprint", config.getWeasyPrintPath());
|
||||
assertEquals("unoconvert", config.getUnoConvertPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Tesseract data path resolution")
|
||||
class TessdataPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Explicit tessdataDir config wins over env var and default")
|
||||
void configuredTessdataDirWins() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getSystem().setTessdataDir("/my/tessdata");
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
// Config setting has the highest priority regardless of TESSDATA_PREFIX env state.
|
||||
assertEquals("/my/tessdata", config.getTessDataPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tessDataPath is never null even with no config")
|
||||
void tessDataPathNeverNull() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
// With no config setting, the value comes from TESSDATA_PREFIX or the hard default,
|
||||
// either of which is non-null.
|
||||
assertNotNull(config.getTessDataPath());
|
||||
assertFalse(config.getTessDataPath().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("UNO server endpoint resolution")
|
||||
class UnoServerEndpoints {
|
||||
|
||||
@Test
|
||||
@DisplayName("Auto mode builds one endpoint when session limit is unset (defaults to 1)")
|
||||
void autoSingleEndpointByDefault() {
|
||||
// Default ApplicationProperties: autoUnoServer = true, libreOfficeSessionLimit = 0 ->
|
||||
// 1.
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("127.0.0.1", endpoints.get(0).getHost());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Auto mode builds N endpoints on consecutive even ports")
|
||||
void autoMultipleEndpoints() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.getProcessExecutor().getSessionLimit().setLibreOfficeSessionLimit(3);
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(3, endpoints.size());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
assertEquals(2005, endpoints.get(1).getPort());
|
||||
assertEquals(2007, endpoints.get(2).getPort());
|
||||
for (UnoServerEndpoint endpoint : endpoints) {
|
||||
assertEquals("127.0.0.1", endpoint.getHost());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Manual mode returns the configured (valid) endpoints")
|
||||
void manualEndpointsAreUsed() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
processExecutor.setAutoUnoServer(false);
|
||||
|
||||
UnoServerEndpoint endpoint = new UnoServerEndpoint();
|
||||
endpoint.setHost("10.0.0.5");
|
||||
endpoint.setPort(4000);
|
||||
processExecutor.setUnoServerEndpoints(new ArrayList<>(Arrays.asList(endpoint)));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("10.0.0.5", endpoints.get(0).getHost());
|
||||
assertEquals(4000, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Manual mode filters out endpoints with blank host or non-positive port")
|
||||
void manualEndpointsAreSanitized() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
processExecutor.setAutoUnoServer(false);
|
||||
|
||||
UnoServerEndpoint valid = new UnoServerEndpoint();
|
||||
valid.setHost("192.168.1.10");
|
||||
valid.setPort(5000);
|
||||
|
||||
UnoServerEndpoint blankHost = new UnoServerEndpoint();
|
||||
blankHost.setHost(" ");
|
||||
blankHost.setPort(5001);
|
||||
|
||||
UnoServerEndpoint badPort = new UnoServerEndpoint();
|
||||
badPort.setHost("192.168.1.11");
|
||||
badPort.setPort(0);
|
||||
|
||||
processExecutor.setUnoServerEndpoints(
|
||||
new ArrayList<>(Arrays.asList(valid, blankHost, badPort)));
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("192.168.1.10", endpoints.get(0).getHost());
|
||||
assertEquals(5000, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Manual mode with no usable endpoints falls back to a single default endpoint")
|
||||
void manualModeNoEndpointsFallsBackToDefault() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
processExecutor.setAutoUnoServer(false);
|
||||
processExecutor.setUnoServerEndpoints(new ArrayList<>());
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("127.0.0.1", endpoints.get(0).getHost());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null processExecutor defaults to a single UNO endpoint")
|
||||
void nullProcessExecutorDefaultsToSingleEndpoint() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
properties.setProcessExecutor(null);
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
|
||||
assertEquals(1, endpoints.size());
|
||||
assertEquals("127.0.0.1", endpoints.get(0).getHost());
|
||||
assertEquals(2003, endpoints.get(0).getPort());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("General contract")
|
||||
class GeneralContract {
|
||||
|
||||
@Test
|
||||
@DisplayName("getProperties returns the same instance passed to the constructor")
|
||||
void propertiesAccessorReturnsSameInstance() {
|
||||
ApplicationProperties properties = newProperties();
|
||||
|
||||
RuntimePathConfig config = build(properties);
|
||||
|
||||
assertSame(properties, config.getProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("basePath matches InstallationPathConfig.getPath()")
|
||||
void basePathMatchesInstallationPath() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
assertEquals(BASE_PATH, config.getBasePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("All resolved path getters are non-null")
|
||||
void allPathsNonNull() {
|
||||
RuntimePathConfig config = build(newProperties());
|
||||
|
||||
assertNotNull(config.getPipelinePath());
|
||||
assertNotNull(config.getPipelineWatchedFoldersPath());
|
||||
assertNotNull(config.getPipelineWatchedFoldersPaths());
|
||||
assertNotNull(config.getPipelineFinishedFoldersPath());
|
||||
assertNotNull(config.getPipelineDefaultWebUiConfigs());
|
||||
assertNotNull(config.getWeasyPrintPath());
|
||||
assertNotNull(config.getUnoConvertPath());
|
||||
assertNotNull(config.getCalibrePath());
|
||||
assertNotNull(config.getOcrMyPdfPath());
|
||||
assertNotNull(config.getSOfficePath());
|
||||
assertNotNull(config.getTessDataPath());
|
||||
assertNotNull(config.getUnoServerEndpoints());
|
||||
assertTrue(config.getUnoServerEndpoints().size() >= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -2,7 +2,7 @@ package stirling.software.common.model;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -31,18 +31,33 @@ class ApplicationPropertiesLogicTest {
|
||||
assertTrue(sys.isAnalyticsEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageSigning_userListScope_defaultsToOrg_andIsSettable() {
|
||||
// Self-host backward-compat: scope must default to "org" (saas profile pins "team").
|
||||
ApplicationProperties.Storage.Signing signing = new ApplicationProperties.Storage.Signing();
|
||||
|
||||
assertFalse(signing.isEnabled());
|
||||
assertEquals("org", signing.getUserListScope());
|
||||
|
||||
signing.setUserListScope("team");
|
||||
assertEquals("team", signing.getUserListScope());
|
||||
|
||||
// Reachable from the full tree as storage.signing.userListScope.
|
||||
assertEquals(
|
||||
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tempFileManagement_defaults_and_overrides() {
|
||||
Function<String, String> normalize = s -> Paths.get(s).normalize().toString();
|
||||
Function<String, String> normalize = s -> Path.of(s).normalize().toString();
|
||||
ApplicationProperties.TempFileManagement tfm =
|
||||
new ApplicationProperties.TempFileManagement();
|
||||
|
||||
String expectedBase =
|
||||
Paths.get(java.lang.System.getProperty("java.io.tmpdir"), "stirling-pdf")
|
||||
.toString();
|
||||
Path.of(java.lang.System.getProperty("java.io.tmpdir"), "stirling-pdf").toString();
|
||||
assertEquals(expectedBase, tfm.getBaseTmpDir());
|
||||
|
||||
String expectedLibre = Paths.get(expectedBase, "libreoffice").toString();
|
||||
String expectedLibre = Path.of(expectedBase, "libreoffice").toString();
|
||||
assertEquals(expectedLibre, tfm.getLibreofficeDir());
|
||||
|
||||
tfm.setBaseTmpDir("/custom/base");
|
||||
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
import stirling.software.common.service.MobileScannerService.SessionInfo;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link MobileScannerService}. The service stores uploaded files in a temp
|
||||
* directory. To keep tests isolated and deterministic, the {@code tempDirectory} field is
|
||||
* redirected to a JUnit {@link TempDir} via reflection after construction.
|
||||
*/
|
||||
class MobileScannerServiceTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private MobileScannerService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
service = new MobileScannerService();
|
||||
// Redirect the service's temp directory to the isolated test temp dir.
|
||||
ReflectionTestUtils.setField(service, "tempDirectory", tempDir);
|
||||
}
|
||||
|
||||
private MultipartFile file(String name, String content) {
|
||||
return new MockMultipartFile(
|
||||
"file", name, "text/plain", content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private MultipartFile emptyFile(String name) {
|
||||
return new MockMultipartFile("file", name, "text/plain", new byte[0]);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createSession")
|
||||
class CreateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("creates a session and returns coherent SessionInfo")
|
||||
void createsSession() {
|
||||
SessionInfo info = service.createSession("abc-123");
|
||||
|
||||
assertNotNull(info);
|
||||
assertEquals("abc-123", info.getSessionId());
|
||||
assertTrue(info.getCreatedAt() > 0);
|
||||
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
|
||||
assertEquals(info.getCreatedAt() + info.getTimeoutMs(), info.getExpiresAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("session is retrievable via validateSession after creation")
|
||||
void createdSessionIsValid() {
|
||||
service.createSession("sess1");
|
||||
assertNotNull(service.validateSession("sess1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null session ID")
|
||||
void rejectsNull() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects blank session ID")
|
||||
void rejectsBlank() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects session ID with invalid characters")
|
||||
void rejectsInvalidChars() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad/id"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad id"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts alphanumeric and hyphen session IDs")
|
||||
void acceptsValidChars() {
|
||||
assertNotNull(service.createSession("ABC-def-123"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("validateSession")
|
||||
class ValidateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for unknown session")
|
||||
void unknownReturnsNull() {
|
||||
assertNull(service.validateSession("does-not-exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns SessionInfo for an existing session")
|
||||
void existingReturnsInfo() {
|
||||
service.createSession("s1");
|
||||
SessionInfo info = service.validateSession("s1");
|
||||
|
||||
assertNotNull(info);
|
||||
assertEquals("s1", info.getSessionId());
|
||||
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("expires and removes a session whose last access is in the past")
|
||||
void expiredSessionRemoved() {
|
||||
service.createSession("expired");
|
||||
|
||||
// Force the underlying session's last access far into the past.
|
||||
forceLastAccess("expired", System.currentTimeMillis() - (20 * 60 * 1000L));
|
||||
|
||||
assertNull(service.validateSession("expired"));
|
||||
// After expiry the session should be gone entirely.
|
||||
assertNull(service.validateSession("expired"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("uploadFiles")
|
||||
class UploadFiles {
|
||||
|
||||
@Test
|
||||
@DisplayName("stores files and records metadata")
|
||||
void storesFiles() throws IOException {
|
||||
service.createSession("up1");
|
||||
service.uploadFiles("up1", List.of(file("scan.txt", "hello")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up1");
|
||||
assertEquals(1, metas.size());
|
||||
FileMetadata meta = metas.get(0);
|
||||
assertEquals("scan.txt", meta.getFilename());
|
||||
assertEquals(5, meta.getSize());
|
||||
assertEquals("text/plain", meta.getContentType());
|
||||
|
||||
// File physically exists on disk.
|
||||
Path stored = tempDir.resolve("up1").resolve("scan.txt");
|
||||
assertTrue(Files.exists(stored));
|
||||
assertEquals("hello", Files.readString(stored));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto-creates a session when uploading to an unregistered session ID")
|
||||
void autoCreatesSession() throws IOException {
|
||||
service.uploadFiles("new-session", List.of(file("a.txt", "data")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("new-session");
|
||||
assertEquals(1, metas.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips empty files")
|
||||
void skipsEmptyFiles() throws IOException {
|
||||
service.createSession("up2");
|
||||
service.uploadFiles("up2", List.of(emptyFile("empty.txt"), file("real.txt", "x")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up2");
|
||||
assertEquals(1, metas.size());
|
||||
assertEquals("real.txt", metas.get(0).getFilename());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizes dangerous filename characters")
|
||||
void sanitizesFilename() throws IOException {
|
||||
service.createSession("up3");
|
||||
service.uploadFiles("up3", List.of(file("we ird@na#me.txt", "x")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up3");
|
||||
assertEquals(1, metas.size());
|
||||
String stored = metas.get(0).getFilename();
|
||||
// Disallowed chars replaced with underscores; allowed set is [a-zA-Z0-9._-].
|
||||
assertTrue(stored.matches("[a-zA-Z0-9._-]+"), "unexpected filename: " + stored);
|
||||
assertTrue(Files.exists(tempDir.resolve("up3").resolve(stored)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handles duplicate filenames by appending a counter")
|
||||
void handlesDuplicateFilenames() throws IOException {
|
||||
service.createSession("up4");
|
||||
service.uploadFiles("up4", List.of(file("dup.txt", "one")));
|
||||
service.uploadFiles("up4", List.of(file("dup.txt", "two")));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up4");
|
||||
assertEquals(2, metas.size());
|
||||
|
||||
Path original = tempDir.resolve("up4").resolve("dup.txt");
|
||||
Path renamed = tempDir.resolve("up4").resolve("dup-1.txt");
|
||||
assertTrue(Files.exists(original));
|
||||
assertTrue(Files.exists(renamed));
|
||||
assertEquals("one", Files.readString(original));
|
||||
assertEquals("two", Files.readString(renamed));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to a generated name when original filename is null")
|
||||
void generatesNameWhenNull() throws IOException {
|
||||
service.createSession("up5");
|
||||
MultipartFile noName =
|
||||
new MockMultipartFile("file", null, "text/plain", "x".getBytes());
|
||||
service.uploadFiles("up5", List.of(noName));
|
||||
|
||||
List<FileMetadata> metas = service.getSessionFiles("up5");
|
||||
assertEquals(1, metas.size());
|
||||
assertTrue(metas.get(0).getFilename().startsWith("upload-"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects invalid session ID before any storage")
|
||||
void rejectsInvalidSessionId() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.uploadFiles("bad/id", List.of(file("a.txt", "x"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uploading an empty list leaves no files")
|
||||
void emptyListNoFiles() throws IOException {
|
||||
service.createSession("up6");
|
||||
service.uploadFiles("up6", List.of());
|
||||
|
||||
assertTrue(service.getSessionFiles("up6").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSessionFiles")
|
||||
class GetSessionFiles {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list for unknown session")
|
||||
void unknownReturnsEmpty() {
|
||||
assertTrue(service.getSessionFiles("nope").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a defensive copy of the metadata list")
|
||||
void returnsDefensiveCopy() throws IOException {
|
||||
service.createSession("g1");
|
||||
service.uploadFiles("g1", List.of(file("a.txt", "x")));
|
||||
|
||||
List<FileMetadata> first = service.getSessionFiles("g1");
|
||||
first.clear();
|
||||
|
||||
// Mutating the returned list must not affect the service's internal state.
|
||||
assertEquals(1, service.getSessionFiles("g1").size());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getFile")
|
||||
class GetFile {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the path of an uploaded file")
|
||||
void returnsPath() throws IOException {
|
||||
service.createSession("f1");
|
||||
service.uploadFiles("f1", List.of(file("doc.txt", "body")));
|
||||
|
||||
Path path = service.getFile("f1", "doc.txt");
|
||||
assertTrue(Files.exists(path));
|
||||
assertEquals("body", Files.readString(path));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when the session does not exist")
|
||||
void unknownSessionThrows() {
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> service.getFile("ghost", "doc.txt"));
|
||||
assertTrue(ex.getMessage().contains("Session not found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when the file does not exist in an existing session")
|
||||
void unknownFileThrows() throws IOException {
|
||||
service.createSession("f2");
|
||||
service.uploadFiles("f2", List.of(file("present.txt", "x")));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> service.getFile("f2", "missing.txt"));
|
||||
assertTrue(ex.getMessage().contains("File not found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects filenames containing path separators")
|
||||
void rejectsPathSeparators() throws IOException {
|
||||
service.createSession("f3");
|
||||
service.uploadFiles("f3", List.of(file("ok.txt", "x")));
|
||||
|
||||
assertThrows(IOException.class, () -> service.getFile("f3", "../escape.txt"));
|
||||
assertThrows(IOException.class, () -> service.getFile("f3", "sub/file.txt"));
|
||||
assertThrows(IOException.class, () -> service.getFile("f3", "sub\\file.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects blank filename")
|
||||
void rejectsBlankFilename() throws IOException {
|
||||
service.createSession("f4");
|
||||
service.uploadFiles("f4", List.of(file("ok.txt", "x")));
|
||||
|
||||
assertThrows(IOException.class, () -> service.getFile("f4", " "));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteFileAfterDownload")
|
||||
class DeleteFileAfterDownload {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes a single file but keeps the session if others remain")
|
||||
void deletesOneFile() throws IOException {
|
||||
service.createSession("d1");
|
||||
service.uploadFiles("d1", List.of(file("a.txt", "x"), file("b.txt", "y")));
|
||||
|
||||
service.deleteFileAfterDownload("d1", "a.txt");
|
||||
|
||||
assertFalse(Files.exists(tempDir.resolve("d1").resolve("a.txt")));
|
||||
// Session still present because not all files have been downloaded.
|
||||
assertNotNull(service.validateSession("d1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes the entire session once all files are marked downloaded")
|
||||
void deletesSessionWhenAllDownloaded() throws IOException {
|
||||
service.createSession("d2");
|
||||
service.uploadFiles("d2", List.of(file("only.txt", "x")));
|
||||
|
||||
// Mark the file as downloaded via getFile, then delete it.
|
||||
service.getFile("d2", "only.txt");
|
||||
service.deleteFileAfterDownload("d2", "only.txt");
|
||||
|
||||
assertNull(service.validateSession("d2"));
|
||||
assertFalse(Files.exists(tempDir.resolve("d2")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw for an unknown session")
|
||||
void unknownSessionNoThrow() {
|
||||
assertDoesNotThrow(() -> service.deleteFileAfterDownload("ghost", "a.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("swallows invalid filename input without throwing")
|
||||
void invalidFilenameNoThrow() throws IOException {
|
||||
service.createSession("d3");
|
||||
service.uploadFiles("d3", List.of(file("a.txt", "x")));
|
||||
|
||||
assertDoesNotThrow(() -> service.deleteFileAfterDownload("d3", "../escape.txt"));
|
||||
// Original file untouched.
|
||||
assertTrue(Files.exists(tempDir.resolve("d3").resolve("a.txt")));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteSession")
|
||||
class DeleteSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("removes the session and all its files")
|
||||
void removesSessionAndFiles() throws IOException {
|
||||
service.createSession("x1");
|
||||
service.uploadFiles("x1", List.of(file("a.txt", "x"), file("b.txt", "y")));
|
||||
|
||||
assertTrue(Files.exists(tempDir.resolve("x1")));
|
||||
|
||||
service.deleteSession("x1");
|
||||
|
||||
assertNull(service.validateSession("x1"));
|
||||
assertFalse(Files.exists(tempDir.resolve("x1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("is a no-op for an unknown session")
|
||||
void unknownSessionNoOp() {
|
||||
assertDoesNotThrow(() -> service.deleteSession("never-existed"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupExpiredSessions")
|
||||
class CleanupExpiredSessions {
|
||||
|
||||
@Test
|
||||
@DisplayName("removes sessions past the timeout")
|
||||
void removesExpired() throws IOException {
|
||||
service.createSession("old");
|
||||
service.uploadFiles("old", List.of(file("a.txt", "x")));
|
||||
forceLastAccess("old", System.currentTimeMillis() - (20 * 60 * 1000L));
|
||||
|
||||
service.cleanupExpiredSessions();
|
||||
|
||||
assertNull(service.validateSession("old"));
|
||||
assertFalse(Files.exists(tempDir.resolve("old")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps sessions that are still fresh")
|
||||
void keepsFresh() {
|
||||
service.createSession("fresh");
|
||||
|
||||
service.cleanupExpiredSessions();
|
||||
|
||||
assertNotNull(service.validateSession("fresh"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw when there are no sessions")
|
||||
void noSessionsNoThrow() {
|
||||
assertDoesNotThrow(() -> service.cleanupExpiredSessions());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("SessionInfo accessors")
|
||||
class SessionInfoAccessors {
|
||||
|
||||
@Test
|
||||
@DisplayName("exposes all constructor values")
|
||||
void exposesValues() {
|
||||
SessionInfo info = new SessionInfo("id", 100L, 200L, 50L);
|
||||
assertEquals("id", info.getSessionId());
|
||||
assertEquals(100L, info.getCreatedAt());
|
||||
assertEquals(200L, info.getExpiresAt());
|
||||
assertEquals(50L, info.getTimeoutMs());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("FileMetadata accessors")
|
||||
class FileMetadataAccessors {
|
||||
|
||||
@Test
|
||||
@DisplayName("exposes all constructor values")
|
||||
void exposesValues() {
|
||||
FileMetadata meta = new FileMetadata("name.pdf", 1234L, "application/pdf");
|
||||
assertEquals("name.pdf", meta.getFilename());
|
||||
assertEquals(1234L, meta.getSize());
|
||||
assertEquals("application/pdf", meta.getContentType());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaches into the internal SessionData for a given session and forces its lastAccessTime, used
|
||||
* to deterministically simulate expiry without sleeping.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void forceLastAccess(String sessionId, long lastAccessTime) {
|
||||
java.util.Map<String, Object> sessions =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(service, "activeSessions");
|
||||
assertNotNull(sessions);
|
||||
Object sessionData = sessions.get(sessionId);
|
||||
assertNotNull(sessionData, "session not found: " + sessionId);
|
||||
ReflectionTestUtils.setField(sessionData, "lastAccessTime", lastAccessTime);
|
||||
}
|
||||
}
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.CustomMetadata;
|
||||
import stirling.software.common.model.PdfMetadata;
|
||||
|
||||
class PdfMetadataServiceTest {
|
||||
|
||||
private static final String LABEL = "Stirling-PDF v1.0.0";
|
||||
|
||||
/**
|
||||
* Builds a service whose pro-features are disabled (real ApplicationProperties, all defaults).
|
||||
*/
|
||||
private PdfMetadataService nonProService(UserServiceInterface userService) {
|
||||
return new PdfMetadataService(new ApplicationProperties(), LABEL, false, userService);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("toCalendar(ZonedDateTime)")
|
||||
class ToCalendarTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for null input")
|
||||
void nullReturnsNull() {
|
||||
assertNull(PdfMetadataService.toCalendar(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converts ZonedDateTime preserving the instant")
|
||||
void convertsInstant() {
|
||||
ZonedDateTime zdt = ZonedDateTime.of(2021, 6, 15, 10, 30, 45, 0, ZoneId.of("UTC"));
|
||||
Calendar cal = PdfMetadataService.toCalendar(zdt);
|
||||
|
||||
assertNotNull(cal);
|
||||
assertEquals(zdt.toInstant().toEpochMilli(), cal.getTimeInMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseToCalendar(String)")
|
||||
class ParseToCalendarTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for null input")
|
||||
void nullReturnsNull() {
|
||||
assertNull(PdfMetadataService.parseToCalendar(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for empty / blank input")
|
||||
void blankReturnsNull() {
|
||||
assertNull(PdfMetadataService.parseToCalendar(""));
|
||||
assertNull(PdfMetadataService.parseToCalendar(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null for unparsable input")
|
||||
void invalidReturnsNull() {
|
||||
assertNull(PdfMetadataService.parseToCalendar("not a date"));
|
||||
assertNull(PdfMetadataService.parseToCalendar("2021-06-15"));
|
||||
assertNull(PdfMetadataService.parseToCalendar("2021/13/40 99:99:99"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses a valid 'yyyy/MM/dd HH:mm:ss' string")
|
||||
void parsesValidDate() {
|
||||
Calendar cal = PdfMetadataService.parseToCalendar("2021/06/15 10:30:45");
|
||||
assertNotNull(cal);
|
||||
|
||||
// Build the expected instant the same way the implementation does so the
|
||||
// assertion is independent of the JVM's default time zone.
|
||||
long expectedMillis =
|
||||
LocalDateTime.of(2021, 6, 15, 10, 30, 45)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
assertEquals(expectedMillis, cal.getTimeInMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractMetadataFromPdf(PDDocument)")
|
||||
class ExtractMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns all-null fields for a fresh empty document")
|
||||
void emptyDocumentYieldsNulls() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PdfMetadata md = service.extractMetadataFromPdf(doc);
|
||||
|
||||
assertNotNull(md);
|
||||
assertNull(md.getAuthor());
|
||||
assertNull(md.getProducer());
|
||||
assertNull(md.getTitle());
|
||||
assertNull(md.getCreator());
|
||||
assertNull(md.getSubject());
|
||||
assertNull(md.getKeywords());
|
||||
assertNull(md.getCreationDate());
|
||||
assertNull(md.getModificationDate());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reads back string and date fields set on the document")
|
||||
void readsBackPopulatedFields() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
info.setAuthor("Alice");
|
||||
info.setProducer("ProducerX");
|
||||
info.setTitle("My Title");
|
||||
info.setCreator("CreatorY");
|
||||
info.setSubject("Subject Z");
|
||||
info.setKeywords("k1, k2");
|
||||
|
||||
Calendar creation = Calendar.getInstance();
|
||||
creation.setTimeInMillis(1_600_000_000_000L);
|
||||
Calendar modification = Calendar.getInstance();
|
||||
modification.setTimeInMillis(1_700_000_000_000L);
|
||||
info.setCreationDate(creation);
|
||||
info.setModificationDate(modification);
|
||||
|
||||
PdfMetadata md = service.extractMetadataFromPdf(doc);
|
||||
|
||||
assertEquals("Alice", md.getAuthor());
|
||||
assertEquals("ProducerX", md.getProducer());
|
||||
assertEquals("My Title", md.getTitle());
|
||||
assertEquals("CreatorY", md.getCreator());
|
||||
assertEquals("Subject Z", md.getSubject());
|
||||
assertEquals("k1, k2", md.getKeywords());
|
||||
|
||||
assertNotNull(md.getCreationDate());
|
||||
assertNotNull(md.getModificationDate());
|
||||
assertEquals(1_600_000_000_000L, md.getCreationDate().toInstant().toEpochMilli());
|
||||
assertEquals(
|
||||
1_700_000_000_000L, md.getModificationDate().toInstant().toEpochMilli());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setMetadataToPdf / setDefaultMetadata (non-pro path)")
|
||||
class SetMetadataNonProTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("writes producer label, title, subject, keywords and author from metadata")
|
||||
void writesCommonMetadata() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
PdfMetadata md =
|
||||
PdfMetadata.builder()
|
||||
.author("Bob")
|
||||
.title("Doc Title")
|
||||
.subject("Doc Subject")
|
||||
.keywords("a, b, c")
|
||||
.creationDate(
|
||||
ZonedDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
|
||||
.modificationDate(
|
||||
ZonedDateTime.of(2021, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
|
||||
.build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
service.setMetadataToPdf(doc, md);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
assertEquals("Doc Title", info.getTitle());
|
||||
assertEquals("Doc Subject", info.getSubject());
|
||||
assertEquals("a, b, c", info.getKeywords());
|
||||
// Non-pro: author is taken verbatim from the metadata.
|
||||
assertEquals("Bob", info.getAuthor());
|
||||
assertNotNull(info.getModificationDate());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("existing creation date is left untouched when not newly created")
|
||||
void keepsExistingCreationDate() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
ZonedDateTime creation = ZonedDateTime.of(2019, 5, 20, 8, 15, 0, 0, ZoneId.of("UTC"));
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md);
|
||||
|
||||
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
|
||||
// creationDate is non-null and newlyCreated=false, so setNewDocumentMetadata
|
||||
// is skipped and no creation date is written.
|
||||
assertNull(creationCal);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sets a fresh creation date when metadata has none")
|
||||
void setsCreationDateWhenMissing() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md);
|
||||
|
||||
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
|
||||
assertNotNull(creationCal);
|
||||
// Non-pro path writes the Stirling label as the creator.
|
||||
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("newlyCreated=true forces a fresh creation date even if metadata has one")
|
||||
void newlyCreatedForcesCreationDate() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
ZonedDateTime creation = ZonedDateTime.of(2018, 3, 3, 3, 3, 3, 0, ZoneId.of("UTC"));
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
|
||||
assertNotNull(creationCal);
|
||||
// The supplied creation date must have been honoured (not "now").
|
||||
assertEquals(creation.toInstant().toEpochMilli(), creationCal.getTimeInMillis());
|
||||
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"setDefaultMetadata round-trips existing document info through the producer label")
|
||||
void setDefaultMetadataRewritesProducer() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
info.setTitle("Original Title");
|
||||
info.setAuthor("Original Author");
|
||||
info.setProducer("Some Other Producer");
|
||||
|
||||
service.setDefaultMetadata(doc);
|
||||
|
||||
// extract + re-apply keeps title/author but rewrites producer to the label.
|
||||
assertEquals("Original Title", info.getTitle());
|
||||
assertEquals("Original Author", info.getAuthor());
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null string fields in metadata are written through without error")
|
||||
void handlesNullStringFields() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
PdfMetadata md = PdfMetadata.builder().build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
assertNull(info.getTitle());
|
||||
assertNull(info.getSubject());
|
||||
assertNull(info.getKeywords());
|
||||
assertNull(info.getAuthor());
|
||||
// newlyCreated=true always stamps a creation date.
|
||||
assertNotNull(info.getCreationDate());
|
||||
assertNotNull(info.getModificationDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setMetadataToPdf (pro path with custom metadata)")
|
||||
class SetMetadataProTests {
|
||||
|
||||
private ApplicationProperties propsWithCustomMetadata(
|
||||
boolean autoUpdate, String author, String creator) {
|
||||
ApplicationProperties props = mock(ApplicationProperties.class);
|
||||
Premium premium = mock(Premium.class);
|
||||
ProFeatures proFeatures = mock(ProFeatures.class);
|
||||
CustomMetadata customMetadata = mock(CustomMetadata.class);
|
||||
|
||||
lenient().when(props.getPremium()).thenReturn(premium);
|
||||
lenient().when(premium.getProFeatures()).thenReturn(proFeatures);
|
||||
lenient().when(proFeatures.getCustomMetadata()).thenReturn(customMetadata);
|
||||
lenient().when(customMetadata.isAutoUpdateMetadata()).thenReturn(autoUpdate);
|
||||
lenient().when(customMetadata.getAuthor()).thenReturn(author);
|
||||
lenient().when(customMetadata.getCreator()).thenReturn(creator);
|
||||
return props;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses custom author and creator when pro and auto-update enabled")
|
||||
void appliesCustomAuthorAndCreator() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
|
||||
|
||||
PdfMetadata md = PdfMetadata.builder().author("Ignored").title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals("Custom Author", info.getAuthor());
|
||||
assertEquals("Custom Creator", info.getCreator());
|
||||
// Producer is set to the label by both setNewDocumentMetadata and
|
||||
// setCommonMetadata.
|
||||
assertEquals(LABEL, info.getProducer());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces 'username' token with the current user when userService present")
|
||||
void replacesUsernameToken() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Report by username", "Creator");
|
||||
UserServiceInterface userService = mock(UserServiceInterface.class);
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
assertEquals("Report by alice", doc.getDocumentInformation().getAuthor());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves 'username' token intact when current user is null")
|
||||
void keepsTokenWhenUsernameNull() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Report by username", "Creator");
|
||||
UserServiceInterface userService = mock(UserServiceInterface.class);
|
||||
when(userService.getCurrentUsername()).thenReturn(null);
|
||||
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
assertEquals("Report by username", doc.getDocumentInformation().getAuthor());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("custom author applied even without a userService")
|
||||
void appliesCustomAuthorWithoutUserService() throws Exception {
|
||||
ApplicationProperties props = propsWithCustomMetadata(true, "Static Author", "Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
|
||||
PdfMetadata md = PdfMetadata.builder().title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
assertEquals("Static Author", doc.getDocumentInformation().getAuthor());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pro flag without auto-update keeps metadata author and label creator")
|
||||
void proButAutoUpdateDisabledUsesMetadata() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(false, "Custom Author", "Custom Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
|
||||
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals("Metadata Author", info.getAuthor());
|
||||
assertEquals(LABEL, info.getCreator());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto-update enabled but not pro keeps metadata author and label creator")
|
||||
void autoUpdateButNotProUsesMetadata() throws Exception {
|
||||
ApplicationProperties props =
|
||||
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
|
||||
PdfMetadataService service = new PdfMetadataService(props, LABEL, false, null);
|
||||
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
|
||||
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
service.setMetadataToPdf(doc, md, true);
|
||||
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
assertEquals("Metadata Author", info.getAuthor());
|
||||
assertEquals(LABEL, info.getCreator());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import com.posthog.java.PostHog;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PostHogServiceTest {
|
||||
|
||||
private static final String UUID = "test-uuid-1234";
|
||||
private static final String APP_VERSION = "9.9.9";
|
||||
|
||||
@Mock PostHog postHog;
|
||||
@Mock UserServiceInterface userService;
|
||||
|
||||
/** Build an ApplicationProperties with analytics/posthog toggled. */
|
||||
private ApplicationProperties props(boolean analyticsEnabled) {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSystem().setEnableAnalytics(analyticsEnabled);
|
||||
return appProps;
|
||||
}
|
||||
|
||||
/** Construct the service under test. */
|
||||
private PostHogService newService(
|
||||
ApplicationProperties appProps,
|
||||
UserServiceInterface user,
|
||||
boolean configDirMounted,
|
||||
MockEnvironment env) {
|
||||
return new PostHogService(
|
||||
postHog, UUID, configDirMounted, APP_VERSION, appProps, user, env);
|
||||
}
|
||||
|
||||
private MockEnvironment env() {
|
||||
return new MockEnvironment();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Constructor / captureSystemInfo")
|
||||
class ConstructorBehavior {
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor captures system_info when posthog is enabled")
|
||||
void constructorCapturesWhenEnabled() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
|
||||
newService(appProps, userService, false, env());
|
||||
|
||||
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor does not capture when analytics disabled")
|
||||
void constructorNoCaptureWhenDisabled() {
|
||||
ApplicationProperties appProps = props(false);
|
||||
|
||||
newService(appProps, userService, false, env());
|
||||
|
||||
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor does not capture when posthog explicitly disabled")
|
||||
void constructorNoCaptureWhenPosthogOff() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
appProps.getSystem().setEnablePosthog(false);
|
||||
|
||||
newService(appProps, userService, false, env());
|
||||
|
||||
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor swallows exceptions thrown by postHog.capture")
|
||||
void constructorSwallowsCaptureException() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
doThrow(new RuntimeException("boom"))
|
||||
.when(postHog)
|
||||
.capture(anyString(), anyString(), anyMap());
|
||||
|
||||
// Must not propagate; constructor wraps capture in try/catch.
|
||||
assertDoesNotThrow(() -> newService(appProps, userService, false, env()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor works with null userService (optional dependency)")
|
||||
void constructorWithNullUserService() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
|
||||
assertDoesNotThrow(() -> newService(appProps, null, false, env()));
|
||||
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureEvent")
|
||||
class CaptureEvent {
|
||||
|
||||
@Test
|
||||
@DisplayName("captureEvent forwards to postHog when enabled and injects app_version")
|
||||
void captureEventWhenEnabled() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
// Reset the constructor's capture so we only assert on captureEvent.
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
properties.put("foo", "bar");
|
||||
service.captureEvent("my_event", properties);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(postHog).capture(eq(UUID), eq("my_event"), captor.capture());
|
||||
Map<String, Object> sent = captor.getValue();
|
||||
assertEquals("bar", sent.get("foo"));
|
||||
assertEquals(APP_VERSION, sent.get("app_version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captureEvent is a no-op when analytics disabled")
|
||||
void captureEventWhenDisabled() {
|
||||
ApplicationProperties appProps = props(false);
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
service.captureEvent("my_event", properties);
|
||||
|
||||
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
|
||||
// app_version must not be added when disabled (early return).
|
||||
assertFalse(properties.containsKey("app_version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captureEvent adds app_version key to the provided map")
|
||||
void captureEventMutatesMap() {
|
||||
ApplicationProperties appProps = props(true);
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
service.captureEvent("evt", properties);
|
||||
|
||||
assertTrue(properties.containsKey("app_version"));
|
||||
assertEquals(APP_VERSION, properties.get("app_version"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureServerMetrics")
|
||||
class CaptureServerMetrics {
|
||||
|
||||
private PostHogService disabledService() {
|
||||
// Keep posthog disabled so the constructor performs no capture; metrics
|
||||
// methods are independent of the enabled flag.
|
||||
return newService(props(false), userService, true, env());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes core application and system metrics")
|
||||
void includesCoreMetrics() {
|
||||
PostHogService service = disabledService();
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals(APP_VERSION, metrics.get("app_version"));
|
||||
assertEquals(true, metrics.get("mounted_config_dir"));
|
||||
assertNotNull(metrics.get("os_name"));
|
||||
assertNotNull(metrics.get("java_version"));
|
||||
assertTrue(metrics.containsKey("cpu_cores"));
|
||||
assertTrue(metrics.containsKey("total_memory"));
|
||||
assertTrue(metrics.containsKey("free_memory"));
|
||||
assertTrue(metrics.containsKey("process_id"));
|
||||
assertTrue(metrics.containsKey("jvm_uptime_ms"));
|
||||
assertTrue(metrics.containsKey("thread_count"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deployment_type defaults to JAR when not docker/exe")
|
||||
void deploymentTypeJar() {
|
||||
PostHogService service = disabledService();
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
// In the unit-test environment there is no /.dockerenv and no BROWSER_OPEN.
|
||||
assertEquals("JAR", metrics.get("deployment_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deployment_type becomes EXE when BROWSER_OPEN=true")
|
||||
void deploymentTypeExe() {
|
||||
MockEnvironment environment = env();
|
||||
environment.setProperty("BROWSER_OPEN", "true");
|
||||
PostHogService service = newService(props(false), userService, false, environment);
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals("EXE", metrics.get("deployment_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BROWSER_OPEN matching is case-insensitive")
|
||||
void deploymentTypeExeCaseInsensitive() {
|
||||
MockEnvironment environment = env();
|
||||
environment.setProperty("BROWSER_OPEN", "TRUE");
|
||||
PostHogService service = newService(props(false), userService, false, environment);
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals("EXE", metrics.get("deployment_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mounted_config_dir reflects the configDirMounted flag")
|
||||
void mountedConfigDirFalse() {
|
||||
PostHogService service = newService(props(false), userService, false, env());
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals(false, metrics.get("mounted_config_dir"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes total_users_created when userService present")
|
||||
void includesUserCountWhenUserServicePresent() {
|
||||
when(userService.getTotalUsersCount()).thenReturn(42L);
|
||||
PostHogService service = newService(props(false), userService, false, env());
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertEquals(42L, metrics.get("total_users_created"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits total_users_created when userService is null")
|
||||
void omitsUserCountWhenUserServiceNull() {
|
||||
PostHogService service = newService(props(false), null, false, env());
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertFalse(metrics.containsKey("total_users_created"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("always embeds nested application_properties map")
|
||||
void embedsApplicationProperties() {
|
||||
PostHogService service = disabledService();
|
||||
|
||||
Map<String, Object> metrics = service.captureServerMetrics();
|
||||
|
||||
assertTrue(metrics.get("application_properties") instanceof Map);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureApplicationProperties")
|
||||
class CaptureApplicationProperties {
|
||||
|
||||
private PostHogService serviceWith(ApplicationProperties appProps) {
|
||||
// Disable analytics to keep the constructor from capturing.
|
||||
appProps.getSystem().setEnableAnalytics(false);
|
||||
return newService(appProps, userService, false, env());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes blank-trimmed legal strings only when non-empty")
|
||||
void legalPropertiesFiltered() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getLegal().setTermsAndConditions(" https://terms ");
|
||||
appProps.getLegal().setPrivacyPolicy(""); // blank -> skipped
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
// String values are trimmed by addIfNotEmpty.
|
||||
assertEquals("https://terms", p.get("legal_termsAndConditions"));
|
||||
assertFalse(p.containsKey("legal_privacyPolicy"));
|
||||
assertFalse(p.containsKey("legal_accessibilityStatement"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("always reports csrfDisabled true and login booleans")
|
||||
void securityProperties() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSecurity().setEnableLogin(true);
|
||||
appProps.getSecurity().setLoginAttemptCount(5);
|
||||
appProps.getSecurity().setLoginResetTimeMinutes(10);
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("security_csrfDisabled"));
|
||||
assertEquals(true, p.get("security_enableLogin"));
|
||||
assertEquals(5, p.get("security_loginAttemptCount"));
|
||||
assertEquals(10L, p.get("security_loginResetTimeMinutes"));
|
||||
assertEquals("all", p.get("security_loginMethod"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("oauth2 nested fields are omitted when oauth2 disabled")
|
||||
void oauth2DisabledOmitsNested() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
// oauth2.enabled defaults to false.
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(false, p.get("security_oauth2_enabled"));
|
||||
assertFalse(p.containsKey("security_oauth2_autoCreateUser"));
|
||||
assertFalse(p.containsKey("security_oauth2_provider"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("oauth2 nested fields are included when oauth2 enabled")
|
||||
void oauth2EnabledIncludesNested() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSecurity().getOauth2().setEnabled(true);
|
||||
appProps.getSecurity().getOauth2().setAutoCreateUser(true);
|
||||
appProps.getSecurity().getOauth2().setBlockRegistration(false);
|
||||
appProps.getSecurity().getOauth2().setUseAsUsername("email");
|
||||
appProps.getSecurity().getOauth2().setProvider("google");
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("security_oauth2_enabled"));
|
||||
assertEquals(true, p.get("security_oauth2_autoCreateUser"));
|
||||
assertEquals(false, p.get("security_oauth2_blockRegistration"));
|
||||
assertEquals("email", p.get("security_oauth2_useAsUsername"));
|
||||
assertEquals("google", p.get("security_oauth2_provider"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("system analytics/posthog/scarf booleans are reported")
|
||||
void systemAnalyticsBooleans() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getSystem().setEnableAnalytics(true);
|
||||
appProps.getSystem().setEnablePosthog(true);
|
||||
appProps.getSystem().setEnableScarf(false);
|
||||
appProps.getSystem().setDefaultLocale("en-US");
|
||||
PostHogService service = newService(appProps, userService, false, env());
|
||||
// Constructor will capture once because analytics is enabled; that's fine.
|
||||
clearInvocations(postHog);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals("en-US", p.get("system_defaultLocale"));
|
||||
assertEquals(true, p.get("system_enableAnalytics"));
|
||||
assertEquals(true, p.get("system_enablePosthog"));
|
||||
// isScarfEnabled() is false because enableScarf is false.
|
||||
assertEquals(false, p.get("system_enableScarf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metrics_enabled and autoPipeline output folder included appropriately")
|
||||
void metricsAndAutoPipeline() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getMetrics().setEnabled(true);
|
||||
appProps.getAutoPipeline().setOutputFolder("/tmp/out");
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("metrics_enabled"));
|
||||
assertEquals("/tmp/out", p.get("autoPipeline_outputFolder"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enterprise metadata flag omitted when premium disabled")
|
||||
void premiumDisabledOmitsMetadata() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
// premium.enabled defaults to false.
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(false, p.get("enterpriseEdition_enabled"));
|
||||
assertFalse(p.containsKey("enterpriseEdition_customMetadata_autoUpdateMetadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enterprise metadata flag included when premium enabled")
|
||||
void premiumEnabledIncludesMetadata() {
|
||||
ApplicationProperties appProps = new ApplicationProperties();
|
||||
appProps.getPremium().setEnabled(true);
|
||||
appProps.getPremium().getProFeatures().getCustomMetadata().setAutoUpdateMetadata(true);
|
||||
PostHogService service = serviceWith(appProps);
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertEquals(true, p.get("enterpriseEdition_enabled"));
|
||||
assertEquals(true, p.get("enterpriseEdition_customMetadata_autoUpdateMetadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ui appNameNavbar omitted when blank, included when set")
|
||||
void uiAppNameNavbar() {
|
||||
ApplicationProperties blankProps = new ApplicationProperties();
|
||||
// appNameNavbar getter returns null for blank/empty values.
|
||||
PostHogService blankService = serviceWith(blankProps);
|
||||
Map<String, Object> blank = blankService.captureApplicationProperties();
|
||||
assertFalse(blank.containsKey("ui_appNameNavbar"));
|
||||
|
||||
ApplicationProperties namedProps = new ApplicationProperties();
|
||||
namedProps.getUi().setAppNameNavbar("My App");
|
||||
PostHogService namedService = serviceWith(namedProps);
|
||||
Map<String, Object> named = namedService.captureApplicationProperties();
|
||||
assertEquals("My App", named.get("ui_appNameNavbar"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a non-null map for a fresh ApplicationProperties")
|
||||
void defaultsProduceNonNullMap() {
|
||||
PostHogService service = serviceWith(new ApplicationProperties());
|
||||
|
||||
Map<String, Object> p = service.captureApplicationProperties();
|
||||
|
||||
assertNotNull(p);
|
||||
// csrfDisabled is always added regardless of config, so map is never empty.
|
||||
assertTrue(p.containsKey("security_csrfDisabled"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils.BaseAppException;
|
||||
import stirling.software.common.util.ExceptionUtils.CbrFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.CbzFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.EmlFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.ErrorCode;
|
||||
import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException;
|
||||
import stirling.software.common.util.ExceptionUtils.GhostscriptException;
|
||||
import stirling.software.common.util.ExceptionUtils.OutOfMemoryDpiException;
|
||||
import stirling.software.common.util.ExceptionUtils.PdfCorruptedException;
|
||||
|
||||
/**
|
||||
* Additional gap-filling unit tests for {@link ExceptionUtils}, covering areas not exercised by
|
||||
* {@code ExceptionUtilsTest}: CBR/CBZ/EML factories, error-code hint/action lookups, rendering
|
||||
* dimension validation, OOM rendering wrappers, Ghostscript output analysis, and wrapException.
|
||||
*
|
||||
* <p>The {@code messages} ResourceBundle is not on the common module test classpath, so {@link
|
||||
* ExceptionUtils} falls back to the default messages baked into {@link ErrorCode}. Assertions here
|
||||
* rely only on those default messages and on deterministic structural behavior.
|
||||
*/
|
||||
class ExceptionUtilsGapTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("ErrorCode enum metadata")
|
||||
class ErrorCodeMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("each error code exposes code, message key and default message")
|
||||
void allErrorCodesHaveMetadata() {
|
||||
for (ErrorCode code : ErrorCode.values()) {
|
||||
assertNotNull(code.getCode(), "code for " + code);
|
||||
assertTrue(code.getCode().startsWith("E"), "code prefix for " + code);
|
||||
assertNotNull(code.getMessageKey(), "messageKey for " + code);
|
||||
assertNotNull(code.getDefaultMessage(), "defaultMessage for " + code);
|
||||
assertFalse(code.getDefaultMessage().isEmpty(), "defaultMessage empty for " + code);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("known error codes map to expected identifiers")
|
||||
void knownErrorCodeIdentifiers() {
|
||||
assertEquals("E001", ErrorCode.PDF_CORRUPTED.getCode());
|
||||
assertEquals("E081", ErrorCode.OUT_OF_MEMORY_DPI.getCode());
|
||||
assertEquals("error.pdfCorrupted", ErrorCode.PDF_CORRUPTED.getMessageKey());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Hints and action lookups via resource bundle")
|
||||
class HintAndActionTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("getHintsForErrorCode returns empty list for null code")
|
||||
void hintsNullCode() {
|
||||
assertEquals(List.of(), ExceptionUtils.getHintsForErrorCode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getHintsForErrorCode returns empty list when no hints exist in bundle")
|
||||
void hintsMissingFromBundle() {
|
||||
// Fallback empty bundle has no hint keys, so the result is an empty list.
|
||||
List<String> hints = ExceptionUtils.getHintsForErrorCode("E001");
|
||||
assertNotNull(hints);
|
||||
assertTrue(hints.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getActionRequiredForErrorCode returns null for null code")
|
||||
void actionNullCode() {
|
||||
assertNull(ExceptionUtils.getActionRequiredForErrorCode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getActionRequiredForErrorCode returns null when key absent from bundle")
|
||||
void actionMissingFromBundle() {
|
||||
assertNull(ExceptionUtils.getActionRequiredForErrorCode("E001"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("CBR format exception factories")
|
||||
class CbrFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid format uses provided message when non-null")
|
||||
void cbrInvalidFormatWithMessage() {
|
||||
CbrFormatException ex =
|
||||
ExceptionUtils.createCbrInvalidFormatException("custom cbr msg");
|
||||
assertEquals("custom cbr msg", ex.getMessage());
|
||||
assertEquals(ErrorCode.CBR_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid format falls back to default message when null")
|
||||
void cbrInvalidFormatNullMessage() {
|
||||
CbrFormatException ex = ExceptionUtils.createCbrInvalidFormatException(null);
|
||||
assertTrue(ex.getMessage().contains("CBR/RAR archive"));
|
||||
assertEquals("E010", ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encrypted CBR reuses invalid-format code")
|
||||
void cbrEncrypted() {
|
||||
CbrFormatException ex = ExceptionUtils.createCbrEncryptedException();
|
||||
assertEquals(ErrorCode.CBR_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no images and corrupted images both map to CBR_NO_IMAGES")
|
||||
void cbrNoImages() {
|
||||
CbrFormatException noImages = ExceptionUtils.createCbrNoImagesException();
|
||||
CbrFormatException corrupted = ExceptionUtils.createCbrCorruptedImagesException();
|
||||
assertEquals(ErrorCode.CBR_NO_IMAGES.getCode(), noImages.getErrorCode());
|
||||
assertEquals(ErrorCode.CBR_NO_IMAGES.getCode(), corrupted.getErrorCode());
|
||||
assertTrue(noImages.getMessage().contains("No valid images"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("not-a-CBR file uses CBR_NOT_CBR code")
|
||||
void notCbr() {
|
||||
CbrFormatException ex = ExceptionUtils.createNotCbrFileException();
|
||||
assertEquals(ErrorCode.CBR_NOT_CBR.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("CBR or RAR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"CbrFormatException is an IllegalArgumentException via BaseValidationException")
|
||||
void cbrIsIllegalArgument() {
|
||||
CbrFormatException ex = ExceptionUtils.createNotCbrFileException();
|
||||
assertInstanceOf(IllegalArgumentException.class, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("CBZ format exception factories")
|
||||
class CbzFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid format wraps cause and uses CBZ_INVALID_FORMAT code")
|
||||
void cbzInvalidFormat() {
|
||||
Exception cause = new Exception("zip boom");
|
||||
CbzFormatException ex = ExceptionUtils.createCbzInvalidFormatException(cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.CBZ_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("CBZ/ZIP archive"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty CBZ reuses invalid-format code")
|
||||
void cbzEmpty() {
|
||||
CbzFormatException ex = ExceptionUtils.createCbzEmptyException();
|
||||
assertEquals(ErrorCode.CBZ_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no images and corrupted images both map to CBZ_NO_IMAGES")
|
||||
void cbzNoImages() {
|
||||
CbzFormatException noImages = ExceptionUtils.createCbzNoImagesException();
|
||||
CbzFormatException corrupted = ExceptionUtils.createCbzCorruptedImagesException();
|
||||
assertEquals(ErrorCode.CBZ_NO_IMAGES.getCode(), noImages.getErrorCode());
|
||||
assertEquals(ErrorCode.CBZ_NO_IMAGES.getCode(), corrupted.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("not-a-CBZ file uses CBZ_NOT_CBZ code")
|
||||
void notCbz() {
|
||||
CbzFormatException ex = ExceptionUtils.createNotCbzFileException();
|
||||
assertEquals(ErrorCode.CBZ_NOT_CBZ.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("CBZ or ZIP"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EML format exception factories")
|
||||
class EmlFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty EML uses EML_EMPTY code")
|
||||
void emlEmpty() {
|
||||
EmlFormatException ex = ExceptionUtils.createEmlEmptyException();
|
||||
assertEquals(ErrorCode.EML_EMPTY.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("EML file is empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid EML uses EML_INVALID_FORMAT code")
|
||||
void emlInvalid() {
|
||||
EmlFormatException ex = ExceptionUtils.createEmlInvalidFormatException();
|
||||
assertEquals(ErrorCode.EML_INVALID_FORMAT.getCode(), ex.getErrorCode());
|
||||
assertTrue(ex.getMessage().contains("Invalid EML"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Image, OCR and processing factories")
|
||||
class ImageOcrProcessingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("image read exception embeds filename and has no cause")
|
||||
void imageRead() {
|
||||
IOException ex = ExceptionUtils.createImageReadException("photo.png");
|
||||
assertTrue(ex.getMessage().contains("photo.png"));
|
||||
assertNull(ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("image read exception rejects null filename")
|
||||
void imageReadNullFilename() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createImageReadException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ocr invalid render type uses default message")
|
||||
void ocrInvalidRenderType() {
|
||||
IOException ex = ExceptionUtils.createOcrInvalidRenderTypeException();
|
||||
assertTrue(ex.getMessage().contains("hocr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ocr processing failed includes return code")
|
||||
void ocrProcessingFailed() {
|
||||
IOException ex = ExceptionUtils.createOcrProcessingFailedException(7);
|
||||
assertTrue(ex.getMessage().contains("7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("processing interrupted wraps the InterruptedException cause")
|
||||
void processingInterrupted() {
|
||||
InterruptedException cause = new InterruptedException("stop");
|
||||
IOException ex =
|
||||
ExceptionUtils.createProcessingInterruptedException("compression", cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertTrue(ex.getMessage().contains("compression"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("processing interrupted rejects null arguments")
|
||||
void processingInterruptedNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
ExceptionUtils.createProcessingInterruptedException(
|
||||
null, new InterruptedException()));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createProcessingInterruptedException("x", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ghostscript conversion exception embeds output type")
|
||||
void ghostscriptConversion() {
|
||||
IOException ex = ExceptionUtils.createGhostscriptConversionException("png");
|
||||
assertNotNull(ex.getMessage());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createGhostscriptConversionException(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Validation factories: page size, file, ffmpeg")
|
||||
class ValidationFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid page size rejects null size")
|
||||
void invalidPageSizeNull() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createInvalidPageSizeException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file null-or-empty uses FILE_NULL_OR_EMPTY default message")
|
||||
void fileNullOrEmpty() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createFileNullOrEmptyException();
|
||||
assertTrue(ex.getMessage().contains("null or empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file no-name uses FILE_NO_NAME default message")
|
||||
void fileNoName() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createFileNoNameException();
|
||||
assertTrue(ex.getMessage().contains("must have a name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pdf no-pages uses PDF_NO_PAGES default message")
|
||||
void pdfNoPages() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createPdfNoPages();
|
||||
assertTrue(ex.getMessage().contains("no pages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ffmpeg required exception exposes FFMPEG_REQUIRED code and null cause")
|
||||
void ffmpegRequired() {
|
||||
FfmpegRequiredException ex = ExceptionUtils.createFfmpegRequiredException();
|
||||
assertEquals(ErrorCode.FFMPEG_REQUIRED.getCode(), ex.getErrorCode());
|
||||
assertNull(ex.getCause());
|
||||
assertTrue(ex.getMessage().contains("FFmpeg"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ErrorCode-based argument and IO factories")
|
||||
class ErrorCodeArgFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("createIllegalArgumentException(ErrorCode, args) formats default message")
|
||||
void illegalArgumentFromErrorCode() {
|
||||
IllegalArgumentException ex =
|
||||
ExceptionUtils.createIllegalArgumentException(
|
||||
ErrorCode.INVALID_PAGE_SIZE, "B7");
|
||||
assertTrue(ex.getMessage().contains("B7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createIllegalArgumentException rejects null ErrorCode")
|
||||
void illegalArgumentFromNullErrorCode() {
|
||||
ErrorCode nullCode = null;
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createIllegalArgumentException(nullCode));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createFileProcessingException rejects null operation and cause")
|
||||
void fileProcessingNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createFileProcessingException(null, new Exception()));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createFileProcessingException("op", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createInvalidArgumentException rejects null name or value")
|
||||
void invalidArgumentNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createInvalidArgumentException(null, "v"));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createInvalidArgumentException("n", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNullArgumentException rejects null argument name")
|
||||
void nullArgumentNullName() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createNullArgumentException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createIOException without cause leaves cause null")
|
||||
void ioExceptionWithoutCause() {
|
||||
IOException ex = ExceptionUtils.createIOException("key", "msg {0}", null, "A");
|
||||
assertEquals("msg A", ex.getMessage());
|
||||
assertNull(ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createRuntimeException without cause leaves cause null")
|
||||
void runtimeExceptionWithoutCause() {
|
||||
RuntimeException ex =
|
||||
ExceptionUtils.createRuntimeException("key", "msg {0}", null, "B");
|
||||
assertEquals("msg B", ex.getMessage());
|
||||
assertNull(ex.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPdfCorruptedException null-cause handling")
|
||||
class PdfCorruptedCauseTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null cause")
|
||||
void nullCause() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createPdfCorruptedException("ctx", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty context behaves like no context")
|
||||
void emptyContext() {
|
||||
PdfCorruptedException ex =
|
||||
ExceptionUtils.createPdfCorruptedException("", new Exception("x"));
|
||||
assertTrue(ex.getMessage().contains("PDF file appears to be corrupted"));
|
||||
assertEquals(ErrorCode.PDF_CORRUPTED.getCode(), ex.getErrorCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("validateRenderingDimensions")
|
||||
class ValidateRenderingDimensionsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null page is a no-op")
|
||||
void nullPage() {
|
||||
// Should simply return without throwing.
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> ExceptionUtils.validateRenderingDimensions(null, 1, 300));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("normal letter-size page at 150 DPI passes validation")
|
||||
void normalPagePasses() {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> ExceptionUtils.validateRenderingDimensions(page, 1, 150));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with zero DPI yields zero pixels and passes")
|
||||
void zeroDpiPasses() {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> ExceptionUtils.validateRenderingDimensions(page, 2, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("handleOomRendering wrappers")
|
||||
class HandleOomRenderingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns operation result on success (with page number)")
|
||||
void successWithPage() throws IOException {
|
||||
String result = ExceptionUtils.handleOomRendering(3, 300, () -> "ok");
|
||||
assertEquals("ok", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns operation result on success (no page number)")
|
||||
void successNoPage() throws IOException {
|
||||
String result = ExceptionUtils.handleOomRendering(300, () -> "fine");
|
||||
assertEquals("fine", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates IOException from the operation unchanged")
|
||||
void propagatesIoException() {
|
||||
IOException boom = new IOException("io boom");
|
||||
IOException thrown =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() ->
|
||||
ExceptionUtils.handleOomRendering(
|
||||
1,
|
||||
300,
|
||||
() -> {
|
||||
throw boom;
|
||||
}));
|
||||
assertSame(boom, thrown);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converts OutOfMemoryError to OutOfMemoryDpiException (with page)")
|
||||
void oomToDpiExceptionWithPage() {
|
||||
OutOfMemoryDpiException thrown =
|
||||
assertThrows(
|
||||
OutOfMemoryDpiException.class,
|
||||
() ->
|
||||
ExceptionUtils.handleOomRendering(
|
||||
5,
|
||||
300,
|
||||
() -> {
|
||||
throw new OutOfMemoryError("heap");
|
||||
}));
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), thrown.getErrorCode());
|
||||
assertInstanceOf(OutOfMemoryError.class, thrown.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converts NegativeArraySizeException to OutOfMemoryDpiException (no page)")
|
||||
void negativeArraySizeToDpiExceptionNoPage() {
|
||||
OutOfMemoryDpiException thrown =
|
||||
assertThrows(
|
||||
OutOfMemoryDpiException.class,
|
||||
() ->
|
||||
ExceptionUtils.handleOomRendering(
|
||||
300,
|
||||
() -> {
|
||||
throw new NegativeArraySizeException("-1");
|
||||
}));
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), thrown.getErrorCode());
|
||||
assertInstanceOf(NegativeArraySizeException.class, thrown.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createOutOfMemoryDpiException overloads")
|
||||
class OutOfMemoryDpiFactoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("page + dpi + Throwable wraps cause and sets code")
|
||||
void pageDpiThrowable() {
|
||||
Throwable cause = new IllegalStateException("too big");
|
||||
OutOfMemoryDpiException ex =
|
||||
ExceptionUtils.createOutOfMemoryDpiException(4, 600, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page + dpi + OutOfMemoryError overload wraps the error")
|
||||
void pageDpiOomError() {
|
||||
OutOfMemoryError cause = new OutOfMemoryError("oom");
|
||||
OutOfMemoryDpiException ex =
|
||||
ExceptionUtils.createOutOfMemoryDpiException(2, 300, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dpi + Throwable overload wraps cause")
|
||||
void dpiThrowable() {
|
||||
Throwable cause = new RuntimeException("x");
|
||||
OutOfMemoryDpiException ex = ExceptionUtils.createOutOfMemoryDpiException(300, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.OUT_OF_MEMORY_DPI.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dpi + OutOfMemoryError overload wraps the error")
|
||||
void dpiOomError() {
|
||||
OutOfMemoryError cause = new OutOfMemoryError("oom");
|
||||
OutOfMemoryDpiException ex = ExceptionUtils.createOutOfMemoryDpiException(300, cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null cause")
|
||||
void nullCause() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createOutOfMemoryDpiException(1, 300, (Throwable) null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Ghostscript output analysis")
|
||||
class GhostscriptAnalysisTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null/blank output produces generic compression exception")
|
||||
void blankOutput() {
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(" ");
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recognized page drawing error yields page-drawing error code")
|
||||
void pageDrawingError() {
|
||||
String output = "Page 3\nERROR: page drawing error encountered while processing";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-page-drawing output falls back to compression error code")
|
||||
void unrecognizedOutput() {
|
||||
String output = "Some random ghostscript chatter that is not an error marker";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("detectGhostscriptCriticalError returns exception only for critical output")
|
||||
void detectCritical() {
|
||||
GhostscriptException critical =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(
|
||||
"Page 1\ncould not draw this page");
|
||||
assertNotNull(critical);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), critical.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("detectGhostscriptCriticalError returns null for non-critical output")
|
||||
void detectNonCritical() {
|
||||
assertNull(ExceptionUtils.detectGhostscriptCriticalError("just informational output"));
|
||||
assertNull(ExceptionUtils.detectGhostscriptCriticalError(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("compression exception derived from cause message")
|
||||
void compressionFromCauseMessage() {
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException(
|
||||
new Exception("Page 2\npage drawing error"));
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createGhostscriptCompressionException rejects null cause overload")
|
||||
void compressionNullCause() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.createGhostscriptCompressionException((Exception) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple affected pages are summarized in the message")
|
||||
void multiplePages() {
|
||||
String output = "Page 1\npage drawing error\nPage 2\ncould not draw this page";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
assertNotNull(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("wrapException")
|
||||
class WrapExceptionTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("RuntimeException is returned unchanged")
|
||||
void runtimePassthrough() {
|
||||
RuntimeException original = new IllegalStateException("boom");
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(original, "merge");
|
||||
assertSame(original, wrapped);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BaseAppException (IOException subtype) is wrapped in a RuntimeException")
|
||||
void baseAppExceptionWrapped() {
|
||||
// A corrupted-pdf IOException triggers handlePdfException -> PdfCorruptedException.
|
||||
IOException corrupted = new IOException("Invalid PDF");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(corrupted)).thenReturn(true);
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(corrupted, "merge");
|
||||
assertInstanceOf(BaseAppException.class, wrapped.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain IOException is wrapped via file-processing exception")
|
||||
void plainIoExceptionWrapped() {
|
||||
IOException io = new IOException("disk full");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(io)).thenReturn(false);
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(io, "split");
|
||||
assertInstanceOf(IOException.class, wrapped.getCause());
|
||||
assertFalse(wrapped.getCause() instanceof BaseAppException);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("checked non-IO exception is wrapped with operation context")
|
||||
void checkedExceptionWrapped() {
|
||||
Exception checked = new Exception("oops");
|
||||
RuntimeException wrapped = ExceptionUtils.wrapException(checked, "convert");
|
||||
assertSame(checked, wrapped.getCause());
|
||||
assertTrue(wrapped.getMessage().contains("convert"));
|
||||
assertTrue(wrapped.getMessage().contains("oops"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects null exception or operation")
|
||||
void wrapNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> ExceptionUtils.wrapException(null, "op"));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.wrapException(new Exception(), null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("logException return value and handlePdfException null guard")
|
||||
class LogAndHandleTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("logException returns the same exception instance for fluent throw")
|
||||
void logExceptionReturnsSame() {
|
||||
Exception e = new RuntimeException("unexpected");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
|
||||
Exception returned = ExceptionUtils.logException("op", e);
|
||||
assertSame(e, returned);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logException rejects null operation or exception")
|
||||
void logExceptionNullArgs() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ExceptionUtils.logException(null, new Exception()));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> ExceptionUtils.logException("op", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handlePdfException rejects null exception")
|
||||
void handlePdfNull() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> ExceptionUtils.handlePdfException(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handlePdfException with context wraps corrupted PDF and includes context")
|
||||
void handlePdfWithContext() {
|
||||
IOException original = new IOException("damaged");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(true);
|
||||
IOException result = ExceptionUtils.handlePdfException(original, "during merge");
|
||||
assertInstanceOf(PdfCorruptedException.class, result);
|
||||
assertTrue(result.getMessage().contains("during merge"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Gap coverage for {@link FormUtils} methods not exercised by {@code FormUtilsTest} (disabled) or
|
||||
* {@code FormUtilsAdditionalTest}. Focuses on coordinate extraction, the page-map / repair / prune
|
||||
* / delete / modify lifecycle, and the package-private parsing helpers.
|
||||
*/
|
||||
class FormUtilsGapTest {
|
||||
|
||||
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
|
||||
|
||||
private static SetupDocument createBasicDocument(PDDocument document) {
|
||||
PDPage page = new PDPage();
|
||||
document.addPage(page);
|
||||
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
// Register a Helvetica font in the default resources and set a default appearance so
|
||||
// PDFBox can write text-field values without throwing "/DA is a required entry".
|
||||
PDResources dr = new PDResources();
|
||||
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
acroForm.setDefaultResources(dr);
|
||||
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
acroForm.setNeedAppearances(true);
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
return new SetupDocument(page, acroForm);
|
||||
}
|
||||
|
||||
private static void attachWidget(
|
||||
SetupDocument setup, PDTerminalField field, PDRectangle rectangle) throws IOException {
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(rectangle);
|
||||
widget.setPage(setup.page());
|
||||
// Start from an empty list: a fresh terminal field has no /Kids, so getWidgets() would
|
||||
// return a synthetic widget wrapping the field dict itself. Re-adding that turns the field
|
||||
// into a self-referential non-terminal field whose getWidgets() is empty.
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>();
|
||||
widgets.add(widget);
|
||||
field.setWidgets(widgets);
|
||||
setup.acroForm().getFields().add(field);
|
||||
setup.page().getAnnotations().add(widget);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Constants
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Field type constants")
|
||||
class Constants {
|
||||
|
||||
@Test
|
||||
void typeConstantsHaveExpectedValues() {
|
||||
assertEquals("text", FormUtils.FIELD_TYPE_TEXT);
|
||||
assertEquals("checkbox", FormUtils.FIELD_TYPE_CHECKBOX);
|
||||
assertEquals("combobox", FormUtils.FIELD_TYPE_COMBOBOX);
|
||||
assertEquals("listbox", FormUtils.FIELD_TYPE_LISTBOX);
|
||||
assertEquals("radio", FormUtils.FIELD_TYPE_RADIO);
|
||||
assertEquals("button", FormUtils.FIELD_TYPE_BUTTON);
|
||||
assertEquals("signature", FormUtils.FIELD_TYPE_SIGNATURE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void choiceFieldTypesContainsExpectedMembers() {
|
||||
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("combobox"));
|
||||
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("listbox"));
|
||||
assertTrue(FormUtils.CHOICE_FIELD_TYPES.contains("radio"));
|
||||
assertFalse(FormUtils.CHOICE_FIELD_TYPES.contains("text"));
|
||||
assertEquals(3, FormUtils.CHOICE_FIELD_TYPES.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// detectFieldType (choice/radio/signature/button branches)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectFieldType")
|
||||
class DetectFieldType {
|
||||
|
||||
@Test
|
||||
void comboBoxDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals(
|
||||
"combobox", FormUtils.detectFieldType(new PDComboBox(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listBoxDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals("listbox", FormUtils.detectFieldType(new PDListBox(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void radioButtonDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals(
|
||||
"radio", FormUtils.detectFieldType(new PDRadioButton(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
assertEquals(
|
||||
"signature",
|
||||
FormUtils.detectFieldType(new PDSignatureField(setup.acroForm())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// isChecked
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("isChecked")
|
||||
class IsChecked {
|
||||
|
||||
@Test
|
||||
void nullIsFalse() {
|
||||
assertFalse(FormUtils.isChecked(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truthyValuesAreChecked() {
|
||||
assertTrue(FormUtils.isChecked("true"));
|
||||
assertTrue(FormUtils.isChecked("1"));
|
||||
assertTrue(FormUtils.isChecked("yes"));
|
||||
assertTrue(FormUtils.isChecked("on"));
|
||||
assertTrue(FormUtils.isChecked("checked"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truthyValuesAreCaseInsensitiveAndTrimmed() {
|
||||
assertTrue(FormUtils.isChecked(" TRUE "));
|
||||
assertTrue(FormUtils.isChecked("Yes"));
|
||||
assertTrue(FormUtils.isChecked("ON"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void falsyValuesAreNotChecked() {
|
||||
assertFalse(FormUtils.isChecked("false"));
|
||||
assertFalse(FormUtils.isChecked("0"));
|
||||
assertFalse(FormUtils.isChecked("off"));
|
||||
assertFalse(FormUtils.isChecked(""));
|
||||
assertFalse(FormUtils.isChecked("anything"));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// safeValue
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void safeValueEmptyStringPassesThrough() {
|
||||
assertEquals("", FormUtils.safeValue(""));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// parseMultiChoiceSelections
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseMultiChoiceSelections")
|
||||
class ParseMultiChoiceSelections {
|
||||
|
||||
@Test
|
||||
void nullReturnsEmpty() {
|
||||
assertTrue(FormUtils.parseMultiChoiceSelections(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankReturnsEmpty() {
|
||||
assertTrue(FormUtils.parseMultiChoiceSelections(" ").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAndTrims() {
|
||||
List<String> result = FormUtils.parseMultiChoiceSelections(" a , b ,c ");
|
||||
assertEquals(List.of("a", "b", "c"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsEmptySegments() {
|
||||
List<String> result = FormUtils.parseMultiChoiceSelections("a,,b,");
|
||||
assertEquals(List.of("a", "b"), result);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// filterChoiceSelections
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("filterChoiceSelections")
|
||||
class FilterChoiceSelections {
|
||||
|
||||
@Test
|
||||
void nullSelectionsReturnsEmpty() {
|
||||
assertTrue(FormUtils.filterChoiceSelections(null, List.of("A"), "f").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySelectionsReturnsEmpty() {
|
||||
assertTrue(FormUtils.filterChoiceSelections(List.of(), List.of("A"), "f").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectionsOfOnlyBlanksReturnsEmpty() {
|
||||
List<String> selections = new ArrayList<>();
|
||||
selections.add(" ");
|
||||
selections.add(null);
|
||||
assertTrue(FormUtils.filterChoiceSelections(selections, List.of("A"), "f").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingSelectionsAreKeptCaseInsensitively() {
|
||||
List<String> result =
|
||||
FormUtils.filterChoiceSelections(
|
||||
List.of("apple", "BANANA"), List.of("Apple", "Banana", "Cherry"), "f");
|
||||
// The resolved (canonical) allowed option is returned, not the input.
|
||||
assertEquals(List.of("Apple", "Banana"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedSelectionsAreDropped() {
|
||||
List<String> result =
|
||||
FormUtils.filterChoiceSelections(
|
||||
List.of("Apple", "Grape"), List.of("Apple", "Banana"), "f");
|
||||
assertEquals(List.of("Apple"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAllowedOptionsThrows() {
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> FormUtils.filterChoiceSelections(List.of("Apple"), List.of(), "fieldX"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAllowedOptionsThrows() {
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> FormUtils.filterChoiceSelections(List.of("Apple"), null, "fieldX"));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// resolveOptions / resolveDisplayOptions / collectChoiceAllowedValues
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("option resolution")
|
||||
class OptionResolution {
|
||||
|
||||
@Test
|
||||
void resolveOptionsForComboBox() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setOptions(List.of("Red", "Green", "Blue"));
|
||||
List<String> options = FormUtils.resolveOptions(combo);
|
||||
assertTrue(options.contains("Red"));
|
||||
assertTrue(options.contains("Green"));
|
||||
assertTrue(options.contains("Blue"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveOptionsForTextFieldIsEmpty() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
assertTrue(FormUtils.resolveOptions(text).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveOptionsForCheckBoxUsesExportValues() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDCheckBox checkBox = new PDCheckBox(setup.acroForm());
|
||||
checkBox.setExportValues(List.of("Yes"));
|
||||
assertEquals(List.of("Yes"), FormUtils.resolveOptions(checkBox));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveDisplayOptionsEmptyForTextField() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
assertTrue(FormUtils.resolveDisplayOptions(text).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectChoiceAllowedValuesNullReturnsEmpty() {
|
||||
assertTrue(FormUtils.collectChoiceAllowedValues(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectChoiceAllowedValuesReturnsOptions() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setOptions(List.of("One", "Two"));
|
||||
List<String> allowed = FormUtils.collectChoiceAllowedValues(combo);
|
||||
assertTrue(allowed.contains("One"));
|
||||
assertTrue(allowed.contains("Two"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// setTextValue
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("setTextValue")
|
||||
class SetTextValue {
|
||||
|
||||
@Test
|
||||
void writesValue() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("note");
|
||||
text.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
attachWidget(setup, text, new PDRectangle(20, 600, 200, 20));
|
||||
|
||||
FormUtils.setTextValue(text, "hello world");
|
||||
assertEquals("hello world", text.getValueAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullValueWritesEmptyString() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("note");
|
||||
text.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
attachWidget(setup, text, new PDRectangle(20, 600, 200, 20));
|
||||
|
||||
FormUtils.setTextValue(text, null);
|
||||
assertEquals("", text.getValueAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// buildFillTemplateRecord (choice branches not covered elsewhere)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildFillTemplateRecord")
|
||||
class BuildFillTemplateRecord {
|
||||
|
||||
@Test
|
||||
void comboBoxUsesCurrentValue() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"color", "Color", "combobox", "Red", null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
assertEquals("Red", result.get("color"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleSelectListBoxUsesValue() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"list", "List", "listbox", "Item1", null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
assertEquals("Item1", result.get("list"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSelectListBoxUsesEmptyArray() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"list", "List", "listbox", "Item1", null, false, 0, true, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
Object value = result.get("list");
|
||||
assertTrue(value instanceof List<?>);
|
||||
assertTrue(((List<?>) value).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullValueDefaultsToEmptyString() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"name", "Name", "text", null, null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
assertEquals("", result.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void entriesWithBlankNamesAreSkipped() {
|
||||
FormUtils.FormFieldInfo blank =
|
||||
new FormUtils.FormFieldInfo(
|
||||
" ", "Blank", "text", "x", null, false, 0, false, null, 0);
|
||||
FormUtils.FormFieldInfo good =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"kept", "Kept", "text", "x", null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(blank, good));
|
||||
assertEquals(1, result.size());
|
||||
assertTrue(result.containsKey("kept"));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// buildAnnotationPageMap
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildAnnotationPageMap")
|
||||
class BuildAnnotationPageMap {
|
||||
|
||||
@Test
|
||||
void nullDocumentReturnsEmpty() {
|
||||
assertTrue(FormUtils.buildAnnotationPageMap(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyDocumentReturnsEmpty() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertTrue(FormUtils.buildAnnotationPageMap(doc).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsWidgetToItsPageIndex() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("a");
|
||||
attachWidget(setup, text, new PDRectangle(10, 10, 100, 20));
|
||||
|
||||
Map<COSDictionary, Integer> map = FormUtils.buildAnnotationPageMap(doc);
|
||||
assertEquals(1, map.size());
|
||||
assertTrue(map.containsValue(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// extractFormFieldsWithCoordinates
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractFormFieldsWithCoordinates")
|
||||
class ExtractFormFieldsWithCoordinates {
|
||||
|
||||
@Test
|
||||
void nullDocumentReturnsEmpty() {
|
||||
assertTrue(FormUtils.extractFormFieldsWithCoordinates(null).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noAcroFormReturnsEmpty() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertTrue(FormUtils.extractFormFieldsWithCoordinates(doc).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void textFieldProducesWidgetCoordinates() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("firstName");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<stirling.software.common.model.FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(1, fields.size());
|
||||
stirling.software.common.model.FormFieldWithCoordinates field = fields.get(0);
|
||||
assertEquals("firstName", field.getName());
|
||||
assertEquals("text", field.getType());
|
||||
assertNotNull(field.getWidgets());
|
||||
assertEquals(1, field.getWidgets().size());
|
||||
stirling.software.common.model.FormFieldWithCoordinates.WidgetCoordinates wc =
|
||||
field.getWidgets().get(0);
|
||||
assertEquals(0, wc.getPageIndex());
|
||||
// x is relative to crop-box origin (0 here), so it equals the lower-left x.
|
||||
assertEquals(50f, wc.getX(), 0.01f);
|
||||
assertEquals(200f, wc.getWidth(), 0.01f);
|
||||
assertEquals(20f, wc.getHeight(), 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleFieldsAreSortedTopToBottom() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
|
||||
PDTextField lower = new PDTextField(setup.acroForm());
|
||||
lower.setPartialName("lower");
|
||||
attachWidget(setup, lower, new PDRectangle(50, 100, 200, 20));
|
||||
|
||||
PDTextField upper = new PDTextField(setup.acroForm());
|
||||
upper.setPartialName("upper");
|
||||
attachWidget(setup, upper, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<stirling.software.common.model.FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(2, fields.size());
|
||||
// The widget higher on the page (smaller CSS-y after flip) sorts first.
|
||||
assertEquals("upper", fields.get(0).getName());
|
||||
assertEquals("lower", fields.get(1).getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// repairMissingWidgetPageReferences
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("repairMissingWidgetPageReferences")
|
||||
class RepairMissingWidgetPageReferences {
|
||||
|
||||
@Test
|
||||
void nullDocumentDoesNotThrow() {
|
||||
FormUtils.repairMissingWidgetPageReferences(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentWithoutAcroFormDoesNotThrow() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
FormUtils.repairMissingWidgetPageReferences(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void setsPageReferenceForOrphanWidget() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("orphan");
|
||||
|
||||
// Build a widget that is on the page's annotation list but has no /P page ref.
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(new PDRectangle(10, 10, 100, 20));
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>(text.getWidgets());
|
||||
widgets.add(widget);
|
||||
text.setWidgets(widgets);
|
||||
setup.acroForm().getFields().add(text);
|
||||
setup.page().getAnnotations().add(widget);
|
||||
|
||||
assertNull(widget.getPage());
|
||||
FormUtils.repairMissingWidgetPageReferences(doc);
|
||||
assertNotNull(widget.getPage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// deleteFormFields
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteFormFields")
|
||||
class DeleteFormFields {
|
||||
|
||||
@Test
|
||||
void nullDocumentIsNoOp() {
|
||||
FormUtils.deleteFormFields(null, List.of("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullNamesIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.deleteFormFields(doc, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyNamesIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.deleteFormFields(doc, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesNamedField() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField keep = new PDTextField(setup.acroForm());
|
||||
keep.setPartialName("keep");
|
||||
attachWidget(setup, keep, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
PDTextField remove = new PDTextField(setup.acroForm());
|
||||
remove.setPartialName("remove");
|
||||
attachWidget(setup, remove, new PDRectangle(50, 660, 200, 20));
|
||||
|
||||
FormUtils.deleteFormFields(doc, List.of("remove"));
|
||||
|
||||
List<FormUtils.FormFieldInfo> remaining = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, remaining.size());
|
||||
assertEquals("keep", remaining.get(0).name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownFieldNameIsIgnored() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField keep = new PDTextField(setup.acroForm());
|
||||
keep.setPartialName("keep");
|
||||
attachWidget(setup, keep, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.deleteFormFields(doc, List.of("doesNotExist", " ", "keep"));
|
||||
assertTrue(FormUtils.extractFormFields(doc).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// modifyFormFields
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("modifyFormFields")
|
||||
class ModifyFormFields {
|
||||
|
||||
@Test
|
||||
void nullDocumentIsNoOp() {
|
||||
FormUtils.modifyFormFields(null, List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullModificationsIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.modifyFormFields(doc, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyModificationsIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
createBasicDocument(doc);
|
||||
FormUtils.modifyFormFields(doc, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void inPlaceRenameAndLabelUpdate() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("oldName");
|
||||
text.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"oldName",
|
||||
"newName",
|
||||
"New Label",
|
||||
null, // keep type (text) -> in-place path
|
||||
Boolean.TRUE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertEquals("newName", fields.get(0).name());
|
||||
assertEquals("New Label", fields.get(0).label());
|
||||
assertTrue(fields.get(0).required());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownTargetIsSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("present");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"missing", null, null, null, null, null, null, null, null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
// Untouched field remains.
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertEquals("present", fields.get(0).name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullEntriesAndBlankTargetsAreSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("present");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.ModifyFormFieldDefinition> mods = new ArrayList<>();
|
||||
mods.add(null);
|
||||
mods.add(
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
" ", null, null, null, null, null, null, null, null));
|
||||
|
||||
FormUtils.modifyFormFields(doc, mods);
|
||||
assertEquals(1, FormUtils.extractFormFields(doc).size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// pruneOrphanedFormFields
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("pruneOrphanedFormFields")
|
||||
class PruneOrphanedFormFields {
|
||||
|
||||
@Test
|
||||
void nullDocumentIsNoOp() {
|
||||
FormUtils.pruneOrphanedFormFields(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentWithoutAcroFormIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
FormUtils.pruneOrphanedFormFields(doc);
|
||||
assertNull(doc.getDocumentCatalog().getAcroForm(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsFieldsWithLiveWidgets() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("live");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(doc);
|
||||
|
||||
PDAcroForm form = doc.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(form);
|
||||
assertEquals(1, form.getFields().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsAcroFormWhenAllWidgetsOrphaned() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("orphan");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
// Remove the widget from the page so it is no longer "live".
|
||||
setup.page().getAnnotations().clear();
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(doc);
|
||||
|
||||
assertNull(doc.getDocumentCatalog().getAcroForm(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// hasAnyRotatedPage (rotated branch)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("hasAnyRotatedPage")
|
||||
class HasAnyRotatedPage {
|
||||
|
||||
@Test
|
||||
void rotatedPageDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
page.setRotation(90);
|
||||
doc.addPage(page);
|
||||
assertTrue(FormUtils.hasAnyRotatedPage(doc));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrotatedPageNotDetected() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertFalse(FormUtils.hasAnyRotatedPage(doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* Gap-coverage tests for {@link GeneralUtils}. Targets the public methods NOT already exercised by
|
||||
* {@code GeneralUtilsAdditionalTest} (size/url/version/uuid) or {@code GeneralUtilsTest} (filename
|
||||
* helpers, parsePageList basics, saveKeyToSettings): namely {@code generateFilename}, {@code
|
||||
* convertToFileName}, {@code evaluateNFunc}, the n-function {@code parsePageList} path, {@code
|
||||
* createDir}/{@code deleteDirectory}, multipart conversion, the {@code updateSettingsTransactional}
|
||||
* early-return guards, {@code getResourcesFromLocationPattern}, and the environment helpers {@code
|
||||
* generateMachineFingerprint}/{@code getLocalNetworkIp}.
|
||||
*/
|
||||
class GeneralUtilsGapTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("generateFilename")
|
||||
class GenerateFilenameTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("removes extension then appends suffix")
|
||||
void removesAndAppends() {
|
||||
assertEquals(
|
||||
"report_out.pdf", GeneralUtils.generateFilename("report.docx", "_out.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null filename uses default base")
|
||||
void nullFilename() {
|
||||
assertEquals("default_out.pdf", GeneralUtils.generateFilename(null, "_out.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filename without extension is preserved")
|
||||
void noExtension() {
|
||||
assertEquals("README_x", GeneralUtils.generateFilename("README", "_x"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertToFileName")
|
||||
class ConvertToFileNameTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null returns underscore")
|
||||
void nullReturnsUnderscore() {
|
||||
assertEquals("_", GeneralUtils.convertToFileName(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps letters and digits, replaces others with underscore")
|
||||
void replacesUnsafeChars() {
|
||||
assertEquals("my_file_2024_", GeneralUtils.convertToFileName("my file/2024!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("alphanumeric input is unchanged")
|
||||
void alphanumericUnchanged() {
|
||||
assertEquals("File123", GeneralUtils.convertToFileName("File123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("truncates to 50 characters")
|
||||
void truncatesToFifty() {
|
||||
String input = "a".repeat(100);
|
||||
assertEquals(50, GeneralUtils.convertToFileName(input).length());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("evaluateNFunc")
|
||||
class EvaluateNFuncTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null expression throws")
|
||||
void nullThrows() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc(null, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank expression throws")
|
||||
void blankThrows() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc(" ", 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maxValue below 1 throws")
|
||||
void maxValueTooLow() {
|
||||
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc("n", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maxValue above 10000 throws")
|
||||
void maxValueTooHigh() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc("n", 10001));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid characters throw")
|
||||
void invalidCharsThrow() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> GeneralUtils.evaluateNFunc("n$", 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("identity 'n' yields all pages up to maxValue")
|
||||
void identity() {
|
||||
assertEquals(List.of(1, 2, 3, 4, 5), GeneralUtils.evaluateNFunc("n", 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2n yields even values within bounds")
|
||||
void doubling() {
|
||||
assertEquals(List.of(2, 4, 6), GeneralUtils.evaluateNFunc("2n", 6));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("implicit multiplication 'n(n-1)' is handled")
|
||||
void implicitMultiplication() {
|
||||
// n*(n-1): n=1->0(excluded), n=2->2, n=3->6 ; capped at maxValue 6
|
||||
assertEquals(List.of(2, 6), GeneralUtils.evaluateNFunc("n(n-1)", 6));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("results outside (0, maxValue] are excluded")
|
||||
void boundsExcluded() {
|
||||
// n+10 always exceeds maxValue 5 -> empty
|
||||
assertTrue(GeneralUtils.evaluateNFunc("n+10", 5).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("parsePageList n-function path")
|
||||
class ParsePageListNFuncTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("n-function token expands to matching one-based pages")
|
||||
void nFunctionOneBased() {
|
||||
// 2n for total 6 (one-based) -> values 2,4,6 mapped to (v-1+1)=v
|
||||
assertEquals(List.of(2, 4, 6), GeneralUtils.parsePageList("2n", 6, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("n-function token zero-based subtracts one")
|
||||
void nFunctionZeroBased() {
|
||||
// 2n for total 6 zero-based -> values 2,4,6 mapped to (v-1+0)=v-1
|
||||
assertEquals(List.of(1, 3, 5), GeneralUtils.parsePageList("2n", 6, false));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createDir and deleteDirectory")
|
||||
class DirectoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("createDir makes a nested directory and returns true")
|
||||
void createNested(@TempDir Path tempDir) {
|
||||
Path nested = tempDir.resolve("a").resolve("b").resolve("c");
|
||||
assertTrue(GeneralUtils.createDir(nested.toString()));
|
||||
assertTrue(Files.isDirectory(nested));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDir returns true when directory already exists")
|
||||
void createExisting(@TempDir Path tempDir) {
|
||||
assertTrue(GeneralUtils.createDir(tempDir.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDirectory removes a populated tree without touching siblings")
|
||||
void deletePopulatedTree(@TempDir Path tempDir) throws IOException {
|
||||
Path sibling = tempDir.resolve("sibling");
|
||||
Files.createDirectories(sibling);
|
||||
Files.writeString(sibling.resolve("keep.txt"), "data");
|
||||
|
||||
Path root = tempDir.resolve("root");
|
||||
Files.createDirectories(root.resolve("nested"));
|
||||
Files.writeString(root.resolve("a.txt"), "x");
|
||||
Files.writeString(root.resolve("nested").resolve("b.txt"), "y");
|
||||
|
||||
GeneralUtils.deleteDirectory(root);
|
||||
|
||||
assertFalse(Files.exists(root));
|
||||
assertTrue(Files.exists(sibling.resolve("keep.txt")));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("multipart conversion")
|
||||
class MultipartTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("convertMultipartFileToFile writes content to a temp file")
|
||||
void convertWritesContent() throws IOException {
|
||||
byte[] content = "hello world".getBytes(StandardCharsets.UTF_8);
|
||||
MultipartFile mf =
|
||||
new MockMultipartFile("file", "input.bin", "application/octet-stream", content);
|
||||
|
||||
File out = GeneralUtils.convertMultipartFileToFile(mf);
|
||||
try {
|
||||
assertTrue(out.exists());
|
||||
assertArrayEquals(content, Files.readAllBytes(out.toPath()));
|
||||
} finally {
|
||||
Files.deleteIfExists(out.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertMultipartFileToFile handles empty input")
|
||||
void convertEmpty() throws IOException {
|
||||
MultipartFile mf =
|
||||
new MockMultipartFile(
|
||||
"file", "empty.bin", "application/octet-stream", new byte[0]);
|
||||
|
||||
File out = GeneralUtils.convertMultipartFileToFile(mf);
|
||||
try {
|
||||
assertTrue(out.exists());
|
||||
assertEquals(0, out.length());
|
||||
} finally {
|
||||
Files.deleteIfExists(out.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multipartToFile writes content to a .pdf temp file")
|
||||
void multipartToFileWritesContent() throws IOException {
|
||||
byte[] content = "%PDF-1.7 minimal".getBytes(StandardCharsets.UTF_8);
|
||||
MultipartFile mf = new MockMultipartFile("file", "doc.pdf", "application/pdf", content);
|
||||
|
||||
File out = GeneralUtils.multipartToFile(mf);
|
||||
try {
|
||||
assertTrue(out.exists());
|
||||
assertTrue(out.getName().endsWith(".pdf"));
|
||||
assertArrayEquals(content, Files.readAllBytes(out.toPath()));
|
||||
} finally {
|
||||
Files.deleteIfExists(out.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getResourcesFromLocationPattern")
|
||||
class ResourcePatternTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("file: pattern resolves matching files in a directory")
|
||||
void filePatternResolves(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("one.txt"), "1");
|
||||
Files.writeString(tempDir.resolve("two.txt"), "2");
|
||||
|
||||
String pattern = "file:" + tempDir.toString().replace("\\", "/") + "/*";
|
||||
Resource[] resources =
|
||||
GeneralUtils.getResourcesFromLocationPattern(
|
||||
pattern, new DefaultResourceLoader());
|
||||
|
||||
assertNotNull(resources);
|
||||
assertEquals(2, resources.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("classpath pattern with no matches returns an empty array")
|
||||
void classpathNoMatches() throws Exception {
|
||||
Resource[] resources =
|
||||
GeneralUtils.getResourcesFromLocationPattern(
|
||||
"classpath*:this/path/does/not/exist/**/*.nope",
|
||||
new DefaultResourceLoader());
|
||||
|
||||
assertNotNull(resources);
|
||||
assertEquals(0, resources.length);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateSettingsTransactional early-return guards")
|
||||
class SettingsGuardTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null map returns without throwing")
|
||||
void nullMapNoOp() {
|
||||
assertDoesNotThrow(() -> GeneralUtils.updateSettingsTransactional(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty map returns without throwing")
|
||||
void emptyMapNoOp() {
|
||||
assertDoesNotThrow(() -> GeneralUtils.updateSettingsTransactional(Map.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("environment-dependent helpers")
|
||||
class EnvironmentHelperTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("generateMachineFingerprint returns a non-blank, deterministic value")
|
||||
void fingerprintStable() {
|
||||
String first = GeneralUtils.generateMachineFingerprint();
|
||||
assertNotNull(first);
|
||||
assertFalse(first.isBlank());
|
||||
// Deterministic within the same JVM/host
|
||||
assertEquals(first, GeneralUtils.generateMachineFingerprint());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getLocalNetworkIp returns null or a dotted IPv4 string")
|
||||
void localIpFormat() {
|
||||
String ip = GeneralUtils.getLocalNetworkIp();
|
||||
if (ip != null) {
|
||||
assertTrue(ip.matches("\\d{1,3}(\\.\\d{1,3}){3}"), "unexpected IP form: " + ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PdfAttachmentHandlerGapTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
// ----- helpers -------------------------------------------------------
|
||||
|
||||
/** Builds a tiny one-page PDF whose page renders the given text lines, each on its own line. */
|
||||
private static byte[] pdfWithLines(String... lines) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
float y = 720f;
|
||||
for (String line : lines) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, y);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
y -= 20f;
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a blank one-page PDF with no text. */
|
||||
private static byte[] blankPdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static EmlParser.EmailAttachment attachment(String filename, byte[] data) {
|
||||
EmlParser.EmailAttachment a = new EmlParser.EmailAttachment();
|
||||
a.setFilename(filename);
|
||||
a.setData(data);
|
||||
a.setContentType("application/pdf");
|
||||
return a;
|
||||
}
|
||||
|
||||
private static List<String> embeddedFileNames(byte[] pdfBytes) throws Exception {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDDocumentNameDictionary docNames = doc.getDocumentCatalog().getNames();
|
||||
if (docNames == null) {
|
||||
return names;
|
||||
}
|
||||
PDEmbeddedFilesNameTreeNode tree = docNames.getEmbeddedFiles();
|
||||
if (tree == null) {
|
||||
return names;
|
||||
}
|
||||
Map<String, PDComplexFileSpecification> map = tree.getNames();
|
||||
if (map != null) {
|
||||
names.addAll(map.keySet());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// ----- attachFilesToPdf: short-circuit branches ----------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("attachFilesToPdf short-circuit handling")
|
||||
class ShortCircuitTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null attachment list returns the original bytes untouched")
|
||||
void nullAttachments_returnsOriginalBytes() throws Exception {
|
||||
byte[] original = {1, 2, 3, 4};
|
||||
byte[] result =
|
||||
PdfAttachmentHandler.attachFilesToPdf(original, null, pdfDocumentFactory);
|
||||
assertSame(original, result);
|
||||
verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty attachment list returns the original bytes untouched")
|
||||
void emptyAttachments_returnsOriginalBytes() throws Exception {
|
||||
byte[] original = {9, 8, 7};
|
||||
byte[] result =
|
||||
PdfAttachmentHandler.attachFilesToPdf(
|
||||
original, new ArrayList<>(), pdfDocumentFactory);
|
||||
assertSame(original, result);
|
||||
verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("attachments with no usable data are skipped and a clean PDF is returned")
|
||||
void attachmentsWithoutData_produceNoEmbeddedFiles() throws Exception {
|
||||
byte[] pdfBytes = blankPdf();
|
||||
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
|
||||
|
||||
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.add(attachment("empty.pdf", new byte[0]));
|
||||
attachments.add(attachment("alsoEmpty.pdf", null));
|
||||
|
||||
byte[] result =
|
||||
PdfAttachmentHandler.attachFilesToPdf(
|
||||
pdfBytes, attachments, pdfDocumentFactory);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
assertTrue(embeddedFileNames(result).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
// ----- attachFilesToPdf: embedding happy paths -----------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("attachFilesToPdf embedding behaviour")
|
||||
class EmbeddingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("embeds attachment data even when no '@' marker exists in the PDF text")
|
||||
void embedsAttachment_withoutMarker() throws Exception {
|
||||
byte[] pdfBytes = pdfWithLines("Just a plain document with no attachment markers");
|
||||
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
|
||||
|
||||
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.add(attachment("report.pdf", "hello".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
byte[] result =
|
||||
PdfAttachmentHandler.attachFilesToPdf(
|
||||
pdfBytes, attachments, pdfDocumentFactory);
|
||||
|
||||
List<String> embedded = embeddedFileNames(result);
|
||||
assertEquals(1, embedded.size());
|
||||
assertTrue(embedded.contains("report.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("embeds attachment and adds an annotation when an '@' marker matches")
|
||||
void embedsAttachment_withMatchingMarker() throws Exception {
|
||||
byte[] pdfBytes =
|
||||
pdfWithLines("Email body text here", "Attachments (1)", "@report.pdf (5 KB)");
|
||||
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
|
||||
|
||||
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.add(attachment("report.pdf", "PDFDATA".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
byte[] result =
|
||||
PdfAttachmentHandler.attachFilesToPdf(
|
||||
pdfBytes, attachments, pdfDocumentFactory);
|
||||
|
||||
List<String> embedded = embeddedFileNames(result);
|
||||
assertTrue(embedded.contains("report.pdf"));
|
||||
|
||||
// The annotation pass should have run and produced at least one annotation on the
|
||||
// page that contains the marker (a blank source page has none).
|
||||
try (PDDocument doc = Loader.loadPDF(result)) {
|
||||
assertFalse(doc.getPage(0).getAnnotations().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("attachment without a filename falls back to a generated embedded name")
|
||||
void embedsAttachment_withGeneratedName() throws Exception {
|
||||
byte[] pdfBytes = blankPdf();
|
||||
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
|
||||
|
||||
EmlParser.EmailAttachment a = new EmlParser.EmailAttachment();
|
||||
a.setFilename(null);
|
||||
a.setData("x".getBytes(StandardCharsets.UTF_8));
|
||||
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.add(a);
|
||||
|
||||
byte[] result =
|
||||
PdfAttachmentHandler.attachFilesToPdf(
|
||||
pdfBytes, attachments, pdfDocumentFactory);
|
||||
|
||||
// A single embedded file should exist with a non-blank generated name.
|
||||
List<String> embedded = embeddedFileNames(result);
|
||||
assertEquals(1, embedded.size());
|
||||
assertFalse(embedded.get(0).isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("duplicate attachment filenames produce uniquely named embedded files")
|
||||
void embedsAttachments_withDuplicateNames() throws Exception {
|
||||
byte[] pdfBytes = blankPdf();
|
||||
when(pdfDocumentFactory.load(pdfBytes)).thenReturn(Loader.loadPDF(pdfBytes));
|
||||
|
||||
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.add(attachment("dup.pdf", "a".getBytes(StandardCharsets.UTF_8)));
|
||||
attachments.add(attachment("dup.pdf", "b".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
byte[] result =
|
||||
PdfAttachmentHandler.attachFilesToPdf(
|
||||
pdfBytes, attachments, pdfDocumentFactory);
|
||||
|
||||
List<String> embedded = embeddedFileNames(result);
|
||||
assertEquals(2, embedded.size());
|
||||
assertTrue(embedded.contains("dup.pdf"));
|
||||
// The second one must have been disambiguated, not overwritten.
|
||||
assertTrue(embedded.stream().anyMatch(n -> !"dup.pdf".equals(n)));
|
||||
}
|
||||
}
|
||||
|
||||
// ----- attachFilesToPdf: error wrapping ------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("attachFilesToPdf error handling")
|
||||
class ErrorHandlingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from the factory load propagates to the caller")
|
||||
void factoryIOException_propagates() throws Exception {
|
||||
byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
|
||||
when(pdfDocumentFactory.load(pdfBytes))
|
||||
.thenThrow(new java.io.IOException("boom from factory"));
|
||||
|
||||
List<EmlParser.EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.add(attachment("a.pdf", "data".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
java.io.IOException ex =
|
||||
assertThrows(
|
||||
java.io.IOException.class,
|
||||
() ->
|
||||
PdfAttachmentHandler.attachFilesToPdf(
|
||||
pdfBytes, attachments, pdfDocumentFactory));
|
||||
assertTrue(ex.getMessage().contains("boom from factory"));
|
||||
}
|
||||
}
|
||||
|
||||
// ----- AttachmentMarkerPositionFinder --------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("AttachmentMarkerPositionFinder")
|
||||
class MarkerFinderTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("finds marker positions inside an attachments section")
|
||||
void findsMarkerPositions() throws Exception {
|
||||
byte[] pdfBytes =
|
||||
pdfWithLines(
|
||||
"Some intro text",
|
||||
"Attachments (2)",
|
||||
"@invoice.pdf (10 KB)",
|
||||
"@photo.png (4 KB)");
|
||||
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PdfAttachmentHandler.AttachmentMarkerPositionFinder finder =
|
||||
new PdfAttachmentHandler.AttachmentMarkerPositionFinder();
|
||||
finder.setSortByPosition(false);
|
||||
String returned = finder.getText(doc);
|
||||
|
||||
// getText is overridden to return an empty string (positions are the payload).
|
||||
assertEquals("", returned);
|
||||
|
||||
List<PdfAttachmentHandler.MarkerPosition> positions = finder.getPositions();
|
||||
assertEquals(2, positions.size());
|
||||
|
||||
List<String> filenames =
|
||||
positions.stream()
|
||||
.map(PdfAttachmentHandler.MarkerPosition::getFilename)
|
||||
.toList();
|
||||
assertTrue(filenames.contains("invoice.pdf"));
|
||||
assertTrue(filenames.contains("photo.png"));
|
||||
|
||||
for (PdfAttachmentHandler.MarkerPosition p : positions) {
|
||||
assertEquals("@", p.getCharacter());
|
||||
assertEquals(0, p.getPageIndex());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("collects no positions when there is no attachments section")
|
||||
void noAttachmentSection_noPositions() throws Exception {
|
||||
byte[] pdfBytes =
|
||||
pdfWithLines("Plain email body", "Contact us @ support address", "Goodbye");
|
||||
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PdfAttachmentHandler.AttachmentMarkerPositionFinder finder =
|
||||
new PdfAttachmentHandler.AttachmentMarkerPositionFinder();
|
||||
finder.getText(doc);
|
||||
assertTrue(finder.getPositions().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sortByPosition reorders collected positions deterministically")
|
||||
void sortByPosition_sortsPositions() throws Exception {
|
||||
byte[] pdfBytes =
|
||||
pdfWithLines("Attachments (2)", "@first.pdf (1 KB)", "@second.pdf (2 KB)");
|
||||
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PdfAttachmentHandler.AttachmentMarkerPositionFinder finder =
|
||||
new PdfAttachmentHandler.AttachmentMarkerPositionFinder();
|
||||
finder.setSortByPosition(true);
|
||||
finder.getText(doc);
|
||||
|
||||
List<PdfAttachmentHandler.MarkerPosition> positions = finder.getPositions();
|
||||
assertEquals(2, positions.size());
|
||||
// With descending-Y sorting and same page, the higher-on-page marker comes first.
|
||||
assertTrue(positions.get(0).getY() >= positions.get(1).getY());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- processInlineImages -------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("processInlineImages")
|
||||
class ProcessInlineImagesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces a cid: reference with an inline base64 data URI")
|
||||
void replacesCidWithDataUri() {
|
||||
byte[] imageData = {(byte) 0x89, 'P', 'N', 'G'};
|
||||
EmlParser.EmailAttachment img = new EmlParser.EmailAttachment();
|
||||
img.setEmbedded(true);
|
||||
img.setContentId("img001");
|
||||
img.setFilename("pic.png");
|
||||
img.setContentType("image/png");
|
||||
img.setData(imageData);
|
||||
|
||||
EmlParser.EmailContent content = new EmlParser.EmailContent();
|
||||
List<EmlParser.EmailAttachment> list = new ArrayList<>();
|
||||
list.add(img);
|
||||
content.setAttachments(list);
|
||||
|
||||
String html = "<html><body><img src=\"cid:img001\"/></body></html>";
|
||||
String result = PdfAttachmentHandler.processInlineImages(html, content);
|
||||
|
||||
String expectedB64 = Base64.getEncoder().encodeToString(imageData);
|
||||
assertTrue(result.contains("data:image/png;base64," + expectedB64));
|
||||
assertFalse(result.contains("cid:img001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves a cid: reference untouched when no attachment matches it")
|
||||
void unmatchedCid_isUnchanged() {
|
||||
EmlParser.EmailAttachment img = new EmlParser.EmailAttachment();
|
||||
img.setEmbedded(true);
|
||||
img.setContentId("known");
|
||||
img.setFilename("known.png");
|
||||
img.setContentType("image/png");
|
||||
img.setData(new byte[] {1, 2, 3});
|
||||
|
||||
EmlParser.EmailContent content = new EmlParser.EmailContent();
|
||||
List<EmlParser.EmailAttachment> list = new ArrayList<>();
|
||||
list.add(img);
|
||||
content.setAttachments(list);
|
||||
|
||||
String html = "<img src=\"cid:unknown\"/>";
|
||||
String result = PdfAttachmentHandler.processInlineImages(html, content);
|
||||
|
||||
// The unknown cid reference is preserved verbatim.
|
||||
assertTrue(result.contains("cid:unknown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns original html when there are no embedded images to map")
|
||||
void noEmbeddedImages_returnsOriginal() {
|
||||
EmlParser.EmailAttachment nonEmbedded = new EmlParser.EmailAttachment();
|
||||
nonEmbedded.setEmbedded(false);
|
||||
nonEmbedded.setContentId("x");
|
||||
nonEmbedded.setData(new byte[] {1});
|
||||
|
||||
EmlParser.EmailContent content = new EmlParser.EmailContent();
|
||||
List<EmlParser.EmailAttachment> list = new ArrayList<>();
|
||||
list.add(nonEmbedded);
|
||||
content.setAttachments(list);
|
||||
|
||||
String html = "<img src=\"cid:x\"/>";
|
||||
assertEquals(html, PdfAttachmentHandler.processInlineImages(html, content));
|
||||
}
|
||||
}
|
||||
|
||||
// ----- formatEmailDate (deterministic UTC) ---------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("formatEmailDate determinism")
|
||||
class FormatEmailDateTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a known instant formats to a stable UTC string regardless of input zone")
|
||||
void zonedDateTime_formatsToUtc() {
|
||||
// 2024-06-15 12:00 in Tokyo is 03:00 UTC the same day.
|
||||
ZonedDateTime tokyo =
|
||||
ZonedDateTime.of(2024, 6, 15, 12, 0, 0, 0, ZoneId.of("Asia/Tokyo"));
|
||||
String result = PdfAttachmentHandler.formatEmailDate(tokyo);
|
||||
assertEquals("Sat, Jun 15, 2024 at 3:00 AM UTC", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Date overload converts a fixed epoch instant to the expected UTC string")
|
||||
void date_formatsToUtc() {
|
||||
// Epoch milli 0 == 1970-01-01T00:00:00Z.
|
||||
String result = PdfAttachmentHandler.formatEmailDate(new Date(0L));
|
||||
assertEquals("Thu, Jan 1, 1970 at 12:00 AM UTC", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null inputs yield an empty string for both overloads")
|
||||
void nullInputs_returnEmpty() {
|
||||
assertEquals("", PdfAttachmentHandler.formatEmailDate((Date) null));
|
||||
assertEquals("", PdfAttachmentHandler.formatEmailDate((ZonedDateTime) null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
/**
|
||||
* Additional unit tests for {@link PdfUtils} targeting methods not exercised by {@code
|
||||
* PdfUtilsTest}: convertFromPdf, convertPdfToPdfImage, imageToPdf, addImageToDocument,
|
||||
* overlayImage, containsTextInFile and the error branch of pageSize.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdfUtilsGapTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/** Builds a tiny single-page PDF and returns it serialized to bytes. */
|
||||
private static byte[] simplePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a PDF with the given number of empty A4 pages. */
|
||||
private static PDDocument docWithPages(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a PDF with the given number of tiny pages. convertPdfToPdfImage rasterises every page
|
||||
* at 300 DPI, so page area drives the cost; tiny pages keep render work minimal while still
|
||||
* exercising the per-page loop. Page size is non-square so dimension preservation stays
|
||||
* verifiable.
|
||||
*/
|
||||
private static PDDocument docWithTinyPages(int pages, float width, float height) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(new PDRectangle(width, height)));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Builds a PDF whose pages each contain the given text phrase. */
|
||||
private static PDDocument docWithText(String... pageTexts) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (String text : pageTexts) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(100, 700);
|
||||
cs.showText(text);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Encodes a small solid-color image to bytes in the requested format. */
|
||||
private static byte[] imageBytes(String format, Color color) throws IOException {
|
||||
BufferedImage img = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, format, baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ---- convertFromPdf -----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertFromPdf")
|
||||
class ConvertFromPdf {
|
||||
|
||||
@Test
|
||||
@DisplayName("single PNG image is produced from a one-page PDF")
|
||||
void singlePng() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(1));
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
pdfDocumentFactory, bytes, "png", ImageType.RGB, true, 72, "doc", true);
|
||||
|
||||
assertNotNull(out);
|
||||
assertTrue(out.length > 0);
|
||||
// A valid PNG starts with the 8-byte PNG signature.
|
||||
assertEquals((byte) 0x89, out[0]);
|
||||
assertEquals('P', out[1]);
|
||||
assertEquals('N', out[2]);
|
||||
assertEquals('G', out[3]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single combined JPEG image is produced for multi-page PDF")
|
||||
void singleJpegMultiPage() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(2));
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
pdfDocumentFactory,
|
||||
bytes,
|
||||
"jpg",
|
||||
ImageType.RGB,
|
||||
true,
|
||||
72,
|
||||
"doc",
|
||||
false);
|
||||
|
||||
assertNotNull(out);
|
||||
assertTrue(out.length > 0);
|
||||
// JPEG magic bytes.
|
||||
assertEquals((byte) 0xFF, out[0]);
|
||||
assertEquals((byte) 0xD8, out[1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single TIFF image sequence is produced for multi-page PDF")
|
||||
void singleTiffMultiPage() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(2));
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
pdfDocumentFactory,
|
||||
bytes,
|
||||
"tiff",
|
||||
ImageType.RGB,
|
||||
true,
|
||||
72,
|
||||
"doc",
|
||||
true);
|
||||
|
||||
assertNotNull(out);
|
||||
assertTrue(out.length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-single image mode returns a non-empty zip of per-page images")
|
||||
void zipOfImages() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(2));
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
pdfDocumentFactory,
|
||||
bytes,
|
||||
"png",
|
||||
ImageType.RGB,
|
||||
false,
|
||||
72,
|
||||
"myfile",
|
||||
true);
|
||||
|
||||
assertNotNull(out);
|
||||
assertTrue(out.length > 0);
|
||||
// ZIP local-file-header magic "PK\003\004".
|
||||
assertEquals('P', out[0]);
|
||||
assertEquals('K', out[1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DPI above the safe limit throws IllegalArgumentException")
|
||||
void dpiTooHighThrows() {
|
||||
byte[] bytes = new byte[] {1, 2, 3};
|
||||
// The DPI check happens before the document is loaded.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
pdfDocumentFactory,
|
||||
bytes,
|
||||
"png",
|
||||
ImageType.RGB,
|
||||
true,
|
||||
9999,
|
||||
"doc",
|
||||
true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("annotations excluded path still renders successfully")
|
||||
void withoutAnnotations() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
when(pdfDocumentFactory.load(bytes)).thenReturn(docWithPages(1));
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
pdfDocumentFactory,
|
||||
bytes,
|
||||
"png",
|
||||
ImageType.RGB,
|
||||
true,
|
||||
72,
|
||||
"doc",
|
||||
false);
|
||||
|
||||
assertNotNull(out);
|
||||
assertTrue(out.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- convertPdfToPdfImage -----------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToPdfImage")
|
||||
class ConvertPdfToPdfImage {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a new document with the same page count")
|
||||
void preservesPageCount() throws IOException {
|
||||
// Page size is irrelevant to the count assertion; tiny pages avoid a 300 DPI A4 raster.
|
||||
try (PDDocument source = docWithTinyPages(2, 6f, 9f);
|
||||
PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves page dimensions of the source")
|
||||
void preservesPageSize() throws IOException {
|
||||
// A small non-square page still proves width/height are carried through (and not
|
||||
// swapped) without rastering a full LETTER page at 300 DPI.
|
||||
float width = 60f;
|
||||
float height = 90f;
|
||||
try (PDDocument source = new PDDocument()) {
|
||||
source.addPage(new PDPage(new PDRectangle(width, height)));
|
||||
try (PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(width, box.getWidth(), 0.5f);
|
||||
assertEquals(height, box.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty document yields an empty document")
|
||||
void emptyDocument() throws IOException {
|
||||
try (PDDocument source = new PDDocument();
|
||||
PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
|
||||
assertEquals(0, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- imageToPdf ---------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("imageToPdf")
|
||||
class ImageToPdf {
|
||||
|
||||
private byte[] runImageToPdf(MultipartFile[] files, String fitOption, boolean autoRotate)
|
||||
throws IOException {
|
||||
when(pdfDocumentFactory.createNewDocument()).thenReturn(new PDDocument());
|
||||
return PdfUtils.imageToPdf(files, fitOption, autoRotate, "color", pdfDocumentFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single PNG image becomes a one-page PDF")
|
||||
void singlePngImage() throws IOException {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"image.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
imageBytes("png", Color.RED));
|
||||
|
||||
byte[] pdfOut = runImageToPdf(new MultipartFile[] {file}, "fillPage", false);
|
||||
|
||||
assertNotNull(pdfOut);
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JPEG image uses the lossy factory path and produces a PDF")
|
||||
void jpegImage() throws IOException {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"image.jpg",
|
||||
MediaType.IMAGE_JPEG_VALUE,
|
||||
imageBytes("jpg", Color.BLUE));
|
||||
|
||||
byte[] pdfOut = runImageToPdf(new MultipartFile[] {file}, "maintainAspectRatio", false);
|
||||
|
||||
assertNotNull(pdfOut);
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fitDocumentToImage sizes the page to the image")
|
||||
void fitDocumentToImage() throws IOException {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"image.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
imageBytes("png", Color.GREEN));
|
||||
|
||||
byte[] pdfOut = runImageToPdf(new MultipartFile[] {file}, "fitDocumentToImage", false);
|
||||
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertEquals(20f, box.getWidth(), 0.5f);
|
||||
assertEquals(20f, box.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple images become multiple pages")
|
||||
void multipleImages() throws IOException {
|
||||
MockMultipartFile a =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"a.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
imageBytes("png", Color.RED));
|
||||
MockMultipartFile b =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"b.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
imageBytes("png", Color.BLUE));
|
||||
|
||||
byte[] pdfOut = runImageToPdf(new MultipartFile[] {a, b}, "fillPage", true);
|
||||
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
|
||||
assertEquals(2, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- addImageToDocument -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("addImageToDocument")
|
||||
class AddImageToDocument {
|
||||
|
||||
private PDImageXObject portraitImage(PDDocument doc) throws IOException {
|
||||
BufferedImage img = new BufferedImage(40, 80, BufferedImage.TYPE_INT_RGB);
|
||||
return org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory.createFromImage(
|
||||
doc, img);
|
||||
}
|
||||
|
||||
private PDImageXObject landscapeImage(PDDocument doc) throws IOException {
|
||||
BufferedImage img = new BufferedImage(80, 40, BufferedImage.TYPE_INT_RGB);
|
||||
return org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory.createFromImage(
|
||||
doc, img);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fillPage adds an A4 page")
|
||||
void fillPage() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PdfUtils.addImageToDocument(doc, portraitImage(doc), "fillPage", false);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertEquals(PDRectangle.A4.getWidth(), box.getWidth(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maintainAspectRatio adds an A4 page and centers the image")
|
||||
void maintainAspectRatio() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PdfUtils.addImageToDocument(doc, portraitImage(doc), "maintainAspectRatio", false);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fitDocumentToImage sizes the page to the image bounds")
|
||||
void fitDocumentToImage() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PdfUtils.addImageToDocument(doc, portraitImage(doc), "fitDocumentToImage", false);
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertEquals(40f, box.getWidth(), 0.5f);
|
||||
assertEquals(80f, box.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("autoRotate with a landscape image swaps to landscape A4")
|
||||
void autoRotateLandscape() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PdfUtils.addImageToDocument(doc, landscapeImage(doc), "maintainAspectRatio", true);
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
// Landscape: width should now exceed height.
|
||||
assertTrue(box.getWidth() > box.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown fit option still adds a page without drawing")
|
||||
void unknownFitOption() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PdfUtils.addImageToDocument(doc, portraitImage(doc), "unknownOption", false);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- overlayImage -------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("overlayImage")
|
||||
class OverlayImage {
|
||||
|
||||
@Test
|
||||
@DisplayName("overlays only the first page when everyPage is false")
|
||||
void firstPageOnly() throws IOException {
|
||||
byte[] pdf = simplePdfBytes();
|
||||
when(pdfDocumentFactory.load(pdf)).thenReturn(docWithPages(3));
|
||||
byte[] image = imageBytes("png", Color.RED);
|
||||
|
||||
byte[] out = PdfUtils.overlayImage(pdfDocumentFactory, pdf, image, 10f, 10f, false);
|
||||
|
||||
assertNotNull(out);
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
|
||||
assertEquals(3, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("overlays every page when everyPage is true")
|
||||
void everyPage() throws IOException {
|
||||
byte[] pdf = simplePdfBytes();
|
||||
when(pdfDocumentFactory.load(pdf)).thenReturn(docWithPages(2));
|
||||
byte[] image = imageBytes("png", Color.BLUE);
|
||||
|
||||
byte[] out = PdfUtils.overlayImage(pdfDocumentFactory, pdf, image, 0f, 0f, true);
|
||||
|
||||
assertNotNull(out);
|
||||
assertTrue(out.length > 0);
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
|
||||
assertEquals(2, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- containsTextInFile -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("containsTextInFile")
|
||||
class ContainsTextInFile {
|
||||
|
||||
@Test
|
||||
@DisplayName("finds text when searching all pages")
|
||||
void allPagesMatch() throws IOException {
|
||||
PDDocument doc = docWithText("HelloWorld");
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "HelloWorld", "all"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null pagesToCheck is treated as all pages")
|
||||
void nullPagesTreatedAsAll() throws IOException {
|
||||
PDDocument doc = docWithText("FindThis");
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "FindThis", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when text is absent")
|
||||
void noMatch() throws IOException {
|
||||
PDDocument doc = docWithText("SomeText");
|
||||
assertFalse(PdfUtils.containsTextInFile(doc, "Missing", "all"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("matches text on an individual page number")
|
||||
void individualPage() throws IOException {
|
||||
PDDocument doc = docWithText("PageOne", "PageTwo");
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "PageTwo", "2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("matches text within a page range")
|
||||
void pageRange() throws IOException {
|
||||
PDDocument doc = docWithText("Alpha", "Beta", "Gamma");
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "Gamma", "1-3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("whitespace in the page spec is stripped before parsing")
|
||||
void whitespaceStripped() throws IOException {
|
||||
PDDocument doc = docWithText("One", "Two");
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "Two", " 1 , 2 "));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pageSize error branch ---------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("pageSize parsing")
|
||||
class PageSizeParsing {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-numeric expected size throws NumberFormatException")
|
||||
void nonNumericThrows() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
assertThrows(
|
||||
NumberFormatException.class, () -> PdfUtils.pageSize(doc, "widthxheight"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Gap-filling unit tests for {@link ProcessExecutor}. Focused on the pure logic that can be
|
||||
* exercised without launching any real OS process: command validation branches, the unoserver
|
||||
* endpoint helper methods (via reflection), the {@link ProcessExecutor.Processes} enum, the
|
||||
* singleton/getInstance behaviour, the static unoserver pool setter, and the nested {@link
|
||||
* ProcessExecutor.ProcessExecutorResult} value type.
|
||||
*/
|
||||
class ProcessExecutorGapTest {
|
||||
|
||||
// ----- reflection helpers -------------------------------------------------
|
||||
|
||||
private void invokeValidateCommand(ProcessExecutor executor, List<String> command)
|
||||
throws Exception {
|
||||
Method method = ProcessExecutor.class.getDeclaredMethod("validateCommand", List.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
method.invoke(executor, command);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> invokeStripUnoEndpointArgs(ProcessExecutor executor, List<String> command)
|
||||
throws Exception {
|
||||
Method method = ProcessExecutor.class.getDeclaredMethod("stripUnoEndpointArgs", List.class);
|
||||
method.setAccessible(true);
|
||||
return (List<String>) method.invoke(executor, command);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> invokeApplyUnoServerEndpoint(
|
||||
ProcessExecutor executor,
|
||||
List<String> command,
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint)
|
||||
throws Exception {
|
||||
Method method =
|
||||
ProcessExecutor.class.getDeclaredMethod(
|
||||
"applyUnoServerEndpoint",
|
||||
List.class,
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint.class);
|
||||
method.setAccessible(true);
|
||||
return (List<String>) method.invoke(executor, command, endpoint);
|
||||
}
|
||||
|
||||
private boolean invokeShouldUseUnoServerPool(ProcessExecutor executor, List<String> command)
|
||||
throws Exception {
|
||||
Method method =
|
||||
ProcessExecutor.class.getDeclaredMethod("shouldUseUnoServerPool", List.class);
|
||||
method.setAccessible(true);
|
||||
return (boolean) method.invoke(executor, command);
|
||||
}
|
||||
|
||||
private ProcessExecutor qpdfExecutor() {
|
||||
return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
|
||||
}
|
||||
|
||||
private ProcessExecutor libreOfficeExecutor() {
|
||||
return ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
|
||||
}
|
||||
|
||||
/** The static unoserver pool is global state; clear it after every test that touches it. */
|
||||
@AfterEach
|
||||
void resetUnoServerPool() {
|
||||
ProcessExecutor.setUnoServerPool(null);
|
||||
}
|
||||
|
||||
// ----- validateCommand deeper branches -----------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("validateCommand path/executable branches")
|
||||
class ValidateCommandPathTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("absolute path executable that does not exist is rejected")
|
||||
void absolutePathExecutableMissing() {
|
||||
String bogus =
|
||||
System.getProperty("os.name").toLowerCase().contains("win")
|
||||
? "C:\\definitely\\does\\not\\exist\\tool.exe"
|
||||
: "/definitely/does/not/exist/tool";
|
||||
IllegalArgumentException ex =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> invokeValidateCommand(qpdfExecutor(), List.of(bogus)));
|
||||
assertTrue(ex.getMessage().contains("does not exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("path that exists but is a directory is rejected (not a regular file)")
|
||||
void directoryPathExecutableRejected(@TempDir Path tempDir) {
|
||||
String dirPath = tempDir.toString();
|
||||
// Ensure the path contains a separator so the path-based validation branch is taken.
|
||||
assertTrue(dirPath.contains("/") || dirPath.contains("\\"));
|
||||
IllegalArgumentException ex =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> invokeValidateCommand(qpdfExecutor(), List.of(dirPath)));
|
||||
assertTrue(ex.getMessage().contains("not a regular file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("absolute path to an existing regular file passes validation")
|
||||
void existingRegularFileExecutablePasses(@TempDir Path tempDir) throws Exception {
|
||||
Path file = tempDir.resolve("fakebinary");
|
||||
Files.writeString(file, "#!/bin/sh\n");
|
||||
assertTrue(Files.exists(file));
|
||||
// Should not throw.
|
||||
invokeValidateCommand(qpdfExecutor(), List.of(file.toString(), "--version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("path traversal anywhere in the executable is rejected")
|
||||
void pathTraversalInExecutable() {
|
||||
IllegalArgumentException ex =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
invokeValidateCommand(
|
||||
qpdfExecutor(), List.of("/usr/bin/../bin/tool")));
|
||||
assertTrue(ex.getMessage().contains("path traversal"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null byte / newline checks run across every argument, not just the first")
|
||||
void invalidCharactersInLaterArgument() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "ok", "bad\0arg")));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "ok", "bad\narg")));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "ok", "bad\rarg")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("relative simple command (no separators) is trusted and passes")
|
||||
void relativeSimpleCommandPasses() throws Exception {
|
||||
invokeValidateCommand(qpdfExecutor(), List.of("qpdf", "--help"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null first-argument executable is rejected")
|
||||
void nullExecutableRejected() {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(null);
|
||||
// null arg is caught by the per-arg null check before the executable check.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> invokeValidateCommand(qpdfExecutor(), command));
|
||||
}
|
||||
}
|
||||
|
||||
// ----- stripUnoEndpointArgs ----------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("stripUnoEndpointArgs")
|
||||
class StripUnoEndpointArgsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("removes space-separated --host/--port/--host-location/--protocol pairs")
|
||||
void stripsSpaceSeparatedArgs() throws Exception {
|
||||
List<String> input =
|
||||
List.of(
|
||||
"unoconvert",
|
||||
"--host",
|
||||
"1.2.3.4",
|
||||
"--port",
|
||||
"9999",
|
||||
"--host-location",
|
||||
"remote",
|
||||
"--protocol",
|
||||
"https",
|
||||
"in.docx",
|
||||
"out.pdf");
|
||||
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
|
||||
assertEquals(List.of("unoconvert", "in.docx", "out.pdf"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removes equals-form --host=.../--port=... arguments")
|
||||
void stripsEqualsFormArgs() throws Exception {
|
||||
List<String> input =
|
||||
List.of(
|
||||
"unoconvert",
|
||||
"--host=5.6.7.8",
|
||||
"--port=4002",
|
||||
"--host-location=local",
|
||||
"--protocol=http",
|
||||
"doc.odt");
|
||||
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
|
||||
assertEquals(List.of("unoconvert", "doc.odt"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves a command without endpoint args unchanged")
|
||||
void leavesPlainCommandUntouched() throws Exception {
|
||||
List<String> input = List.of("unoconvert", "in.docx", "out.pdf");
|
||||
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
|
||||
assertEquals(input, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a fresh list, not the same instance")
|
||||
void returnsNewList() throws Exception {
|
||||
List<String> input = new ArrayList<>(List.of("unoconvert", "a", "b"));
|
||||
List<String> result = invokeStripUnoEndpointArgs(qpdfExecutor(), input);
|
||||
assertNotSame(input, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- applyUnoServerEndpoint --------------------------------------------
|
||||
|
||||
private ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint(
|
||||
String host, int port, String hostLocation, String protocol) {
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
|
||||
ep.setHost(host);
|
||||
ep.setPort(port);
|
||||
ep.setHostLocation(hostLocation);
|
||||
ep.setProtocol(protocol);
|
||||
return ep;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyUnoServerEndpoint")
|
||||
class ApplyUnoServerEndpointTests {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"injects --host/--port after the executable, defaults omit host-location and protocol")
|
||||
void injectsHostAndPortWithDefaults() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx", "out.pdf");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
endpoint("9.9.9.9", 7777, "auto", "http");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
|
||||
assertEquals(
|
||||
List.of(
|
||||
"unoconvert",
|
||||
"--host",
|
||||
"9.9.9.9",
|
||||
"--port",
|
||||
"7777",
|
||||
"in.docx",
|
||||
"out.pdf"),
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-default host-location and protocol are injected")
|
||||
void injectsHostLocationAndProtocolWhenNonDefault() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
endpoint("10.0.0.5", 2200, "remote", "https");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
|
||||
assertEquals(
|
||||
List.of(
|
||||
"unoconvert",
|
||||
"--host",
|
||||
"10.0.0.5",
|
||||
"--port",
|
||||
"2200",
|
||||
"--host-location",
|
||||
"remote",
|
||||
"--protocol",
|
||||
"https",
|
||||
"in.docx"),
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank host falls back to 127.0.0.1 and non-positive port falls back to 2003")
|
||||
void appliesHostAndPortFallbacks() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
endpoint(" ", 0, "auto", "http");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
|
||||
assertEquals(
|
||||
List.of("unoconvert", "--host", "127.0.0.1", "--port", "2003", "in.docx"),
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid host-location and protocol values are normalised to defaults")
|
||||
void invalidHostLocationAndProtocolNormalised() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
endpoint("1.1.1.1", 3000, "sideways", "gopher");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
|
||||
// Both invalid -> normalised to defaults (auto/http) -> neither injected.
|
||||
assertEquals(
|
||||
List.of("unoconvert", "--host", "1.1.1.1", "--port", "3000", "in.docx"),
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("host-location and protocol matching is case-insensitive and trimmed")
|
||||
void hostLocationAndProtocolCaseInsensitive() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
endpoint("1.1.1.1", 3000, " REMOTE ", " HTTPS ");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
|
||||
assertEquals(
|
||||
List.of(
|
||||
"unoconvert",
|
||||
"--host",
|
||||
"1.1.1.1",
|
||||
"--port",
|
||||
"3000",
|
||||
"--host-location",
|
||||
"remote",
|
||||
"--protocol",
|
||||
"https",
|
||||
"in.docx"),
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("existing endpoint args are stripped before re-injection")
|
||||
void stripsExistingEndpointArgsBeforeInjecting() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "--host", "old", "--port", "1", "in.docx");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
endpoint("2.2.2.2", 2222, "auto", "http");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
|
||||
assertEquals(
|
||||
List.of("unoconvert", "--host", "2.2.2.2", "--port", "2222", "in.docx"),
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null endpoint returns the command unchanged")
|
||||
void nullEndpointReturnsCommandUnchanged() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, null);
|
||||
assertEquals(command, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty command returns the command unchanged")
|
||||
void emptyCommandReturnedUnchanged() throws Exception {
|
||||
List<String> command = List.of();
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
endpoint("1.1.1.1", 2003, "auto", "http");
|
||||
List<String> result = invokeApplyUnoServerEndpoint(qpdfExecutor(), command, ep);
|
||||
assertEquals(command, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- shouldUseUnoServerPool --------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("shouldUseUnoServerPool")
|
||||
class ShouldUseUnoServerPoolTests {
|
||||
|
||||
private UnoServerPool nonEmptyPool() {
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
|
||||
return new UnoServerPool(List.of(ep));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"false for non-LIBRE_OFFICE process type even with a pool and unoconvert command")
|
||||
void falseForNonLibreOfficeProcessType() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(nonEmptyPool());
|
||||
assertFalse(
|
||||
invokeShouldUseUnoServerPool(qpdfExecutor(), List.of("unoconvert", "in.docx")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false when no pool is configured")
|
||||
void falseWhenPoolNull() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(null);
|
||||
assertFalse(
|
||||
invokeShouldUseUnoServerPool(
|
||||
libreOfficeExecutor(), List.of("unoconvert", "in.docx")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false when the configured pool is empty")
|
||||
void falseWhenPoolEmpty() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(new UnoServerPool(List.of()));
|
||||
assertFalse(
|
||||
invokeShouldUseUnoServerPool(
|
||||
libreOfficeExecutor(), List.of("unoconvert", "in.docx")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false for null or empty command")
|
||||
void falseForNullOrEmptyCommand() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(nonEmptyPool());
|
||||
assertFalse(invokeShouldUseUnoServerPool(libreOfficeExecutor(), null));
|
||||
assertFalse(invokeShouldUseUnoServerPool(libreOfficeExecutor(), List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("true for a plain unoconvert command with a non-empty pool")
|
||||
void trueForUnoconvertCommand() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(nonEmptyPool());
|
||||
assertTrue(
|
||||
invokeShouldUseUnoServerPool(
|
||||
libreOfficeExecutor(), List.of("unoconvert", "in.docx", "out.pdf")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("true for a unoconvert path with directories and a .exe extension")
|
||||
void trueForUnoconvertWithPathAndExeExtension() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(nonEmptyPool());
|
||||
assertTrue(
|
||||
invokeShouldUseUnoServerPool(
|
||||
libreOfficeExecutor(),
|
||||
List.of("C:\\tools\\bin\\unoconvert.exe", "in.docx")));
|
||||
assertTrue(
|
||||
invokeShouldUseUnoServerPool(
|
||||
libreOfficeExecutor(),
|
||||
List.of("/usr/local/bin/unoconvert", "in.docx")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("true for the legacy 'unoconv' executable name")
|
||||
void trueForLegacyUnoconv() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(nonEmptyPool());
|
||||
assertTrue(
|
||||
invokeShouldUseUnoServerPool(
|
||||
libreOfficeExecutor(), List.of("unoconv", "in.docx")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false for soffice, which must not be routed through the pool")
|
||||
void falseForSoffice() throws Exception {
|
||||
ProcessExecutor.setUnoServerPool(nonEmptyPool());
|
||||
assertFalse(
|
||||
invokeShouldUseUnoServerPool(
|
||||
libreOfficeExecutor(),
|
||||
List.of("/usr/bin/soffice", "--headless", "in.docx")));
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Processes enum -----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Processes enum")
|
||||
class ProcessesEnumTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("contains all expected process types")
|
||||
void containsExpectedValues() {
|
||||
ProcessExecutor.Processes[] values = ProcessExecutor.Processes.values();
|
||||
assertEquals(13, values.length);
|
||||
assertEquals(
|
||||
ProcessExecutor.Processes.LIBRE_OFFICE,
|
||||
ProcessExecutor.Processes.valueOf("LIBRE_OFFICE"));
|
||||
assertEquals(
|
||||
ProcessExecutor.Processes.CFF_CONVERTER,
|
||||
ProcessExecutor.Processes.valueOf("CFF_CONVERTER"));
|
||||
assertEquals(
|
||||
ProcessExecutor.Processes.FFMPEG, ProcessExecutor.Processes.valueOf("FFMPEG"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("valueOf rejects an unknown name")
|
||||
void valueOfRejectsUnknown() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ProcessExecutor.Processes.valueOf("NOT_A_PROCESS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getInstance resolves a non-null singleton for every enum value")
|
||||
void getInstanceForEveryProcessType() {
|
||||
for (ProcessExecutor.Processes p : ProcessExecutor.Processes.values()) {
|
||||
ProcessExecutor instance = ProcessExecutor.getInstance(p);
|
||||
assertNotNull(instance, "instance should not be null for " + p);
|
||||
// Same key returns the cached singleton.
|
||||
assertSame(instance, ProcessExecutor.getInstance(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- getInstance / liveUpdates -----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getInstance behaviour")
|
||||
class GetInstanceTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("single-arg getInstance delegates to liveUpdates=true and is cached")
|
||||
void singleArgDelegatesAndCaches() {
|
||||
ProcessExecutor a = ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
ProcessExecutor b =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT, true);
|
||||
assertSame(a, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the liveUpdates flag of the first call wins because the instance is cached")
|
||||
void firstCallWinsForCachedInstance() {
|
||||
// First resolution for this type fixes its configuration.
|
||||
ProcessExecutor first =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF, false);
|
||||
ProcessExecutor second =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF, true);
|
||||
assertSame(first, second);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- setUnoServerPool ---------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("setUnoServerPool")
|
||||
class SetUnoServerPoolTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("setting then clearing the pool flips shouldUseUnoServerPool")
|
||||
void poolSetterAffectsRouting() throws Exception {
|
||||
ProcessExecutor exec = libreOfficeExecutor();
|
||||
List<String> command = List.of("unoconvert", "in.docx");
|
||||
|
||||
ProcessExecutor.setUnoServerPool(null);
|
||||
assertFalse(invokeShouldUseUnoServerPool(exec, command));
|
||||
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
|
||||
ProcessExecutor.setUnoServerPool(new UnoServerPool(List.of(ep)));
|
||||
assertTrue(invokeShouldUseUnoServerPool(exec, command));
|
||||
|
||||
ProcessExecutor.setUnoServerPool(null);
|
||||
assertFalse(invokeShouldUseUnoServerPool(exec, command));
|
||||
}
|
||||
}
|
||||
|
||||
// ----- ProcessExecutorResult ---------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("ProcessExecutorResult value type")
|
||||
class ProcessExecutorResultTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor stores rc and messages; setters mutate them")
|
||||
void constructorAndSetters() {
|
||||
ProcessExecutor exec = qpdfExecutor();
|
||||
ProcessExecutor.ProcessExecutorResult result = exec.new ProcessExecutorResult(0, "ok");
|
||||
assertEquals(0, result.getRc());
|
||||
assertEquals("ok", result.getMessages());
|
||||
|
||||
result.setRc(42);
|
||||
result.setMessages("boom");
|
||||
assertEquals(42, result.getRc());
|
||||
assertEquals("boom", result.getMessages());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("messages may be null")
|
||||
void allowsNullMessages() {
|
||||
ProcessExecutor exec = qpdfExecutor();
|
||||
ProcessExecutor.ProcessExecutorResult result = exec.new ProcessExecutorResult(3, null);
|
||||
assertEquals(3, result.getRc());
|
||||
assertNull(result.getMessages());
|
||||
}
|
||||
}
|
||||
|
||||
// ----- runCommandWithOutputHandling validation entry point ---------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("runCommandWithOutputHandling validation (no process launched)")
|
||||
class RunCommandValidationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty command is rejected before any process is started")
|
||||
void emptyCommandRejected() {
|
||||
ProcessExecutor exec = qpdfExecutor();
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> exec.runCommandWithOutputHandling(List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("command containing a null byte is rejected before any process is started")
|
||||
void nullByteCommandRejected() {
|
||||
ProcessExecutor exec = qpdfExecutor();
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> exec.runCommandWithOutputHandling(List.of("qpdf", "bad\0arg")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("absolute non-existent executable is rejected before any process is started")
|
||||
void missingAbsoluteExecutableRejected() {
|
||||
ProcessExecutor exec = qpdfExecutor();
|
||||
String bogus =
|
||||
System.getProperty("os.name").toLowerCase().contains("win")
|
||||
? "C:\\no\\such\\tool.exe"
|
||||
: "/no/such/tool";
|
||||
IllegalArgumentException ex =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> exec.runCommandWithOutputHandling(List.of(bogus)));
|
||||
assertTrue(ex.getMessage().contains("does not exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("validation exception type is not an IOException for bad input")
|
||||
void validationThrowsIllegalArgumentNotIOException() {
|
||||
ProcessExecutor exec = qpdfExecutor();
|
||||
Exception thrown =
|
||||
assertThrows(
|
||||
Exception.class,
|
||||
() -> exec.runCommandWithOutputHandling(List.of("qpdf", "x\ny")));
|
||||
assertInstanceOf(IllegalArgumentException.class, thrown);
|
||||
assertFalse(thrown instanceof IOException);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,6 +335,12 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
// locations to avoid stale/duplicate assets lingering in the jar.
|
||||
delete generatedFrontendPaths.collect { new File(resourcesMetaInfDir, it) }
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
|
||||
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
|
||||
// api-landing.html is a real backend source file, not a generated artifact.
|
||||
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html'])
|
||||
// Nested prerendered route pages (e.g. settings/people.html)
|
||||
delete new File(resourcesStaticDir, 'settings')
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
|
||||
@@ -99,7 +99,7 @@ public class ExternalAppDepConfig {
|
||||
checkDependencyAndDisableGroup(cmd);
|
||||
return null;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
invokeAllWithTimeout(tasks, DEFAULT_TIMEOUT.plusSeconds(3));
|
||||
|
||||
// Python / OpenCV special handling
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -119,7 +118,7 @@ public class UIDataController {
|
||||
|
||||
if (new java.io.File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) {
|
||||
try (Stream<Path> paths =
|
||||
Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) {
|
||||
Files.walk(Path.of(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) {
|
||||
List<Path> jsonFiles =
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(p -> p.toString().endsWith(".json"))
|
||||
|
||||
+1
-2
@@ -9,7 +9,6 @@ import java.awt.print.PrinterJob;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
@@ -63,7 +62,7 @@ public class PrintFileController {
|
||||
stirling.software.common.model.MultipartFile file = request.getFileInput();
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename != null
|
||||
&& (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) {
|
||||
&& (originalFilename.contains("..") || Path.of(originalFilename).isAbsolute())) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalid.filepath", "Invalid file path detected: " + originalFilename);
|
||||
}
|
||||
|
||||
+2
-3
@@ -7,7 +7,6 @@ import java.nio.file.FileVisitOption;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
@@ -83,7 +82,7 @@ public class PipelineDirectoryProcessor {
|
||||
|
||||
try {
|
||||
for (String watchedFoldersDir : watchedFoldersDirs) {
|
||||
scanWatchedFolder(Paths.get(watchedFoldersDir).toAbsolutePath());
|
||||
scanWatchedFolder(Path.of(watchedFoldersDir).toAbsolutePath());
|
||||
}
|
||||
} finally {
|
||||
// Clean up ThreadLocal to prevent memory leaks
|
||||
@@ -443,7 +442,7 @@ public class PipelineDirectoryProcessor {
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString()))
|
||||
.replaceAll("");
|
||||
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
|
||||
return Path.of(outputDir).isAbsolute() ? Path.of(outputDir) : Path.of(".", outputDir);
|
||||
}
|
||||
|
||||
private void deleteOriginalFiles(List<File> filesToProcess, Path processingDir)
|
||||
|
||||
+2
-3
@@ -5,7 +5,6 @@ import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -336,12 +335,12 @@ public class PipelineProcessor {
|
||||
}
|
||||
List<Resource> outputFiles = new ArrayList<>();
|
||||
for (File file : files) {
|
||||
Path normalizedPath = Paths.get(file.getName()).normalize();
|
||||
Path normalizedPath = Path.of(file.getName()).normalize();
|
||||
if (normalizedPath.startsWith("..")) {
|
||||
throw new SecurityException(
|
||||
"Potential path traversal attempt in file name: " + file.getName());
|
||||
}
|
||||
Path path = Paths.get(file.getAbsolutePath());
|
||||
Path path = Path.of(file.getAbsolutePath());
|
||||
// debug statement
|
||||
log.info("Reading file: {}", path);
|
||||
if (Files.exists(path)) {
|
||||
|
||||
+3
-4
@@ -577,10 +577,9 @@ public class ReactRoutingController {
|
||||
.formatted(escapedBaseUrlHtml, serverUrl);
|
||||
}
|
||||
|
||||
// Replacements for Spring's org.springframework.web.util.HtmlUtils.htmlEscape and
|
||||
// org.springframework.web.util.JavaScriptUtils.javaScriptEscape (no Quarkus/Jakarta equivalent
|
||||
// and commons-text is not a dependency). These mirror the subset of behavior required for the
|
||||
// context-path string injected into the fallback/callback HTML.
|
||||
// Local replacements for the former Spring HtmlUtils/JavaScriptUtils escapers (no Quarkus or
|
||||
// Jakarta equivalent and commons-text is not a dependency). These mirror the subset of behavior
|
||||
// required for the context-path string injected into the fallback/callback HTML.
|
||||
private static String htmlEscape(String input) {
|
||||
if (input == null) {
|
||||
return "";
|
||||
|
||||
@@ -588,7 +588,7 @@ public class PdfJsonConversionService {
|
||||
.replaceAll("");
|
||||
return String.format("%s (%s)", cleanName, subtype);
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
.toList();
|
||||
long type3Fonts =
|
||||
responseFonts.stream().filter(f -> "Type3".equals(f.getSubtype())).count();
|
||||
|
||||
@@ -1554,7 +1554,7 @@ public class PdfJsonConversionService {
|
||||
.glyphName(outline.getGlyphName())
|
||||
.unicode(outline.getUnicode())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.debug(
|
||||
|
||||
+1
-2
@@ -10,7 +10,6 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
@@ -283,7 +282,7 @@ public class Type3FontLibrary {
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String normalizeAlias(String alias) {
|
||||
|
||||
+2
-3
@@ -3,7 +3,6 @@ package stirling.software.SPDF.service.pdfjson.type3.tool;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -285,9 +284,9 @@ public final class Type3SignatureTool {
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String arg = args[i];
|
||||
if ("--pdf".equals(arg) && i + 1 < args.length) {
|
||||
pdf = Paths.get(args[++i]);
|
||||
pdf = Path.of(args[++i]);
|
||||
} else if ("--output".equals(arg) && i + 1 < args.length) {
|
||||
output = Paths.get(args[++i]);
|
||||
output = Path.of(args[++i]);
|
||||
} else if ("--pretty".equals(arg)) {
|
||||
pretty = true;
|
||||
} else if ("--help".equals(arg) || "-h".equals(arg)) {
|
||||
|
||||
+2
-3
@@ -8,7 +8,6 @@ import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
@@ -414,7 +413,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
|
||||
private Path getInboxFolder(Long chatId) throws IOException {
|
||||
Path baseInbox =
|
||||
Paths.get(
|
||||
Path.of(
|
||||
runtimePathConfig.getPipelineWatchedFoldersPath(),
|
||||
telegramProperties.getPipelineInboxFolder());
|
||||
|
||||
@@ -448,7 +447,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
|
||||
private List<Path> waitForPipelineOutputs(PipelineFileInfo info) throws IOException {
|
||||
|
||||
Path finishedDir = Paths.get(runtimePathConfig.getPipelineFinishedFoldersPath());
|
||||
Path finishedDir = Path.of(runtimePathConfig.getPipelineFinishedFoldersPath());
|
||||
Files.createDirectories(finishedDir);
|
||||
|
||||
Instant start = info.savedAt();
|
||||
|
||||
@@ -290,6 +290,7 @@ storage:
|
||||
linkExpirationDays: 3 # Number of days before share links expire
|
||||
signing:
|
||||
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
|
||||
userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only.
|
||||
autoPipeline:
|
||||
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
|
||||
fileReadiness:
|
||||
@@ -390,6 +391,9 @@ mcp:
|
||||
jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration.
|
||||
resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp).
|
||||
# Required: tokens must list this id in `aud` or the request is rejected.
|
||||
acceptedAudiences: [] # Extra `aud` values accepted on top of resourceId. Empty = strict RFC 8707.
|
||||
# For IdPs that cannot mint resource audiences (Supabase OAuth server always
|
||||
# issues aud=authenticated) list that audience here, e.g. ['authenticated'].
|
||||
usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username')
|
||||
requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended)
|
||||
engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine
|
||||
|
||||
@@ -2215,6 +2215,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-security-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-servlet",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
@@ -2320,6 +2327,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-security",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
@@ -2438,6 +2452,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "7.0.5",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
/**
|
||||
* Gap tests for {@link MergeController} private helper logic reachable via reflection. Focuses on
|
||||
* sort comparators, file-order reordering, client file-id parsing, date extraction and filename
|
||||
* lookup. The external JPDFium merge path is not exercised here (covered structurally elsewhere).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class MergeControllerGapTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@InjectMocks private MergeController mergeController;
|
||||
|
||||
private MockMultipartFile fileA;
|
||||
private MockMultipartFile fileB;
|
||||
private MockMultipartFile fileC;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileA =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "Apple.pdf", MediaType.APPLICATION_PDF_VALUE, "a".getBytes());
|
||||
fileB =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "banana.pdf", MediaType.APPLICATION_PDF_VALUE, "b".getBytes());
|
||||
fileC =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "Cherry.pdf", MediaType.APPLICATION_PDF_VALUE, "c".getBytes());
|
||||
}
|
||||
|
||||
// ---- reflection helpers -------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private java.util.Comparator<MultipartFile> sortComparator(String sortType) throws Exception {
|
||||
Method m = MergeController.class.getDeclaredMethod("getSortComparator", String.class);
|
||||
m.setAccessible(true);
|
||||
return (java.util.Comparator<MultipartFile>) m.invoke(mergeController, sortType);
|
||||
}
|
||||
|
||||
private MultipartFile[] reorder(MultipartFile[] files, String fileOrder) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"reorderFilesByProvidedOrder", MultipartFile[].class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (MultipartFile[]) m.invoke(null, files, fileOrder);
|
||||
}
|
||||
|
||||
private String[] parseClientFileIds(String value) throws Exception {
|
||||
Method m = MergeController.class.getDeclaredMethod("parseClientFileIds", String.class);
|
||||
m.setAccessible(true);
|
||||
return (String[]) m.invoke(mergeController, value);
|
||||
}
|
||||
|
||||
private long getPdfDateTimeSafe(MultipartFile file) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod("getPdfDateTimeSafe", MultipartFile.class);
|
||||
m.setAccessible(true);
|
||||
return (long) m.invoke(mergeController, file);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private int indexOfByOriginalFilename(List<MultipartFile> list, String name) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"indexOfByOriginalFilename", List.class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (int) m.invoke(null, list, name);
|
||||
}
|
||||
|
||||
private static PDDocument docWithTitle(String title) {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getTitle()).thenReturn(title);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ---- getSortComparator: byFileName --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSortComparator: byFileName")
|
||||
class ByFileName {
|
||||
|
||||
@Test
|
||||
@DisplayName("sorts case-insensitively by original filename")
|
||||
void sortsCaseInsensitively() throws Exception {
|
||||
MultipartFile[] files = {fileC, fileA, fileB};
|
||||
Arrays.sort(files, sortComparator("byFileName"));
|
||||
assertArrayEquals(new MultipartFile[] {fileA, fileB, fileC}, files);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null original filename is treated as empty and sorts first")
|
||||
void nullFilenameSortsFirst() throws Exception {
|
||||
MultipartFile nullName = mock(MultipartFile.class);
|
||||
when(nullName.getOriginalFilename()).thenReturn(null);
|
||||
MultipartFile[] files = {fileB, nullName, fileA};
|
||||
Arrays.sort(files, sortComparator("byFileName"));
|
||||
assertSame(nullName, files[0]);
|
||||
assertSame(fileA, files[1]);
|
||||
assertSame(fileB, files[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getSortComparator: byPDFTitle --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSortComparator: byPDFTitle")
|
||||
class ByPdfTitle {
|
||||
|
||||
@Test
|
||||
@DisplayName("orders documents by their PDF title, ignoring case")
|
||||
void ordersByTitle() throws Exception {
|
||||
PDDocument docZ = docWithTitle("Zebra");
|
||||
PDDocument docA = docWithTitle("alpha");
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(docZ);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(docA);
|
||||
|
||||
int cmp = sortComparator("byPDFTitle").compare(fileA, fileB);
|
||||
assertTrue(cmp > 0, "Zebra should sort after alpha");
|
||||
// and the documents are closed via try-with-resources
|
||||
verify(docZ).close();
|
||||
verify(docA).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("both titles null yields equal (0)")
|
||||
void bothNullTitlesEqual() throws Exception {
|
||||
PDDocument d1 = docWithTitle(null);
|
||||
PDDocument d2 = docWithTitle(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
|
||||
|
||||
assertEquals(0, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("first title null sorts after non-null (returns 1)")
|
||||
void firstNullSortsLast() throws Exception {
|
||||
PDDocument d1 = docWithTitle(null);
|
||||
PDDocument d2 = docWithTitle("Beta");
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
|
||||
|
||||
assertEquals(1, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("second title null sorts first (returns -1)")
|
||||
void secondNullSortsFirst() throws Exception {
|
||||
PDDocument d1 = docWithTitle("Alpha");
|
||||
PDDocument d2 = docWithTitle(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(d1);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(d2);
|
||||
|
||||
assertEquals(-1, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException while loading yields equal (0)")
|
||||
void ioExceptionYieldsEqual() throws Exception {
|
||||
when(pdfDocumentFactory.load(fileA)).thenThrow(new IOException("boom"));
|
||||
assertEquals(0, sortComparator("byPDFTitle").compare(fileA, fileB));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getSortComparator: date-based and no-op orders ---------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSortComparator: date-based and pass-through orders")
|
||||
class DateAndPassThrough {
|
||||
|
||||
private PDDocument docWithModDate(long millis) {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
Calendar cal = new GregorianCalendar();
|
||||
cal.setTimeInMillis(millis);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(cal);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byDateModified orders newest first (descending)")
|
||||
void byDateModifiedNewestFirst() throws Exception {
|
||||
PDDocument older = docWithModDate(1_000L);
|
||||
PDDocument newer = docWithModDate(9_000L);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(older);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(newer);
|
||||
|
||||
// file1=older, file2=newer -> Long.compare(t2=newer, t1=older) > 0 -> older after newer
|
||||
int cmp = sortComparator("byDateModified").compare(fileA, fileB);
|
||||
assertTrue(cmp > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byDateCreated uses the same descending logic")
|
||||
void byDateCreatedNewestFirst() throws Exception {
|
||||
PDDocument older = docWithModDate(2_000L);
|
||||
PDDocument newer = docWithModDate(8_000L);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(newer);
|
||||
when(pdfDocumentFactory.load(fileB)).thenReturn(older);
|
||||
|
||||
int cmp = sortComparator("byDateCreated").compare(fileA, fileB);
|
||||
assertTrue(cmp < 0, "newer (file1) should sort before older (file2)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("orderProvided is a stable no-op comparator (0)")
|
||||
void orderProvidedNoOp() throws Exception {
|
||||
assertEquals(0, sortComparator("orderProvided").compare(fileA, fileB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown sort type falls back to no-op comparator (0)")
|
||||
void unknownSortTypeNoOp() throws Exception {
|
||||
assertEquals(0, sortComparator("somethingElse").compare(fileA, fileB));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getPdfDateTimeSafe -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getPdfDateTimeSafe")
|
||||
class GetPdfDateTimeSafe {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns modification date millis when present")
|
||||
void returnsModificationDate() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
Calendar cal = new GregorianCalendar();
|
||||
cal.setTimeInMillis(123_456L);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(cal);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(123_456L, getPdfDateTimeSafe(fileA));
|
||||
verify(doc).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to creation date when modification date is null")
|
||||
void fallsBackToCreationDate() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
Calendar cal = new GregorianCalendar();
|
||||
cal.setTimeInMillis(777L);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(null);
|
||||
when(info.getCreationDate()).thenReturn(cal);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(777L, getPdfDateTimeSafe(fileA));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 when no info dates and no XMP metadata present")
|
||||
void returnsZeroWhenNoDates() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
|
||||
when(doc.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getModificationDate()).thenReturn(null);
|
||||
when(info.getCreationDate()).thenReturn(null);
|
||||
when(doc.getDocumentCatalog()).thenReturn(catalog);
|
||||
when(catalog.getMetadata()).thenReturn(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(0L, getPdfDateTimeSafe(fileA));
|
||||
verify(doc).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 when document info itself is null")
|
||||
void returnsZeroWhenInfoNull() throws Exception {
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
|
||||
when(doc.getDocumentInformation()).thenReturn(null);
|
||||
when(doc.getDocumentCatalog()).thenReturn(catalog);
|
||||
when(catalog.getMetadata()).thenReturn(null);
|
||||
when(pdfDocumentFactory.load(fileA)).thenReturn(doc);
|
||||
|
||||
assertEquals(0L, getPdfDateTimeSafe(fileA));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 and swallows IOException on load failure")
|
||||
void returnsZeroOnLoadFailure() throws Exception {
|
||||
when(pdfDocumentFactory.load(fileA)).thenThrow(new IOException("cannot open"));
|
||||
assertEquals(0L, getPdfDateTimeSafe(fileA));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseClientFileIds -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseClientFileIds")
|
||||
class ParseClientFileIds {
|
||||
|
||||
@Test
|
||||
@DisplayName("null input returns empty array")
|
||||
void nullReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(null).length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank input returns empty array")
|
||||
void blankReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(" ").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty JSON array returns empty array")
|
||||
void emptyArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("[]").length);
|
||||
assertEquals(0, parseClientFileIds("[ ]").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-array text returns empty array")
|
||||
void nonArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("not-an-array").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses quoted, comma-separated ids and strips surrounding quotes")
|
||||
void parsesQuotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[\"id1\", \"id2\",\"id3\"]");
|
||||
assertArrayEquals(new String[] {"id1", "id2", "id3"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses unquoted ids as-is after trimming")
|
||||
void parsesUnquotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[a, b , c]");
|
||||
assertArrayEquals(new String[] {"a", "b", "c"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single element array yields a one-element result")
|
||||
void singleElement() throws Exception {
|
||||
assertArrayEquals(new String[] {"only"}, parseClientFileIds("[\"only\"]"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reorderFilesByProvidedOrder ----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("reorderFilesByProvidedOrder")
|
||||
class ReorderFilesByProvidedOrder {
|
||||
|
||||
@Test
|
||||
@DisplayName("reorders files to match the newline-separated order list")
|
||||
void reordersToMatchOrder() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB, fileC};
|
||||
MultipartFile[] result = reorder(files, "Cherry.pdf\nApple.pdf\nbanana.pdf");
|
||||
assertArrayEquals(new MultipartFile[] {fileC, fileA, fileB}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handles CRLF separators")
|
||||
void handlesCrlf() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB};
|
||||
MultipartFile[] result = reorder(files, "banana.pdf\r\nApple.pdf");
|
||||
assertArrayEquals(new MultipartFile[] {fileB, fileA}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unmatched names are skipped and remaining files appended in original order")
|
||||
void unmatchedNamesAppendedAtEnd() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB, fileC};
|
||||
// only mention Cherry; ghost.pdf is ignored; Apple+banana keep original relative order
|
||||
MultipartFile[] result = reorder(files, "Cherry.pdf\nghost.pdf");
|
||||
assertArrayEquals(new MultipartFile[] {fileC, fileA, fileB}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank/empty order entries are skipped")
|
||||
void blankEntriesSkipped() throws Exception {
|
||||
MultipartFile[] files = {fileA, fileB};
|
||||
MultipartFile[] result = reorder(files, "\n \nbanana.pdf\n");
|
||||
assertArrayEquals(new MultipartFile[] {fileB, fileA}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty file array returns empty array")
|
||||
void emptyFilesReturnsEmpty() throws Exception {
|
||||
MultipartFile[] result = reorder(new MultipartFile[0], "anything.pdf");
|
||||
assertEquals(0, result.length);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- indexOfByOriginalFilename ------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("indexOfByOriginalFilename")
|
||||
class IndexOfByOriginalFilename {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns index of matching filename")
|
||||
void returnsMatchIndex() throws Exception {
|
||||
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, fileB, fileC));
|
||||
assertEquals(1, indexOfByOriginalFilename(list, "banana.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns first match index when duplicates exist")
|
||||
void returnsFirstMatch() throws Exception {
|
||||
MockMultipartFile dup =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"Apple.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"dup".getBytes());
|
||||
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, dup));
|
||||
assertEquals(0, indexOfByOriginalFilename(list, "Apple.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns -1 when not found")
|
||||
void returnsMinusOneWhenAbsent() throws Exception {
|
||||
List<MultipartFile> list = new ArrayList<>(Arrays.asList(fileA, fileB));
|
||||
assertEquals(-1, indexOfByOriginalFilename(list, "missing.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns -1 for empty list")
|
||||
void returnsMinusOneForEmpty() throws Exception {
|
||||
assertEquals(-1, indexOfByOriginalFilename(new ArrayList<>(), "x.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mergeDocuments null-collaborator wiring ----------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("mergeDocuments wiring")
|
||||
class MergeDocumentsWiring {
|
||||
|
||||
@Test
|
||||
@DisplayName("creates a fresh document from the factory and returns it")
|
||||
void createsFromFactory() throws Exception {
|
||||
PDDocument merged = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.createNewDocument()).thenReturn(merged);
|
||||
|
||||
PDDocument result = mergeController.mergeDocuments(List.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertSame(merged, result);
|
||||
verify(pdfDocumentFactory).createNewDocument();
|
||||
verify(merged, never()).close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.PosterPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PosterPdfControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private PosterPdfController controller;
|
||||
|
||||
private final AtomicInteger tempCounter = new AtomicInteger();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// new TempFile(tempFileManager, suffix) delegates to createTempFile(suffix);
|
||||
// hand back real, writable files in the test temp dir so the controller's
|
||||
// real file/zip I/O works end to end.
|
||||
lenient()
|
||||
.when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
String suffix = inv.getArgument(0);
|
||||
File f =
|
||||
tempDir.resolve(
|
||||
"poster-"
|
||||
+ tempCounter.incrementAndGet()
|
||||
+ suffix)
|
||||
.toFile();
|
||||
Files.createFile(f.toPath());
|
||||
return f;
|
||||
});
|
||||
}
|
||||
|
||||
private MockMultipartFile createRealPdf(int numPages, String name) throws IOException {
|
||||
return createRealPdf(numPages, name, PDRectangle.A4, 0);
|
||||
}
|
||||
|
||||
private MockMultipartFile createRealPdf(
|
||||
int numPages, String name, PDRectangle size, int rotation) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
PDPage page = new PDPage(size);
|
||||
page.setRotation(rotation);
|
||||
doc.addPage(page);
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, baos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private PosterPdfRequest createRequest(MockMultipartFile file) {
|
||||
PosterPdfRequest req = new PosterPdfRequest();
|
||||
req.setFileInput(file);
|
||||
return req;
|
||||
}
|
||||
|
||||
/** Drain a file-backed Resource body to bytes. */
|
||||
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Read the single PDF entry out of a ZIP byte array. */
|
||||
private byte[] firstPdfEntry(byte[] zipBytes) throws IOException {
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertThat(entry).isNotNull();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
zis.transferTo(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void stubFactory(MockMultipartFile file) throws IOException {
|
||||
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
|
||||
PDDocument outputDoc = new PDDocument();
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenReturn(outputDoc);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("posterPdf happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Default 2x2 grid on single page yields a ZIP with a 4-page PDF")
|
||||
void defaultGrid_singlePage() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "doc.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("doc_poster.zip");
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
byte[] zipBytes = drainBody(response);
|
||||
assertThat(zipBytes).isNotEmpty();
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(zipBytes);
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
// 1 source page * (xFactor 2 * yFactor 2) = 4 output pages
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ZIP entry is named <base>_poster.pdf")
|
||||
void zipEntryNamedAfterBase() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "report.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] zipBytes = drainBody(response);
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertThat(entry).isNotNull();
|
||||
assertThat(entry.getName()).isEqualTo("report_poster.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Multi-page source multiplies output page count by grid size")
|
||||
void multiPageSource() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(3, "multi.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setXFactor(2);
|
||||
request.setYFactor(3);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
// 3 pages * (2 * 3) = 18
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(18);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1x1 grid produces one output page per source page")
|
||||
void oneByOneGrid() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(2, "one.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setXFactor(1);
|
||||
request.setYFactor(1);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Right-to-left ordering still produces the full grid")
|
||||
void rightToLeft() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "rtl.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setRightToLeft(true);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Rotated source page (90 degrees) is handled without error")
|
||||
void rotatedSourcePage() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "rot.pdf", PDRectangle.A4, 90);
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
byte[] pdfBytes = firstPdfEntry(drainBody(response));
|
||||
try (PDDocument result = Loader.loadPDF(pdfBytes)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Filename without extension is preserved in output names")
|
||||
void filenameWithoutExtension() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "noext");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("noext_poster.zip");
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(drainBody(response)))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertThat(entry).isNotNull();
|
||||
assertThat(entry.getName()).isEqualTo("noext_poster.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null original filename falls back to default base name")
|
||||
void nullOriginalFilename() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
null,
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
createRealPdf(1, "x.pdf").getBytes());
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("_poster.zip");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Page size handling")
|
||||
class PageSizes {
|
||||
|
||||
@Test
|
||||
@DisplayName("Each supported page size produces a valid ZIP")
|
||||
void supportedSizes() throws Exception {
|
||||
for (String size : new String[] {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"}) {
|
||||
MockMultipartFile file = createRealPdf(1, "s.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setPageSize(size);
|
||||
stubFactory(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.posterPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode())
|
||||
.as("page size %s", size)
|
||||
.isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).as("body for %s", size).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Invalid page size throws IllegalArgumentException")
|
||||
void invalidPageSize() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "bad.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
request.setPageSize("NotAPageSize");
|
||||
stubFactory(file);
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getTargetPageSize private mapping")
|
||||
class TargetPageSize {
|
||||
|
||||
private PDRectangle invoke(String size) throws Exception {
|
||||
Method m =
|
||||
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
|
||||
m.setAccessible(true);
|
||||
return (PDRectangle) m.invoke(controller, size);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Known sizes map to expected PDRectangles")
|
||||
void knownSizes() throws Exception {
|
||||
assertThat(invoke("A4")).isEqualTo(PDRectangle.A4);
|
||||
assertThat(invoke("Letter")).isEqualTo(PDRectangle.LETTER);
|
||||
assertThat(invoke("A3")).isEqualTo(PDRectangle.A3);
|
||||
assertThat(invoke("A5")).isEqualTo(PDRectangle.A5);
|
||||
assertThat(invoke("Legal")).isEqualTo(PDRectangle.LEGAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tabloid maps to 11x17 inch (792x1224 pt) rectangle")
|
||||
void tabloidSize() throws Exception {
|
||||
PDRectangle r = invoke("Tabloid");
|
||||
assertThat(r.getWidth()).isEqualTo(792f);
|
||||
assertThat(r.getHeight()).isEqualTo(1224f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unknown size raises IllegalArgumentException")
|
||||
void unknownSize() throws Exception {
|
||||
Method m =
|
||||
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
|
||||
m.setAccessible(true);
|
||||
assertThatThrownBy(() -> m.invoke(controller, "Unknown"))
|
||||
.isInstanceOf(InvocationTargetException.class)
|
||||
.hasCauseInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null size raises IllegalArgumentException")
|
||||
void nullSize() throws Exception {
|
||||
Method m =
|
||||
PosterPdfController.class.getDeclaredMethod("getTargetPageSize", String.class);
|
||||
m.setAccessible(true);
|
||||
assertThatThrownBy(() -> m.invoke(controller, new Object[] {null}))
|
||||
.isInstanceOf(InvocationTargetException.class)
|
||||
.hasCauseInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error propagation")
|
||||
class Errors {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from load propagates to caller")
|
||||
void loadIoException() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "io.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("load failed"));
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("load failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RuntimeException from createNewDocument propagates and closes zip temp file")
|
||||
void createNewDocumentRuntimeException() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "rt.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenThrow(new IllegalStateException("boom"));
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("boom");
|
||||
|
||||
sourceDoc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Collaborator interactions")
|
||||
class Interactions {
|
||||
|
||||
@Test
|
||||
@DisplayName("Both load and createNewDocumentBasedOnOldDocument are invoked")
|
||||
void factoryCalled() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "calls.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
|
||||
PDDocument outputDoc = new PDDocument();
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc))
|
||||
.thenReturn(outputDoc);
|
||||
|
||||
controller.posterPdf(request);
|
||||
|
||||
verify(pdfDocumentFactory).load(file);
|
||||
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(sourceDoc);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Zip temp file is never created when load fails before zip work")
|
||||
void noOutputWhenLoadFails() throws Exception {
|
||||
MockMultipartFile file = createRealPdf(1, "fail.pdf");
|
||||
PosterPdfRequest request = createRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("nope"));
|
||||
|
||||
assertThatThrownBy(() -> controller.posterPdf(request)).isInstanceOf(IOException.class);
|
||||
|
||||
// createNewDocumentBasedOnOldDocument is never reached after load throws.
|
||||
verify(pdfDocumentFactory, never())
|
||||
.createNewDocumentBasedOnOldDocument(
|
||||
org.mockito.ArgumentMatchers.any(PDDocument.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class SettingsControllerTest {
|
||||
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
|
||||
private SettingsController settingsController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
settingsController = new SettingsController(applicationProperties, endpointConfiguration);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateApiKey (update-enable-analytics)")
|
||||
class UpdateApiKey {
|
||||
|
||||
@Test
|
||||
@DisplayName("persists and returns 200 OK when analytics flag not yet set (null)")
|
||||
void updatesWhenNotPreviouslySet() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(null);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.TRUE);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertEquals("Updated", response.getBody().get("message"));
|
||||
|
||||
generalUtils.verify(
|
||||
() ->
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAnalytics", Boolean.TRUE),
|
||||
times(1));
|
||||
}
|
||||
|
||||
verify(system).setEnableAnalytics(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("persists the false value when enabling analytics is declined")
|
||||
void updatesWithFalseValue() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(null);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.FALSE);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals("Updated", response.getBody().get("message"));
|
||||
|
||||
generalUtils.verify(
|
||||
() ->
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAnalytics", Boolean.FALSE),
|
||||
times(1));
|
||||
}
|
||||
|
||||
verify(system).setEnableAnalytics(Boolean.FALSE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 208 ALREADY_REPORTED and does not persist when flag already true")
|
||||
void alreadyReportedWhenAlreadyTrue() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.TRUE);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.TRUE);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.ALREADY_REPORTED, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
Object message = response.getBody().get("message");
|
||||
assertNotNull(message);
|
||||
assertTrue(
|
||||
message.toString().startsWith("Setting has already been set"),
|
||||
"Unexpected message: " + message);
|
||||
|
||||
generalUtils.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
|
||||
}
|
||||
|
||||
verify(system, never()).setEnableAnalytics(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 208 ALREADY_REPORTED when flag already false (any non-null is set)")
|
||||
void alreadyReportedWhenAlreadyFalse() throws Exception {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.FALSE);
|
||||
|
||||
try (MockedStatic<GeneralUtils> generalUtils = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
settingsController.updateApiKey(Boolean.TRUE);
|
||||
|
||||
assertEquals(HttpStatus.ALREADY_REPORTED, response.getStatusCode());
|
||||
generalUtils.verify(
|
||||
() -> GeneralUtils.saveKeyToSettings(eq("system.enableAnalytics"), any()),
|
||||
never());
|
||||
}
|
||||
|
||||
verify(system, never()).setEnableAnalytics(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getDisabledEndpoints (get-endpoints-status)")
|
||||
class GetDisabledEndpoints {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 OK with the endpoint status map from EndpointConfiguration")
|
||||
void returnsEndpointStatuses() {
|
||||
Map<String, Boolean> statuses = new ConcurrentHashMap<>();
|
||||
statuses.put("merge-pdfs", Boolean.TRUE);
|
||||
statuses.put("remove-blanks", Boolean.FALSE);
|
||||
when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses);
|
||||
|
||||
ResponseEntity<Map<String, Boolean>> response =
|
||||
settingsController.getDisabledEndpoints();
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertSame(statuses, response.getBody());
|
||||
assertEquals(Boolean.TRUE, response.getBody().get("merge-pdfs"));
|
||||
assertEquals(Boolean.FALSE, response.getBody().get("remove-blanks"));
|
||||
verify(endpointConfiguration).getEndpointStatuses();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 OK with an empty map when no statuses are configured")
|
||||
void returnsEmptyMap() {
|
||||
Map<String, Boolean> statuses = new HashMap<>();
|
||||
when(endpointConfiguration.getEndpointStatuses()).thenReturn(statuses);
|
||||
|
||||
ResponseEntity<Map<String, Boolean>> response =
|
||||
settingsController.getDisabledEndpoints();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ToSinglePageControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private ToSinglePageController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Each managed temp file is backed by a real on-disk file so the response can be read back.
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("tsp-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
f.deleteOnExit();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
when(tf.getFile()).thenReturn(f);
|
||||
when(tf.getPath()).thenReturn(f.toPath());
|
||||
when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a real in-memory PDF with the given per-page sizes and return its bytes. */
|
||||
private byte[] createPdf(PDRectangle... pageSizes) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (PDRectangle size : pageSizes) {
|
||||
doc.addPage(new PDPage(size));
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private PDFFile requestFor(String filename, byte[] pdfBytes) {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", filename, MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(file);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the factory: load() returns a real PDDocument parsed from the PDFFile bytes, and
|
||||
* createNewDocumentBasedOnOldDocument() returns a fresh empty document.
|
||||
*/
|
||||
private void setupFactory() throws IOException {
|
||||
when(pdfDocumentFactory.load(any(PDFFile.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
PDFFile pf = inv.getArgument(0);
|
||||
return Loader.loadPDF(pf.getFileInput().getBytes());
|
||||
});
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
}
|
||||
|
||||
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Multi-page PDF collapses to one tall page")
|
||||
void multiPageCollapsesToSinglePage() throws Exception {
|
||||
// Three A4 portrait pages.
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A4, PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
byte[] out = drainBody(response);
|
||||
assertTrue(out.length > 0, "output PDF must be non-empty");
|
||||
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertEquals(1, result.getNumberOfPages(), "result must be a single page");
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(
|
||||
PDRectangle.A4.getWidth(),
|
||||
box.getWidth(),
|
||||
0.5f,
|
||||
"width matches the input width");
|
||||
assertEquals(
|
||||
PDRectangle.A4.getHeight() * 3,
|
||||
box.getHeight(),
|
||||
0.5f,
|
||||
"height is the sum of all page heights");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-page input produces a single page of the same size")
|
||||
void singlePageInput() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("single.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(PDRectangle.A4.getWidth(), box.getWidth(), 0.5f);
|
||||
assertEquals(PDRectangle.A4.getHeight(), box.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Mixed page sizes: width is the max, height is the sum")
|
||||
void mixedPageSizes() throws Exception {
|
||||
// A4 (595x842) + A3 (842x1191) -> width should be max (A3 width), height the sum.
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A3);
|
||||
PDFFile request = requestFor("mixed.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(PDRectangle.A3.getWidth(), box.getWidth(), 0.5f);
|
||||
assertEquals(
|
||||
PDRectangle.A4.getHeight() + PDRectangle.A3.getHeight(),
|
||||
box.getHeight(),
|
||||
0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Landscape pages are handled (width from landscape, height summed)")
|
||||
void landscapePages() throws Exception {
|
||||
PDRectangle landscape = new PDRectangle(842, 595);
|
||||
byte[] pdfBytes = createPdf(landscape, landscape);
|
||||
PDFFile request = requestFor("landscape.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
try (PDDocument result = Loader.loadPDF(drainBody(response))) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
assertEquals(842f, box.getWidth(), 0.5f);
|
||||
assertEquals(1190f, box.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Collaborator interactions")
|
||||
class Collaborators {
|
||||
|
||||
@Test
|
||||
@DisplayName("Source document is loaded from the request and then closed")
|
||||
void loadsAndClosesSourceDocument() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4, PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
|
||||
PDDocument sourceSpy = spy(Loader.loadPDF(pdfBytes));
|
||||
when(pdfDocumentFactory.load(any(PDFFile.class))).thenReturn(sourceSpy);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
controller.pdfToSinglePage(request);
|
||||
|
||||
verify(pdfDocumentFactory).load(any(PDFFile.class));
|
||||
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(any(PDDocument.class));
|
||||
// try-with-resources must close the loaded source document.
|
||||
verify(sourceSpy).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("A managed temp file is requested for the response body")
|
||||
void requestsManagedTempFile() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
controller.pdfToSinglePage(request);
|
||||
|
||||
verify(tempFileManager).createManagedTempFile(".pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Filename handling")
|
||||
class FilenameHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("Original filename is reflected in the Content-Disposition header")
|
||||
void filenameInContentDisposition() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("MyReport.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
String disposition =
|
||||
response.getHeaders()
|
||||
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
// generateFilename strips the extension and appends _singlePage.pdf
|
||||
assertTrue(
|
||||
disposition.contains("MyReport_singlePage.pdf"),
|
||||
"disposition should carry the generated single-page filename: " + disposition);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null original filename does not throw and yields a default name")
|
||||
void nullOriginalFilename() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
// MockMultipartFile with a null original filename.
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(file);
|
||||
setupFactory();
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response.getBody());
|
||||
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
|
||||
String disposition =
|
||||
response.getHeaders()
|
||||
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
assertTrue(
|
||||
disposition.contains("_singlePage.pdf"),
|
||||
"disposition should carry the empty-base single-page name: " + disposition);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error branches")
|
||||
class ErrorBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from load() propagates to the caller")
|
||||
void loadIOExceptionPropagates() throws Exception {
|
||||
PDFFile request = requestFor("broken.pdf", new byte[] {1, 2, 3});
|
||||
when(pdfDocumentFactory.load(any(PDFFile.class)))
|
||||
.thenThrow(new IOException("cannot load"));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> controller.pdfToSinglePage(request));
|
||||
assertEquals("cannot load", ex.getMessage());
|
||||
// No new document or temp file should be created when load fails.
|
||||
verify(pdfDocumentFactory, never())
|
||||
.createNewDocumentBasedOnOldDocument(any(PDDocument.class));
|
||||
verifyNoInteractions(tempFileManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from temp file creation propagates")
|
||||
void tempFileIOExceptionPropagates() throws Exception {
|
||||
byte[] pdfBytes = createPdf(PDRectangle.A4);
|
||||
PDFFile request = requestFor("input.pdf", pdfBytes);
|
||||
setupFactory();
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenThrow(new IOException("disk full"));
|
||||
|
||||
IOException ex =
|
||||
assertThrows(IOException.class, () -> controller.pdfToSinglePage(request));
|
||||
assertEquals("disk full", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.model.Dependency;
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class UIDataControllerGapTest {
|
||||
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
@Mock private ApplicationProperties.Legal legal;
|
||||
@Mock private SharedSignatureService signatureService;
|
||||
@Mock private UserServiceInterface userService;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private final ResourceLoader resourceLoader = new DefaultResourceLoader();
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
|
||||
private UIDataController controller(UserServiceInterface user) {
|
||||
return new UIDataController(
|
||||
applicationProperties,
|
||||
signatureService,
|
||||
user,
|
||||
resourceLoader,
|
||||
runtimePathConfig,
|
||||
objectMapper);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(system);
|
||||
lenient().when(applicationProperties.getLegal()).thenReturn(legal);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getFooterData")
|
||||
class FooterData {
|
||||
|
||||
@Test
|
||||
@DisplayName("maps all legal and analytics fields onto the response body")
|
||||
void mapsAllFields() {
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.TRUE);
|
||||
when(legal.getTermsAndConditions()).thenReturn("https://terms");
|
||||
when(legal.getPrivacyPolicy()).thenReturn("https://privacy");
|
||||
when(legal.getAccessibilityStatement()).thenReturn("https://a11y");
|
||||
when(legal.getCookiePolicy()).thenReturn("https://cookies");
|
||||
when(legal.getImpressum()).thenReturn("https://impressum");
|
||||
|
||||
ResponseEntity<UIDataController.FooterData> response =
|
||||
controller(userService).getFooterData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.FooterData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(Boolean.TRUE, body.getAnalyticsEnabled());
|
||||
assertEquals("https://terms", body.getTermsAndConditions());
|
||||
assertEquals("https://privacy", body.getPrivacyPolicy());
|
||||
assertEquals("https://a11y", body.getAccessibilityStatement());
|
||||
assertEquals("https://cookies", body.getCookiePolicy());
|
||||
assertEquals("https://impressum", body.getImpressum());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates null/false values from configuration")
|
||||
void handlesNulls() {
|
||||
when(system.getEnableAnalytics()).thenReturn(Boolean.FALSE);
|
||||
when(legal.getTermsAndConditions()).thenReturn(null);
|
||||
when(legal.getPrivacyPolicy()).thenReturn(null);
|
||||
when(legal.getAccessibilityStatement()).thenReturn(null);
|
||||
when(legal.getCookiePolicy()).thenReturn(null);
|
||||
when(legal.getImpressum()).thenReturn(null);
|
||||
|
||||
ResponseEntity<UIDataController.FooterData> response =
|
||||
controller(userService).getFooterData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.FooterData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(Boolean.FALSE, body.getAnalyticsEnabled());
|
||||
assertNull(body.getTermsAndConditions());
|
||||
assertNull(body.getPrivacyPolicy());
|
||||
assertNull(body.getAccessibilityStatement());
|
||||
assertNull(body.getCookiePolicy());
|
||||
assertNull(body.getImpressum());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getHomeData")
|
||||
class HomeData {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns OK with a populated body regardless of survey env var")
|
||||
void returnsOk() {
|
||||
ResponseEntity<UIDataController.HomeData> response =
|
||||
controller(userService).getHomeData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
// SHOW_SURVEY is unset in the test JVM, so the default (true) applies.
|
||||
assertTrue(response.getBody().isShowSurveyFromDocker());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getLicensesData")
|
||||
class LicensesData {
|
||||
|
||||
@Test
|
||||
@DisplayName("loads the bundled 3rdPartyLicenses.json from the classpath")
|
||||
void loadsDependencies() {
|
||||
ResponseEntity<UIDataController.LicensesData> response =
|
||||
controller(userService).getLicensesData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.LicensesData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
List<Dependency> deps = body.getDependencies();
|
||||
assertNotNull(deps);
|
||||
assertFalse(deps.isEmpty());
|
||||
// Each parsed dependency should at least carry a module name.
|
||||
assertNotNull(deps.get(0).getModuleName());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getPipelineData")
|
||||
class PipelineData {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the placeholder entry when the config directory is missing")
|
||||
void missingDirectoryYieldsPlaceholder() {
|
||||
String missing = Path.of("nonexistent-pipeline-dir-" + UUID.randomUUID()).toString();
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(missing);
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertTrue(body.getPipelineConfigs().isEmpty());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
Map<String, String> placeholder = body.getPipelineConfigsWithNames().get(0);
|
||||
assertEquals("", placeholder.get("json"));
|
||||
assertEquals("No preloaded configs found", placeholder.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the embedded name field when present")
|
||||
void readsConfigWithName(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(
|
||||
dir.resolve("config1.json"),
|
||||
"{\"name\":\"My Pipeline\",\"operations\":[]}",
|
||||
StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(1, body.getPipelineConfigs().size());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals("My Pipeline", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
assertTrue(
|
||||
body.getPipelineConfigsWithNames().get(0).get("json").contains("My Pipeline"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the filename (sans extension) when name is missing")
|
||||
void fallsBackToFilenameWhenNameMissing(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(
|
||||
dir.resolve("fallback-name.json"),
|
||||
"{\"operations\":[]}",
|
||||
StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals("fallback-name", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the filename when name is blank")
|
||||
void fallsBackToFilenameWhenNameBlank(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(
|
||||
dir.resolve("blank-name.json"),
|
||||
"{\"name\":\"\",\"operations\":[]}",
|
||||
StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals("blank-name", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ignores non-json files in the config directory")
|
||||
void ignoresNonJsonFiles(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("notes.txt"), "ignore me", StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
dir.resolve("real.json"), "{\"name\":\"Real\"}", StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(1, body.getPipelineConfigs().size());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals("Real", body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the placeholder when the directory exists but holds no json")
|
||||
void emptyDirectoryYieldsPlaceholder(@TempDir Path dir) {
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response =
|
||||
controller(userService).getPipelineData();
|
||||
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertTrue(body.getPipelineConfigs().isEmpty());
|
||||
assertEquals(1, body.getPipelineConfigsWithNames().size());
|
||||
assertEquals(
|
||||
"No preloaded configs found",
|
||||
body.getPipelineConfigsWithNames().get(0).get("name"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSignData")
|
||||
class SignData {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the current username from the user service to fetch signatures")
|
||||
void usesUsernameFromUserService() {
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
List<SignatureFile> sigs = List.of(new SignatureFile("sig.png", "Personal"));
|
||||
when(signatureService.getAvailableSignatures("alice")).thenReturn(sigs);
|
||||
|
||||
ResponseEntity<UIDataController.SignData> response =
|
||||
controller(userService).getSignData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.SignData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(sigs, body.getSignatures());
|
||||
// Fonts come from the real resource loader; the list is never null.
|
||||
assertNotNull(body.getFonts());
|
||||
verify(userService).getCurrentUsername();
|
||||
verify(signatureService).getAvailableSignatures("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to an empty username when no user service is wired")
|
||||
void nullUserServiceUsesEmptyUsername() {
|
||||
when(signatureService.getAvailableSignatures("")).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<UIDataController.SignData> response = controller(null).getSignData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.SignData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertNotNull(body.getSignatures());
|
||||
assertNotNull(body.getFonts());
|
||||
verify(signatureService).getAvailableSignatures("");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getOcrPdfData")
|
||||
class OcrData {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns an empty language list when the tessdata directory is absent")
|
||||
void absentTessdataDirYieldsEmptyList() {
|
||||
when(runtimePathConfig.getTessDataPath())
|
||||
.thenReturn(Path.of("nonexistent-tessdata-" + UUID.randomUUID()).toString());
|
||||
|
||||
ResponseEntity<UIDataController.OcrData> response =
|
||||
controller(userService).getOcrPdfData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.OcrData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertNotNull(body.getLanguages());
|
||||
assertTrue(body.getLanguages().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lists trained languages, excludes osd, and sorts alphabetically")
|
||||
void listsAndSortsTrainedLanguages(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("eng.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
Files.writeString(dir.resolve("deu.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
Files.writeString(dir.resolve("osd.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
// Non-traineddata files must be ignored.
|
||||
Files.writeString(dir.resolve("readme.txt"), "x", StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.OcrData> response =
|
||||
controller(userService).getOcrPdfData();
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
UIDataController.OcrData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
// osd filtered out; remaining sorted alphabetically.
|
||||
assertEquals(List.of("deu", "eng"), body.getLanguages());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("excludes osd case-insensitively")
|
||||
void excludesOsdCaseInsensitively(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("OSD.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
Files.writeString(dir.resolve("fra.traineddata"), "x", StandardCharsets.UTF_8);
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(dir.toString());
|
||||
|
||||
ResponseEntity<UIDataController.OcrData> response =
|
||||
controller(userService).getOcrPdfData();
|
||||
|
||||
UIDataController.OcrData body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(List.of("fra"), body.getLanguages());
|
||||
assertFalse(body.getLanguages().contains("OSD"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("FontResource format mapping")
|
||||
class FontResourceMapping {
|
||||
|
||||
@Test
|
||||
@DisplayName("maps known extensions to their CSS font-format strings")
|
||||
void mapsKnownExtensions() {
|
||||
assertEquals("truetype", new UIDataController.FontResource("Arial", "ttf").getType());
|
||||
assertEquals("woff", new UIDataController.FontResource("Arial", "woff").getType());
|
||||
assertEquals("woff2", new UIDataController.FontResource("Arial", "woff2").getType());
|
||||
assertEquals(
|
||||
"embedded-opentype",
|
||||
new UIDataController.FontResource("Arial", "eot").getType());
|
||||
assertEquals("svg", new UIDataController.FontResource("Arial", "svg").getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps unknown extensions to an empty type and preserves name/extension")
|
||||
void mapsUnknownExtensionToEmpty() {
|
||||
UIDataController.FontResource resource =
|
||||
new UIDataController.FontResource("Arial", "otf");
|
||||
assertEquals("", resource.getType());
|
||||
assertEquals("Arial", resource.getName());
|
||||
assertEquals("otf", resource.getExtension());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Cross-cutting behaviour")
|
||||
class CrossCutting {
|
||||
|
||||
@Test
|
||||
@DisplayName("getFooterData never touches the signature or user services")
|
||||
void footerDataIsIndependentOfUserState() {
|
||||
when(system.getEnableAnalytics()).thenReturn(null);
|
||||
|
||||
controller(userService).getFooterData();
|
||||
|
||||
verify(signatureService, never())
|
||||
.getAvailableSignatures(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(userService, never()).getCurrentUsername();
|
||||
}
|
||||
}
|
||||
}
|
||||
+826
@@ -0,0 +1,826 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertCbrToPdfRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertCbzToPdfRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbrRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbzRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CbrUtils;
|
||||
import stirling.software.common.util.CbzUtils;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfToCbrUtils;
|
||||
import stirling.software.common.util.PdfToCbzUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Gap coverage for {@link ConvertImgPDFController}, exercising the comic-book and image converters
|
||||
* left untested by ConvertImgPDFControllerTest. External binaries (Ghostscript, Python, RAR) are
|
||||
* never invoked: the utility boundaries are mocked statically.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertImgPDFControllerGapTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@InjectMocks private ConvertImgPDFController controller;
|
||||
|
||||
/** Builds a tiny, valid single-page A4 PDF as bytes. */
|
||||
private static byte[] tinyPdfBytes(int pages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a fresh in-memory PDDocument (the factory must hand back a real, open document). */
|
||||
private static PDDocument tinyDocument(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdfFile(String name, byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", name, "application/pdf", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf")
|
||||
class ConvertCbzToPdf {
|
||||
|
||||
@Test
|
||||
@DisplayName("disables ebook optimization when Ghostscript is not enabled")
|
||||
void disablesOptimizationWhenGhostscriptMissing() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "book.cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
|
||||
.thenReturn("book_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(tempFile, "book_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
cbz.verify(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps ebook optimization when Ghostscript is enabled")
|
||||
void keepsOptimizationWhenGhostscriptEnabled() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "book.cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(true)))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
|
||||
.thenReturn("book_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(tempFile, "book_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
cbz.verify(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(true)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the default comic name when the original filename is null")
|
||||
void usesDefaultNameWhenFilenameNull() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", null, "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"))
|
||||
.thenReturn("comic_converted.pdf");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
tempFile, "comic_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
// Default comic name is resolved when the upload carries no filename.
|
||||
gu.verify(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf")
|
||||
class ConvertCbrToPdf {
|
||||
|
||||
@Test
|
||||
@DisplayName("disables ebook optimization when Ghostscript is not enabled")
|
||||
void disablesOptimizationWhenGhostscriptMissing() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "book.cbr", "application/x-rar", new byte[] {1});
|
||||
ConvertCbrToPdfRequest request = new ConvertCbrToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
|
||||
byte[] pdfBytes = "converted-pdf".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
|
||||
|
||||
try (MockedStatic<CbrUtils> cbr = Mockito.mockStatic(CbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbr.when(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)))
|
||||
.thenReturn(pdfBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("book", "_converted.pdf"))
|
||||
.thenReturn("book_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "book_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertCbrToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
cbr.verify(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(false)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps ebook optimization when Ghostscript is enabled")
|
||||
void keepsOptimizationWhenGhostscriptEnabled() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "story.cbr", "application/x-rar", new byte[] {1});
|
||||
ConvertCbrToPdfRequest request = new ConvertCbrToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(true);
|
||||
|
||||
Mockito.when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
|
||||
byte[] pdfBytes = "converted-pdf".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
|
||||
|
||||
try (MockedStatic<CbrUtils> cbr = Mockito.mockStatic(CbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbr.when(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
eq(file),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager),
|
||||
eq(true)))
|
||||
.thenReturn(pdfBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("story", "_converted.pdf"))
|
||||
.thenReturn("story_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "story_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertCbrToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToCbz")
|
||||
class ConvertPdfToCbz {
|
||||
|
||||
@Test
|
||||
@DisplayName("passes the requested DPI through and returns a zip response")
|
||||
void passesThroughRequestedDpi() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbzRequest request = new ConvertPdfToCbzRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(200);
|
||||
|
||||
TempFile cbzFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<PdfToCbzUtils> p2c = Mockito.mockStatic(PdfToCbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
eq(file),
|
||||
eq(200),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager)))
|
||||
.thenReturn(cbzFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbz"))
|
||||
.thenReturn("doc_converted.cbz");
|
||||
wr.when(() -> WebResponseUtils.zipFileToWebResponse(cbzFile, "doc_converted.cbz"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToCbz(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("defaults DPI to 300 when a non-positive value is supplied")
|
||||
void defaultsDpiWhenNonPositive() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbzRequest request = new ConvertPdfToCbzRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(0);
|
||||
|
||||
TempFile cbzFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<PdfToCbzUtils> p2c = Mockito.mockStatic(PdfToCbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
eq(file),
|
||||
eq(300),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager)))
|
||||
.thenReturn(cbzFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbz"))
|
||||
.thenReturn("doc_converted.cbz");
|
||||
wr.when(() -> WebResponseUtils.zipFileToWebResponse(cbzFile, "doc_converted.cbz"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToCbz(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
// Negative/zero DPI is replaced by the 300 default before delegating.
|
||||
p2c.verify(
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
eq(file),
|
||||
eq(300),
|
||||
eq(pdfDocumentFactory),
|
||||
eq(tempFileManager)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToCbr")
|
||||
class ConvertPdfToCbr {
|
||||
|
||||
@Test
|
||||
@DisplayName("passes the requested DPI and wraps bytes as an octet-stream response")
|
||||
void passesThroughRequestedDpi() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbrRequest request = new ConvertPdfToCbrRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(150);
|
||||
|
||||
byte[] cbrBytes = "cbr-archive".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(cbrBytes);
|
||||
|
||||
try (MockedStatic<PdfToCbrUtils> p2c = Mockito.mockStatic(PdfToCbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
eq(file), eq(150), eq(pdfDocumentFactory)))
|
||||
.thenReturn(cbrBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbr"))
|
||||
.thenReturn("doc_converted.cbr");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(cbrBytes),
|
||||
eq("doc_converted.cbr"),
|
||||
eq(MediaType.APPLICATION_OCTET_STREAM)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertPdfToCbr(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("defaults DPI to 300 when a non-positive value is supplied")
|
||||
void defaultsDpiWhenNonPositive() throws Exception {
|
||||
MockMultipartFile file = pdfFile("doc.pdf", tinyPdfBytes(1));
|
||||
ConvertPdfToCbrRequest request = new ConvertPdfToCbrRequest();
|
||||
request.setFileInput(file);
|
||||
request.setDpi(-5);
|
||||
|
||||
byte[] cbrBytes = "cbr-archive".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(cbrBytes);
|
||||
|
||||
try (MockedStatic<PdfToCbrUtils> p2c = Mockito.mockStatic(PdfToCbrUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
p2c.when(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
eq(file), eq(300), eq(pdfDocumentFactory)))
|
||||
.thenReturn(cbrBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("doc", "_converted.cbr"))
|
||||
.thenReturn("doc_converted.cbr");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(cbrBytes),
|
||||
eq("doc_converted.cbr"),
|
||||
eq(MediaType.APPLICATION_OCTET_STREAM)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertPdfToCbr(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
p2c.verify(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
eq(file), eq(300), eq(pdfDocumentFactory)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertToImage")
|
||||
class ConvertToImage {
|
||||
|
||||
private MockMultipartFile imagePdf(byte[] bytes) {
|
||||
return pdfFile("source.pdf", bytes);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-image PNG path returns the rendered bytes")
|
||||
void singleImagePng() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("png");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(false);
|
||||
|
||||
// rearrangePdfPages loads a real document; convertFromPdf is the boundary we stub.
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
byte[] imageBytes = "png-image".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("PNG"),
|
||||
eq(ImageType.RGB),
|
||||
eq(true),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(false)))
|
||||
.thenReturn(imageBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(imageBytes),
|
||||
any(String.class),
|
||||
any(MediaType.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple-image path zips the rendered output with octet-stream type")
|
||||
void multipleImagesZip() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(2);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("jpg");
|
||||
request.setSingleOrMultiple("multiple");
|
||||
request.setColorType("greyscale");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(true);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(2));
|
||||
|
||||
byte[] zipBytes = "zip-bytes".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(zipBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
// greyscale -> ImageType.GRAY, multiple -> singleImage=false
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("JPG"),
|
||||
eq(ImageType.GRAY),
|
||||
eq(false),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(true)))
|
||||
.thenReturn(zipBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(zipBytes),
|
||||
any(String.class),
|
||||
eq(MediaType.APPLICATION_OCTET_STREAM)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blackwhite color type maps to BINARY image type")
|
||||
void blackwhiteMapsToBinary() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("png");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("blackwhite");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(false);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
byte[] imageBytes = "bw-image".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("PNG"),
|
||||
eq(ImageType.BINARY),
|
||||
eq(true),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(false)))
|
||||
.thenReturn(imageBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(imageBytes),
|
||||
any(String.class),
|
||||
any(MediaType.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null page numbers fall back to all pages")
|
||||
void nullPageNumbersDefaultsToAll() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("png");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers(null);
|
||||
request.setIncludeAnnotations(null);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
byte[] imageBytes = "png-image".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
// includeAnnotations null -> false
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("PNG"),
|
||||
eq(ImageType.RGB),
|
||||
eq(true),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(false)))
|
||||
.thenReturn(imageBytes);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
eq(imageBytes),
|
||||
any(String.class),
|
||||
any(MediaType.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("webp requested without Python throws the python-required IOException")
|
||||
void webpWithoutPythonThrows() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
MockMultipartFile file = imagePdf(pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("webp");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(false);
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<CheckProgramInstall> cpi =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
|
||||
// webp renders to PNG first, then requires Python for the final conversion.
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("png"),
|
||||
any(ImageType.class),
|
||||
anyBoolean(),
|
||||
anyInt(),
|
||||
any(String.class),
|
||||
anyBoolean()))
|
||||
.thenReturn("png-image".getBytes());
|
||||
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> controller.convertToImage(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createConvertedFilename (via the comic converters)")
|
||||
class CreateConvertedFilename {
|
||||
|
||||
@Test
|
||||
@DisplayName("strips only the trailing extension from a multi-dot filename")
|
||||
void stripsTrailingExtensionOnly() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "my.archive.cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("my.archive", "_converted.pdf"))
|
||||
.thenReturn("my.archive_converted.pdf");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
tempFile, "my.archive_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
// Only the final ".cbz" is removed, the inner dot is preserved.
|
||||
gu.verify(() -> GeneralUtils.generateFilename("my.archive", "_converted.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the default comic name when stripping leaves a blank base name")
|
||||
void fallsBackToComicWhenBaseNameBlank() throws Exception {
|
||||
// ".cbz" strips to an empty base, which the controller replaces with "comic".
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", ".cbz", "application/zip", new byte[] {1});
|
||||
ConvertCbzToPdfRequest request = new ConvertCbzToPdfRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOptimizeForEbook(false);
|
||||
|
||||
TempFile tempFile = Mockito.mock(TempFile.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<Resource> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<CbzUtils> cbz = Mockito.mockStatic(CbzUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
cbz.when(() -> CbzUtils.convertCbzToPdf(any(), any(), any(), anyBoolean()))
|
||||
.thenReturn(tempFile);
|
||||
gu.when(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"))
|
||||
.thenReturn("comic_converted.pdf");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
tempFile, "comic_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertCbzToPdf(request);
|
||||
|
||||
assertSame(expected, response);
|
||||
gu.verify(() -> GeneralUtils.generateFilename("comic", "_converted.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("controller is constructed with its injected collaborators")
|
||||
void controllerIsConstructed() {
|
||||
assertNotNull(controller);
|
||||
assertEquals(0, 0);
|
||||
}
|
||||
}
|
||||
+869
@@ -0,0 +1,869 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Gap-filling unit tests for {@link ConvertPDFToPDFA}. Focuses on validation/option/error branches
|
||||
* and the small pure helpers, mocking the collaborators so that no external binary (ghostscript,
|
||||
* qpdf, libreoffice) or network is invoked.
|
||||
*/
|
||||
@DisplayName("ConvertPDFToPDFA gap tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertPDFToPDFAGapTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private VeraPDFService veraPDFService;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ConvertPDFToPDFA newController() {
|
||||
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
|
||||
}
|
||||
|
||||
// ---- reflection helpers ----------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T invokeStatic(String methodName, Object... args) throws Exception {
|
||||
Method method = findMethod(methodName, args.length);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (T) method.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T invokeInstance(Object target, String methodName, Object... args)
|
||||
throws Exception {
|
||||
Method method = findMethod(methodName, args.length);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (T) method.invoke(target, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Method findMethod(String methodName, int argCount) {
|
||||
for (Method method : ConvertPDFToPDFA.class.getDeclaredMethods()) {
|
||||
if (method.getName().equals(methodName) && method.getParameterCount() == argCount) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"No method named " + methodName + " with " + argCount + " params");
|
||||
}
|
||||
|
||||
private static Exception unwrap(InvocationTargetException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof Exception ex) {
|
||||
return ex;
|
||||
}
|
||||
return new RuntimeException(cause);
|
||||
}
|
||||
|
||||
// ---- pdf builders ----------------------------------------------------------------------
|
||||
|
||||
private PDDocument simplePdf() throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(100, 700);
|
||||
cs.showText("hello world");
|
||||
cs.endText();
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private byte[] simplePdfBytes() throws IOException {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("PdfaProfile.fromRequest token resolution")
|
||||
class PdfaProfileResolution {
|
||||
|
||||
// invokes the enum's static fromRequest via reflection, then reads getPart()/displayName
|
||||
private Object resolveProfile(String token) throws Exception {
|
||||
Class<?> enumClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("PdfaProfile")) {
|
||||
enumClass = inner;
|
||||
}
|
||||
}
|
||||
assertThat(enumClass).isNotNull();
|
||||
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, token);
|
||||
}
|
||||
|
||||
private int partOf(Object profile) throws Exception {
|
||||
Method getPart = profile.getClass().getDeclaredMethod("getPart");
|
||||
getPart.setAccessible(true);
|
||||
return (int) getPart.invoke(profile);
|
||||
}
|
||||
|
||||
private String suffixOf(Object profile) throws Exception {
|
||||
Method m = profile.getClass().getDeclaredMethod("outputSuffix");
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(profile);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null token defaults to PDF/A-2b")
|
||||
void nullDefaultsToPdfA2() throws Exception {
|
||||
Object profile = resolveProfile(null);
|
||||
assertThat(partOf(profile)).isEqualTo(2);
|
||||
assertThat(suffixOf(profile)).isEqualTo("_PDFA-2b.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfa-1' resolves to PDF/A-1b")
|
||||
void pdfa1Resolves() throws Exception {
|
||||
assertThat(partOf(resolveProfile("pdfa-1"))).isEqualTo(1);
|
||||
assertThat(suffixOf(resolveProfile("pdfa-1"))).isEqualTo("_PDFA-1b.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfa' and 'pdfa-2b' resolve to PDF/A-2b")
|
||||
void pdfa2Resolves() throws Exception {
|
||||
assertThat(partOf(resolveProfile("pdfa"))).isEqualTo(2);
|
||||
assertThat(partOf(resolveProfile("pdfa-2b"))).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfa-3' and 'pdfa-3b' resolve to PDF/A-3b")
|
||||
void pdfa3Resolves() throws Exception {
|
||||
assertThat(partOf(resolveProfile("pdfa-3"))).isEqualTo(3);
|
||||
assertThat(partOf(resolveProfile("pdfa-3b"))).isEqualTo(3);
|
||||
assertThat(suffixOf(resolveProfile("pdfa-3"))).isEqualTo("_PDFA-3b.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("token is trimmed and case-insensitive")
|
||||
void caseInsensitiveAndTrimmed() throws Exception {
|
||||
assertThat(partOf(resolveProfile(" PDFA-1 "))).isEqualTo(1);
|
||||
assertThat(partOf(resolveProfile("PDFA-3B"))).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown token falls back to PDF/A-2b")
|
||||
void unknownFallsBack() throws Exception {
|
||||
assertThat(partOf(resolveProfile("not-a-real-format"))).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("PdfXProfile.fromRequest token resolution")
|
||||
class PdfXProfileResolution {
|
||||
|
||||
private Object resolveProfile(String token) throws Exception {
|
||||
Class<?> enumClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("PdfXProfile")) {
|
||||
enumClass = inner;
|
||||
}
|
||||
}
|
||||
assertThat(enumClass).isNotNull();
|
||||
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, token);
|
||||
}
|
||||
|
||||
private String suffixOf(Object profile) throws Exception {
|
||||
Method m = profile.getClass().getDeclaredMethod("outputSuffix");
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(profile);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null token defaults to PDF/X")
|
||||
void nullDefaultsToPdfX() throws Exception {
|
||||
assertThat(suffixOf(resolveProfile(null))).isEqualTo("_PDFX.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'pdfx' resolves to the PDF/X profile")
|
||||
void pdfxResolves() throws Exception {
|
||||
assertThat(suffixOf(resolveProfile("pdfx"))).isEqualTo("_PDFX.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown token falls back to PDF/X")
|
||||
void unknownFallsBack() throws Exception {
|
||||
assertThat(suffixOf(resolveProfile("garbage"))).isEqualTo("_PDFX.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("detectMimeTypeFromFilename")
|
||||
class MimeTypeDetection {
|
||||
|
||||
private String detect(String fileName) throws Exception {
|
||||
return invokeInstance(newController(), "detectMimeTypeFromFilename", fileName);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("known extensions map to their MIME type")
|
||||
void knownExtensions() throws Exception {
|
||||
assertThat(detect("data.xml")).isEqualTo("application/xml");
|
||||
assertThat(detect("data.json")).isEqualTo("application/json");
|
||||
assertThat(detect("notes.txt")).isEqualTo("text/plain");
|
||||
assertThat(detect("image.png")).isEqualTo("image/png");
|
||||
assertThat(detect("photo.JPEG")).isEqualTo("image/jpeg");
|
||||
assertThat(detect("archive.zip")).isEqualTo("application/zip");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown extension yields octet-stream default")
|
||||
void unknownExtension() throws Exception {
|
||||
assertThat(detect("file.unknownext")).isEqualTo("application/octet-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null and empty file names yield octet-stream default")
|
||||
void nullAndEmpty() throws Exception {
|
||||
assertThat(detect(null)).isEqualTo("application/octet-stream");
|
||||
assertThat(detect("")).isEqualTo("application/octet-stream");
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("countGlyphs / buildStandardType1GlyphSet")
|
||||
class GlyphHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("countGlyphs counts forward slashes")
|
||||
void countsSlashes() throws Exception {
|
||||
assertThat((int) invokeStatic("countGlyphs", "/a/b/c")).isEqualTo(3);
|
||||
assertThat((int) invokeStatic("countGlyphs", "no-slashes")).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("countGlyphs handles null and empty")
|
||||
void countsNullEmpty() throws Exception {
|
||||
assertThat((int) invokeStatic("countGlyphs", (Object) null)).isEqualTo(0);
|
||||
assertThat((int) invokeStatic("countGlyphs", "")).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("standard glyph set is space-separated and contains core glyphs")
|
||||
void standardGlyphSet() throws Exception {
|
||||
String glyphs = invokeStatic("buildStandardType1GlyphSet");
|
||||
assertThat(glyphs).isNotBlank();
|
||||
assertThat(glyphs).contains(".notdef", "space", "A", "z", "zero", "period");
|
||||
// space-separated; no leading slash format here
|
||||
assertThat(glyphs.split(" ").length).isGreaterThan(100);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("isType1Font / quad and rect validation")
|
||||
class TypeAndGeometryHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("isType1Font true for Standard14 Type1 font")
|
||||
void isType1True() throws Exception {
|
||||
PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
assertThat((boolean) invokeStatic("isType1Font", font)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isValidQuadPoints accepts multiples of 8, rejects otherwise")
|
||||
void quadValidation() throws Exception {
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
assertThat(
|
||||
(boolean)
|
||||
invokeInstance(
|
||||
controller, "isValidQuadPoints", (Object) new float[8]))
|
||||
.isTrue();
|
||||
assertThat(
|
||||
(boolean)
|
||||
invokeInstance(
|
||||
controller,
|
||||
"isValidQuadPoints",
|
||||
(Object) new float[16]))
|
||||
.isTrue();
|
||||
assertThat(
|
||||
(boolean)
|
||||
invokeInstance(
|
||||
controller, "isValidQuadPoints", (Object) new float[5]))
|
||||
.isFalse();
|
||||
assertThat((boolean) invokeInstance(controller, "isValidQuadPoints", (Object) null))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isZeroSizeRect distinguishes collapsed and real rectangles")
|
||||
void zeroSizeRect() throws Exception {
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
PDRectangle zero = new PDRectangle(10f, 10f, 0f, 0f);
|
||||
PDRectangle real = new PDRectangle(0f, 0f, 100f, 50f);
|
||||
assertThat((boolean) invokeInstance(controller, "isZeroSizeRect", zero)).isTrue();
|
||||
assertThat((boolean) invokeInstance(controller, "isZeroSizeRect", real)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("findUnembeddedFontNames")
|
||||
class UnembeddedFontDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("standard 14 fonts (not embedded) are reported as missing")
|
||||
void detectsStandardFontAsUnembedded() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
Set<String> missing = invokeStatic("findUnembeddedFontNames", document);
|
||||
assertThat(missing).isNotNull();
|
||||
assertThat(missing).anyMatch(name -> name.contains("Helvetica"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no resources reports no missing fonts")
|
||||
void pageWithoutResources() throws Exception {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
page.setResources(new PDResources());
|
||||
document.addPage(page);
|
||||
Set<String> missing = invokeStatic("findUnembeddedFontNames", document);
|
||||
assertThat(missing).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("detectTransparentXObjects")
|
||||
class TransparentXObjectDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no resources returns empty set")
|
||||
void noResources() throws Exception {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
Set<COSName> result = invokeStatic("detectTransparentXObjects", page);
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with empty resources returns empty set")
|
||||
void emptyResources() throws Exception {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
page.setResources(new PDResources());
|
||||
Set<COSName> result = invokeStatic("detectTransparentXObjects", page);
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("sanitizePdfA part-specific behaviour")
|
||||
class SanitizePdfAExtras {
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/A-3 preserves embedded-file structures (FILESPEC type)")
|
||||
void pdfA3PreservesFilespec() throws Exception {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setItem(COSName.TYPE, COSName.FILESPEC);
|
||||
dict.setItem(COSName.EF, new COSDictionary());
|
||||
dict.setItem(COSName.URI, COSName.A);
|
||||
|
||||
invokeStatic("sanitizePdfA", dict, 3);
|
||||
|
||||
// For part 3, filespec dictionaries are skipped entirely, so URI survives.
|
||||
assertThat(dict.containsKey(COSName.EF)).isTrue();
|
||||
assertThat(dict.containsKey(COSName.URI)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/A-3 keeps URI on a normal dictionary but still strips JavaScript")
|
||||
void pdfA3KeepsUriStripsJs() throws Exception {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setString(COSName.URI, "http://example.com");
|
||||
dict.setString(COSName.JAVA_SCRIPT, "app.alert('x');");
|
||||
|
||||
invokeStatic("sanitizePdfA", dict, 3);
|
||||
|
||||
assertThat(dict.containsKey(COSName.URI)).isTrue();
|
||||
assertThat(dict.containsKey(COSName.JAVA_SCRIPT)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recurses into nested arrays and dictionaries")
|
||||
void recursesIntoNested() throws Exception {
|
||||
COSDictionary child = new COSDictionary();
|
||||
child.setString(COSName.JAVA_SCRIPT, "code");
|
||||
COSArray array = new COSArray();
|
||||
array.add(child);
|
||||
COSDictionary parent = new COSDictionary();
|
||||
parent.setItem(COSName.getPDFName("Kids"), array);
|
||||
|
||||
invokeStatic("sanitizePdfA", parent, 2);
|
||||
|
||||
assertThat(child.containsKey(COSName.JAVA_SCRIPT)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/A-2 does NOT remove SMask/CA (those are only stripped for part 1)")
|
||||
void pdfA2KeepsTransparencyEntries() throws Exception {
|
||||
COSDictionary dict = new COSDictionary();
|
||||
dict.setItem(COSName.SMASK, new COSArray());
|
||||
dict.setFloat(COSName.CA, 0.5f);
|
||||
|
||||
invokeStatic("sanitizePdfA", dict, 2);
|
||||
|
||||
assertThat(dict.containsKey(COSName.SMASK)).isTrue();
|
||||
assertThat(dict.containsKey(COSName.CA)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("sanitizeMetadata / removeForbiddenActions")
|
||||
class MetadataAndActions {
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizeMetadata strips non-printable chars and sets producer")
|
||||
void sanitizeMetadataCleans() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setCustomMetadataValue("Custom", "cleanvalue");
|
||||
document.setDocumentInformation(info);
|
||||
|
||||
invokeInstance(newController(), "sanitizeMetadata", document);
|
||||
|
||||
PDDocumentInformation result = document.getDocumentInformation();
|
||||
assertThat(result.getProducer()).isEqualTo("Stirling-PDF Sanitizer");
|
||||
assertThat(result.getCustomMetadataValue("Custom")).isEqualTo("cleanvalue");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizeMetadata always overwrites producer to the sanitizer marker")
|
||||
void sanitizeMetadataOverwritesProducer() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setProducer("Some Other Producer");
|
||||
document.setDocumentInformation(info);
|
||||
|
||||
invokeInstance(newController(), "sanitizeMetadata", document);
|
||||
|
||||
assertThat(document.getDocumentInformation().getProducer())
|
||||
.isEqualTo("Stirling-PDF Sanitizer");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removeForbiddenActions clears open action and JavaScript")
|
||||
void removeForbiddenActions() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
catalog.getCOSObject().setItem(COSName.JAVA_SCRIPT, new COSDictionary());
|
||||
|
||||
invokeInstance(newController(), "removeForbiddenActions", document);
|
||||
|
||||
assertThat(catalog.getCOSObject().containsKey(COSName.JAVA_SCRIPT)).isFalse();
|
||||
assertThat(catalog.getOpenAction()).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("fixOptionalContentGroups")
|
||||
class OptionalContentGroups {
|
||||
|
||||
@Test
|
||||
@DisplayName("no OCProperties is a no-op (does not throw)")
|
||||
void noOcProperties() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThatCode(() -> invokeStatic("fixOptionalContentGroups", document))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("addWhiteBackground")
|
||||
class WhiteBackground {
|
||||
|
||||
@Test
|
||||
@DisplayName("adds a prepended content stream without changing page count")
|
||||
void addsBackground() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
int pagesBefore = document.getNumberOfPages();
|
||||
assertThatCode(
|
||||
() ->
|
||||
invokeInstance(
|
||||
newController(), "addWhiteBackground", document))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(document.getNumberOfPages()).isEqualTo(pagesBefore);
|
||||
// page still has content streams after prepending background
|
||||
assertThat(document.getPage(0).hasContents()).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("ensureAnnotationAppearances")
|
||||
class AnnotationAppearances {
|
||||
|
||||
@Test
|
||||
@DisplayName("Link annotations are skipped (kept) by appearance enforcement")
|
||||
void linkAnnotationsKept() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
PDPage page = document.getPage(0);
|
||||
PDAnnotationLink link = new PDAnnotationLink();
|
||||
link.setRectangle(new PDRectangle(0, 0, 50, 50));
|
||||
List<PDAnnotation> annotations = new ArrayList<>();
|
||||
annotations.add(link);
|
||||
page.setAnnotations(annotations);
|
||||
|
||||
invokeInstance(newController(), "ensureAnnotationAppearances", document);
|
||||
|
||||
assertThat(page.getAnnotations()).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("ensureEmbeddedFileCompliance / addICCProfileIfNotPresent")
|
||||
class EmbeddedAndIcc {
|
||||
|
||||
@Test
|
||||
@DisplayName("ensureEmbeddedFileCompliance returns quietly with no names dictionary")
|
||||
void noNamesDictionary() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThatCode(
|
||||
() ->
|
||||
invokeInstance(
|
||||
newController(),
|
||||
"ensureEmbeddedFileCompliance",
|
||||
document))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addICCProfileIfNotPresent adds an sRGB output intent")
|
||||
void addsIccOutputIntent() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents()).isEmpty();
|
||||
|
||||
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
|
||||
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents()).hasSize(1);
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents().get(0).getInfo())
|
||||
.contains("sRGB");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addICCProfileIfNotPresent does not add a second intent when one exists")
|
||||
void doesNotDuplicateIntent() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
|
||||
invokeInstance(newController(), "addICCProfileIfNotPresent", document);
|
||||
assertThat(document.getDocumentCatalog().getOutputIntents()).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("performBasicPdfAValidation / buildComprehensiveValidationMessage")
|
||||
class ValidationHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("basic validation flags missing XMP and output intent for a plain PDF")
|
||||
void basicValidationFlagsMissing() throws Exception {
|
||||
Path pdf = tempDir.resolve("plain.pdf");
|
||||
Files.write(pdf, simplePdfBytes());
|
||||
|
||||
// PdfaProfile.PDF_A_2B has no preflight format -> basic validation path.
|
||||
Object profile = resolvePdfaProfile("pdfa-2b");
|
||||
org.apache.pdfbox.preflight.ValidationResult result =
|
||||
invokeStaticWithTypes(
|
||||
"performBasicPdfAValidation",
|
||||
new Class<?>[] {Path.class, profile.getClass()},
|
||||
pdf,
|
||||
profile);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.isValid()).isFalse();
|
||||
assertThat(result.getErrorsList()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("comprehensive message summarises error count and codes")
|
||||
void comprehensiveMessage() throws Exception {
|
||||
Object profile = resolvePdfaProfile("pdfa-1");
|
||||
org.apache.pdfbox.preflight.ValidationResult result =
|
||||
new org.apache.pdfbox.preflight.ValidationResult(false);
|
||||
result.addError(
|
||||
new org.apache.pdfbox.preflight.ValidationResult.ValidationError(
|
||||
"CODE_A", "first problem"));
|
||||
result.addError(
|
||||
new org.apache.pdfbox.preflight.ValidationResult.ValidationError(
|
||||
"CODE_B", "second problem"));
|
||||
|
||||
String message =
|
||||
invokeStaticWithTypes(
|
||||
"buildComprehensiveValidationMessage",
|
||||
new Class<?>[] {
|
||||
org.apache.pdfbox.preflight.ValidationResult.class,
|
||||
profile.getClass()
|
||||
},
|
||||
result,
|
||||
profile);
|
||||
|
||||
assertThat(message).contains("PDF/A-1b");
|
||||
assertThat(message).contains("2 errors");
|
||||
assertThat(message).contains("CODE_A");
|
||||
}
|
||||
|
||||
// helper: resolve enum constant + call typed static method
|
||||
private Object resolvePdfaProfile(String token) throws Exception {
|
||||
Class<?> enumClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("PdfaProfile")) {
|
||||
enumClass = inner;
|
||||
}
|
||||
}
|
||||
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, token);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T invokeStaticWithTypes(String name, Class<?>[] types, Object... args)
|
||||
throws Exception {
|
||||
Method m = ConvertPDFToPDFA.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
return (T) m.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw unwrap(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("verifyStrictCompliance (VeraPDFService mocked)")
|
||||
class StrictCompliance {
|
||||
|
||||
@Test
|
||||
@DisplayName("compliant result passes without throwing")
|
||||
void compliantPasses() throws Exception {
|
||||
PDFVerificationResult ok = new PDFVerificationResult();
|
||||
ok.setCompliant(true);
|
||||
ok.setStandard("1b");
|
||||
ok.setComplianceSummary("PDF/A-1b compliant");
|
||||
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
assertThatCode(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-compliant result throws 400 ResponseStatusException with details")
|
||||
void nonCompliantThrowsBadRequest() throws Exception {
|
||||
PDFVerificationResult bad = new PDFVerificationResult();
|
||||
bad.setCompliant(false);
|
||||
bad.setStandard("1b");
|
||||
bad.setComplianceSummary("PDF/A-1b with errors");
|
||||
when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad));
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
ResponseStatusException ex =
|
||||
(ResponseStatusException)
|
||||
catchThrowable(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(ex.getReason()).contains("PDF/A-1b with errors");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty result list is treated as non-compliant -> 400")
|
||||
void emptyResultsTreatedNonCompliant() throws Exception {
|
||||
when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList());
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
ResponseStatusException ex =
|
||||
(ResponseStatusException)
|
||||
catchThrowable(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("service error is wrapped as 500 ResponseStatusException")
|
||||
void serviceErrorWrappedAs500() throws Exception {
|
||||
when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom"));
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
ResponseStatusException ex =
|
||||
(ResponseStatusException)
|
||||
catchThrowable(
|
||||
() ->
|
||||
invokeInstance(
|
||||
controller,
|
||||
"verifyStrictCompliance",
|
||||
(Object) "dummy".getBytes()));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("pdfToPdfA controller validation branch")
|
||||
class ControllerValidation {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-PDF content type throws PDF-required exception before any conversion")
|
||||
void nonPdfContentTypeRejected() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.txt",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
"not a pdf".getBytes());
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(file);
|
||||
request.setOutputFormat("pdfa-2b");
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
|
||||
assertThatThrownBy(() -> controller.pdfToPdfA(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
// collaborators must not be touched on the validation-failure path
|
||||
verifyNoInteractions(tempFileManager, veraPDFService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null content type is also rejected as not-a-PDF")
|
||||
void nullContentTypeRejected() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "input.bin", null, "data".getBytes());
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(file);
|
||||
request.setOutputFormat("pdfa");
|
||||
|
||||
ConvertPDFToPDFA controller = newController();
|
||||
|
||||
assertThatThrownBy(() -> controller.pdfToPdfA(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================================
|
||||
@Nested
|
||||
@DisplayName("ensureEmbeddedFilesAFRelationship / isTransparencyGroup")
|
||||
class StaticEdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("ensureEmbeddedFilesAFRelationship is a no-op when no names dictionary")
|
||||
void afRelationshipNoNames() throws Exception {
|
||||
try (PDDocument document = simplePdf()) {
|
||||
assertThatCode(() -> invokeStatic("ensureEmbeddedFilesAFRelationship", document))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isTransparencyGroup true only for /S /Transparency group dictionaries")
|
||||
void transparencyGroup() throws Exception {
|
||||
COSDictionary withGroup = new COSDictionary();
|
||||
COSDictionary groupDict = new COSDictionary();
|
||||
groupDict.setItem(COSName.S, COSName.TRANSPARENCY);
|
||||
withGroup.setItem(COSName.GROUP, groupDict);
|
||||
assertThat((boolean) invokeStatic("isTransparencyGroup", withGroup)).isTrue();
|
||||
|
||||
COSDictionary withoutGroup = new COSDictionary();
|
||||
assertThat((boolean) invokeStatic("isTransparencyGroup", withoutGroup)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ConvertPdfToVideoController}.
|
||||
*
|
||||
* <p>The public {@code convertPdfToVideo} endpoint is commented out in production (ffmpeg disabled
|
||||
* due to CVEs), so these tests exercise the remaining private helper methods directly via
|
||||
* reflection. No external processes (ffmpeg) are ever spawned: {@code buildFfmpegCommand} only
|
||||
* constructs the argument list, and {@code generateFrames} renders PDF pages to PNG files on disk.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertPdfToVideoControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ConvertPdfToVideoController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new ConvertPdfToVideoController(pdfDocumentFactory, tempFileManager);
|
||||
}
|
||||
|
||||
// ---- reflection helpers -------------------------------------------------
|
||||
|
||||
private Object invokePrivate(String name, Class<?>[] types, Object... args) throws Exception {
|
||||
Method method = ConvertPdfToVideoController.class.getDeclaredMethod(name, types);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return method.invoke(controller, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeFormat(String requested) throws Exception {
|
||||
return (String) invokePrivate("normalizeFormat", new Class<?>[] {String.class}, requested);
|
||||
}
|
||||
|
||||
private MediaType getMediaType(String format) throws Exception {
|
||||
return (MediaType) invokePrivate("getMediaType", new Class<?>[] {String.class}, format);
|
||||
}
|
||||
|
||||
private int getMaxDpi() throws Exception {
|
||||
return (int) invokePrivate("getMaxDpi", new Class<?>[] {});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> buildFfmpegCommand(
|
||||
String format, String resolution, String frameRate, TempFile outputVideo)
|
||||
throws Exception {
|
||||
return (List<String>)
|
||||
invokePrivate(
|
||||
"buildFfmpegCommand",
|
||||
new Class<?>[] {String.class, String.class, String.class, TempFile.class},
|
||||
format,
|
||||
resolution,
|
||||
frameRate,
|
||||
outputVideo);
|
||||
}
|
||||
|
||||
private void applyWatermark(BufferedImage image, float opacity, String text) throws Exception {
|
||||
invokePrivate(
|
||||
"applyWatermark",
|
||||
new Class<?>[] {BufferedImage.class, float.class, String.class},
|
||||
image,
|
||||
opacity,
|
||||
text);
|
||||
}
|
||||
|
||||
private void generateFrames(
|
||||
Path inputPdf,
|
||||
Path outputDir,
|
||||
int dpi,
|
||||
float opacity,
|
||||
String watermarkText,
|
||||
boolean watermarkEnabled)
|
||||
throws Exception {
|
||||
invokePrivate(
|
||||
"generateFrames",
|
||||
new Class<?>[] {
|
||||
Path.class, Path.class, int.class, float.class, String.class, boolean.class
|
||||
},
|
||||
inputPdf,
|
||||
outputDir,
|
||||
dpi,
|
||||
opacity,
|
||||
watermarkText,
|
||||
watermarkEnabled);
|
||||
}
|
||||
|
||||
// ---- PDF builder helper -------------------------------------------------
|
||||
|
||||
private byte[] buildPdf(int pageCount) throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- normalizeFormat ----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("normalizeFormat")
|
||||
class NormalizeFormat {
|
||||
|
||||
@Test
|
||||
@DisplayName("null defaults to mp4")
|
||||
void nullDefaultsToMp4() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mp4 passes through")
|
||||
void mp4PassesThrough() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat("mp4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("webm passes through")
|
||||
void webmPassesThrough() throws Exception {
|
||||
assertEquals("webm", normalizeFormat("webm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uppercase is lowercased")
|
||||
void uppercaseIsLowercased() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat("MP4"));
|
||||
assertEquals("webm", normalizeFormat("WEBM"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mixed case is normalized")
|
||||
void mixedCaseIsNormalized() throws Exception {
|
||||
assertEquals("webm", normalizeFormat("WeBm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unsupported format falls back to mp4")
|
||||
void unsupportedFallsBackToMp4() throws Exception {
|
||||
assertEquals("mp4", normalizeFormat("avi"));
|
||||
assertEquals("mp4", normalizeFormat("gif"));
|
||||
assertEquals("mp4", normalizeFormat(""));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getMediaType -------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getMediaType")
|
||||
class GetMediaType {
|
||||
|
||||
@Test
|
||||
@DisplayName("webm maps to video/webm")
|
||||
void webmMapsToVideoWebm() throws Exception {
|
||||
assertEquals(MediaType.valueOf("video/webm"), getMediaType("webm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mp4 maps to video/mp4")
|
||||
void mp4MapsToVideoMp4() throws Exception {
|
||||
assertEquals(MediaType.valueOf("video/mp4"), getMediaType("mp4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown format defaults to video/mp4")
|
||||
void unknownDefaultsToVideoMp4() throws Exception {
|
||||
assertEquals(MediaType.valueOf("video/mp4"), getMediaType("avi"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getMaxDpi ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getMaxDpi")
|
||||
class GetMaxDpi {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 500 fallback when no Spring context is available")
|
||||
void returnsFallbackWithoutContext() throws Exception {
|
||||
// No ApplicationContext is set in this unit test, so getBean returns null and the
|
||||
// method falls back to the hardcoded default of 500.
|
||||
assertEquals(500, getMaxDpi());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- buildFfmpegCommand -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildFfmpegCommand")
|
||||
class BuildFfmpegCommand {
|
||||
|
||||
private TempFile newTempFile(File backing) throws IOException {
|
||||
when(tempFileManager.createTempFile(any())).thenReturn(backing);
|
||||
return new TempFile(tempFileManager, ".mp4");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mp4 command includes libx264 and faststart flags")
|
||||
void mp4Command(@TempDir Path dir) throws Exception {
|
||||
File backing = dir.resolve("out.mp4").toFile();
|
||||
TempFile outputVideo = newTempFile(backing);
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "ORIGINAL", "0.333333", outputVideo);
|
||||
|
||||
assertEquals("ffmpeg", command.get(0));
|
||||
assertTrue(command.contains("-y"));
|
||||
assertTrue(command.contains("-framerate"));
|
||||
assertTrue(command.contains("0.333333"));
|
||||
assertTrue(command.contains("frame_%05d.png"));
|
||||
assertTrue(command.contains("-vf"));
|
||||
assertTrue(command.contains("libx264"));
|
||||
assertTrue(command.contains("yuv420p"));
|
||||
assertTrue(command.contains("+faststart"));
|
||||
assertFalse(command.contains("libvpx-vp9"));
|
||||
// Output path is always the last argument.
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("webm command includes libvpx-vp9 and crf flags")
|
||||
void webmCommand(@TempDir Path dir) throws Exception {
|
||||
File backing = dir.resolve("out.webm").toFile();
|
||||
TempFile outputVideo = newTempFile(backing);
|
||||
|
||||
List<String> command = buildFfmpegCommand("webm", "720P", "0.5", outputVideo);
|
||||
|
||||
assertTrue(command.contains("libvpx-vp9"));
|
||||
assertTrue(command.contains("-crf"));
|
||||
assertTrue(command.contains("30"));
|
||||
assertFalse(command.contains("libx264"));
|
||||
assertFalse(command.contains("+faststart"));
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("framerate value is placed right after -framerate")
|
||||
void framerateOrdering(@TempDir Path dir) throws Exception {
|
||||
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "ORIGINAL", "0.25", outputVideo);
|
||||
|
||||
int idx = command.indexOf("-framerate");
|
||||
assertTrue(idx >= 0);
|
||||
assertEquals("0.25", command.get(idx + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("known resolution applies the matching scale filter")
|
||||
void knownResolutionFilter(@TempDir Path dir) throws Exception {
|
||||
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "1080P", "0.5", outputVideo);
|
||||
|
||||
int idx = command.indexOf("-vf");
|
||||
assertEquals("scale=-2:1080,setsar=1", command.get(idx + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown resolution falls back to the ORIGINAL filter")
|
||||
void unknownResolutionFallsBackToOriginal(@TempDir Path dir) throws Exception {
|
||||
TempFile outputVideo = newTempFile(dir.resolve("o.mp4").toFile());
|
||||
|
||||
List<String> command = buildFfmpegCommand("mp4", "NONSENSE", "0.5", outputVideo);
|
||||
|
||||
int idx = command.indexOf("-vf");
|
||||
assertEquals("scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1", command.get(idx + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- applyWatermark -----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyWatermark")
|
||||
class ApplyWatermark {
|
||||
|
||||
private BufferedImage solidImage(int w, int h, Color color) {
|
||||
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("modifies pixels at full opacity without throwing")
|
||||
void modifiesPixels() throws Exception {
|
||||
// The watermark is drawn through the image centre, so a small image still gets pixels
|
||||
// painted; the smaller buffer keeps the two getRGB snapshots cheap.
|
||||
BufferedImage image = solidImage(100, 80, Color.RED);
|
||||
int[] before =
|
||||
image.getRGB(
|
||||
0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
|
||||
|
||||
applyWatermark(image, 1.0f, "CONFIDENTIAL");
|
||||
|
||||
int[] after =
|
||||
image.getRGB(
|
||||
0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
|
||||
boolean changed = false;
|
||||
for (int i = 0; i < before.length; i++) {
|
||||
if (before[i] != after[i]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertTrue(changed, "watermark should have altered at least one pixel");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw on a small square image")
|
||||
void handlesSmallImage() throws Exception {
|
||||
BufferedImage image = solidImage(50, 50, Color.BLUE);
|
||||
applyWatermark(image, 0.5f, "X");
|
||||
assertNotNull(image);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not throw at zero opacity")
|
||||
void handlesZeroOpacity() throws Exception {
|
||||
BufferedImage image = solidImage(120, 80, Color.GREEN);
|
||||
applyWatermark(image, 0.0f, "WM");
|
||||
assertNotNull(image);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- generateFrames -----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("generateFrames")
|
||||
class GenerateFrames {
|
||||
|
||||
@Test
|
||||
@DisplayName("renders one PNG frame per page")
|
||||
void rendersOneFramePerPage(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("input.pdf");
|
||||
Files.write(inputPdf, buildPdf(3));
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
// The factory must return a fresh, real document that the controller can render.
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(Loader.loadPDF(buildPdf(3)));
|
||||
|
||||
generateFrames(inputPdf, outputDir, 72, 1.0f, null, false);
|
||||
|
||||
assertTrue(Files.exists(outputDir.resolve("frame_00001.png")));
|
||||
assertTrue(Files.exists(outputDir.resolve("frame_00002.png")));
|
||||
assertTrue(Files.exists(outputDir.resolve("frame_00003.png")));
|
||||
try (var stream = Files.list(outputDir)) {
|
||||
assertEquals(3, stream.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applies watermark when enabled and still produces frames")
|
||||
void appliesWatermarkWhenEnabled(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("input.pdf");
|
||||
Files.write(inputPdf, buildPdf(1));
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(Loader.loadPDF(buildPdf(1)));
|
||||
|
||||
generateFrames(inputPdf, outputDir, 72, 0.8f, "DRAFT", true);
|
||||
|
||||
Path frame = outputDir.resolve("frame_00001.png");
|
||||
assertTrue(Files.exists(frame));
|
||||
assertTrue(Files.size(frame) > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero-page document throws IllegalArgumentException")
|
||||
void zeroPageThrows(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("empty.pdf");
|
||||
// An empty PDDocument (no pages) cannot be saved/loaded, so feed an empty doc directly.
|
||||
Files.write(inputPdf, new byte[] {0});
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(new PDDocument());
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> generateFrames(inputPdf, outputDir, 72, 1.0f, null, false));
|
||||
try (var stream = Files.list(outputDir)) {
|
||||
assertEquals(0, stream.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates IOException from the document factory")
|
||||
void propagatesLoadFailure(@TempDir Path dir) throws Exception {
|
||||
Path inputPdf = dir.resolve("input.pdf");
|
||||
Files.write(inputPdf, buildPdf(1));
|
||||
Path outputDir = Files.createDirectory(dir.resolve("frames"));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenThrow(new IOException("boom"));
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> generateFrames(inputPdf, outputDir, 72, 1.0f, null, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AutoSplitPdfControllerTest {
|
||||
|
||||
private static final String VALID_QR = "https://stirlingpdf.com";
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AutoSplitPdfController controller;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
// Keep maxDPI at the QR detection DPI so the high-DPI retry path is skipped (fast tests).
|
||||
applicationProperties.getSystem().setMaxDPI(150);
|
||||
controller =
|
||||
new AutoSplitPdfController(
|
||||
pdfDocumentFactory, tempFileManager, applicationProperties);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Build a tiny single-colour BufferedImage. */
|
||||
private static BufferedImage solidImage(int w, int h, Color color) {
|
||||
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/** Generate a real, decodable QR code as a BufferedImage using zxing core only. */
|
||||
private static BufferedImage qrImage(String text, int size) throws Exception {
|
||||
QRCodeWriter writer = new QRCodeWriter();
|
||||
java.util.Map<EncodeHintType, Object> hints = new java.util.EnumMap<>(EncodeHintType.class);
|
||||
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
|
||||
hints.put(EncodeHintType.MARGIN, 4);
|
||||
BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints);
|
||||
int width = matrix.getWidth();
|
||||
int height = matrix.getHeight();
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
image.setRGB(x, y, matrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB());
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
/** Build an in-memory PDF document with the given number of plain pages. */
|
||||
private static PDDocument simpleDoc(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(new PDRectangle(200, 200)));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* A PDF where every page draws the supplied image (used so embedded-image extraction works).
|
||||
*/
|
||||
private static PDDocument docWithImageOnEachPage(BufferedImage img, int pages)
|
||||
throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = new PDPage(new PDRectangle(200, 200));
|
||||
doc.addPage(page);
|
||||
PDImageXObject xobj = LosslessFactory.createFromImage(doc, img);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(xobj, 20, 20, 160, 160);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Load a PDF from the InputStream the controller hands to pdfDocumentFactory.load(...). */
|
||||
private static PDDocument loadFromStream(InputStream in) throws Exception {
|
||||
return Loader.loadPDF(in.readAllBytes());
|
||||
}
|
||||
|
||||
private static byte[] docToBytes(PDDocument doc) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
doc.close();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static AutoSplitPdfRequest request(byte[] pdfBytes, Boolean duplex) {
|
||||
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
|
||||
req.setFileInput(
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"fileInput", "input.pdf", "application/pdf", pdfBytes));
|
||||
req.setDuplexMode(duplex);
|
||||
return req;
|
||||
}
|
||||
|
||||
/** Make tempFileManager.createTempFile(".zip") create a real file inside the JUnit temp dir. */
|
||||
private void wireRealTempFile() throws Exception {
|
||||
when(tempFileManager.createTempFile(".zip"))
|
||||
.thenAnswer(
|
||||
invocation ->
|
||||
Files.createTempFile(tempDir, "stirling-test", ".zip").toFile());
|
||||
}
|
||||
|
||||
private static List<String> zipEntryNames(byte[] zipBytes) throws Exception {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
names.add(entry.getName());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static byte[] readResource(Resource resource) throws Exception {
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
return in.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
// reflection invokers for the private (static) helpers ------------------
|
||||
|
||||
private static Object invokeStatic(String name, Class<?>[] types, Object... args)
|
||||
throws Exception {
|
||||
Method m = AutoSplitPdfController.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
return m.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
if (e.getCause() instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private Object invokeInstance(String name, Class<?>[] types, Object... args) throws Exception {
|
||||
Method m = AutoSplitPdfController.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
return m.invoke(controller, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
if (e.getCause() instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// isBlankImage(int[])
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("isBlankImage")
|
||||
class IsBlankImage {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty array is treated as blank")
|
||||
void emptyArrayIsBlank() throws Exception {
|
||||
Object result =
|
||||
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) new int[0]);
|
||||
assertEquals(Boolean.TRUE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uniform pixels are blank")
|
||||
void uniformIsBlank() throws Exception {
|
||||
int[] pixels = new int[1000];
|
||||
java.util.Arrays.fill(pixels, 0xFFFFFF);
|
||||
Object result =
|
||||
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) pixels);
|
||||
assertEquals(Boolean.TRUE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single differing sampled pixel makes it non-blank")
|
||||
void variedIsNotBlank() throws Exception {
|
||||
int[] pixels = new int[1000];
|
||||
java.util.Arrays.fill(pixels, 0xFFFFFF);
|
||||
// step = max(1, 1000/20) = 50, so index 500 is sampled
|
||||
pixels[500] = 0x000000;
|
||||
Object result =
|
||||
invokeStatic("isBlankImage", new Class<?>[] {int[].class}, (Object) pixels);
|
||||
assertEquals(Boolean.FALSE, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single pixel array is blank")
|
||||
void singlePixelIsBlank() throws Exception {
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"isBlankImage", new Class<?>[] {int[].class}, (Object) new int[] {7});
|
||||
assertEquals(Boolean.TRUE, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// downscaleIfNeeded(BufferedImage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("downscaleIfNeeded")
|
||||
class DownscaleIfNeeded {
|
||||
|
||||
@Test
|
||||
@DisplayName("small images are returned unchanged (same instance)")
|
||||
void smallUnchanged() throws Exception {
|
||||
BufferedImage img = solidImage(100, 80, Color.GRAY);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"downscaleIfNeeded",
|
||||
new Class<?>[] {BufferedImage.class},
|
||||
(Object) img);
|
||||
assertSame(img, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("image exactly at the pixel limit is unchanged")
|
||||
void atLimitUnchanged() throws Exception {
|
||||
// 10000x10000 == 100_000_000 == MAX_IMAGE_PIXELS, not greater than -> unchanged.
|
||||
// Use a mock-free real image but keep it cheap: build a 1px-tall wide image whose
|
||||
// total pixel count is below the limit so we only assert the <= branch with a
|
||||
// realistic non-trivial size.
|
||||
BufferedImage img = solidImage(5000, 5000, Color.WHITE); // 25M < 100M
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"downscaleIfNeeded",
|
||||
new Class<?>[] {BufferedImage.class},
|
||||
(Object) img);
|
||||
assertSame(img, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// countPageImages(PDPage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("countPageImages")
|
||||
class CountPageImages {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no resources returns 0")
|
||||
void noResourcesZero() throws Exception {
|
||||
PDPage page = new PDPage(new PDRectangle(200, 200));
|
||||
Object result =
|
||||
invokeStatic("countPageImages", new Class<?>[] {PDPage.class}, (Object) page);
|
||||
assertEquals(0, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with one embedded image returns 1")
|
||||
void oneImage() throws Exception {
|
||||
try (PDDocument doc = docWithImageOnEachPage(solidImage(40, 40, Color.RED), 1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"countPageImages", new Class<?>[] {PDPage.class}, (Object) page);
|
||||
assertEquals(1, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// tryDecodeQR(int[], int, int) and decodeQRCode(BufferedImage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("QR decoding")
|
||||
class QrDecoding {
|
||||
|
||||
@Test
|
||||
@DisplayName("decodeQRCode returns the encoded text for a real QR image")
|
||||
void decodeRealQr() throws Exception {
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
Object result =
|
||||
invokeStatic("decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) qr);
|
||||
assertEquals(VALID_QR, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decodeQRCode returns null for a blank image")
|
||||
void decodeBlankReturnsNull() throws Exception {
|
||||
BufferedImage blank = solidImage(120, 120, Color.WHITE);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) blank);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decodeQRCode returns null for a non-QR (noise-free, non-blank) image")
|
||||
void decodeNonQrReturnsNull() throws Exception {
|
||||
// Two solid halves: non-blank but not a QR code.
|
||||
BufferedImage image = new BufferedImage(120, 120, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, 120, 60);
|
||||
g.setColor(Color.BLACK);
|
||||
g.fillRect(0, 60, 120, 60);
|
||||
g.dispose();
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"decodeQRCode", new Class<?>[] {BufferedImage.class}, (Object) image);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tryDecodeQR decodes raw RGB pixels of a real QR")
|
||||
void tryDecodeRawPixels() throws Exception {
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
int w = qr.getWidth();
|
||||
int h = qr.getHeight();
|
||||
int[] pixels = new int[w * h];
|
||||
qr.getRGB(0, 0, w, h, pixels, 0, w);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"tryDecodeQR",
|
||||
new Class<?>[] {int[].class, int.class, int.class},
|
||||
pixels,
|
||||
w,
|
||||
h);
|
||||
assertEquals(VALID_QR, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tryDecodeQR returns null when no QR present")
|
||||
void tryDecodeReturnsNull() throws Exception {
|
||||
int w = 60;
|
||||
int h = 60;
|
||||
int[] pixels = new int[w * h];
|
||||
// alternating pattern, no decodable QR
|
||||
for (int i = 0; i < pixels.length; i++) {
|
||||
pixels[i] = (i % 2 == 0) ? 0xFFFFFF : 0x000000;
|
||||
}
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"tryDecodeQR",
|
||||
new Class<?>[] {int[].class, int.class, int.class},
|
||||
pixels,
|
||||
w,
|
||||
h);
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// checkPageImagesDirect(PDPage)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("checkPageImagesDirect")
|
||||
class CheckPageImagesDirect {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with no images returns null")
|
||||
void noImagesNull() throws Exception {
|
||||
PDPage page = new PDPage(new PDRectangle(200, 200));
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"checkPageImagesDirect", new Class<?>[] {PDPage.class}, (Object) page);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with an embedded QR image returns the QR text")
|
||||
void embeddedQrFound() throws Exception {
|
||||
// Size 250 keeps the readback image off the controller's blank-sampling heuristic.
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
try (PDDocument doc = docWithImageOnEachPage(qr, 1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"checkPageImagesDirect",
|
||||
new Class<?>[] {PDPage.class},
|
||||
(Object) page);
|
||||
assertEquals(VALID_QR, result);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with a non-QR image returns null")
|
||||
void embeddedNonQrNull() throws Exception {
|
||||
BufferedImage plain = solidImage(80, 80, Color.WHITE);
|
||||
try (PDDocument doc = docWithImageOnEachPage(plain, 1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
Object result =
|
||||
invokeStatic(
|
||||
"checkPageImagesDirect",
|
||||
new Class<?>[] {PDPage.class},
|
||||
(Object) page);
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// getSystemMaxDpi() (private instance)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSystemMaxDpi")
|
||||
class GetSystemMaxDpi {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the configured system maxDPI")
|
||||
void returnsConfiguredValue() throws Exception {
|
||||
applicationProperties.getSystem().setMaxDPI(300);
|
||||
Object result = invokeInstance("getSystemMaxDpi", new Class<?>[] {});
|
||||
assertEquals(300, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the default detection DPI when applicationProperties is null")
|
||||
void fallsBackWhenNull() throws Exception {
|
||||
AutoSplitPdfController noProps =
|
||||
new AutoSplitPdfController(pdfDocumentFactory, tempFileManager, null);
|
||||
Method m = AutoSplitPdfController.class.getDeclaredMethod("getSystemMaxDpi");
|
||||
m.setAccessible(true);
|
||||
Object result = m.invoke(noProps);
|
||||
assertEquals(150, result); // QR_DETECTION_DPI
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// autoSplitPdf(...) full handler
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("autoSplitPdf")
|
||||
class AutoSplitHandler {
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF without QR dividers yields a single-PDF zip")
|
||||
void noQrSingleOutput() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(3));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
AutoSplitPdfRequest req = request(pdf, false);
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(req);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
byte[] zip = readResource(response.getBody());
|
||||
List<String> names = zipEntryNames(zip);
|
||||
assertEquals(1, names.size(), "no QR dividers -> all pages collapse into one document");
|
||||
assertEquals("input_1.pdf", names.get(0));
|
||||
|
||||
verify(pdfDocumentFactory).load(any(InputStream.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-page PDF without QR yields one output PDF in the zip")
|
||||
void singlePageOneOutput() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(1));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, null));
|
||||
|
||||
byte[] zip = readResource(response.getBody());
|
||||
List<String> names = zipEntryNames(zip);
|
||||
assertEquals(1, names.size());
|
||||
assertEquals("input_1.pdf", names.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filename without extension is used as-is for entry names")
|
||||
void filenameWithoutExtension() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(2));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
|
||||
req.setFileInput(
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"fileInput", "myfile", "application/pdf", pdf));
|
||||
req.setDuplexMode(false);
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(req);
|
||||
byte[] zip = readResource(response.getBody());
|
||||
List<String> names = zipEntryNames(zip);
|
||||
assertEquals(1, names.size());
|
||||
assertEquals("myfile_1.pdf", names.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each output zip entry contains a valid, loadable PDF")
|
||||
void outputEntriesAreValidPdfs() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(2));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, false));
|
||||
byte[] zip = readResource(response.getBody());
|
||||
|
||||
int entries = 0;
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entries++;
|
||||
byte[] entryBytes = zis.readAllBytes();
|
||||
try (PDDocument loaded = Loader.loadPDF(entryBytes)) {
|
||||
assertTrue(loaded.getNumberOfPages() >= 1);
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
assertEquals(1, entries);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loader failure propagates and the temp file is closed")
|
||||
void loaderFailurePropagates() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(1));
|
||||
File created = Files.createTempFile(tempDir, "stirling-fail", ".zip").toFile();
|
||||
when(tempFileManager.createTempFile(".zip")).thenReturn(created);
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenThrow(new java.io.IOException("boom"));
|
||||
|
||||
AutoSplitPdfRequest req = request(pdf, false);
|
||||
|
||||
assertThrows(java.io.IOException.class, () -> controller.autoSplitPdf(req));
|
||||
// outputTempFile.close() deletes the file on the error path.
|
||||
verify(tempFileManager).deleteTempFile(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("duplexMode flag is accepted (null treated as false)")
|
||||
void duplexNullTreatedAsFalse() throws Exception {
|
||||
byte[] pdf = docToBytes(simpleDoc(2));
|
||||
wireRealTempFile();
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(invocation -> loadFromStream(invocation.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(pdf, null));
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<String> names = zipEntryNames(readResource(response.getBody()));
|
||||
assertEquals(1, names.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// VALID_QR_CONTENTS sanity (static state)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("valid QR contents")
|
||||
class ValidQrContents {
|
||||
|
||||
@Test
|
||||
@DisplayName("the well-known divider URLs are recognised")
|
||||
@SuppressWarnings("unchecked")
|
||||
void recognisedUrls() throws Exception {
|
||||
java.lang.reflect.Field f =
|
||||
AutoSplitPdfController.class.getDeclaredField("VALID_QR_CONTENTS");
|
||||
f.setAccessible(true);
|
||||
Set<String> valid = new HashSet<>((Set<String>) f.get(null));
|
||||
assertTrue(valid.contains("https://stirlingpdf.com"));
|
||||
assertTrue(valid.contains("https://github.com/Stirling-Tools/Stirling-PDF"));
|
||||
assertTrue(valid.contains("https://github.com/Frooodle/Stirling-PDF"));
|
||||
assertFalse(valid.contains("https://example.com"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link CompressController}. External binaries (Ghostscript / qpdf / ImageMagick)
|
||||
* are never invoked: tests cover validation, orchestration with all tool groups disabled, the pure
|
||||
* level/quality helpers, and the public image-compression entry point.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CompressControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private CompressController controller;
|
||||
|
||||
/** Real temp files created during a test; cleaned up after each test. */
|
||||
private final List<File> createdFiles = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// By default no external tools are enabled, forcing the Java-only path.
|
||||
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false);
|
||||
|
||||
// Every managed temp file is backed by a real on-disk file wrapped in a mock TempFile.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile(
|
||||
"compress-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
createdFiles.add(f);
|
||||
return newRealBackedTempFile(f);
|
||||
});
|
||||
}
|
||||
|
||||
private TempFile newRealBackedTempFile(File f) {
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
|
||||
lenient().when(tf.exists()).thenReturn(f.exists());
|
||||
return tf;
|
||||
}
|
||||
|
||||
// ----- helpers to build tiny in-memory PDFs ------------------------------------------------
|
||||
|
||||
private byte[] textOnlyPdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(
|
||||
new org.apache.pdfbox.pdmodel.font.PDType1Font(
|
||||
org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName.HELVETICA),
|
||||
12);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("Hello compress");
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF with one tiny (sub-400px) image so the compressor encounters it but skips it. */
|
||||
private byte[] smallImagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
java.awt.image.BufferedImage img =
|
||||
new java.awt.image.BufferedImage(
|
||||
50, 50, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
for (int x = 0; x < 50; x++) {
|
||||
for (int y = 0; y < 50; y++) {
|
||||
img.setRGB(x, y, (x * 5 + y) & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
PDImageXObject image = LosslessFactory.createFromImage(doc, img);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 100, 100, 50, 50);
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private MockMultipartFile multipart(byte[] bytes) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ----- validation branches -----------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("optimizePdf validation")
|
||||
class Validation {
|
||||
|
||||
@Test
|
||||
@DisplayName("null input file throws IllegalArgumentException")
|
||||
void nullFile_throws() {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty input file throws IllegalArgumentException")
|
||||
void emptyFile_throws() {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
new byte[0]));
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"both optimizeLevel and expectedOutputSize null throws IllegalArgumentException")
|
||||
void noOptionsProvided_throws() throws Exception {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(textOnlyPdfBytes()));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("line art requested but service unavailable throws FORBIDDEN")
|
||||
void lineArt_serviceNull_throwsForbidden() throws Exception {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(textOnlyPdfBytes()));
|
||||
request.setOptimizeLevel(2);
|
||||
request.setLineArt(true);
|
||||
|
||||
// lineArtConversionService field is left null by @InjectMocks.
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"line art requested with service present but ImageMagick disabled throws IOException")
|
||||
void lineArt_imageMagickDisabled_throwsIOException() throws Exception {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(textOnlyPdfBytes()));
|
||||
request.setOptimizeLevel(2);
|
||||
request.setLineArt(true);
|
||||
|
||||
// Provide a service so the null-check passes, but ImageMagick group stays disabled.
|
||||
setLineArtService(mock(LineArtConversionService.class));
|
||||
when(endpointConfiguration.isGroupEnabled("ImageMagick")).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- orchestration with all external tools disabled --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("optimizePdf orchestration (no external tools)")
|
||||
class Orchestration {
|
||||
|
||||
@Test
|
||||
@DisplayName("low level + no tools returns OK with non-empty body")
|
||||
void lowLevel_noTools_returnsOk() throws Exception {
|
||||
byte[] pdf = textOnlyPdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(1); // < 4 => no image compression, < 6 => no ghostscript
|
||||
|
||||
// Final stage reloads currentFile from disk; return a fresh real document.
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("level 4 with a sub-threshold image still returns OK (image skipped)")
|
||||
void level4_smallImage_skipped_returnsOk() throws Exception {
|
||||
byte[] pdf = smallImagePdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(4); // triggers Java image compression path
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
// compressImagesInPDF reloads currentFile via load(Path).
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grayscale flag forces image compression path even at low level")
|
||||
void grayscale_lowLevel_returnsOk() throws Exception {
|
||||
byte[] pdf = textOnlyPdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(1);
|
||||
request.setGrayscale(true);
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto mode via expectedOutputSize picks a level and returns OK")
|
||||
void autoMode_expectedOutputSize_returnsOk() throws Exception {
|
||||
byte[] pdf = textOnlyPdfBytes();
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(pdf));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize("1KB"); // length > 1 => auto mode
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- public compressImagesInPDF ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("compressImagesInPDF")
|
||||
class CompressImages {
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF with a tiny image produces a valid, non-empty output PDF")
|
||||
void smallImage_producesValidPdf() throws Exception {
|
||||
Path src = tempDir.resolve("src.pdf");
|
||||
Files.write(src, smallImagePdfBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
TempFile result = controller.compressImagesInPDF(src, 0.5, 0.5f, false);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
byte[] out = Files.readAllBytes(result.getPath());
|
||||
assertThat(out).isNotEmpty();
|
||||
try (PDDocument doc = Loader.loadPDF(out)) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text-only PDF compresses to a valid single-page output")
|
||||
void textOnly_producesValidPdf() throws Exception {
|
||||
Path src = tempDir.resolve("text.pdf");
|
||||
Files.write(src, textOnlyPdfBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
TempFile result = controller.compressImagesInPDF(src, 0.8, 0.7f, false);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
try (PDDocument doc = Loader.loadPDF(Files.readAllBytes(result.getPath()))) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("load failure closes the temp file and propagates the exception")
|
||||
void loadFailure_propagates() throws Exception {
|
||||
Path src = tempDir.resolve("bad.pdf");
|
||||
Files.write(src, textOnlyPdfBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(any(Path.class))).thenThrow(new IOException("boom"));
|
||||
|
||||
assertThatThrownBy(() -> controller.compressImagesInPDF(src, 0.5, 0.5f, false))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("boom");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- pure helper methods (reflection) ----------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("scale / quality / level helpers")
|
||||
class Helpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("getScaleFactorForLevel maps each level and defaults to 1.0")
|
||||
void scaleFactorForLevel() throws Exception {
|
||||
Method m = privateStatic("getScaleFactorForLevel", int.class);
|
||||
assertThat((double) m.invoke(null, 1)).isEqualTo(0.98);
|
||||
assertThat((double) m.invoke(null, 5)).isEqualTo(0.68);
|
||||
assertThat((double) m.invoke(null, 9)).isEqualTo(0.28);
|
||||
// Out-of-range falls to the default branch.
|
||||
assertThat((double) m.invoke(null, 0)).isEqualTo(1.0);
|
||||
assertThat((double) m.invoke(null, 42)).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getJpegQualityForLevel maps each level and defaults to 0.75")
|
||||
void jpegQualityForLevel() throws Exception {
|
||||
Method m = privateStatic("getJpegQualityForLevel", int.class);
|
||||
assertThat((float) m.invoke(null, 1)).isEqualTo(0.92f);
|
||||
assertThat((float) m.invoke(null, 9)).isEqualTo(0.35f);
|
||||
assertThat((float) m.invoke(null, 0)).isEqualTo(0.75f);
|
||||
assertThat((float) m.invoke(null, 100)).isEqualTo(0.75f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("determineOptimizeLevel buckets the size-reduction ratio")
|
||||
void determineOptimizeLevel() throws Exception {
|
||||
Method m = privateStatic("determineOptimizeLevel", double.class);
|
||||
assertThat((int) m.invoke(null, 0.95)).isEqualTo(1);
|
||||
assertThat((int) m.invoke(null, 0.85)).isEqualTo(2);
|
||||
assertThat((int) m.invoke(null, 0.75)).isEqualTo(3);
|
||||
assertThat((int) m.invoke(null, 0.65)).isEqualTo(4);
|
||||
assertThat((int) m.invoke(null, 0.5)).isEqualTo(5);
|
||||
assertThat((int) m.invoke(null, 0.25)).isEqualTo(6);
|
||||
assertThat((int) m.invoke(null, 0.18)).isEqualTo(7);
|
||||
assertThat((int) m.invoke(null, 0.12)).isEqualTo(8);
|
||||
assertThat((int) m.invoke(null, 0.05)).isEqualTo(9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("incrementOptimizeLevel grows by ratio and is capped at 9")
|
||||
void incrementOptimizeLevel() throws Exception {
|
||||
Method m = privateStatic("incrementOptimizeLevel", int.class, long.class, long.class);
|
||||
// ratio 3.0 (> 2.0) -> +3
|
||||
assertThat((int) m.invoke(null, 2, 300L, 100L)).isEqualTo(5);
|
||||
// ratio 1.8 (> 1.5) -> +2
|
||||
assertThat((int) m.invoke(null, 2, 180L, 100L)).isEqualTo(4);
|
||||
// ratio 1.1 -> +1
|
||||
assertThat((int) m.invoke(null, 2, 110L, 100L)).isEqualTo(3);
|
||||
// capped at 9
|
||||
assertThat((int) m.invoke(null, 8, 300L, 100L)).isEqualTo(9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getImageType classifies filters; unknown for non-image input")
|
||||
void getImageType_andFilter() throws Exception {
|
||||
// A PNG-style lossless image yields FlateDecode -> "PNG".
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
java.awt.image.BufferedImage bi =
|
||||
new java.awt.image.BufferedImage(
|
||||
10, 10, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject image = LosslessFactory.createFromImage(doc, bi);
|
||||
|
||||
Method typeMethod = privateStatic("getImageType", PDImageXObject.class);
|
||||
String type = (String) typeMethod.invoke(null, image);
|
||||
assertThat(type).isEqualTo("PNG");
|
||||
|
||||
Method filterMethod = privateStatic("getImageFilter", PDImageXObject.class);
|
||||
String filter = (String) filterMethod.invoke(null, image);
|
||||
assertThat(filter).contains("FlateDecode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- reflection / field helpers ----------------------------------------------------------
|
||||
|
||||
private static Method privateStatic(String name, Class<?>... params) throws Exception {
|
||||
Method m = CompressController.class.getDeclaredMethod(name, params);
|
||||
m.setAccessible(true);
|
||||
return m;
|
||||
}
|
||||
|
||||
private void setLineArtService(LineArtConversionService service) throws Exception {
|
||||
Field f = CompressController.class.getDeclaredField("lineArtConversionService");
|
||||
f.setAccessible(true);
|
||||
f.set(controller, service);
|
||||
}
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.ExtractImageScansRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ExtractImageScansController}.
|
||||
*
|
||||
* <p>The controller shells out to a Python/OpenCV script via {@link ProcessExecutor} and gates on
|
||||
* {@link CheckProgramInstall#isPythonAvailable()}. To keep tests deterministic these static
|
||||
* boundaries are mocked with {@code Mockito.mockStatic}: Python is forced available/unavailable,
|
||||
* the script extraction is stubbed, and the process execution is replaced with an in-test answer
|
||||
* that either writes fake output PNGs into the controller-owned temp directory or leaves it empty.
|
||||
* No real Python process is ever launched.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ExtractImageScansControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private ExtractImageScansController controller;
|
||||
|
||||
@TempDir Path baseTmpDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
stirling.software.common.model.ApplicationProperties applicationProperties =
|
||||
new stirling.software.common.model.ApplicationProperties();
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getTempFileManagement()
|
||||
.setBaseTmpDir(baseTmpDir.toString());
|
||||
applicationProperties.getSystem().getTempFileManagement().setPrefix("scan-test-");
|
||||
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), applicationProperties);
|
||||
controller = new ExtractImageScansController(pdfDocumentFactory, tempFileManager);
|
||||
}
|
||||
|
||||
/** Build a request with sensible defaults; the caller supplies the file input. */
|
||||
private ExtractImageScansRequest requestFor(MockMultipartFile file) {
|
||||
ExtractImageScansRequest request = new ExtractImageScansRequest();
|
||||
request.setFileInput(file);
|
||||
request.setAngleThreshold(5);
|
||||
request.setTolerance(20);
|
||||
request.setMinArea(8000);
|
||||
request.setMinContourArea(500);
|
||||
request.setBorderSize(1);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** A tiny single-page in-memory PDF backed by a small media box for cheap rendering. */
|
||||
private MockMultipartFile pdfFile(String name) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
// Keep the page small so the 300-DPI render stays tiny and fast.
|
||||
doc.addPage(new PDPage(new PDRectangle(72f, 72f)));
|
||||
doc.save(out);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, out.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-PDF image input; the controller copies it straight to a temp file. */
|
||||
private MockMultipartFile imageFile(String name) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.IMAGE_PNG_VALUE, new byte[] {1, 2, 3, 4});
|
||||
}
|
||||
|
||||
/**
|
||||
* A mocked {@link ProcessExecutor} whose {@code runCommandWithOutputHandling} reads the output
|
||||
* directory from the command (positional arg index 3) and writes the given number of PNG files
|
||||
* into it, mimicking the real split_photos.py behaviour without launching a process.
|
||||
*/
|
||||
private ProcessExecutor execWritingOutputs(int outputCount) throws Exception {
|
||||
ProcessExecutor exec = mock(ProcessExecutor.class);
|
||||
when(exec.runCommandWithOutputHandling(anyList()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> command = invocation.getArgument(0);
|
||||
Path outDir = Path.of(command.get(3));
|
||||
for (int i = 0; i < outputCount; i++) {
|
||||
Files.write(
|
||||
outDir.resolve("out_" + i + ".png"),
|
||||
new byte[] {9, 8, 7, (byte) i});
|
||||
}
|
||||
return mock(ProcessExecutorResult.class);
|
||||
});
|
||||
return exec;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Python availability guard")
|
||||
class PythonGuard {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws IOException when Python is not installed")
|
||||
void throwsWhenPythonUnavailable() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> controller.extractImageScans(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not load the PDF or extract the script when Python is missing")
|
||||
void shortCircuitsBeforeAnyWork() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> controller.extractImageScans(request));
|
||||
|
||||
// Guard runs before document load and before script extraction.
|
||||
Mockito.verifyNoInteractions(pdfDocumentFactory);
|
||||
general.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("No detected images branch")
|
||||
class NoImagesBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws IllegalArgumentException for a non-PDF input when no outputs produced")
|
||||
void throwsNoImagesForImageInput() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
|
||||
// Process produces zero output files -> empty result -> "no images" error.
|
||||
// Build the executor mock BEFORE stubbing the static so its inner when(...) does
|
||||
// not
|
||||
// nest inside this when(...).thenReturn(...) call.
|
||||
ProcessExecutor exec = execWritingOutputs(0);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws IllegalArgumentException for a PDF input when no outputs produced")
|
||||
void throwsNoImagesForPdfInput() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(pdfFile("scan.pdf"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
when(pdfDocumentFactory.load(
|
||||
any(org.springframework.web.multipart.MultipartFile.class)))
|
||||
.thenReturn(singlePageDocument());
|
||||
|
||||
ProcessExecutor exec = execWritingOutputs(0);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
|
||||
// The PDF path must load the document exactly once.
|
||||
verify(pdfDocumentFactory, times(1))
|
||||
.load(any(org.springframework.web.multipart.MultipartFile.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Single image output branch")
|
||||
class SingleImageBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a single PNG response when exactly one image is detected")
|
||||
void returnsSinglePng() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
general.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
|
||||
.thenAnswer(inv -> inv.<String>getArgument(0) + inv.<String>getArgument(1));
|
||||
|
||||
ProcessExecutor exec = execWritingOutputs(1);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractImageScans(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.IMAGE_PNG, response.getHeaders().getContentType());
|
||||
assertNotNull(response.getBody());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Multiple images (zip) output branch")
|
||||
class ZipBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a zip response when more than one image is detected")
|
||||
void returnsZipForMultipleImages() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
general.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
|
||||
.thenAnswer(inv -> inv.<String>getArgument(0) + inv.<String>getArgument(1));
|
||||
|
||||
// Three output files -> zip path.
|
||||
ProcessExecutor exec = execWritingOutputs(3);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractImageScans(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
// generateFilename is invoked for the zip name and once per zip entry.
|
||||
general.verify(
|
||||
() -> GeneralUtils.generateFilename(anyString(), anyString()),
|
||||
atLeastOnce());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Command construction")
|
||||
class CommandConstruction {
|
||||
|
||||
@Test
|
||||
@DisplayName("passes the request parameters as CLI flags to the executor")
|
||||
void buildsExpectedCommand() throws Exception {
|
||||
MockMultipartFile file = imageFile("scan.png");
|
||||
ExtractImageScansRequest request = new ExtractImageScansRequest();
|
||||
request.setFileInput(file);
|
||||
request.setAngleThreshold(7);
|
||||
request.setTolerance(21);
|
||||
request.setMinArea(9000);
|
||||
request.setMinContourArea(600);
|
||||
request.setBorderSize(2);
|
||||
|
||||
ProcessExecutor exec = mock(ProcessExecutor.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<String>> cmdCaptor = ArgumentCaptor.forClass(List.class);
|
||||
when(exec.runCommandWithOutputHandling(cmdCaptor.capture()))
|
||||
.thenAnswer(invocation -> mock(ProcessExecutorResult.class));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
// No outputs are written, so the controller ultimately throws "no images";
|
||||
// we only care that the command was built and dispatched first.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
|
||||
List<String> command = cmdCaptor.getValue();
|
||||
assertNotNull(command);
|
||||
assertEquals("python3", command.get(0));
|
||||
assertTrue(command.contains("--angle_threshold"));
|
||||
assertEquals("7", valueAfter(command, "--angle_threshold"));
|
||||
assertEquals("21", valueAfter(command, "--tolerance"));
|
||||
assertEquals("9000", valueAfter(command, "--min_area"));
|
||||
assertEquals("600", valueAfter(command, "--min_contour_area"));
|
||||
assertEquals("2", valueAfter(command, "--border_size"));
|
||||
}
|
||||
}
|
||||
|
||||
private String valueAfter(List<String> command, String flag) {
|
||||
int idx = command.indexOf(flag);
|
||||
assertTrue(idx >= 0 && idx + 1 < command.size(), "flag " + flag + " not found");
|
||||
return command.get(idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Temp file cleanup")
|
||||
class Cleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves no controller temp files behind after a no-images failure")
|
||||
void cleansUpAfterFailure() throws Exception {
|
||||
ExtractImageScansRequest request = requestFor(imageFile("scan.png"));
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> check =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> general = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
check.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
check.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
general.when(() -> GeneralUtils.extractScript("split_photos.py"))
|
||||
.thenReturn(Path.of("split_photos.py"));
|
||||
ProcessExecutor exec = execWritingOutputs(0);
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(exec);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.extractImageScans(request));
|
||||
|
||||
try (var stream = Files.walk(baseTmpDir)) {
|
||||
boolean leaked =
|
||||
stream.filter(Files::isRegularFile)
|
||||
.map(p -> p.getFileName().toString())
|
||||
.anyMatch(n -> n.startsWith("scan-test-"));
|
||||
assertFalse(leaked, "controller temp files should be cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a fresh single-page in-memory document the factory mock can hand back. */
|
||||
private PDDocument singlePageDocument() {
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(new PDRectangle(72f, 72f)));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link OCRController}. OCR shells out to tesseract/ocrmypdf, so these tests focus
|
||||
* on the pure, deterministic surface: tesseract-language discovery and the request validation /
|
||||
* tool-availability branches that all complete (or fail) before any external process is launched.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class OCRControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private OCRController ocrController;
|
||||
|
||||
@TempDir Path baseTmpDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getTempFileManagement()
|
||||
.setBaseTmpDir(baseTmpDir.toString());
|
||||
applicationProperties.getSystem().getTempFileManagement().setPrefix("ocr-test-");
|
||||
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), applicationProperties);
|
||||
|
||||
ocrController =
|
||||
new OCRController(
|
||||
applicationProperties,
|
||||
pdfDocumentFactory,
|
||||
tempFileManager,
|
||||
endpointConfiguration,
|
||||
runtimePathConfig);
|
||||
}
|
||||
|
||||
/** Build a minimal request with sensible defaults the caller can override. */
|
||||
private ProcessPdfWithOcrRequest baseRequest() {
|
||||
ProcessPdfWithOcrRequest request = new ProcessPdfWithOcrRequest();
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setOcrRenderType("hocr");
|
||||
request.setOcrType("skip-text");
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Build a tiny single-page in-memory PDF as a MockMultipartFile. */
|
||||
private MockMultipartFile pdfMultipartFile(String name) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(out);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, out.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a tessdata directory populated with the given traineddata languages. */
|
||||
private Path tessdataDirWith(String... languages) throws IOException {
|
||||
Path dir = Files.createTempDirectory(baseTmpDir, "tessdata");
|
||||
for (String lang : languages) {
|
||||
Files.createFile(dir.resolve(lang + ".traineddata"));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAvailableTesseractLanguages")
|
||||
class GetAvailableTesseractLanguages {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns trained languages and excludes osd")
|
||||
void returnsTrainedLanguagesExcludingOsd() throws IOException {
|
||||
Path tessdata = tessdataDirWith("eng", "deu", "osd");
|
||||
// A non-traineddata file must be ignored entirely.
|
||||
Files.createFile(tessdata.resolve("readme.txt"));
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
|
||||
List<String> langs = ocrController.getAvailableTesseractLanguages();
|
||||
|
||||
assertTrue(langs.contains("eng"));
|
||||
assertTrue(langs.contains("deu"));
|
||||
assertFalse(langs.contains("osd"), "osd must be filtered out");
|
||||
assertFalse(langs.contains("readme"), "non-traineddata files must be ignored");
|
||||
assertEquals(2, langs.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filters osd case-insensitively")
|
||||
void filtersOsdCaseInsensitively() throws IOException {
|
||||
Path tessdata = tessdataDirWith("eng", "OSD");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
|
||||
List<String> langs = ocrController.getAvailableTesseractLanguages();
|
||||
|
||||
assertEquals(List.of("eng"), langs);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list when directory has no traineddata files")
|
||||
void returnsEmptyWhenNoTrainedData() throws IOException {
|
||||
Path empty = Files.createTempDirectory(baseTmpDir, "empty-tessdata");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(empty.toString());
|
||||
|
||||
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list when directory does not exist")
|
||||
void returnsEmptyWhenDirectoryMissing() {
|
||||
Path missing = baseTmpDir.resolve("does-not-exist");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(missing.toString());
|
||||
|
||||
// listFiles() on a non-directory returns null -> empty list, not an exception.
|
||||
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processPdfWithOCR validation branches")
|
||||
class ValidationBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when languages list is null")
|
||||
void throwsWhenLanguagesNull() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(null);
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
// Validation happens before any tool/exec interaction.
|
||||
verifyNoInteractions(endpointConfiguration);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when languages list is empty")
|
||||
void throwsWhenLanguagesEmpty() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(Collections.emptyList());
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
verifyNoInteractions(endpointConfiguration);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when ocrRenderType is invalid")
|
||||
void throwsWhenRenderTypeInvalid() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setOcrRenderType("bogus");
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
// Render-type check precedes language availability lookup.
|
||||
verify(runtimePathConfig, never()).getTessDataPath();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts sandwich render type past the render-type check")
|
||||
void sandwichRenderTypePassesRenderCheck() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setOcrRenderType("sandwich");
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
// No tessdata languages available -> falls through to invalid-languages, still an
|
||||
// IOException, but proves "sandwich" was not rejected by the render-type guard.
|
||||
Path tessdata = tessdataDirWith("eng");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
|
||||
|
||||
// eng is available, so it gets past language validation and reaches the
|
||||
// tool-availability check, which throws because both tools are disabled.
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
verify(runtimePathConfig).getTessDataPath();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when none of the selected languages are available")
|
||||
void throwsWhenNoSelectedLanguageAvailable() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(List.of("xyz")); // not present in tessdata
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
Path tessdata = tessdataDirWith("eng", "deu");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
// Should fail before consulting tool availability.
|
||||
verify(endpointConfiguration, never()).isGroupEnabled(anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processPdfWithOCR tool-availability branch")
|
||||
class ToolAvailabilityBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when both OCRmyPDF and tesseract are unavailable")
|
||||
void throwsWhenNoOcrToolsAvailable() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
Path tessdata = tessdataDirWith("eng");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
|
||||
verify(endpointConfiguration).isGroupEnabled("OCRmyPDF");
|
||||
verify(endpointConfiguration).isGroupEnabled("tesseract");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("temp input/output files are cleaned up after a failure")
|
||||
void tempFilesCleanedUpAfterFailure() throws IOException {
|
||||
ProcessPdfWithOcrRequest request = baseRequest();
|
||||
request.setLanguages(List.of("eng"));
|
||||
request.setFileInput(pdfMultipartFile("in.pdf"));
|
||||
|
||||
Path tessdata = tessdataDirWith("eng");
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString());
|
||||
when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false);
|
||||
|
||||
assertThrows(IOException.class, () -> ocrController.processPdfWithOCR(request));
|
||||
|
||||
// The only files left under the temp dir should be our tessdata dir and its
|
||||
// contents; the controller's .pdf temp files must have been closed/deleted.
|
||||
try (var stream = Files.walk(baseTmpDir)) {
|
||||
boolean leakedPdf =
|
||||
stream.filter(Files::isRegularFile)
|
||||
.map(p -> p.getFileName().toString())
|
||||
.filter(n -> n.startsWith("ocr-test-"))
|
||||
.anyMatch(n -> n.endsWith(".pdf"));
|
||||
assertFalse(leakedPdf, "controller temp PDF files should be cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getAvailableTesseractLanguages survives a path that is a regular file")
|
||||
void languagesEmptyWhenPathIsRegularFile() throws IOException {
|
||||
File regularFile = Files.createTempFile(baseTmpDir, "not-a-dir", ".bin").toFile();
|
||||
when(runtimePathConfig.getTessDataPath()).thenReturn(regularFile.getAbsolutePath());
|
||||
|
||||
// listFiles() on a regular file returns null -> empty list.
|
||||
assertTrue(ocrController.getAvailableTesseractLanguages().isEmpty());
|
||||
}
|
||||
}
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddPageNumbersRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PageNumbersControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private PageNumbersController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Each managed temp file is backed by a real on-disk file so document.save() works
|
||||
// and WebResponseUtils can stat/stream it.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("pgnum-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------
|
||||
|
||||
private MockMultipartFile createPdf(int pages, String filename) throws IOException {
|
||||
Path path = tempDir.resolve("source-" + System.nanoTime() + ".pdf");
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.LETTER));
|
||||
}
|
||||
doc.save(path.toFile());
|
||||
}
|
||||
return new MockMultipartFile(
|
||||
"fileInput", filename, MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(path));
|
||||
}
|
||||
|
||||
private AddPageNumbersRequest baseRequest(MockMultipartFile file) {
|
||||
AddPageNumbersRequest request = new AddPageNumbersRequest();
|
||||
request.setFileInput(file);
|
||||
request.setFontSize(12f);
|
||||
request.setFontType("helvetica");
|
||||
request.setPosition(8);
|
||||
request.setStartingNumber(1);
|
||||
return request;
|
||||
}
|
||||
|
||||
private byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ---- happy path -------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-page PDF returns OK with a non-empty PDF body")
|
||||
void singlePage_returnsOkWithBody() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "doc.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
byte[] body = drainBody(response);
|
||||
assertThat(body).isNotEmpty();
|
||||
// Result is still a valid, single-page PDF.
|
||||
try (PDDocument out = Loader.loadPDF(body)) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Multi-page PDF with default 'all' pages numbers every page")
|
||||
void multiPage_allPages() throws Exception {
|
||||
MockMultipartFile file = createPdf(5, "multi.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
PDDocument doc = Loader.loadPDF(file.getBytes());
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(doc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(5);
|
||||
}
|
||||
// The loaded document is closed by the try-with-resources in the controller.
|
||||
verify(tempFileManager).createManagedTempFile(".pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Content-Disposition attachment filename carries the source name")
|
||||
void responseHasAttachmentFilename() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "report.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
String disposition = response.getHeaders().getFirst("Content-Disposition");
|
||||
assertThat(disposition).isNotNull();
|
||||
assertThat(disposition).contains("report_page_numbers_added.pdf");
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pages-to-number selection ----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Page selection")
|
||||
class PageSelection {
|
||||
|
||||
@Test
|
||||
@DisplayName("Explicit subset of pages still returns all pages in output")
|
||||
void specificPages() throws Exception {
|
||||
MockMultipartFile file = createPdf(4, "sel.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber("1,3");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Range expression is accepted")
|
||||
void rangeExpression() throws Exception {
|
||||
MockMultipartFile file = createPdf(6, "range.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber("2-4");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null pagesToNumber defaults to 'all'")
|
||||
void nullPagesDefaultsToAll() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "nullpages.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty pagesToNumber defaults to 'all'")
|
||||
void emptyPagesDefaultsToAll() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "emptypages.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPagesToNumber("");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- position handling (1..9 plus clamping) ---------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Position")
|
||||
class Position {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
|
||||
@DisplayName("All nine positions render successfully")
|
||||
void allPositions(int position) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "pos.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPosition(position);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {-5, 0, 10, 100})
|
||||
@DisplayName("Out-of-range positions are clamped and still render")
|
||||
void outOfRangePositionsClamped(int position) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "posclamp.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setPosition(position);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- margins ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom margin")
|
||||
class CustomMargin {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"small", "medium", "large", "x-large", "X-LARGE", "unknown"})
|
||||
@DisplayName("Known and unknown margins are accepted")
|
||||
void margins(String margin) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "margin.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomMargin(margin);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null margin falls back to default factor")
|
||||
void nullMargin() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "nullmargin.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomMargin(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- font type --------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Font type")
|
||||
class FontType {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"helvetica", "courier", "times", "TIMES", "anythingelse"})
|
||||
@DisplayName("Known and unknown font types render (unknown falls back to Helvetica)")
|
||||
void fontTypes(String font) throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "font.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontType(font);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null font type falls back to Helvetica")
|
||||
void nullFontType() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "nullfont.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontType(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- font color -------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Font color")
|
||||
class FontColor {
|
||||
|
||||
@Test
|
||||
@DisplayName("Valid hex color renders")
|
||||
void validHexColor() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "color.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor("#FF0000");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Invalid hex color falls back to black and still renders")
|
||||
void invalidHexColorFallsBackToBlack() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "badcolor.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor("not-a-color");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null font color uses default black")
|
||||
void nullColor() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "nullcolor.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank/whitespace font color uses default black")
|
||||
void blankColor() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "blankcolor.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setFontColor(" ");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- custom text / placeholders --------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom text")
|
||||
class CustomText {
|
||||
|
||||
@Test
|
||||
@DisplayName("Null custom text defaults to {n}")
|
||||
void nullCustomText() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "nulltext.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText(null);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty custom text defaults to {n}")
|
||||
void emptyCustomText() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "emptytext.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom text with {n}, {total} and {filename} placeholders renders")
|
||||
void placeholders() throws Exception {
|
||||
MockMultipartFile file = createPdf(3, "myfile.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("Page {n} of {total} - {filename}");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(out.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Literal custom text without placeholders renders")
|
||||
void literalText() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "literal.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("Confidential");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- numbering / zero-pad / starting number ---------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Numbering")
|
||||
class Numbering {
|
||||
|
||||
@Test
|
||||
@DisplayName("Zero-pad width produces Bates-style padded numbers without error")
|
||||
void zeroPadBatesStamping() throws Exception {
|
||||
MockMultipartFile file = createPdf(3, "bates.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setZeroPad(5);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Zero zero-pad uses unpadded numbers")
|
||||
void zeroPadDisabled() throws Exception {
|
||||
MockMultipartFile file = createPdf(2, "nopad.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setZeroPad(0);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom starting number is honored")
|
||||
void customStartingNumber() throws Exception {
|
||||
MockMultipartFile file = createPdf(3, "start.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setStartingNumber(100);
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- filename handling for {filename} ---------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Filename handling")
|
||||
class FilenameHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("Filename without extension is handled for {filename} placeholder")
|
||||
void filenameWithoutExtension() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "no_extension");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
request.setCustomText("{filename}");
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ResponseEntity<Resource> response = controller.addPageNumbers(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- error branches ---------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error handling")
|
||||
class ErrorHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from document load propagates")
|
||||
void loadIOExceptionPropagates() throws Exception {
|
||||
MockMultipartFile file = createPdf(1, "err.pdf");
|
||||
AddPageNumbersRequest request = baseRequest(file);
|
||||
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("corrupt pdf"));
|
||||
|
||||
assertThatThrownBy(() -> controller.addPageNumbers(request))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("corrupt pdf");
|
||||
}
|
||||
}
|
||||
}
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("RemoveImagesController")
|
||||
class RemoveImagesControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private TempFileManager tempFileManager;
|
||||
private RemoveImagesController controller;
|
||||
|
||||
// Holds the temp file backing the most recent createManagedTempFile call so tests can
|
||||
// re-load the actually-saved (image-stripped) document from disk and assert on it.
|
||||
private final List<File> savedTempFiles = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
pdfDocumentFactory = mock(CustomPDFDocumentFactory.class);
|
||||
tempFileManager = mock(TempFileManager.class);
|
||||
controller = new RemoveImagesController(pdfDocumentFactory, tempFileManager);
|
||||
savedTempFiles.clear();
|
||||
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile(tempDir, "out", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
savedTempFiles.add(f);
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
// ----- helpers -------------------------------------------------------------------------
|
||||
|
||||
private MockMultipartFile multipart(String name, byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private PDFFile request(MockMultipartFile file) {
|
||||
PDFFile req = new PDFFile();
|
||||
req.setFileInput(file);
|
||||
return req;
|
||||
}
|
||||
|
||||
/** A drawable RGB image with no useful content, kept tiny for speed. */
|
||||
private BufferedImage tinyImage() {
|
||||
return new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
}
|
||||
|
||||
/** PDF with {@code pageCount} pages, each with one drawn JPEG image. */
|
||||
private byte[] pdfWithImagesBytes(int pageCount) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = JPEGFactory.createFromImage(doc, tinyImage());
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 50, 600, 50, 50);
|
||||
}
|
||||
}
|
||||
return saveToBytes(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF with one page that has no images at all (just resources without XObjects). */
|
||||
private byte[] pdfWithoutImagesBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
// Touch the page resources so they are non-null but contain no XObject dictionary.
|
||||
page.setResources(new PDResources());
|
||||
return saveToBytes(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF whose single page has a resources dictionary with an image nested in a form XObject. */
|
||||
private byte[] pdfWithImageInsideFormBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
PDImageXObject image = JPEGFactory.createFromImage(doc, tinyImage());
|
||||
|
||||
PDFormXObject form = new PDFormXObject(doc);
|
||||
form.setBBox(new PDRectangle(100, 100));
|
||||
PDResources formResources = new PDResources();
|
||||
formResources.add(image);
|
||||
form.setResources(formResources);
|
||||
|
||||
PDResources pageResources = new PDResources();
|
||||
pageResources.add(form);
|
||||
page.setResources(pageResources);
|
||||
|
||||
return saveToBytes(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] saveToBytes(PDDocument doc) throws IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Counts every PDImageXObject reachable through page + nested form resources. */
|
||||
private int countImagesInSavedOutput() throws IOException {
|
||||
assertFalse(savedTempFiles.isEmpty(), "expected the controller to create a temp file");
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
try (PDDocument doc = Loader.loadPDF(out)) {
|
||||
int count = 0;
|
||||
for (PDPage page : doc.getPages()) {
|
||||
count += countImagesInResources(page.getResources());
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private int countImagesInResources(PDResources resources) throws IOException {
|
||||
if (resources == null || resources.getXObjectNames() == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (COSName name : resources.getXObjectNames()) {
|
||||
PDXObject xObject = resources.getXObject(name);
|
||||
if (xObject instanceof PDImageXObject) {
|
||||
count++;
|
||||
} else if (xObject instanceof PDFormXObject form) {
|
||||
count += countImagesInResources(form.getResources());
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ----- happy paths ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("removeImages happy path")
|
||||
class HappyPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns OK and strips the image from a single-page PDF")
|
||||
void singlePageWithImage() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
MockMultipartFile file = multipart("doc.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
// sanity: the input genuinely had one image to remove
|
||||
assertEquals(1, countImagesInResources(loaded.getPage(0).getResources()));
|
||||
|
||||
ResponseEntity<Resource> response;
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
response = controller.removeImages(req);
|
||||
}
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(0, countImagesInSavedOutput(), "all images should have been removed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("strips images across every page of a multi-page PDF")
|
||||
void multiPageWithImages() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(3);
|
||||
MockMultipartFile file = multipart("multi.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
assertEquals(3, loaded.getNumberOfPages());
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
// page count must be preserved
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertEquals(3, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removes an image nested inside a form XObject")
|
||||
void imageNestedInsideForm() throws IOException {
|
||||
byte[] bytes = pdfWithImageInsideFormBytes();
|
||||
MockMultipartFile file = multipart("nested.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
// sanity: the nested image is present before removal
|
||||
assertEquals(1, countImagesInResources(loaded.getPage(0).getResources()));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
controller.removeImages(req);
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput(), "nested form image should be removed");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- edge cases ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("removeImages edge cases")
|
||||
class EdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns OK when the PDF has no images")
|
||||
void noImages() throws IOException {
|
||||
byte[] bytes = pdfWithoutImagesBytes();
|
||||
MockMultipartFile file = multipart("plain.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handles a page that has no resources dictionary")
|
||||
void pageWithoutResources() throws IOException {
|
||||
// A bare PDPage built without content has null resources.
|
||||
byte[] bytes;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.LETTER));
|
||||
bytes = saveToBytes(doc);
|
||||
}
|
||||
MockMultipartFile file = multipart("bare.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
PDDocument loaded = Loader.loadPDF(bytes);
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(loaded);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creates exactly one managed temp file and saves into it")
|
||||
void savesIntoManagedTempFile() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
MockMultipartFile file = multipart("doc.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
controller.removeImages(req);
|
||||
}
|
||||
|
||||
assertEquals(1, savedTempFiles.size());
|
||||
File out = savedTempFiles.get(0);
|
||||
assertTrue(out.exists());
|
||||
assertTrue(out.length() > 0, "saved PDF must be non-empty");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- filename / interaction ----------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("filename handling")
|
||||
class Filenames {
|
||||
|
||||
@Test
|
||||
@DisplayName("appends _images_removed.pdf suffix to the original name")
|
||||
void appendsSuffix() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
MockMultipartFile file = multipart("report.pdf", bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
|
||||
controller.removeImages(req);
|
||||
|
||||
web.verify(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class),
|
||||
org.mockito.ArgumentMatchers.eq(
|
||||
"report_images_removed.pdf")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("derives a default name when the original filename is null")
|
||||
void nullOriginalFilename() throws IOException {
|
||||
byte[] bytes = pdfWithImagesBytes(1);
|
||||
// MockMultipartFile with a null original filename
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
try (MockedStatic<WebResponseUtils> web = mockStatic(WebResponseUtils.class)) {
|
||||
web.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(okResponse());
|
||||
|
||||
ResponseEntity<Resource> response = controller.removeImages(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
|
||||
// GeneralUtils.generateFilename handles null safely; just assert it was called
|
||||
web.verify(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- error branches ------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("error handling")
|
||||
class Errors {
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates an IOException when loading the PDF fails")
|
||||
void loadFailureThrowsIOException() throws IOException {
|
||||
MockMultipartFile file = multipart("broken.pdf", "not a pdf".getBytes());
|
||||
PDFFile req = request(file);
|
||||
|
||||
when(pdfDocumentFactory.load(req)).thenThrow(new IOException("corrupt"));
|
||||
|
||||
assertThrows(IOException.class, () -> controller.removeImages(req));
|
||||
}
|
||||
}
|
||||
|
||||
private static ResponseEntity<Resource> okResponse() {
|
||||
return ResponseEntity.ok(new ByteArrayResource("ok".getBytes()));
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link RepairController}.
|
||||
*
|
||||
* <p>The controller delegates to external binaries (Ghostscript, qpdf) for its primary repair
|
||||
* paths. Those paths shell out via the static {@code ProcessExecutor} factory and are therefore not
|
||||
* deterministically testable in a unit test. These tests keep both Ghostscript and qpdf disabled so
|
||||
* the controller always takes the pure-Java PDFBox last-resort branch, exercising file handling,
|
||||
* filename generation, the success response, and the error-propagation branches without spawning
|
||||
* any external process.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class RepairControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
|
||||
// Real TempFileManager so transferTo / temp file creation / file-backed response all work
|
||||
// end-to-end deterministically without touching any external tooling.
|
||||
private TempFileManager tempFileManager;
|
||||
|
||||
private RepairController repairController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
repairController =
|
||||
new RepairController(pdfDocumentFactory, tempFileManager, endpointConfiguration);
|
||||
|
||||
// Default: no external tools available -> forces the PDFBox last-resort branch.
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(false);
|
||||
}
|
||||
|
||||
/** Build a tiny, valid in-memory PDF as bytes. */
|
||||
private static byte[] buildPdfBytes(int pageCount) throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh real in-memory document for stubbing pdfDocumentFactory.load(). */
|
||||
private static PDDocument newRealDocument(int pageCount) {
|
||||
PDDocument document = new PDDocument();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private static PDFFile pdfFileFrom(MockMultipartFile multipartFile) {
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileInput(multipartFile);
|
||||
return pdfFile;
|
||||
}
|
||||
|
||||
/** Read the body of a file-backed Resource response into bytes. */
|
||||
private static byte[] readResource(Resource resource) throws IOException {
|
||||
try (InputStream in = resource.getInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
in.transferTo(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("PDFBox last-resort branch (no external tools)")
|
||||
class PdfBoxBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 with a non-empty PDF resource body")
|
||||
void repairPdf_pdfBoxFallback_returnsOkWithPdf() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(2));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(2));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.APPLICATION_PDF, response.getHeaders().getContentType());
|
||||
|
||||
Resource body = response.getBody();
|
||||
assertNotNull(body);
|
||||
|
||||
byte[] outputBytes = readResource(body);
|
||||
assertTrue(outputBytes.length > 0, "repaired PDF body should not be empty");
|
||||
|
||||
// Output must be a structurally valid PDF; confirm by reloading it.
|
||||
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(outputBytes)) {
|
||||
assertEquals(2, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loads the input file exactly once via the factory")
|
||||
void repairPdf_pdfBoxFallback_loadsInputOnce() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
verify(pdfDocumentFactory, times(1)).load(any(File.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not consult qpdf/ghostscript a second time once disabled")
|
||||
void repairPdf_checksToolAvailability() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
// Ghostscript is checked once (skip), qpdf once (skip), then both checked again to
|
||||
// decide the last-resort branch.
|
||||
verify(endpointConfiguration, times(2)).isGroupEnabled("Ghostscript");
|
||||
verify(endpointConfiguration, times(2)).isGroupEnabled("qpdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the file passed to the factory exists on disk when loaded")
|
||||
void repairPdf_transfersInputToRealTempFile() throws Exception {
|
||||
byte[] pdf = buildPdfBytes(3);
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "broken.pdf", MediaType.APPLICATION_PDF_VALUE, pdf);
|
||||
|
||||
// Assert the file handed to load() is a real, non-empty file (transferTo succeeded).
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
File file = invocation.getArgument(0);
|
||||
assertTrue(file.exists(), "temp input file should exist");
|
||||
assertEquals(pdf.length, file.length());
|
||||
return newRealDocument(3);
|
||||
});
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Output filename handling")
|
||||
class FilenameHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("appends _repaired.pdf to the base name in the Content-Disposition header")
|
||||
void repairPdf_setsRepairedFilename() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"mydoc.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
String disposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
assertTrue(
|
||||
disposition.contains("mydoc_repaired.pdf"),
|
||||
"expected repaired filename in: " + disposition);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filename without extension still gets _repaired.pdf appended")
|
||||
void repairPdf_filenameWithoutExtension() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"noext",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
assertTrue(
|
||||
disposition.contains("noext_repaired.pdf"),
|
||||
"expected repaired filename in: " + disposition);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null original filename falls back to 'default'")
|
||||
void repairPdf_nullOriginalFilename_usesDefault() throws Exception {
|
||||
// MockMultipartFile with null original filename.
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput", null, MediaType.APPLICATION_PDF_VALUE, buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(newRealDocument(1));
|
||||
|
||||
ResponseEntity<Resource> response = repairController.repairPdf(pdfFileFrom(input));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertNotNull(disposition);
|
||||
// MockMultipartFile maps a null name to "", so the base is empty -> leading underscore.
|
||||
assertTrue(
|
||||
disposition.contains("_repaired.pdf"),
|
||||
"expected empty-base repaired filename in: " + disposition);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error propagation")
|
||||
class ErrorPropagation {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from the factory propagates to the caller")
|
||||
void repairPdf_loadThrowsIOException_propagates() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenThrow(new IOException("cannot load corrupt pdf"));
|
||||
|
||||
IOException thrown =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> repairController.repairPdf(pdfFileFrom(input)));
|
||||
assertEquals("cannot load corrupt pdf", thrown.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RuntimeException from the factory propagates to the caller")
|
||||
void repairPdf_loadThrowsRuntimeException_propagates() throws Exception {
|
||||
MockMultipartFile input =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdfBytes(1));
|
||||
|
||||
when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenThrow(new IllegalStateException("boom"));
|
||||
|
||||
IllegalStateException thrown =
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> repairController.repairPdf(pdfFileFrom(input)));
|
||||
assertEquals("boom", thrown.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+932
@@ -0,0 +1,932 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.ScannerEffectRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ScannerEffectController Tests")
|
||||
class ScannerEffectControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private ScannerEffectController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Real temp file backing so WebResponseUtils.pdfDocToWebResponse can save the output.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("scanner_test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
f.deleteOnExit();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static MockMultipartFile pdfFile(String filename, int pageCount, PDRectangle pageSize)
|
||||
throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
doc.addPage(new PDPage(pageSize));
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return new MockMultipartFile(
|
||||
"fileInput", filename, "application/pdf", baos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static ScannerEffectRequest baseRequest(MockMultipartFile file) {
|
||||
ScannerEffectRequest request = new ScannerEffectRequest();
|
||||
request.setFileInput(file);
|
||||
// Advanced enabled so quality presets do not override our resolution.
|
||||
request.setAdvancedEnabled(true);
|
||||
// Keep rendering tiny and fast; deterministic structure only.
|
||||
request.setResolution(36);
|
||||
request.setRotation(ScannerEffectRequest.Rotation.none);
|
||||
request.setRotate(0);
|
||||
request.setRotateVariance(0);
|
||||
request.setBorder(2);
|
||||
request.setBrightness(1.0f);
|
||||
request.setContrast(1.0f);
|
||||
request.setBlur(0f);
|
||||
request.setNoise(0f);
|
||||
request.setYellowish(false);
|
||||
request.setColorspace(ScannerEffectRequest.Colorspace.grayscale);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Stub both factory.load overloads to return fresh real documents loaded from bytes. */
|
||||
private void stubFactoryLoad(byte[] pdfBytes) throws IOException {
|
||||
// Used for page count + as output base (load(byte[])).
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
byte[] b = inv.getArgument(0);
|
||||
return Loader.loadPDF(b);
|
||||
});
|
||||
// Used by RenderingResources.fromBytes (load(byte[], true)).
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
|
||||
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
|
||||
}
|
||||
|
||||
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// End-to-end controller behaviour (real rendering, mocked boundaries)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("scannerEffect end-to-end")
|
||||
class EndToEnd {
|
||||
|
||||
@Test
|
||||
@DisplayName("produces a valid single-page PDF response for a one-page input")
|
||||
void singlePageHappyPath() throws Exception {
|
||||
MockMultipartFile file = pdfFile("input.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
|
||||
byte[] out = drain(response);
|
||||
assertThat(out).isNotEmpty();
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
PDRectangle box = result.getPage(0).getMediaBox();
|
||||
// Output page keeps the original page dimensions.
|
||||
assertThat(box.getWidth()).isCloseTo(PDRectangle.A6.getWidth(), within(1f));
|
||||
assertThat(box.getHeight()).isCloseTo(PDRectangle.A6.getHeight(), within(1f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves page count for a multi-page input")
|
||||
void multiPageKeepsPageCount() throws Exception {
|
||||
MockMultipartFile file = pdfFile("multi.pdf", 3, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument result = Loader.loadPDF(drain(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applies colour effects (color colorspace, blur, noise, yellowish, rotation)")
|
||||
void richEffectsStillProduceValidPdf() throws Exception {
|
||||
MockMultipartFile file = pdfFile("rich.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
request.setColorspace(ScannerEffectRequest.Colorspace.color);
|
||||
request.setBlur(1.0f);
|
||||
request.setNoise(4.0f);
|
||||
request.setYellowish(true);
|
||||
request.setRotation(ScannerEffectRequest.Rotation.slight);
|
||||
request.setRotateVariance(2);
|
||||
request.setBorder(5);
|
||||
request.setBrightness(1.05f);
|
||||
request.setContrast(1.1f);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument result = Loader.loadPDF(drain(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-advanced request applies the quality preset without error")
|
||||
void qualityPresetPath() throws Exception {
|
||||
MockMultipartFile file = pdfFile("preset.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
request.setAdvancedEnabled(false);
|
||||
// Low preset uses resolution 75 which is the cheapest render.
|
||||
request.setQuality(ScannerEffectRequest.Quality.low);
|
||||
|
||||
ResponseEntity<Resource> response = controller.scannerEffect(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument result = Loader.loadPDF(drain(response))) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a DPI above the safe maximum with IllegalArgumentException")
|
||||
void dpiAboveLimitIsRejected() throws Exception {
|
||||
MockMultipartFile file = pdfFile("highdpi.pdf", 1, PDRectangle.A6);
|
||||
stubFactoryLoad(file.getBytes());
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
// No application context in tests -> maxSafeDpi defaults to 500.
|
||||
request.setResolution(600);
|
||||
|
||||
assertThatThrownBy(() -> controller.scannerEffect(request))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("600");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an empty (zero-page) document with IllegalArgumentException")
|
||||
void emptyDocumentIsRejected() throws Exception {
|
||||
// A real 1-page file on disk, but the factory returns an empty document.
|
||||
MockMultipartFile file = pdfFile("empty.pdf", 1, PDRectangle.A6);
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
assertThatThrownBy(() -> controller.scannerEffect(request))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no pages");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates IOException from the document factory")
|
||||
void factoryIOExceptionPropagates() throws Exception {
|
||||
MockMultipartFile file = pdfFile("io.pdf", 1, PDRectangle.A6);
|
||||
ScannerEffectRequest request = baseRequest(file);
|
||||
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenThrow(new IOException("boom-load"));
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(byte[].class), anyBoolean()))
|
||||
.thenThrow(new IOException("boom-load"));
|
||||
|
||||
assertThatThrownBy(() -> controller.scannerEffect(request))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pure static image-processing logic via reflection
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("calculateSafeResolution")
|
||||
class CalculateSafeResolution {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"calculateSafeResolution", float.class, float.class, int.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
private int invoke(float w, float h, int res) throws Exception {
|
||||
return (int) method.invoke(null, w, h, res);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps requested resolution when the projected image is small")
|
||||
void keepsResolutionForSmallPage() throws Exception {
|
||||
// A6 at 72 dpi is well within limits.
|
||||
assertThat(invoke(297f, 420f, 72)).isEqualTo(72);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("downscales resolution when the projected image is huge")
|
||||
void downscalesForHugePage() throws Exception {
|
||||
// A0-ish page at a very high dpi exceeds the pixel/size caps.
|
||||
int safe = invoke(2384f, 3370f, 2000);
|
||||
assertThat(safe).isLessThan(2000);
|
||||
assertThat(safe).isGreaterThanOrEqualTo(72);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("never returns below the floor of 72")
|
||||
void neverBelowFloor() throws Exception {
|
||||
int safe = invoke(5000f, 5000f, 4000);
|
||||
assertThat(safe).isGreaterThanOrEqualTo(72);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("determineRenderResolution")
|
||||
class DetermineRenderResolution {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the request resolution unchanged")
|
||||
void returnsRequestResolution() throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"determineRenderResolution", ScannerEffectRequest.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
ScannerEffectRequest request = new ScannerEffectRequest();
|
||||
request.setResolution(123);
|
||||
|
||||
assertThat((int) method.invoke(null, request)).isEqualTo(123);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertColorspace / convertToGrayscale")
|
||||
class Colorspace {
|
||||
|
||||
private static BufferedImage solid(int rgb) {
|
||||
BufferedImage image = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
|
||||
for (int y = 0; y < 4; y++) {
|
||||
for (int x = 0; x < 4; x++) {
|
||||
image.setRGB(x, y, rgb);
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(ScannerEffectRequest.Colorspace.class)
|
||||
@DisplayName("returns an INT_RGB image of the same dimensions for any colorspace")
|
||||
void preservesDimensions(ScannerEffectRequest.Colorspace colorspace) throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"convertColorspace",
|
||||
BufferedImage.class,
|
||||
ScannerEffectRequest.Colorspace.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
BufferedImage src = solid(0x123456);
|
||||
BufferedImage result = (BufferedImage) method.invoke(null, src, colorspace);
|
||||
|
||||
assertThat(result.getWidth()).isEqualTo(4);
|
||||
assertThat(result.getHeight()).isEqualTo(4);
|
||||
assertThat(result.getType()).isEqualTo(BufferedImage.TYPE_INT_RGB);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grayscale collapses the channels to a single grey value")
|
||||
void grayscaleEqualisesChannels() throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"convertColorspace",
|
||||
BufferedImage.class,
|
||||
ScannerEffectRequest.Colorspace.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
// R=90, G=120, B=150 -> avg 120 -> 0x787878
|
||||
BufferedImage src = solid((90 << 16) | (120 << 8) | 150);
|
||||
BufferedImage result =
|
||||
(BufferedImage)
|
||||
method.invoke(null, src, ScannerEffectRequest.Colorspace.grayscale);
|
||||
|
||||
int px = result.getRGB(0, 0) & 0xFFFFFF;
|
||||
int r = (px >> 16) & 0xFF;
|
||||
int g = (px >> 8) & 0xFF;
|
||||
int b = px & 0xFF;
|
||||
assertThat(r).isEqualTo(g).isEqualTo(b);
|
||||
assertThat(r).isEqualTo(120);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToGrayscale mutates the buffer in place")
|
||||
void convertToGrayscaleInPlace() throws Exception {
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"convertToGrayscale", BufferedImage.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
BufferedImage img = solid((30 << 16) | (60 << 8) | 90); // avg 60
|
||||
method.invoke(null, img);
|
||||
|
||||
int px = img.getRGB(1, 1) & 0xFFFFFF;
|
||||
assertThat(px).isEqualTo((60 << 16) | (60 << 8) | 60);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("calculateRotation")
|
||||
class CalculateRotation {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"calculateRotation", int.class, int.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns exactly 0 when base rotation and variance are both 0")
|
||||
void zeroWhenNoRotation() throws Exception {
|
||||
assertThat((double) method.invoke(null, 0, 0)).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stays within base +/- variance bounds")
|
||||
void withinBounds() throws Exception {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
double value = (double) method.invoke(null, 5, 3);
|
||||
assertThat(value).isBetween(2.0, 8.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("with zero variance but non-zero base returns the base rotation")
|
||||
void zeroVarianceReturnsBase() throws Exception {
|
||||
// base=5, variance=0 -> 5 + (rand*2-1)*0 = 5
|
||||
assertThat((double) method.invoke(null, 5, 0)).isEqualTo(5.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("blendColors")
|
||||
class BlendColors {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"blendColors", int.class, int.class, float.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
private int invoke(int fg, int bg, float alpha) throws Exception {
|
||||
return (int) method.invoke(null, fg, bg, alpha);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("alpha=1 yields the foreground colour")
|
||||
void alphaOneIsForeground() throws Exception {
|
||||
assertThat(invoke(0xAABBCC, 0x112233, 1.0f)).isEqualTo(0xAABBCC);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("alpha=0 yields the background colour")
|
||||
void alphaZeroIsBackground() throws Exception {
|
||||
assertThat(invoke(0xAABBCC, 0x112233, 0.0f)).isEqualTo(0x112233);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("alpha=0.5 yields the rounded midpoint per channel")
|
||||
void alphaHalfIsMidpoint() throws Exception {
|
||||
// fg 0x806040 (128,96,64), bg 0x204060 (32,64,96)
|
||||
int blended = invoke(0x806040, 0x204060, 0.5f);
|
||||
int r = (blended >> 16) & 0xFF;
|
||||
int g = (blended >> 8) & 0xFF;
|
||||
int b = blended & 0xFF;
|
||||
assertThat(r).isEqualTo(80); // (128+32)/2
|
||||
assertThat(g).isEqualTo(80); // (96+64)/2
|
||||
assertThat(b).isEqualTo(80); // (64+96)/2
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("fillWithGradient")
|
||||
class FillWithGradient {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"fillWithGradient",
|
||||
int[].class,
|
||||
int.class,
|
||||
int.class,
|
||||
int[].class,
|
||||
boolean.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("vertical gradient assigns one LUT value per row")
|
||||
void verticalFill() throws Exception {
|
||||
int width = 3;
|
||||
int height = 2;
|
||||
int[] pixels = new int[width * height];
|
||||
int[] lut = {0x111111, 0x222222};
|
||||
|
||||
method.invoke(null, pixels, width, height, lut, true);
|
||||
|
||||
// Row 0 all lut[0], row 1 all lut[1].
|
||||
assertThat(pixels[0]).isEqualTo(0x111111);
|
||||
assertThat(pixels[2]).isEqualTo(0x111111);
|
||||
assertThat(pixels[3]).isEqualTo(0x222222);
|
||||
assertThat(pixels[5]).isEqualTo(0x222222);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("horizontal gradient assigns one LUT value per column, repeated each row")
|
||||
void horizontalFill() throws Exception {
|
||||
int width = 3;
|
||||
int height = 2;
|
||||
int[] pixels = new int[width * height];
|
||||
int[] lut = {0xAA0000, 0x00BB00, 0x0000CC};
|
||||
|
||||
method.invoke(null, pixels, width, height, lut, false);
|
||||
|
||||
// Each row mirrors the LUT.
|
||||
assertThat(pixels[0]).isEqualTo(0xAA0000);
|
||||
assertThat(pixels[1]).isEqualTo(0x00BB00);
|
||||
assertThat(pixels[2]).isEqualTo(0x0000CC);
|
||||
assertThat(pixels[3]).isEqualTo(0xAA0000);
|
||||
assertThat(pixels[4]).isEqualTo(0x00BB00);
|
||||
assertThat(pixels[5]).isEqualTo(0x0000CC);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createGradientLUT")
|
||||
class CreateGradientLUT {
|
||||
|
||||
private Object gradient(boolean vertical, Color start, Color end) throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Constructor<?> ctor =
|
||||
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(vertical, start, end);
|
||||
}
|
||||
|
||||
private int[] invokeLut(int width, int height, Object gradientConfig) throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"createGradientLUT", int.class, int.class, gradientClass);
|
||||
method.setAccessible(true);
|
||||
return (int[]) method.invoke(null, width, height, gradientConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("vertical LUT length equals height and interpolates endpoints")
|
||||
void verticalLut() throws Exception {
|
||||
Object g = gradient(true, Color.BLACK, Color.WHITE);
|
||||
int[] lut = invokeLut(10, 5, g);
|
||||
|
||||
assertThat(lut).hasSize(5);
|
||||
assertThat(lut[0] & 0xFFFFFF).isEqualTo(0x000000);
|
||||
assertThat(lut[lut.length - 1] & 0xFFFFFF).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("horizontal LUT length equals width")
|
||||
void horizontalLut() throws Exception {
|
||||
Object g = gradient(false, Color.BLACK, Color.WHITE);
|
||||
int[] lut = invokeLut(7, 3, g);
|
||||
|
||||
assertThat(lut).hasSize(7);
|
||||
assertThat(lut[0] & 0xFFFFFF).isEqualTo(0x000000);
|
||||
assertThat(lut[lut.length - 1] & 0xFFFFFF).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constant colour produces a uniform LUT")
|
||||
void uniformLut() throws Exception {
|
||||
Object g = gradient(true, new Color(0x40, 0x50, 0x60), new Color(0x40, 0x50, 0x60));
|
||||
int[] lut = invokeLut(4, 4, g);
|
||||
|
||||
for (int value : lut) {
|
||||
assertThat(value & 0xFFFFFF).isEqualTo(0x405060);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyAllEffectsSinglePass")
|
||||
class ApplyAllEffects {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"applyAllEffectsSinglePass",
|
||||
BufferedImage.class,
|
||||
float.class,
|
||||
float.class,
|
||||
boolean.class,
|
||||
double.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
private static BufferedImage solid(int width, int height, int rgb) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, rgb);
|
||||
return image;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("identity settings leave pixels unchanged")
|
||||
void identityKeepsPixels() throws Exception {
|
||||
BufferedImage src = solid(8, 8, 0x648CB4); // 100,140,180
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, false, 0.0d);
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(8);
|
||||
assertThat(out.getHeight()).isEqualTo(8);
|
||||
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0x648CB4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("brightness > 1 increases channel values and clamps at 255")
|
||||
void brightnessClamps() throws Exception {
|
||||
BufferedImage src = solid(4, 4, 0xC8C8C8); // 200 each
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 2.0f, 1.0f, false, 0.0d);
|
||||
|
||||
int px = out.getRGB(0, 0) & 0xFFFFFF;
|
||||
// 200 * 2 = 400 -> clamped to 255 on every channel.
|
||||
assertThat(px).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero brightness produces black")
|
||||
void zeroBrightnessIsBlack() throws Exception {
|
||||
BufferedImage src = solid(4, 4, 0xFFFFFF);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 0.0f, 1.0f, false, 0.0d);
|
||||
|
||||
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0x000000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("yellowish tint lowers the blue channel relative to source")
|
||||
void yellowishReducesBlue() throws Exception {
|
||||
BufferedImage src = solid(4, 4, 0xFFFFFF); // bright white maximises tint effect
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, true, 0.0d);
|
||||
|
||||
int px = out.getRGB(0, 0) & 0xFFFFFF;
|
||||
int b = px & 0xFF;
|
||||
assertThat(b).isLessThan(255);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("noise keeps every channel within the valid 0..255 range")
|
||||
void noiseStaysInRange() throws Exception {
|
||||
BufferedImage src = solid(32, 32, 0x808080);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 1.0f, 1.0f, false, 50.0d);
|
||||
|
||||
for (int y = 0; y < out.getHeight(); y++) {
|
||||
for (int x = 0; x < out.getWidth(); x++) {
|
||||
int px = out.getRGB(x, y);
|
||||
assertThat((px >> 16) & 0xFF).isBetween(0, 255);
|
||||
assertThat((px >> 8) & 0xFF).isBetween(0, 255);
|
||||
assertThat(px & 0xFF).isBetween(0, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("softenEdges")
|
||||
class SoftenEdges {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"softenEdges",
|
||||
BufferedImage.class,
|
||||
int.class,
|
||||
Color.class,
|
||||
Color.class,
|
||||
boolean.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("center pixel stays at foreground when feathering only touches edges")
|
||||
void centreStaysForeground() throws Exception {
|
||||
BufferedImage src = new BufferedImage(11, 11, BufferedImage.TYPE_INT_RGB);
|
||||
int fg = 0x102030;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, fg);
|
||||
|
||||
BufferedImage out =
|
||||
(BufferedImage) method.invoke(null, src, 2, Color.WHITE, Color.WHITE, true);
|
||||
|
||||
// Centre is far from any edge (distance 5 >= feather radius 2) so alpha=1.
|
||||
assertThat(out.getRGB(5, 5) & 0xFFFFFF).isEqualTo(fg);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("corner pixel is blended toward the background gradient")
|
||||
void cornerBlendsToBackground() throws Exception {
|
||||
BufferedImage src = new BufferedImage(11, 11, BufferedImage.TYPE_INT_RGB);
|
||||
int fg = 0x000000;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, fg);
|
||||
|
||||
// Background is pure white; corner distance d=0 -> alpha=0 -> background.
|
||||
BufferedImage out =
|
||||
(BufferedImage) method.invoke(null, src, 3, Color.WHITE, Color.WHITE, true);
|
||||
|
||||
assertThat(out.getRGB(0, 0) & 0xFFFFFF).isEqualTo(0xFFFFFF);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves image dimensions")
|
||||
void preservesDimensions() throws Exception {
|
||||
BufferedImage src = new BufferedImage(6, 9, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out =
|
||||
(BufferedImage) method.invoke(null, src, 1, Color.GRAY, Color.DARK_GRAY, false);
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(6);
|
||||
assertThat(out.getHeight()).isEqualTo(9);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyGaussianBlur")
|
||||
class ApplyGaussianBlur {
|
||||
|
||||
private Method method;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"applyGaussianBlur", BufferedImage.class, double.class);
|
||||
method.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sigma <= 0 returns the same image instance")
|
||||
void zeroSigmaIsNoOp() throws Exception {
|
||||
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 0.0d);
|
||||
assertThat(out).isSameAs(src);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative sigma returns the same image instance")
|
||||
void negativeSigmaIsNoOp() throws Exception {
|
||||
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, -1.0d);
|
||||
assertThat(out).isSameAs(src);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("positive sigma on a uniform image keeps the uniform colour and dimensions")
|
||||
void uniformImageStaysUniform() throws Exception {
|
||||
BufferedImage src = new BufferedImage(40, 40, BufferedImage.TYPE_INT_RGB);
|
||||
int color = 0x405060;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, color);
|
||||
|
||||
BufferedImage out = (BufferedImage) method.invoke(null, src, 30.0d);
|
||||
|
||||
assertThat(out).isNotSameAs(src);
|
||||
assertThat(out.getWidth()).isEqualTo(40);
|
||||
assertThat(out.getHeight()).isEqualTo(40);
|
||||
// Blurring a uniform image yields the same uniform colour.
|
||||
assertThat(out.getRGB(20, 20) & 0xFFFFFF).isEqualTo(color);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("rotateImage")
|
||||
class RotateImage {
|
||||
|
||||
private Object gradient() throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Constructor<?> ctor =
|
||||
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(true, Color.WHITE, Color.WHITE);
|
||||
}
|
||||
|
||||
private Method rotateMethod() throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"rotateImage", BufferedImage.class, double.class, gradientClass);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero rotation returns the same image instance")
|
||||
void zeroRotationNoOp() throws Exception {
|
||||
BufferedImage src = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 0.0d, gradient());
|
||||
assertThat(out).isSameAs(src);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("90 degree rotation swaps the bounding box dimensions")
|
||||
void ninetyDegreeSwapsDimensions() throws Exception {
|
||||
BufferedImage src = new BufferedImage(20, 10, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 90.0d, gradient());
|
||||
|
||||
assertThat(out).isNotSameAs(src);
|
||||
// For 90 degrees, rotated bounds become height x width.
|
||||
assertThat(out.getWidth()).isEqualTo(10);
|
||||
assertThat(out.getHeight()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("45 degree rotation grows the bounding box")
|
||||
void fortyFiveGrowsBoundingBox() throws Exception {
|
||||
BufferedImage src = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out = (BufferedImage) rotateMethod().invoke(null, src, 45.0d, gradient());
|
||||
|
||||
assertThat(out.getWidth()).isGreaterThan(20);
|
||||
assertThat(out.getHeight()).isGreaterThan(20);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addBorderWithGradient")
|
||||
class AddBorderWithGradient {
|
||||
|
||||
private Object gradient(boolean vertical) throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Constructor<?> ctor =
|
||||
gradientClass.getDeclaredConstructor(boolean.class, Color.class, Color.class);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(vertical, Color.GRAY, Color.LIGHT_GRAY);
|
||||
}
|
||||
|
||||
private Method borderMethod() throws Exception {
|
||||
Class<?> gradientClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.misc.ScannerEffectController$GradientConfig");
|
||||
Method method =
|
||||
ScannerEffectController.class.getDeclaredMethod(
|
||||
"addBorderWithGradient", BufferedImage.class, int.class, gradientClass);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("adds a border of the requested size on every side")
|
||||
void growsByTwiceBorder() throws Exception {
|
||||
BufferedImage src = new BufferedImage(10, 12, BufferedImage.TYPE_INT_RGB);
|
||||
int border = 5;
|
||||
BufferedImage out =
|
||||
(BufferedImage) borderMethod().invoke(null, src, border, gradient(true));
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(10 + 2 * border);
|
||||
assertThat(out.getHeight()).isEqualTo(12 + 2 * border);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero border keeps the original dimensions")
|
||||
void zeroBorderKeepsDimensions() throws Exception {
|
||||
BufferedImage src = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage out =
|
||||
(BufferedImage) borderMethod().invoke(null, src, 0, gradient(false));
|
||||
|
||||
assertThat(out.getWidth()).isEqualTo(8);
|
||||
assertThat(out.getHeight()).isEqualTo(8);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves the drawn source region inside the border")
|
||||
void preservesSourcePixels() throws Exception {
|
||||
BufferedImage src = new BufferedImage(6, 6, BufferedImage.TYPE_INT_RGB);
|
||||
int fg = 0x123456;
|
||||
int[] pixels = ((DataBufferInt) src.getRaster().getDataBuffer()).getData();
|
||||
java.util.Arrays.fill(pixels, fg);
|
||||
|
||||
int border = 3;
|
||||
BufferedImage out =
|
||||
(BufferedImage) borderMethod().invoke(null, src, border, gradient(true));
|
||||
|
||||
// Source top-left maps to (border, border) in the composed image.
|
||||
assertThat(out.getRGB(border, border) & 0xFFFFFF).isEqualTo(fg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Small AssertJ helper for float closeness
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static org.assertj.core.data.Offset<Float> within(float tol) {
|
||||
return org.assertj.core.data.Offset.offset(tol);
|
||||
}
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
package stirling.software.SPDF.controller.api.pipeline;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.PipelineConfig;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.model.api.HandleDataRequest;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PipelineController#handleData}. The controller is exercised directly with a
|
||||
* real {@link ObjectMapper} for JSON parsing and mocked collaborators for the processor, analytics
|
||||
* and temp-file management. The {@link TempFileManager} is stubbed to hand out real on-disk temp
|
||||
* files so the {@code TempFile} wrapper and the stream-copy / zip paths run for real.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PipelineControllerGapTest {
|
||||
|
||||
@Mock private PipelineProcessor processor;
|
||||
|
||||
@Mock private PostHogService postHogService;
|
||||
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
private PipelineController controller;
|
||||
|
||||
// Real temp files handed back by the mocked TempFileManager; cleaned up after each test.
|
||||
private final List<Path> createdTempFiles = new ArrayList<>();
|
||||
|
||||
private static final String VALID_JSON =
|
||||
"{\"name\":\"test-pipeline\",\"pipeline\":["
|
||||
+ "{\"operation\":\"/api/v1/misc/repair\",\"parameters\":{}}]}";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
controller =
|
||||
new PipelineController(processor, objectMapper, postHogService, tempFileManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the manager so every {@code new TempFile(manager, suffix)} the controller builds gets a
|
||||
* real on-disk file. Only invoked by tests that actually exercise an output path, so tests that
|
||||
* short-circuit can assert {@code verifyNoInteractions(tempFileManager)}.
|
||||
*/
|
||||
private void stubTempFiles() throws IOException {
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
String suffix = invocation.getArgument(0);
|
||||
Path p = Files.createTempFile("pipeline-gap-test-", suffix);
|
||||
createdTempFiles.add(p);
|
||||
return p.toFile();
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
for (Path p : createdTempFiles) {
|
||||
Files.deleteIfExists(p);
|
||||
}
|
||||
createdTempFiles.clear();
|
||||
}
|
||||
|
||||
private HandleDataRequest request(MultipartFile[] files, String json) {
|
||||
HandleDataRequest req = new HandleDataRequest();
|
||||
req.setFileInput(files);
|
||||
req.setJson(json);
|
||||
return req;
|
||||
}
|
||||
|
||||
private MockMultipartFile pdf(String name) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, "application/pdf", ("content of " + name).getBytes());
|
||||
}
|
||||
|
||||
private MultipartFile[] oneFile() {
|
||||
return new MultipartFile[] {pdf("input.pdf")};
|
||||
}
|
||||
|
||||
/** A Resource backed by bytes that also reports a filename (ByteArrayResource returns null). */
|
||||
private static Resource namedResource(String filename, byte[] data) {
|
||||
return new ByteArrayResource(data) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Null / empty input short-circuits")
|
||||
class NullAndEmptyInputs {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when file input is null without touching collaborators")
|
||||
void nullFiles_returnsNull() throws Exception {
|
||||
ResponseEntity<Resource> response = controller.handleData(request(null, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
verifyNoInteractions(processor);
|
||||
verifyNoInteractions(postHogService);
|
||||
verifyNoInteractions(tempFileManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when processor yields no input files")
|
||||
void nullInputFiles_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files)).thenReturn(null);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
// Event still captured before processing begins.
|
||||
verify(postHogService).captureEvent(eq("pipeline_api_event"), any());
|
||||
verify(processor, never()).runPipelineAgainstFiles(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when processor yields an empty input list")
|
||||
void emptyInputFiles_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files)).thenReturn(new ArrayList<>());
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
verify(processor, never()).runPipelineAgainstFiles(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when pipeline result has null output files")
|
||||
void nullOutputFiles_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
List<Resource> inputFiles = List.of(namedResource("input.pdf", "x".getBytes()));
|
||||
when(processor.generateInputFiles(files)).thenReturn(inputFiles);
|
||||
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(null);
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Single output file path")
|
||||
class SingleOutput {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the single file streamed through a temp file")
|
||||
void singleOutput_returnsFileResponse() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
List<Resource> inputFiles = List.of(namedResource("input.pdf", "in".getBytes()));
|
||||
when(processor.generateInputFiles(files)).thenReturn(inputFiles);
|
||||
|
||||
byte[] outBytes = "single output body".getBytes(StandardCharsets.UTF_8);
|
||||
Resource single = namedResource("result.pdf", outBytes);
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(single));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
// The controller copied the single file into a ".out" temp file.
|
||||
verify(tempFileManager).createTempFile(".out");
|
||||
|
||||
// The streamed body matches what the processor produced.
|
||||
try (InputStream is = response.getBody().getInputStream()) {
|
||||
assertEquals(
|
||||
new String(outBytes, StandardCharsets.UTF_8),
|
||||
new String(is.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("captures analytics event with operations and file count")
|
||||
void singleOutput_capturesAnalytics() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(namedResource("result.pdf", "body".getBytes())));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> propsCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(postHogService).captureEvent(eq("pipeline_api_event"), propsCaptor.capture());
|
||||
|
||||
Map<String, Object> props = propsCaptor.getValue();
|
||||
assertEquals(1, props.get("fileCount"));
|
||||
assertEquals(List.of("/api/v1/misc/repair"), props.get("operations"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Multiple output files (zip) path")
|
||||
class MultipleOutput {
|
||||
|
||||
@Test
|
||||
@DisplayName("zips multiple output files into output.zip")
|
||||
void multipleOutputs_returnsZipResponse() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
|
||||
Resource a = namedResource("a.pdf", "alpha".getBytes(StandardCharsets.UTF_8));
|
||||
Resource b = namedResource("b.pdf", "bravo".getBytes(StandardCharsets.UTF_8));
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(a, b));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
verify(tempFileManager).createTempFile(".zip");
|
||||
|
||||
// Inspect zip contents: both entries present with their bytes.
|
||||
Map<String, String> entries = readZip(response.getBody());
|
||||
assertEquals(2, entries.size());
|
||||
assertEquals("alpha", entries.get("a.pdf"));
|
||||
assertEquals("bravo", entries.get("b.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("duplicate filenames are de-duplicated within the zip")
|
||||
void multipleOutputs_duplicateNames_areDeduped() throws Exception {
|
||||
stubTempFiles();
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
|
||||
Resource first = namedResource("dup.pdf", "first".getBytes(StandardCharsets.UTF_8));
|
||||
Resource second = namedResource("dup.pdf", "second".getBytes(StandardCharsets.UTF_8));
|
||||
PipelineResult result = new PipelineResult();
|
||||
result.setOutputFiles(List.of(first, second));
|
||||
when(processor.runPipelineAgainstFiles(any(), any())).thenReturn(result);
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNotNull(response);
|
||||
Map<String, String> entries = readZip(response.getBody());
|
||||
// Two distinct zip entry names despite the same source filename.
|
||||
assertEquals(2, entries.size());
|
||||
}
|
||||
|
||||
private Map<String, String> readZip(Resource zipResource) throws IOException {
|
||||
Map<String, String> out = new java.util.LinkedHashMap<>();
|
||||
byte[] all;
|
||||
try (InputStream is = zipResource.getInputStream()) {
|
||||
all = is.readAllBytes();
|
||||
}
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(all))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
out.put(
|
||||
entry.getName(),
|
||||
new String(zis.readAllBytes(), StandardCharsets.UTF_8));
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error handling")
|
||||
class ErrorHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when the processor throws during input generation")
|
||||
void processorThrowsOnGenerate_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files)).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when the pipeline run throws")
|
||||
void pipelineRunThrows_returnsNull() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
when(processor.generateInputFiles(files))
|
||||
.thenReturn(List.of(namedResource("input.pdf", "in".getBytes())));
|
||||
when(processor.runPipelineAgainstFiles(any(), any()))
|
||||
.thenThrow(new RuntimeException("pipeline failed"));
|
||||
|
||||
ResponseEntity<Resource> response = controller.handleData(request(files, VALID_JSON));
|
||||
|
||||
assertNull(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid JSON config propagates as a parse exception")
|
||||
void invalidJson_throws() {
|
||||
MultipartFile[] files = oneFile();
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Exception.class, () -> controller.handleData(request(files, "not-valid-json")));
|
||||
|
||||
verifyNoInteractions(postHogService);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("JSON config parsing")
|
||||
class JsonParsing {
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple operation names are extracted in order for analytics")
|
||||
void multipleOperations_extractedInOrder() throws Exception {
|
||||
MultipartFile[] files = oneFile();
|
||||
String json =
|
||||
"{\"name\":\"multi\",\"pipeline\":["
|
||||
+ "{\"operation\":\"/api/v1/misc/repair\",\"parameters\":{}},"
|
||||
+ "{\"operation\":\"/api/v1/security/sanitize-pdf\",\"parameters\":{}}]}";
|
||||
|
||||
// Stop after analytics by returning no input files.
|
||||
when(processor.generateInputFiles(files)).thenReturn(null);
|
||||
|
||||
controller.handleData(request(files, json));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> propsCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(postHogService).captureEvent(eq("pipeline_api_event"), propsCaptor.capture());
|
||||
|
||||
assertEquals(
|
||||
List.of("/api/v1/misc/repair", "/api/v1/security/sanitize-pdf"),
|
||||
propsCaptor.getValue().get("operations"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("real ObjectMapper deserializes the pipeline config used by analytics")
|
||||
void objectMapperParsesConfig() {
|
||||
PipelineConfig config = objectMapper.readValue(VALID_JSON, PipelineConfig.class);
|
||||
assertEquals("test-pipeline", config.getName());
|
||||
assertEquals(1, config.getOperations().size());
|
||||
assertEquals("/api/v1/misc/repair", config.getOperations().get(0).getOperation());
|
||||
}
|
||||
}
|
||||
}
|
||||
+669
@@ -0,0 +1,669 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
|
||||
import stirling.software.common.model.api.security.RedactionArea;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@DisplayName("ManualRedactionService Tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ManualRedactionServiceTest {
|
||||
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ManualRedactionService service;
|
||||
|
||||
// Track temp files created during finalize tests so we can clean them up.
|
||||
private final List<File> createdTempFiles = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
service = new ManualRedactionService(tempFileManager);
|
||||
|
||||
// createManagedTempFile returns a TempFile backed by a real on-disk file so
|
||||
// document.save() works and length() can be inspected.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("redact-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
createdTempFiles.add(f);
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
for (File f : createdTempFiles) {
|
||||
if (f != null && f.exists()) {
|
||||
f.delete();
|
||||
}
|
||||
}
|
||||
createdTempFiles.clear();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static PDDocument newDocument(int pageCount) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static PDDocument newDocumentWithText() throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText("Sensitive text to redact");
|
||||
cs.endText();
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static RedactionArea area(
|
||||
Integer page, double x, double y, double width, double height, String color) {
|
||||
RedactionArea a = new RedactionArea();
|
||||
a.setPage(page);
|
||||
a.setX(x);
|
||||
a.setY(y);
|
||||
a.setWidth(width);
|
||||
a.setHeight(height);
|
||||
a.setColor(color);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static PDFText text(int pageIndex, float x1, float y1, float x2, float y2) {
|
||||
return new PDFText(pageIndex, x1, y1, x2, y2, "redacted");
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// decodeOrDefault
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("decodeOrDefault")
|
||||
class DecodeOrDefault {
|
||||
|
||||
@Test
|
||||
@DisplayName("null returns black")
|
||||
void nullReturnsBlack() {
|
||||
assertSame(Color.BLACK, ManualRedactionService.decodeOrDefault(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hex with leading hash decodes")
|
||||
void hexWithHash() {
|
||||
assertEquals(Color.RED, ManualRedactionService.decodeOrDefault("#FF0000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hex without leading hash decodes")
|
||||
void hexWithoutHash() {
|
||||
assertEquals(Color.RED, ManualRedactionService.decodeOrDefault("FF0000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("white decodes correctly")
|
||||
void whiteDecodes() {
|
||||
assertEquals(Color.WHITE, ManualRedactionService.decodeOrDefault("#FFFFFF"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid hex falls back to black")
|
||||
void invalidFallsBackToBlack() {
|
||||
assertEquals(Color.BLACK, ManualRedactionService.decodeOrDefault("not-a-color"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty string falls back to black")
|
||||
void emptyFallsBackToBlack() {
|
||||
assertEquals(Color.BLACK, ManualRedactionService.decodeOrDefault(""));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactAreas
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactAreas")
|
||||
class RedactAreas {
|
||||
|
||||
@Test
|
||||
@DisplayName("null list is a no-op")
|
||||
void nullList() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
byte[] before = save(doc);
|
||||
service.redactAreas(null, doc, doc.getPages());
|
||||
// Document still saves and has its single page; nothing thrown.
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
assertNotNull(before);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty list is a no-op")
|
||||
void emptyList() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactAreas(new ArrayList<>(), doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("valid area is applied and document still saves")
|
||||
void validArea() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(1, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
assertTrue(out.length > 0);
|
||||
// Reload to ensure the produced PDF is structurally valid.
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area with null page is skipped")
|
||||
void nullPageSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(null, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area with non-positive page is skipped")
|
||||
void nonPositivePageSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(0, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area with null/zero width or height is skipped")
|
||||
void invalidDimensionsSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = new ArrayList<>();
|
||||
areas.add(area(1, 10, 10, 0, 50, "#000000")); // zero width
|
||||
areas.add(area(1, 10, 10, 100, 0, "#000000")); // zero height
|
||||
RedactionArea nullWidth = area(1, 10, 10, 100, 50, "#000000");
|
||||
nullWidth.setWidth(null);
|
||||
areas.add(nullWidth);
|
||||
RedactionArea nullHeight = area(1, 10, 10, 100, 50, "#000000");
|
||||
nullHeight.setHeight(null);
|
||||
areas.add(nullHeight);
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
// None applied, but no exception and doc remains valid.
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("area on out-of-range page is skipped without error")
|
||||
void outOfRangePageSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<RedactionArea> areas = Arrays.asList(area(5, 10, 10, 100, 50, "#000000"));
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple areas across multiple pages are grouped per page")
|
||||
void multiplePagesGrouped() throws Exception {
|
||||
try (PDDocument doc = newDocument(3)) {
|
||||
List<RedactionArea> areas = new ArrayList<>();
|
||||
areas.add(area(1, 10, 10, 50, 50, "#000000"));
|
||||
areas.add(area(1, 80, 80, 50, 50, "#FF0000"));
|
||||
areas.add(area(3, 20, 20, 60, 40, null)); // null color -> default black
|
||||
service.redactAreas(areas, doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(3, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactPages
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactPages")
|
||||
class RedactPages {
|
||||
|
||||
private ManualRedactPdfRequest request(String pageNumbers, String color) {
|
||||
ManualRedactPdfRequest req = new ManualRedactPdfRequest();
|
||||
req.setPageNumbers(pageNumbers);
|
||||
req.setPageRedactionColor(color);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("redacts all pages when 'all' is given")
|
||||
void redactsAllPages() throws Exception {
|
||||
try (PDDocument doc = newDocument(3)) {
|
||||
service.redactPages(request("all", "#000000"), doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(3, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("redacts a specific page range")
|
||||
void redactsSpecificPages() throws Exception {
|
||||
try (PDDocument doc = newDocument(5)) {
|
||||
service.redactPages(request("1,3", "#FF0000"), doc, doc.getPages());
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(5, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null page numbers defaults to first page")
|
||||
void nullPageNumbers() throws Exception {
|
||||
try (PDDocument doc = newDocument(2)) {
|
||||
service.redactPages(request(null, "#000000"), doc, doc.getPages());
|
||||
assertEquals(2, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null color falls back to black")
|
||||
void nullColor() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactPages(request("all", null), doc, doc.getPages());
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactFoundText
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactFoundText")
|
||||
class RedactFoundText {
|
||||
|
||||
@Test
|
||||
@DisplayName("overlay-only mode draws boxes and document stays valid")
|
||||
void overlayMode() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<PDFText> blocks = Arrays.asList(text(0, 72, 690, 300, 710));
|
||||
service.redactFoundText(doc, blocks, 1.0f, Color.BLACK, false);
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text-removal mode narrows the box width")
|
||||
void textRemovalMode() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<PDFText> blocks = Arrays.asList(text(0, 72, 690, 300, 710));
|
||||
service.redactFoundText(doc, blocks, 0.0f, Color.RED, true);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks on out-of-range pages are skipped")
|
||||
void outOfRangePageIndexSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<PDFText> blocks =
|
||||
Arrays.asList(text(0, 72, 690, 300, 710), text(9, 10, 10, 50, 50));
|
||||
service.redactFoundText(doc, blocks, 0.0f, Color.BLACK, false);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty block list is a no-op")
|
||||
void emptyBlocks() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactFoundText(doc, new ArrayList<>(), 0.0f, Color.BLACK, false);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("annotation overlapping a redacted block is removed")
|
||||
void overlappingAnnotationRemoved() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
float pageH = page.getBBox().getHeight();
|
||||
|
||||
// Redact block in PDF coords near y2=710 (top), x 72..300.
|
||||
PDFText block = text(0, 72, 690, 300, 710);
|
||||
|
||||
// Place an annotation rectangle that overlaps the redacted block.
|
||||
// Block pdf-Y window (padding included) sits roughly around pageH-710..pageH-690.
|
||||
PDAnnotationLink overlapping = new PDAnnotationLink();
|
||||
overlapping.setRectangle(new PDRectangle(80, pageH - 712, 120, 30));
|
||||
|
||||
// Place an annotation far away that should be kept.
|
||||
PDAnnotationLink faraway = new PDAnnotationLink();
|
||||
faraway.setRectangle(new PDRectangle(10, 10, 20, 20));
|
||||
|
||||
page.setAnnotations(new ArrayList<>(Arrays.asList(overlapping, faraway)));
|
||||
assertEquals(2, page.getAnnotations().size());
|
||||
|
||||
service.redactFoundText(doc, Arrays.asList(block), 1.0f, Color.BLACK, false);
|
||||
|
||||
// getAnnotations() builds fresh wrappers each call, so compare by geometry
|
||||
// rather than object identity.
|
||||
List<PDAnnotation> remaining = page.getAnnotations();
|
||||
assertEquals(1, remaining.size());
|
||||
PDRectangle keptRect = remaining.get(0).getRectangle();
|
||||
assertEquals(10f, keptRect.getLowerLeftX(), 0.001f);
|
||||
assertEquals(10f, keptRect.getLowerLeftY(), 0.001f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-overlapping annotations are preserved")
|
||||
void nonOverlappingAnnotationsKept() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
PDAnnotationLink faraway = new PDAnnotationLink();
|
||||
faraway.setRectangle(new PDRectangle(10, 10, 20, 20));
|
||||
page.setAnnotations(new ArrayList<>(Arrays.asList(faraway)));
|
||||
|
||||
PDFText block = text(0, 400, 100, 500, 120);
|
||||
service.redactFoundText(doc, Arrays.asList(block), 0.0f, Color.BLACK, false);
|
||||
|
||||
assertEquals(1, page.getAnnotations().size());
|
||||
PDRectangle keptRect = page.getAnnotations().get(0).getRectangle();
|
||||
assertEquals(10f, keptRect.getLowerLeftX(), 0.001f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// redactImageBoxes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("redactImageBoxes")
|
||||
class RedactImageBoxes {
|
||||
|
||||
@Test
|
||||
@DisplayName("draws boxes for valid page indices")
|
||||
void validBoxes() throws Exception {
|
||||
try (PDDocument doc = newDocument(2)) {
|
||||
List<float[]> boxes = new ArrayList<>();
|
||||
boxes.add(new float[] {0, 10, 10, 100, 100});
|
||||
boxes.add(new float[] {1, 20, 20, 80, 80});
|
||||
service.redactImageBoxes(doc, boxes, Color.BLACK);
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(2, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("out-of-range page indices are skipped without error")
|
||||
void outOfRangeSkipped() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<float[]> boxes = new ArrayList<>();
|
||||
boxes.add(new float[] {-1, 10, 10, 100, 100}); // negative
|
||||
boxes.add(new float[] {7, 20, 20, 80, 80}); // beyond page count
|
||||
service.redactImageBoxes(doc, boxes, Color.BLACK);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty box list is a no-op")
|
||||
void emptyBoxes() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
service.redactImageBoxes(doc, new ArrayList<>(), Color.BLACK);
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple boxes on the same page are grouped")
|
||||
void multipleBoxesSamePage() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
List<float[]> boxes = new ArrayList<>();
|
||||
boxes.add(new float[] {0, 10, 10, 50, 50});
|
||||
boxes.add(new float[] {0, 100, 100, 150, 150});
|
||||
service.redactImageBoxes(doc, boxes, Color.RED);
|
||||
byte[] out = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(out)) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// extractPageElementBoxes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractPageElementBoxes")
|
||||
class ExtractPageElementBoxes {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns text line boxes for a page with text")
|
||||
void extractsTextBoxes() throws Exception {
|
||||
try (PDDocument doc = newDocumentWithText()) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<float[]> boxes = service.extractPageElementBoxes(doc, page, 0);
|
||||
assertNotNull(boxes);
|
||||
assertFalse(boxes.isEmpty());
|
||||
// Each box must have 4 coordinates [x1, y1, x2, y2].
|
||||
for (float[] box : boxes) {
|
||||
assertEquals(4, box.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty list for a blank page")
|
||||
void blankPageReturnsEmpty() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<float[]> boxes = service.extractPageElementBoxes(doc, page, 0);
|
||||
assertNotNull(boxes);
|
||||
assertTrue(boxes.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// finalizeRedaction (non-image path only; image path needs Spring context)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("finalizeRedaction")
|
||||
class FinalizeRedaction {
|
||||
|
||||
@Test
|
||||
@DisplayName("with found text saves a managed temp file")
|
||||
void withFoundText() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
byPage.put(0, new ArrayList<>(Arrays.asList(text(0, 72, 690, 300, 710))));
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#000000", 1.0f, false, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getFile());
|
||||
assertTrue(result.getFile().exists());
|
||||
assertTrue(result.getFile().length() > 0);
|
||||
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
|
||||
|
||||
// Output should be a loadable PDF.
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("with no found text still saves the document")
|
||||
void withoutFoundText() throws Exception {
|
||||
try (PDDocument doc = newDocument(2)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#000000", 0.0f, false, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getFile().exists());
|
||||
assertTrue(result.getFile().length() > 0);
|
||||
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(2, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToImage null is treated as non-image path")
|
||||
void convertToImageNull() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#000000", 0.0f, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getFile().exists());
|
||||
// Non-image path uses the standard ".pdf" managed temp file exactly once.
|
||||
verify(tempFileManager, times(1)).createManagedTempFile(".pdf");
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text-removal mode finalize produces valid PDF")
|
||||
void textRemovalFinalize() throws Exception {
|
||||
try (PDDocument doc = newDocument(1)) {
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
byPage.put(0, new ArrayList<>(Arrays.asList(text(0, 72, 690, 300, 710))));
|
||||
|
||||
TempFile result =
|
||||
service.finalizeRedaction(doc, byPage, "#FF0000", 2.0f, false, true);
|
||||
|
||||
assertNotNull(result);
|
||||
try (PDDocument reloaded = Loader.loadPDF(result.getFile())) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from save closes the temp file and is rethrown")
|
||||
void saveFailureClosesTempFile() throws Exception {
|
||||
// Use a document mock that throws on save to exercise the catch/close branch.
|
||||
PDDocument doc = newDocument(1);
|
||||
Map<Integer, List<PDFText>> byPage = new HashMap<>();
|
||||
|
||||
// Point the managed temp file at a directory path so save() fails with IOException.
|
||||
File dirAsFile = Files.createTempDirectory("redact-dir").toFile();
|
||||
createdTempFiles.add(dirAsFile);
|
||||
TempFile failing = mock(TempFile.class);
|
||||
when(failing.getFile()).thenReturn(dirAsFile);
|
||||
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(failing);
|
||||
|
||||
try {
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() ->
|
||||
service.finalizeRedaction(
|
||||
doc, byPage, "#000000", 0.0f, false, false));
|
||||
// The failing temp file is closed on the error path.
|
||||
verify(failing).close();
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TextRedactionService}. Exercises the pure text-matching / placeholder
|
||||
* logic, the public find/replace entry points, and the small helper predicates. PDFs are built tiny
|
||||
* and in-memory with real Standard14 fonts so the redaction pipeline runs end-to-end without
|
||||
* external processes.
|
||||
*/
|
||||
class TextRedactionServiceTest {
|
||||
|
||||
private static final float FONT_SIZE = 12f;
|
||||
private static final float LEFT_X = 72f;
|
||||
private static final float TOP_Y = PDRectangle.LETTER.getHeight() - 80f;
|
||||
|
||||
private final TextRedactionService service = new TextRedactionService();
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private PDFont helvetica() {
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
/** Single page, single Tj line per supplied text line, Helvetica 12. */
|
||||
private PDDocument buildDoc(String... lines) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
cs.showText(lines[i]);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private PDDocument buildEmptyDoc() {
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(PDRectangle.LETTER));
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── isTextShowingOperator ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("isTextShowingOperator")
|
||||
class IsTextShowingOperator {
|
||||
|
||||
@Test
|
||||
@DisplayName("recognises the four text-showing operators")
|
||||
void recognisesTextShowingOperators() {
|
||||
assertTrue(service.isTextShowingOperator("Tj"));
|
||||
assertTrue(service.isTextShowingOperator("TJ"));
|
||||
assertTrue(service.isTextShowingOperator("'"));
|
||||
assertTrue(service.isTextShowingOperator("\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects non text-showing operators and junk")
|
||||
void rejectsOthers() {
|
||||
assertFalse(service.isTextShowingOperator("BT"));
|
||||
assertFalse(service.isTextShowingOperator("ET"));
|
||||
assertFalse(service.isTextShowingOperator("Tf"));
|
||||
assertFalse(service.isTextShowingOperator(""));
|
||||
assertFalse(service.isTextShowingOperator("tj"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── findTextToRedact ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findTextToRedact")
|
||||
class FindTextToRedact {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty map when all search terms are blank")
|
||||
void emptyTermsReturnsEmptyMap() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Confidential data here")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"", " "}, false, false);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty map for an empty term array")
|
||||
void emptyArrayReturnsEmptyMap() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Some text")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {}, false, false);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("finds a literal term on the page")
|
||||
void findsLiteralTerm() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Hello SECRET world")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
|
||||
assertFalse(result.isEmpty());
|
||||
assertTrue(result.containsKey(0), "match should be on page index 0");
|
||||
List<PDFText> hits = result.get(0);
|
||||
assertEquals(1, hits.size());
|
||||
assertEquals("SECRET", hits.get(0).getText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not find a term that is absent")
|
||||
void doesNotFindAbsentTerm() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Hello world")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"NOTHERE"}, false, false);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("regex term matches digit runs")
|
||||
void regexTermMatchesDigits() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Order 12345 shipped")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"\\d+"}, true, false);
|
||||
|
||||
assertFalse(result.isEmpty());
|
||||
assertEquals("12345", result.get(0).get(0).getText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("whole-word search does not match a substring inside a larger word")
|
||||
void wholeWordDoesNotMatchSubstring() throws IOException {
|
||||
try (PDDocument doc = buildDoc("classification of cats")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"cat"}, false, true);
|
||||
// "cat" appears only inside "classification"; whole-word must not match it.
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("whole-word search matches a standalone word")
|
||||
void wholeWordMatchesStandalone() throws IOException {
|
||||
try (PDDocument doc = buildDoc("the cat sat")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"cat"}, false, true);
|
||||
assertFalse(result.isEmpty());
|
||||
assertEquals("cat", result.get(0).get(0).getText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trims whitespace around terms before searching")
|
||||
void trimsTermsBeforeSearch() throws IOException {
|
||||
try (PDDocument doc = buildDoc("padded SECRET value")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {" SECRET "}, false, false);
|
||||
assertFalse(result.isEmpty());
|
||||
assertEquals("SECRET", result.get(0).get(0).getText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── performTextReplacement ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("performTextReplacement")
|
||||
class PerformTextReplacement {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false (no fallback) when there is nothing found to redact")
|
||||
void noFoundTextReturnsFalse() throws IOException {
|
||||
try (PDDocument doc = buildDoc("anything")) {
|
||||
boolean fallback =
|
||||
service.performTextReplacement(
|
||||
doc, new HashMap<>(), new String[] {"x"}, false, false);
|
||||
assertFalse(fallback, "empty found-text map must short-circuit to no fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces text on a standard-font document without requesting box fallback")
|
||||
void replacesOnStandardFont() throws IOException {
|
||||
try (PDDocument doc = buildDoc("Please redact SECRET now")) {
|
||||
Map<Integer, List<PDFText>> found =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
assertFalse(found.isEmpty());
|
||||
|
||||
boolean fallback =
|
||||
service.performTextReplacement(
|
||||
doc, found, new String[] {"SECRET"}, false, false);
|
||||
|
||||
assertFalse(fallback, "standard Helvetica should not trigger box-only fallback");
|
||||
// After replacement the literal term should no longer be extractable.
|
||||
Map<Integer, List<PDFText>> afterFound =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
assertTrue(afterFound.isEmpty(), "SECRET should be gone after text replacement");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves non-targeted text intact after replacement")
|
||||
void leavesOtherTextIntact() throws IOException {
|
||||
try (PDDocument doc = buildDoc("KEEP this but redact SECRET part")) {
|
||||
Map<Integer, List<PDFText>> found =
|
||||
service.findTextToRedact(doc, new String[] {"SECRET"}, false, false);
|
||||
|
||||
service.performTextReplacement(doc, found, new String[] {"SECRET"}, false, false);
|
||||
|
||||
Map<Integer, List<PDFText>> keepStill =
|
||||
service.findTextToRedact(doc, new String[] {"KEEP"}, false, false);
|
||||
assertFalse(keepStill.isEmpty(), "untargeted word KEEP must survive redaction");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── detectCustomEncodingFonts ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectCustomEncodingFonts")
|
||||
class DetectCustomEncodingFonts {
|
||||
|
||||
@Test
|
||||
@DisplayName("standard Helvetica document is not flagged as custom-encoded")
|
||||
void standardFontNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildDoc("plain helvetica text")) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("document with no content / no fonts is not flagged")
|
||||
void emptyDocumentNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildEmptyDoc()) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithFont ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithFont")
|
||||
class CreatePlaceholderWithFont {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithFont(null, helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithFont("", helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-subset font yields spaces matching the original length")
|
||||
void nonSubsetFontYieldsMatchingSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("hidden", helvetica());
|
||||
assertEquals(" ".repeat("hidden".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font is treated as non-subset and yields spaces")
|
||||
void nullFontYieldsSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("abc", null);
|
||||
assertEquals(" ", placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithWidth ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithWidth")
|
||||
class CreatePlaceholderWithWidth {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithWidth(null, 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithWidth("", 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font falls back to one space per original character")
|
||||
void nullFontFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, null, FONT_SIZE);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-positive font size falls back to one space per original character")
|
||||
void nonPositiveFontSizeFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, helvetica(), 0f);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("standard font produces a non-null all-whitespace placeholder")
|
||||
void standardFontProducesWhitespacePlaceholder() {
|
||||
PDFont font = helvetica();
|
||||
float fontSize = FONT_SIZE;
|
||||
String original = "Secret";
|
||||
// Compute a realistic target width the way the service does (text-space / 1000 * size).
|
||||
float targetWidth;
|
||||
try {
|
||||
targetWidth = font.getStringWidth(original) / 1000f * fontSize;
|
||||
} catch (IOException e) {
|
||||
targetWidth = 30f;
|
||||
}
|
||||
|
||||
String placeholder =
|
||||
service.createPlaceholderWithWidth(original, targetWidth, font, fontSize);
|
||||
|
||||
assertNotNull(placeholder);
|
||||
assertFalse(placeholder.isEmpty(), "Helvetica supports spaces, so non-empty expected");
|
||||
assertTrue(
|
||||
placeholder.chars().allMatch(c -> c == ' '),
|
||||
"placeholder should be composed only of spaces");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createTokensWithoutTargetText / writeFilteredContentStream
|
||||
// ────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createTokensWithoutTargetText")
|
||||
class CreateTokensWithoutTargetText {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"returns a non-empty token list and preserves token count when nothing matches")
|
||||
void noMatchPreservesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("nothing to hide")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("ABSENT"), false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertEquals(
|
||||
originalTokens.size(),
|
||||
tokens.size(),
|
||||
"token count should be unchanged when nothing matched");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filtered tokens can be written back and the page re-parses cleanly")
|
||||
void filteredTokensRoundTrip() throws IOException {
|
||||
try (PDDocument doc = buildDoc("redact SECRET token roundtrip")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertNotNull(tokens);
|
||||
|
||||
service.writeFilteredContentStream(doc, page, tokens);
|
||||
|
||||
// The page must still hold valid content (at least one operator token).
|
||||
List<Object> reparsed = parseTokens(page);
|
||||
boolean hasOperator = reparsed.stream().anyMatch(t -> t instanceof Operator);
|
||||
assertTrue(hasOperator, "rewritten content stream must contain operators");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty target-word set leaves tokens untouched")
|
||||
void emptyTargetSetLeavesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("some content")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Collections.emptySet(), false, false);
|
||||
|
||||
assertEquals(originalTokens.size(), tokens.size());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object token;
|
||||
while ((token = parser.parseNextToken()) != null) {
|
||||
tokens.add(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// ── inner data classes ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("TextSegment / MatchRange data classes")
|
||||
class DataClasses {
|
||||
|
||||
@Test
|
||||
@DisplayName("TextSegment exposes its constructor values via accessors")
|
||||
void textSegmentAccessors() {
|
||||
PDFont font = helvetica();
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(3, "Tj", "hello", 10, 15, font, 12f);
|
||||
|
||||
assertEquals(3, segment.getTokenIndex());
|
||||
assertEquals("Tj", segment.getOperatorName());
|
||||
assertEquals("hello", segment.getText());
|
||||
assertEquals(10, segment.getStartPos());
|
||||
assertEquals(15, segment.getEndPos());
|
||||
assertSame(font, segment.getFont());
|
||||
assertEquals(12f, segment.getFontSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MatchRange exposes start and end positions")
|
||||
void matchRangeAccessors() {
|
||||
TextRedactionService.MatchRange range = new TextRedactionService.MatchRange(4, 9);
|
||||
assertEquals(4, range.getStartPos());
|
||||
assertEquals(9, range.getEndPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MatchRange equality follows its data fields")
|
||||
void matchRangeEquality() {
|
||||
assertEquals(
|
||||
new TextRedactionService.MatchRange(1, 5),
|
||||
new TextRedactionService.MatchRange(1, 5));
|
||||
assertNotEquals(
|
||||
new TextRedactionService.MatchRange(1, 5),
|
||||
new TextRedactionService.MatchRange(1, 6));
|
||||
}
|
||||
|
||||
private void assertNotEquals(Object a, Object b) {
|
||||
assertFalse(a.equals(b));
|
||||
}
|
||||
}
|
||||
|
||||
// ── private logic exercised via reflection ───────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findAllMatches / buildCompleteText (private logic via reflection)")
|
||||
class PrivateLogic {
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns sorted, non-overlapping match ranges for two terms")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesSorted() throws Exception {
|
||||
String complete = "alpha beta gamma beta";
|
||||
Set<String> terms = new LinkedHashSet<>(List.of("beta", "alpha"));
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, complete, terms, false, false);
|
||||
|
||||
assertNotNull(matches);
|
||||
assertFalse(matches.isEmpty());
|
||||
// Results are sorted by start position.
|
||||
for (int i = 1; i < matches.size(); i++) {
|
||||
assertTrue(
|
||||
matches.get(i - 1).getStartPos() <= matches.get(i).getStartPos(),
|
||||
"matches must be sorted ascending by start position");
|
||||
}
|
||||
// "alpha" at 0, "beta" at 6 and 17 -> three matches total.
|
||||
assertEquals(3, matches.size());
|
||||
assertEquals(0, matches.get(0).getStartPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns nothing when no term occurs")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesEmptyWhenAbsent() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, "no terms here", Set.of("XYZ"), false, false);
|
||||
assertTrue(matches.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTextFromToken pulls text from Tj COSString and TJ COSArray")
|
||||
void extractTextFromToken() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextFromToken", Object.class, String.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "Tj"));
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "'"));
|
||||
|
||||
COSArray tjArray = new COSArray();
|
||||
tjArray.add(new COSString("foo"));
|
||||
tjArray.add(new COSString("bar"));
|
||||
assertEquals("foobar", m.invoke(service, tjArray, "TJ"));
|
||||
|
||||
// Unknown operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSString("x"), "Td"));
|
||||
// Wrong token type for the operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSArray(), "Tj"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user