Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23a3ef3080 | ||
|
|
60fff188a6 | ||
|
|
5c47a3fc87 | ||
|
|
1eae354c35 | ||
|
|
f113b4f4b4 | ||
|
|
41181c9da1 | ||
|
|
8e485801c9 | ||
|
|
436afa51d7 | ||
|
|
0a29186ed6 | ||
|
|
101502cf4f | ||
|
|
f2b65f4a77 | ||
|
|
1816bad1ba | ||
|
|
9aee85d55e | ||
|
|
72f8705460 | ||
|
|
dffc292888 | ||
|
|
c95fb89c63 | ||
|
|
956b8000e4 | ||
|
|
a3fe15bfd0 | ||
|
|
1a770af47c | ||
|
|
3870ac3d7d | ||
|
|
b9ea9064c7 | ||
|
|
fe7a2a5ac7 | ||
|
|
6a9876a067 | ||
|
|
3793a6df52 | ||
|
|
66841db2b7 | ||
|
|
377677c182 | ||
|
|
f25f7e5fc9 | ||
|
|
b57958531d | ||
|
|
f8ceca0c3f | ||
|
|
e6d476297d | ||
|
|
3456316569 | ||
|
|
215bba39bc | ||
|
|
900b66b030 | ||
|
|
c3795c1a3c | ||
|
|
c8925acee7 | ||
|
|
eb08e60d67 | ||
|
|
18da914bf9 | ||
|
|
8f46ca0d92 | ||
|
|
9a3bc6b47f | ||
|
|
2b05865a84 | ||
|
|
0c503cc41d | ||
|
|
b1fef4c647 | ||
|
|
06254853af | ||
|
|
d9e6041a75 | ||
|
|
8f81fdc762 |
@@ -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.2
|
||||
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.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -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
|
||||
@@ -51,3 +51,15 @@ jobs:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
# written on main, where PR and merge-queue tauri builds can restore it.
|
||||
warm-tauri-cache:
|
||||
name: Warm Tauri Rust cache
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/tauri-build.yml
|
||||
with:
|
||||
platform: all
|
||||
secrets: inherit
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,9 +155,9 @@ jobs:
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
|
||||
build-args: |
|
||||
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
BASE_VERSION=1.0.0
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -115,6 +115,20 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# Cache the Cargo registry and compiled dependency crates so the build
|
||||
# only recompiles the app crate. Written on main; PRs and the merge queue
|
||||
# restore from it.
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: frontend/editor/src-tauri
|
||||
# Stable key shared across workflows so the nightly warmer.
|
||||
# rust-cache still appends OS + rustc + Cargo.lock.
|
||||
shared-key: tauri-${{ matrix.name }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Save the dependency cache even if a later step fails
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
|
||||
@@ -46,6 +46,12 @@ app/core/storage/
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
# 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
|
||||
!app/core/src/main/resources/static/mobile-upload.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/
|
||||
@@ -53,6 +59,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
|
||||
- --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
|
||||
|
||||
@@ -23,7 +23,9 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
@@ -31,13 +33,15 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
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}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{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}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -60,12 +64,13 @@ tasks:
|
||||
# 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: '{{if .AIENGINE_URL}}true{{else}}false{{end}}'
|
||||
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}}"
|
||||
|
||||
+73
-3
@@ -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
|
||||
@@ -114,9 +130,34 @@ tasks:
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
ignore_error: true
|
||||
deps: [install]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
MOCKS: '{{.MOCKS | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
|
||||
|
||||
dev:portal:proxy:serve:
|
||||
internal: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
|
||||
cmds:
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
@@ -137,8 +178,10 @@ tasks:
|
||||
build:proprietary:
|
||||
desc: "Build for proprietary mode"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PREVIEW: '{{.PREVIEW | default ""}}'
|
||||
cmds:
|
||||
- npx vite build editor --mode proprietary
|
||||
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
@@ -165,8 +208,26 @@ tasks:
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
vars:
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
cmds:
|
||||
- npx vite build portal
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- task: build:proprietary
|
||||
vars: { PREVIEW: '1' }
|
||||
- task: build:portal
|
||||
vars: { SUBPATH: portal }
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -272,6 +333,7 @@ tasks:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project scripts/tsconfig.json
|
||||
- npx tsc --noEmit --project editor/scripts/tsconfig.json
|
||||
|
||||
typecheck:prototypes:
|
||||
@@ -317,9 +379,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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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:
|
||||
# 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 is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
|
||||
# which owns the version and caches the binary here.
|
||||
GITLEAKS_BIN: '.task/bin/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 cached gitleaks binary and the tool virtualenv"
|
||||
cmds:
|
||||
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
|
||||
platforms: [linux, darwin]
|
||||
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
|
||||
platforms: [windows]
|
||||
ignore_error: true
|
||||
|
||||
# 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 --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}}{{.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, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
cmds:
|
||||
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
|
||||
@@ -152,7 +152,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+113
@@ -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
|
||||
# ============================================================
|
||||
@@ -58,6 +78,80 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:
|
||||
desc: "Start backend + developer portal 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}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
desc: "Start backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:proxy:
|
||||
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
SUBPATH: portal
|
||||
MOCKS: 'false'
|
||||
- task: frontend:dev:portal:proxy:serve
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
|
||||
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
cmds:
|
||||
@@ -87,6 +181,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
AIENGINE_ENABLED: "true"
|
||||
- task: 'frontend:dev:{{.FRONTEND}}'
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
@@ -103,6 +198,23 @@ tasks:
|
||||
- task: backend:build
|
||||
- task: frontend:build
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:preview:portal:proxy
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
# ============================================================
|
||||
@@ -173,3 +285,4 @@ tasks:
|
||||
- task: backend:clean
|
||||
- task: frontend:clean
|
||||
- task: engine:clean
|
||||
- task: pre-commit:clean
|
||||
|
||||
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
public String getLocalNetworkIp() {
|
||||
String routed = detectLocalIpViaDefaultRoute();
|
||||
if (routed != null) {
|
||||
return routed;
|
||||
}
|
||||
try {
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) return null;
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
return addr.getHostAddress();
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectBestSiteLocalIp(collectInterfaceInfo());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to detect local network IP", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String detectLocalIpViaDefaultRoute() {
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.connect(InetAddress.getByName("8.8.8.8"), 53);
|
||||
InetAddress local = socket.getLocalAddress();
|
||||
if (local instanceof Inet4Address
|
||||
&& !local.isAnyLocalAddress()
|
||||
&& !local.isLoopbackAddress()
|
||||
&& !local.isLinkLocalAddress()) {
|
||||
return local.getHostAddress();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Default-route IP detection failed; will scan interfaces", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
|
||||
List<NetworkInterfaceInfo> infos = new ArrayList<>();
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) {
|
||||
return infos;
|
||||
}
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
|
||||
List<String> siteLocalIpv4s = new ArrayList<>();
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
siteLocalIpv4s.add(addr.getHostAddress());
|
||||
}
|
||||
}
|
||||
if (siteLocalIpv4s.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] mac = iface.getHardwareAddress();
|
||||
infos.add(
|
||||
new NetworkInterfaceInfo(
|
||||
iface.getName(),
|
||||
iface.getDisplayName(),
|
||||
iface.getIndex(),
|
||||
iface.isUp(),
|
||||
iface.isLoopback(),
|
||||
iface.isPointToPoint(),
|
||||
iface.isVirtual(),
|
||||
mac != null && mac.length > 0,
|
||||
siteLocalIpv4s));
|
||||
} catch (SocketException e) {
|
||||
log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e);
|
||||
}
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
static String selectBestSiteLocalIp(List<NetworkInterfaceInfo> interfaces) {
|
||||
return interfaces.stream()
|
||||
.filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual())
|
||||
.filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName()))
|
||||
.flatMap(
|
||||
i ->
|
||||
i.siteLocalIpv4s().stream()
|
||||
.map(
|
||||
ip ->
|
||||
new ScoredAddress(
|
||||
ip,
|
||||
scoreInterface(i, ip),
|
||||
i.index())))
|
||||
.max(
|
||||
Comparator.comparingInt(ScoredAddress::score)
|
||||
.thenComparing(
|
||||
Comparator.comparingInt(ScoredAddress::interfaceIndex)
|
||||
.reversed()))
|
||||
.map(ScoredAddress::ip)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static int scoreInterface(NetworkInterfaceInfo iface, String ip) {
|
||||
int score = 0;
|
||||
if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) {
|
||||
score += 100;
|
||||
}
|
||||
if (iface.hasHardwareAddress()) {
|
||||
score += 20;
|
||||
}
|
||||
if (ip.startsWith("192.168.")) {
|
||||
score += 30;
|
||||
} else if (ip.startsWith("10.")) {
|
||||
score += 20;
|
||||
} else {
|
||||
score += 5;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
static boolean isLikelyVirtualInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
String[] namePrefixes = {
|
||||
"tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl",
|
||||
"llw"
|
||||
};
|
||||
for (String prefix : namePrefixes) {
|
||||
if (n.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String[] displayMarkers = {
|
||||
"vmware",
|
||||
"virtualbox",
|
||||
"virtual box",
|
||||
"vbox",
|
||||
"hyper-v",
|
||||
"hyperv",
|
||||
"vethernet",
|
||||
"windows subsystem for linux",
|
||||
"wsl",
|
||||
"docker",
|
||||
"tap-windows",
|
||||
"tunnel",
|
||||
"vpn",
|
||||
"zerotier",
|
||||
"tailscale",
|
||||
"bluetooth",
|
||||
"teredo",
|
||||
"isatap",
|
||||
"loopback",
|
||||
"pseudo",
|
||||
"virtual"
|
||||
};
|
||||
for (String marker : displayMarkers) {
|
||||
if (d.contains(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isLikelyPhysicalInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
return n.startsWith("eth")
|
||||
|| n.startsWith("en")
|
||||
|| n.startsWith("wl")
|
||||
|| n.startsWith("em")
|
||||
|| d.contains("ethernet")
|
||||
|| d.contains("wi-fi")
|
||||
|| d.contains("wifi")
|
||||
|| d.contains("wireless");
|
||||
}
|
||||
|
||||
record NetworkInterfaceInfo(
|
||||
String name,
|
||||
String displayName,
|
||||
int index,
|
||||
boolean up,
|
||||
boolean loopback,
|
||||
boolean pointToPoint,
|
||||
boolean virtual,
|
||||
boolean hasHardwareAddress,
|
||||
List<String> siteLocalIpv4s) {}
|
||||
|
||||
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
import java.awt.geom.Point2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
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.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.pdf.parser.PageImageLocator.ImageBox;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PageImageLocator}. PDFs are built in memory with PDFBox so each test is
|
||||
* deterministic and needs no fixtures or native libraries. The locator transforms the image unit
|
||||
* square through the CTM, so an image drawn at {@code (x, y)} with size {@code (w, h)} must yield
|
||||
* the box {@code (x, y, x+w, y+h)}.
|
||||
*/
|
||||
class PageImageLocatorTest {
|
||||
|
||||
/** A tiny opaque raster; pixel content is irrelevant, only its placement matters. */
|
||||
private static PDImageXObject tinyImage(PDDocument doc) throws Exception {
|
||||
BufferedImage img = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
|
||||
return LosslessFactory.createFromImage(doc, img);
|
||||
}
|
||||
|
||||
/** Builds a one-page PDF that draws one image at the given placement. */
|
||||
private static byte[] pdfWithImageAt(float x, float y, float w, float h) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = tinyImage(doc);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, x, y, w, h);
|
||||
}
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("drawImage bounding boxes")
|
||||
class DrawImageBoxes {
|
||||
|
||||
@Test
|
||||
@DisplayName("a single image yields one box with the page index and CTM-derived bounds")
|
||||
void singleImageBox() throws Exception {
|
||||
byte[] pdf = pdfWithImageAt(100f, 200f, 50f, 80f);
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
|
||||
locator.processPage(doc.getPage(0));
|
||||
|
||||
List<ImageBox> boxes = locator.getImageBoxes();
|
||||
assertThat(boxes).hasSize(1);
|
||||
ImageBox box = boxes.get(0);
|
||||
assertThat(box.pageIndex()).isZero();
|
||||
assertThat(box.x1()).isCloseTo(100f, within(0.5f));
|
||||
assertThat(box.y1()).isCloseTo(200f, within(0.5f));
|
||||
assertThat(box.x2()).isCloseTo(150f, within(0.5f));
|
||||
assertThat(box.y2()).isCloseTo(280f, within(0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the supplied page index is stored on every box")
|
||||
void pageIndexStored() throws Exception {
|
||||
byte[] pdf = pdfWithImageAt(10f, 10f, 20f, 20f);
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 7);
|
||||
locator.processPage(doc.getPage(0));
|
||||
assertThat(locator.getImageBoxes().get(0).pageIndex()).isEqualTo(7);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two images on one page yield two boxes")
|
||||
void twoImages() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDImageXObject image = tinyImage(doc);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 50f, 50f, 30f, 30f);
|
||||
cs.drawImage(image, 200f, 400f, 60f, 40f);
|
||||
}
|
||||
byte[] pdf = save(doc);
|
||||
try (PDDocument reopened = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
|
||||
locator.processPage(reopened.getPage(0));
|
||||
assertThat(locator.getImageBoxes()).hasSize(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a page with no images yields no boxes")
|
||||
void noImages() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
byte[] pdf = save(doc);
|
||||
try (PDDocument reopened = Loader.loadPDF(pdf)) {
|
||||
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
|
||||
locator.processPage(reopened.getPage(0));
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getImageBoxes is empty before any page is processed")
|
||||
void emptyBeforeProcessing() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("path operation no-ops")
|
||||
class PathNoOps {
|
||||
|
||||
private PageImageLocator newLocator() {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
return new PageImageLocator(page, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moveTo updates the current point")
|
||||
void moveToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.moveTo(12f, 34f);
|
||||
Point2D current = locator.getCurrentPoint();
|
||||
assertThat(current.getX()).isEqualTo(12d);
|
||||
assertThat(current.getY()).isEqualTo(34d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lineTo updates the current point")
|
||||
void lineToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.lineTo(5f, 6f);
|
||||
assertThat(locator.getCurrentPoint().getX()).isEqualTo(5d);
|
||||
assertThat(locator.getCurrentPoint().getY()).isEqualTo(6d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("curveTo updates the current point to the final control point")
|
||||
void curveToUpdatesPoint() {
|
||||
PageImageLocator locator = newLocator();
|
||||
locator.curveTo(1f, 1f, 2f, 2f, 9f, 8f);
|
||||
assertThat(locator.getCurrentPoint().getX()).isEqualTo(9d);
|
||||
assertThat(locator.getCurrentPoint().getY()).isEqualTo(8d);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rectangle, clip, path and shading operations are no-ops that do not throw")
|
||||
void otherOpsDoNotThrow() {
|
||||
PageImageLocator locator = newLocator();
|
||||
Point2D p = new Point2D.Float(0f, 0f);
|
||||
// None of these record anything or alter state; they must simply not throw.
|
||||
locator.appendRectangle(p, p, p, p);
|
||||
locator.clip(0);
|
||||
locator.closePath();
|
||||
locator.endPath();
|
||||
locator.strokePath();
|
||||
locator.fillPath(0);
|
||||
locator.fillAndStrokePath(0);
|
||||
locator.shadingFill(COSName.getPDFName("Sh0"));
|
||||
assertThat(locator.getImageBoxes()).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class AppConfigTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockEnvironment env;
|
||||
private AppConfig appConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
env = new MockEnvironment();
|
||||
appConfig = new AppConfig(env, applicationProperties);
|
||||
ReflectionTestUtils.setField(appConfig, "contextPath", "/");
|
||||
ReflectionTestUtils.setField(appConfig, "serverPort", "8080");
|
||||
ReflectionTestUtils.setField(appConfig, "v2Enabled", true);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Value-backed getters and simple beans")
|
||||
class SimpleBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("getter fields reflect injected @Value values")
|
||||
void valueGetters() {
|
||||
assertThat(appConfig.getContextPath()).isEqualTo("/");
|
||||
assertThat(appConfig.getServerPort()).isEqualTo("8080");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("v2Enabled bean mirrors the field")
|
||||
void v2EnabledBean() {
|
||||
assertThat(appConfig.v2Enabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constant beans return fixed values")
|
||||
void constants() {
|
||||
assertThat(appConfig.appName()).isEqualTo("Stirling PDF");
|
||||
assertThat(appConfig.homeText()).isEqualTo("null");
|
||||
assertThat(appConfig.contextPath("/ctx")).isEqualTo("/ctx");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appVersion resolves from version.properties on classpath")
|
||||
void appVersion() {
|
||||
assertThat(appConfig.appVersion()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("StirlingPDFLabel embeds version")
|
||||
void stirlingLabel() {
|
||||
assertThat(appConfig.stirlingPDFLabel()).startsWith("Stirling-PDF v");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Beans backed by ApplicationProperties")
|
||||
class PropertyBackedBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("loginEnabled reflects security flag")
|
||||
void loginEnabled() {
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
assertThat(appConfig.loginEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backendUrl falls back to localhost when unset")
|
||||
void backendUrlFallback() {
|
||||
assertThat(appConfig.getBackendUrl()).isEqualTo("http://localhost");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backendUrl returns configured value when present")
|
||||
void backendUrlConfigured() {
|
||||
applicationProperties.getSystem().setBackendUrl("https://api.example.com");
|
||||
assertThat(appConfig.getBackendUrl()).isEqualTo("https://api.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("languages bean returns configured languages list")
|
||||
void languages() {
|
||||
applicationProperties.getUi().setLanguages(List.of("en", "de"));
|
||||
assertThat(appConfig.languages()).containsExactly("en", "de");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("navBarText falls back to Stirling PDF when unset")
|
||||
void navBarTextFallback() {
|
||||
assertThat(appConfig.navBarText()).isEqualTo("Stirling PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("navBarText returns configured value")
|
||||
void navBarTextConfigured() {
|
||||
applicationProperties.getUi().setAppNameNavbar("My PDF");
|
||||
assertThat(appConfig.navBarText()).isEqualTo("My PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enableAlphaFunctionality reflects system flag")
|
||||
void alphaFunctionality() {
|
||||
applicationProperties.getSystem().setEnableAlphaFunctionality(true);
|
||||
assertThat(appConfig.enableAlphaFunctionality()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legal text beans return configured values")
|
||||
void legalBeans() {
|
||||
var legal = applicationProperties.getLegal();
|
||||
legal.setTermsAndConditions("terms");
|
||||
legal.setPrivacyPolicy("privacy");
|
||||
legal.setCookiePolicy("cookie");
|
||||
legal.setImpressum("impressum");
|
||||
legal.setAccessibilityStatement("a11y");
|
||||
assertThat(appConfig.termsAndConditions()).isEqualTo("terms");
|
||||
assertThat(appConfig.privacyPolicy()).isEqualTo("privacy");
|
||||
assertThat(appConfig.cookiePolicy()).isEqualTo("cookie");
|
||||
assertThat(appConfig.impressum()).isEqualTo("impressum");
|
||||
assertThat(appConfig.accessibilityStatement()).isEqualTo("a11y");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsPrompt true when enableAnalytics null")
|
||||
void analyticsPrompt() {
|
||||
applicationProperties.getSystem().setEnableAnalytics(null);
|
||||
assertThat(appConfig.analyticsPrompt()).isTrue();
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
assertThat(appConfig.analyticsPrompt()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsEnabled true when premium enabled regardless of system flag")
|
||||
void analyticsEnabledViaPremium() {
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
assertThat(appConfig.analyticsEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("analyticsEnabled reflects system flag when premium disabled")
|
||||
void analyticsEnabledViaSystem() {
|
||||
applicationProperties.getPremium().setEnabled(false);
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
assertThat(appConfig.analyticsEnabled()).isTrue();
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.FALSE);
|
||||
assertThat(appConfig.analyticsEnabled()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("scarf and posthog beans reflect derived flags")
|
||||
void scarfAndPosthog() {
|
||||
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
|
||||
applicationProperties.getSystem().setEnableScarf(Boolean.TRUE);
|
||||
applicationProperties.getSystem().setEnablePosthog(Boolean.TRUE);
|
||||
assertThat(appConfig.scarfEnabled()).isTrue();
|
||||
assertThat(appConfig.posthogEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uuid bean returns generated UUID")
|
||||
void uuidBean() {
|
||||
applicationProperties.getAutomaticallyGenerated().setUUID("abc-123");
|
||||
assertThat(appConfig.uuid()).isEqualTo("abc-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("typed config beans return live nested instances")
|
||||
void typedConfigBeans() {
|
||||
assertThat(appConfig.security()).isSameAs(applicationProperties.getSecurity());
|
||||
assertThat(appConfig.oAuth2())
|
||||
.isSameAs(applicationProperties.getSecurity().getOauth2());
|
||||
assertThat(appConfig.premium()).isSameAs(applicationProperties.getPremium());
|
||||
assertThat(appConfig.system()).isSameAs(applicationProperties.getSystem());
|
||||
assertThat(appConfig.datasource())
|
||||
.isSameAs(applicationProperties.getSystem().getDatasource());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Profile-default and environment beans")
|
||||
class ProfileAndEnvBeans {
|
||||
|
||||
@Test
|
||||
@DisplayName("default-profile license beans return community defaults")
|
||||
void licenseDefaults() {
|
||||
assertThat(appConfig.runningProOrHigher()).isFalse();
|
||||
assertThat(appConfig.runningEnterprise()).isFalse();
|
||||
assertThat(appConfig.licenseType()).isEqualTo("NORMAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("activeSecurity reflects classpath presence of SecurityConfiguration")
|
||||
void activeSecurity() {
|
||||
// Just exercise the branch; result depends on classpath, assert it does not throw.
|
||||
boolean present = appConfig.missingActiveSecurity();
|
||||
assertThat(present).isIn(true, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rateLimit parses system property")
|
||||
void rateLimitProperty() {
|
||||
String prev = System.getProperty("rateLimit");
|
||||
try {
|
||||
System.setProperty("rateLimit", "true");
|
||||
assertThat(appConfig.rateLimit()).isTrue();
|
||||
} finally {
|
||||
if (prev == null) {
|
||||
System.clearProperty("rateLimit");
|
||||
} else {
|
||||
System.setProperty("rateLimit", prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runningInDocker false outside container")
|
||||
void runningInDocker() {
|
||||
// CI/test host is not a container with /.dockerenv.
|
||||
assertThat(appConfig.runningInDocker()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("configDirMounted defaults to true when not in docker")
|
||||
void configDirMounted() {
|
||||
assertThat(appConfig.isRunningInDockerWithConfig()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("directoryFilter accepts files and rejects processing dirs")
|
||||
void directoryFilter(@org.junit.jupiter.api.io.TempDir Path tempDir) throws Exception {
|
||||
Predicate<Path> filter = appConfig.processOnlyFiles();
|
||||
Path file = Files.createFile(tempDir.resolve("a.txt"));
|
||||
Path normalDir = Files.createDirectory(tempDir.resolve("normal"));
|
||||
Path processingDir = Files.createDirectory(tempDir.resolve("processing"));
|
||||
assertThat(filter.test(file)).isTrue();
|
||||
assertThat(filter.test(normalDir)).isTrue();
|
||||
assertThat(filter.test(processingDir)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("machineType returns Server-jar in plain test environment")
|
||||
void machineTypeServerJar() {
|
||||
assertThat(appConfig.determineMachineType()).isEqualTo("Server-jar");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("machineType returns a Client-* variant when BROWSER_OPEN set")
|
||||
void machineTypeClient() {
|
||||
env.setProperty("BROWSER_OPEN", "true");
|
||||
assertThat(appConfig.determineMachineType()).startsWith("Client-");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
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.mockito.MockedStatic;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
import stirling.software.common.util.YamlHelper;
|
||||
|
||||
class ConfigInitializerMoreTest {
|
||||
|
||||
private static final LoadSettings LOAD_SETTINGS =
|
||||
LoadSettings.builder()
|
||||
.setUseMarks(true)
|
||||
.setMaxAliasesForCollections(Integer.MAX_VALUE)
|
||||
.setAllowRecursiveKeys(true)
|
||||
.setParseComments(true)
|
||||
.build();
|
||||
|
||||
// Template after the enterpriseEdition -> premium rename.
|
||||
private static final String PREMIUM_TEMPLATE =
|
||||
"""
|
||||
premium:
|
||||
enabled: false
|
||||
key: 0000
|
||||
proFeatures:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
""";
|
||||
|
||||
@Nested
|
||||
@DisplayName("migrateEnterpriseEditionToPremium")
|
||||
class EnterpriseMigration {
|
||||
|
||||
@Test
|
||||
@DisplayName("carries legacy enterpriseEdition values forward into premium block")
|
||||
void migratesLegacyEnterpriseValues() throws Exception {
|
||||
String legacy =
|
||||
"""
|
||||
enterpriseEdition:
|
||||
enabled: true
|
||||
key: ABC-123
|
||||
SSOAutoLogin: true
|
||||
CustomMetadata:
|
||||
autoUpdateMetadata: true
|
||||
author: alice
|
||||
creator: bob
|
||||
producer: carol
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
|
||||
|
||||
invokeMigrate(existing, template);
|
||||
|
||||
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("true");
|
||||
assertThat(template.getValueByExactKeyPath("premium", "key")).isEqualTo("ABC-123");
|
||||
assertThat(template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"))
|
||||
.isEqualTo("true");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium",
|
||||
"proFeatures",
|
||||
"customMetadata",
|
||||
"autoUpdateMetadata"))
|
||||
.isEqualTo("true");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"))
|
||||
.isEqualTo("alice");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "creator"))
|
||||
.isEqualTo("bob");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "producer"))
|
||||
.isEqualTo("carol");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no legacy enterpriseEdition block leaves template defaults intact")
|
||||
void noLegacyKeysIsNoOp() throws Exception {
|
||||
String noEnterprise =
|
||||
"""
|
||||
security:
|
||||
enableLogin: false
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, noEnterprise);
|
||||
|
||||
invokeMigrate(existing, template);
|
||||
|
||||
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("false");
|
||||
assertThat(
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"))
|
||||
.isEqualTo("username");
|
||||
}
|
||||
|
||||
private void invokeMigrate(YamlHelper yaml, YamlHelper template) throws Exception {
|
||||
var method =
|
||||
ConfigInitializer.class.getDeclaredMethod(
|
||||
"migrateEnterpriseEditionToPremium",
|
||||
YamlHelper.class,
|
||||
YamlHelper.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(new ConfigInitializer(), yaml, template);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ensureConfigExists - create branch (template absent on common classpath)")
|
||||
class EnsureConfigCreateBranch {
|
||||
|
||||
@Test
|
||||
@DisplayName("no settings file -> attempts create, fails fast when template missing")
|
||||
void createWithoutTemplateThrows(@TempDir Path tempDir) throws Exception {
|
||||
Path settings = tempDir.resolve("configs").resolve("settings.yml");
|
||||
Path custom = tempDir.resolve("configs").resolve("custom_settings.yml");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath)
|
||||
.thenReturn(settings.toString());
|
||||
mocked.when(InstallationPathConfig::getCustomSettingsPath)
|
||||
.thenReturn(custom.toString());
|
||||
|
||||
// settings.yml.template is packaged in the core module, not common, so the
|
||||
// create branch must surface a FileNotFoundException here.
|
||||
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
|
||||
.isInstanceOf(FileNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("short existing settings file is backed up before recreate attempt")
|
||||
void shortFileIsBackedUp(@TempDir Path tempDir) throws Exception {
|
||||
Path configDir = Files.createDirectories(tempDir.resolve("configs"));
|
||||
Path settings = configDir.resolve("settings.yml");
|
||||
Path custom = configDir.resolve("custom_settings.yml");
|
||||
// Fewer than MIN_SETTINGS_FILE_LINES (31) lines triggers the recreate path.
|
||||
Files.writeString(settings, "a: 1\nb: 2\n");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath)
|
||||
.thenReturn(settings.toString());
|
||||
mocked.when(InstallationPathConfig::getCustomSettingsPath)
|
||||
.thenReturn(custom.toString());
|
||||
|
||||
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
|
||||
.isInstanceOf(FileNotFoundException.class);
|
||||
}
|
||||
|
||||
// Original was moved to a timestamped .bak before the failed recreate.
|
||||
try (Stream<Path> files = Files.list(configDir)) {
|
||||
assertThat(files.anyMatch(p -> p.getFileName().toString().contains(".bak")))
|
||||
.isTrue();
|
||||
}
|
||||
assertThat(Files.exists(settings)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package stirling.software.common.pdf;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.text.PageText;
|
||||
import stirling.software.jpdfium.text.Table;
|
||||
import stirling.software.jpdfium.text.TextChar;
|
||||
import stirling.software.jpdfium.text.TextLine;
|
||||
import stirling.software.jpdfium.text.TextWord;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link PdfMarkdownConverter} not covered by {@link
|
||||
* PdfMarkdownConverterTest}: the visible-for-testing column-range detector across a range of
|
||||
* geometries, the package-private extraction helpers, and the full conversion of the wrapped-cell
|
||||
* fixture (only run under a disabled accuracy test in the sibling suite).
|
||||
*/
|
||||
class PdfMarkdownConverterMoreTest {
|
||||
|
||||
@TempDir Path tmp;
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/** A word occupying [x, x+width] on baseline y; chars are synthetic so text length is real. */
|
||||
private static TextWord word(String text, float x, float width) {
|
||||
List<TextChar> chars = new ArrayList<>();
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
chars.add(
|
||||
new TextChar(
|
||||
i,
|
||||
text.charAt(i),
|
||||
x,
|
||||
0f,
|
||||
width / Math.max(1, text.length()),
|
||||
10f,
|
||||
"Helvetica",
|
||||
10f));
|
||||
}
|
||||
return new TextWord(chars, x, 0f, width, 10f);
|
||||
}
|
||||
|
||||
/** A single-line row built from the given words, spanning their full x-range. */
|
||||
private static TextLine row(float y, TextWord... words) {
|
||||
float minX = Float.MAX_VALUE;
|
||||
float maxX = -Float.MAX_VALUE;
|
||||
for (TextWord w : words) {
|
||||
minX = Math.min(minX, w.x());
|
||||
maxX = Math.max(maxX, w.x() + w.width());
|
||||
}
|
||||
return new TextLine(List.of(words), minX, y, maxX - minX, 10f);
|
||||
}
|
||||
|
||||
/** Copies a classpath fixture into the temp dir and returns its path. */
|
||||
private Path fixture(String name) throws IOException {
|
||||
Path dest = tmp.resolve(name);
|
||||
try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + name)) {
|
||||
assertThat(in).as("fixture on classpath: " + name).isNotNull();
|
||||
Files.copy(in, dest);
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
// ---- findColumnRangesFromLines -----------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("findColumnRangesFromLines")
|
||||
class ColumnRanges {
|
||||
|
||||
@Test
|
||||
@DisplayName("two well-separated bands are detected as two columns")
|
||||
void twoColumns() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 4; r++) {
|
||||
float y = 400f - r * 12f;
|
||||
rows.add(row(y, word("left", 50f, 40f), word("right", 190f, 40f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(2);
|
||||
// First band starts near 50, second near 190.
|
||||
assertThat(cols.get(0)[0]).isLessThan(cols.get(1)[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two bands within a narrow gutter merge into one column")
|
||||
void narrowGutterMerges() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 4; r++) {
|
||||
float y = 400f - r * 12f;
|
||||
// Gap of ~10pt is far below the merge threshold for 40pt-wide words.
|
||||
rows.add(row(y, word("aa", 50f, 40f), word("bb", 100f, 40f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single occupied band yields one column (trailing-band flush)")
|
||||
void singleColumn() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 3; r++) {
|
||||
rows.add(row(400f - r * 12f, word("word", 50f, 60f)));
|
||||
}
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
assertThat(cols.get(0)[0]).isCloseTo(50f, org.assertj.core.api.Assertions.within(2f));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rows with no words produce no columns")
|
||||
void noWordsNoColumns() {
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 3; r++) {
|
||||
rows.add(new TextLine(List.of(), 0f, 400f - r * 12f, 0f, 10f));
|
||||
}
|
||||
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(rows)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty row list produces no columns")
|
||||
void emptyInput() {
|
||||
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a sparsely-covered band below the support threshold is dropped")
|
||||
void sparseBandDropped() {
|
||||
// Five rows fill the left band; only one fills a far-right band, which is below the
|
||||
// 35%-of-rows support floor and so is not reported as a column.
|
||||
List<TextLine> rows = new ArrayList<>();
|
||||
for (int r = 0; r < 5; r++) {
|
||||
rows.add(row(400f - r * 12f, word("left", 50f, 40f)));
|
||||
}
|
||||
rows.add(row(320f, word("left", 50f, 40f), word("rareoutlier", 400f, 60f)));
|
||||
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
|
||||
assertThat(cols).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- package-private extraction helpers ---------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extraction helpers")
|
||||
class ExtractionHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("extractAllPageText returns one PageText per page")
|
||||
void extractAllPageText() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<PageText> pages = new PdfMarkdownConverter().extractAllPageText(doc);
|
||||
assertThat(pages).isNotNull();
|
||||
assertThat(pages).hasSize(doc.pageCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTables returns a non-null list for the first page")
|
||||
void extractTables() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<Table> tables = new PdfMarkdownConverter().extractTables(doc, 0);
|
||||
assertThat(tables).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renderTables maps each extracted table to a markdown string")
|
||||
void renderTables() throws IOException {
|
||||
Path pdf = fixture("bordered-table-test_widget.pdf");
|
||||
PdfMarkdownConverter converter = new PdfMarkdownConverter();
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
List<Table> tables = converter.extractTables(doc, 0);
|
||||
List<String> rendered = converter.renderTables(tables);
|
||||
assertThat(rendered).isNotNull();
|
||||
assertThat(rendered).hasSameSizeAs(tables);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renderTables on an empty table list returns an empty list")
|
||||
void renderTablesEmpty() {
|
||||
assertThat(new PdfMarkdownConverter().renderTables(List.of())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- full conversion of additional fixtures -----------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convert full pipeline")
|
||||
class ConvertPipeline {
|
||||
|
||||
@Test
|
||||
@DisplayName("wrapped-cell expense report converts without throwing and yields content")
|
||||
void wrappedCellFixture() throws IOException {
|
||||
Path pdf = fixture("wrapped-cell-test_expense-report.pdf");
|
||||
String md;
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
md = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
assertThat(md).isNotNull();
|
||||
assertThat(md).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("converting a fixture twice is deterministic")
|
||||
void deterministic() throws IOException {
|
||||
Path pdf = fixture("multi-column-test_lorem.pdf");
|
||||
String first;
|
||||
String second;
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
first = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
second = new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the many-tables stress fixture converts without throwing")
|
||||
void manyTablesFixture() throws IOException {
|
||||
Path pdf = fixture("many-tables-test_stress.pdf");
|
||||
assertDoesNotThrow(
|
||||
() -> {
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
return new PdfMarkdownConverter().convert(doc);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stirling.software.common.pdf;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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.jpdfium.text.Table;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TableRenderer}. Tables are built directly from the {@link Table} record so
|
||||
* the renderer can be exercised without any PDF parsing, fixtures, or native calls.
|
||||
*/
|
||||
class TableRendererTest {
|
||||
|
||||
/** Builds a Table from raw rows; geometry is irrelevant to rendering so it is set to zero. */
|
||||
private static Table table(List<List<String>> rows) {
|
||||
return new Table(rows, 0f, 0f, 0f, 0f);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Degenerate tables")
|
||||
class Degenerate {
|
||||
|
||||
@Test
|
||||
@DisplayName("zero rows renders the empty string")
|
||||
void zeroRows() {
|
||||
assertThat(TableRenderer.render(table(List.of()))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single row with one column has no separator and is a plain line")
|
||||
void singleRowOneColumn() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("only"))));
|
||||
assertThat(md).isEqualTo("only");
|
||||
assertThat(md).doesNotContain("|");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single row with several columns becomes newline-separated plain lines")
|
||||
void singleRowManyColumns() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("a", "b", "c"))));
|
||||
// No separator row is possible with a single row, so cells are emitted as lines.
|
||||
assertThat(md).isEqualTo("a\nb\nc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-row cell content is trimmed and escaped")
|
||||
void singleRowTrimsAndEscapes() {
|
||||
String md = TableRenderer.render(table(List.of(List.of(" a|b "))));
|
||||
assertThat(md).isEqualTo("a\\|b");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("GFM rendering")
|
||||
class GfmRendering {
|
||||
|
||||
@Test
|
||||
@DisplayName("two rows produce a header, a separator and a data row")
|
||||
void headerSeparatorData() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(List.of(List.of("Name", "Age"), List.of("Alice", "30"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(3);
|
||||
assertThat(lines[0]).startsWith("|").contains("Name").contains("Age");
|
||||
// Separator row is made only of pipes and dashes.
|
||||
assertThat(lines[1].chars().allMatch(c -> c == '|' || c == '-')).isTrue();
|
||||
assertThat(lines[2]).contains("Alice").contains("30");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("column widths grow to fit the widest cell in each column")
|
||||
void columnWidthsFitContent() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(
|
||||
List.of(
|
||||
List.of("h", "header2"),
|
||||
List.of("averylongvalue", "x"))));
|
||||
String[] lines = md.split("\n");
|
||||
// Every rendered row (header, separator, data) is the same total width.
|
||||
int width = lines[0].length();
|
||||
for (String line : lines) {
|
||||
assertThat(line.length()).isEqualTo(width);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("minimum column width of three dashes is honoured for tiny cells")
|
||||
void minimumWidthThree() {
|
||||
String md = TableRenderer.render(table(List.of(List.of("a", "b"), List.of("c", "d"))));
|
||||
String separator = md.split("\n")[1];
|
||||
// Each column is padded to a minimum of 3, fenced by a dash either side: |-----|-----|.
|
||||
assertThat(separator).isEqualTo("|-----|-----|");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pipe characters in cells are escaped in every rendered row")
|
||||
void escapesPipes() {
|
||||
String md =
|
||||
TableRenderer.render(table(List.of(List.of("a|b", "c"), List.of("d", "e|f"))));
|
||||
// Two literal pipes escaped; the structural pipes are not.
|
||||
assertThat(md).contains("a\\|b").contains("e\\|f");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cells are trimmed before measuring and rendering")
|
||||
void trimsCells() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(List.of(List.of(" Name ", " Age "), List.of("Al", "30"))));
|
||||
assertThat(md).contains("| Name").contains("Age ");
|
||||
assertThat(md).doesNotContain(" Name ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("three rows emit two data rows after the separator")
|
||||
void multipleDataRows() {
|
||||
String md =
|
||||
TableRenderer.render(
|
||||
table(
|
||||
List.of(
|
||||
List.of("c1", "c2"),
|
||||
List.of("a", "b"),
|
||||
List.of("x", "y"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(4);
|
||||
assertThat(lines[2]).contains("a").contains("b");
|
||||
assertThat(lines[3]).contains("x").contains("y");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a short trailing row is padded out to the column count from asGrid")
|
||||
void shortRowPaddedByGrid() {
|
||||
// colCount comes from the first row; a shorter later row is padded with empty cells by
|
||||
// Table.asGrid, so rendering must not throw and the grid stays rectangular.
|
||||
String md =
|
||||
TableRenderer.render(table(List.of(List.of("a", "b", "c"), List.of("only"))));
|
||||
String[] lines = md.split("\n");
|
||||
assertThat(lines).hasSize(3);
|
||||
int width = lines[0].length();
|
||||
for (String line : lines) {
|
||||
assertThat(line.length()).isEqualTo(width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
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.io.MemoryUsageSetting;
|
||||
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
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.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class CustomPDFDocumentFactoryMoreTest {
|
||||
|
||||
private CustomPDFDocumentFactory factory;
|
||||
private byte[] basePdfBytes;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
try (InputStream is = getClass().getResourceAsStream("/example.pdf")) {
|
||||
basePdfBytes = is.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("null-argument guards")
|
||||
class NullGuards {
|
||||
|
||||
@Test
|
||||
@DisplayName("each load overload rejects null with IllegalArgumentException")
|
||||
void nullArguments() {
|
||||
assertThatThrownBy(() -> factory.load((File) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((Path) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((byte[]) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((InputStream) null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> factory.load((InputStream) null, "pw"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cache strategy selection (public overload)")
|
||||
class CacheStrategy {
|
||||
|
||||
@Test
|
||||
@DisplayName("getStreamCacheFunction returns a non-null function for each size band")
|
||||
void cacheFunctionPerBand() {
|
||||
StreamCacheCreateFunction small = factory.getStreamCacheFunction(1024);
|
||||
StreamCacheCreateFunction mixed = factory.getStreamCacheFunction(20L * 1024 * 1024);
|
||||
StreamCacheCreateFunction large = factory.getStreamCacheFunction(60L * 1024 * 1024);
|
||||
assertThat(small).isNotNull();
|
||||
assertThat(mixed).isNotNull();
|
||||
assertThat(large).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("create and round-trip helpers")
|
||||
class CreateAndRoundTrip {
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocument(MemoryUsageSetting) sets default metadata")
|
||||
void createWithMemorySetting() throws IOException {
|
||||
PdfMetadataService svc = mock(PdfMetadataService.class);
|
||||
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
|
||||
try (PDDocument doc = f.createNewDocument(MemoryUsageSetting.setupMainMemoryOnly())) {
|
||||
assertThat(doc).isNotNull();
|
||||
verify(svc).setDefaultMetadata(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("loadToBytes(byte[]) round-trips a loadable PDF")
|
||||
void loadToBytesFromArray() throws IOException {
|
||||
byte[] out = factory.loadToBytes(basePdfBytes);
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocumentBasedOnOldDocument(byte[]) produces a fresh document")
|
||||
void newDocFromOldBytes() throws IOException {
|
||||
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(basePdfBytes)) {
|
||||
assertThat(doc).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createNewDocumentBasedOnOldDocument(File) produces a fresh document")
|
||||
void newDocFromOldFile(@TempDir Path tempDir) throws IOException {
|
||||
File f = Files.write(tempDir.resolve("old.pdf"), basePdfBytes).toFile();
|
||||
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(f)) {
|
||||
assertThat(doc).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("read-only and password handling")
|
||||
class ReadOnlyAndPassword {
|
||||
|
||||
@Test
|
||||
@DisplayName("read-only load from file skips post-processing")
|
||||
void readOnlyFromFile(@TempDir Path tempDir) throws IOException {
|
||||
PdfMetadataService svc = mock(PdfMetadataService.class);
|
||||
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
|
||||
File file = Files.write(tempDir.resolve("ro.pdf"), basePdfBytes).toFile();
|
||||
try (PDDocument doc = f.load(file, true)) {
|
||||
assertThat(doc).isNotNull();
|
||||
org.mockito.Mockito.verify(svc, org.mockito.Mockito.never())
|
||||
.setDefaultMetadata(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encrypted PDF is decrypted on the default (non-read-only) load path")
|
||||
void encryptedPdfDecrypted() throws IOException {
|
||||
byte[] encrypted = buildEncryptedPdf("ownerpw", "userpw");
|
||||
// load(InputStream, password) drives removePassword + setAllSecurityToBeRemoved so the
|
||||
// returned document can be re-saved with no password set.
|
||||
byte[] decryptedSaved;
|
||||
try (PDDocument doc =
|
||||
factory.load(new ByteArrayInputStream(encrypted), "userpw", false)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
decryptedSaved = factory.saveToBytes(doc);
|
||||
}
|
||||
// Re-loading with no password proves security was stripped.
|
||||
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(decryptedSaved)) {
|
||||
assertThat(reloaded.isEncrypted()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile with positive small size uses byte[] path")
|
||||
void smallMultipartLoadsViaBytes() throws IOException {
|
||||
MockMultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "s.pdf", MediaType.APPLICATION_PDF_VALUE, basePdfBytes);
|
||||
try (PDDocument doc = factory.load(multipart)) {
|
||||
assertThat(doc.getNumberOfPages()).isPositive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildEncryptedPdf(String ownerPw, String userPw) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
AccessPermission ap = new AccessPermission();
|
||||
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPw, userPw, ap);
|
||||
spp.setEncryptionKeyLength(128);
|
||||
doc.protect(spp);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
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.Optional;
|
||||
|
||||
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.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
|
||||
import stirling.software.common.service.FileStorage.StoredFile;
|
||||
|
||||
class FileStorageMoreTest {
|
||||
|
||||
@TempDir Path storageDir;
|
||||
|
||||
private FileStorage fileStorage;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileStorage =
|
||||
new FileStorage(
|
||||
mock(FileOrUploadService.class),
|
||||
new LocalDiskFileStore(storageDir.toString()),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeInputStream / getFileSize / retrieveInputStream")
|
||||
class StreamAndSize {
|
||||
|
||||
@Test
|
||||
@DisplayName("storeInputStream returns id and exact byte size")
|
||||
void storeInputStreamReturnsSize() throws IOException {
|
||||
byte[] payload = "twelve bytes".getBytes(StandardCharsets.UTF_8);
|
||||
StoredFile stored =
|
||||
fileStorage.storeInputStream(new ByteArrayInputStream(payload), "in.bin");
|
||||
assertThat(stored.fileId()).isNotBlank();
|
||||
assertThat(stored.size()).isEqualTo(payload.length);
|
||||
assertThat(fileStorage.getFileSize(stored.fileId())).isEqualTo(payload.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("retrieveInputStream yields the stored content")
|
||||
void retrieveInputStreamContent() throws IOException {
|
||||
byte[] payload = "stream-me".getBytes(StandardCharsets.UTF_8);
|
||||
String id = fileStorage.storeBytes(payload, "s.bin");
|
||||
try (InputStream in = fileStorage.retrieveInputStream(id)) {
|
||||
assertThat(in.readAllBytes()).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeFile fast path")
|
||||
class FastPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("file-backed MultipartFile is stored via the Resource fast path")
|
||||
void fileBackedResourceStored(@TempDir Path src) throws IOException {
|
||||
byte[] payload = "file-backed-content".getBytes(StandardCharsets.UTF_8);
|
||||
Path onDisk = Files.write(src.resolve("upload.pdf"), payload);
|
||||
|
||||
// A MultipartFile whose getResource() reports isFile()=true exercises the
|
||||
// file-to-file copy branch in storeFile.
|
||||
MultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "upload.pdf", MediaType.APPLICATION_PDF_VALUE, payload) {
|
||||
@Override
|
||||
public org.springframework.core.io.Resource getResource() {
|
||||
return new FileSystemResource(onDisk);
|
||||
}
|
||||
};
|
||||
|
||||
String id = fileStorage.storeFile(multipart);
|
||||
assertThat(id).isNotBlank();
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("in-memory MultipartFile falls back to the stream copy path")
|
||||
void inMemoryFallback() throws IOException {
|
||||
byte[] payload = "memory-content".getBytes(StandardCharsets.UTF_8);
|
||||
MultipartFile multipart =
|
||||
new MockMultipartFile(
|
||||
"file", "m.pdf", MediaType.APPLICATION_PDF_VALUE, payload);
|
||||
String id = fileStorage.storeFile(multipart);
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("storeFromStreamingBody")
|
||||
class StreamingBody {
|
||||
|
||||
@Test
|
||||
@DisplayName("happy path streams body to storage")
|
||||
void happyPath() throws IOException {
|
||||
byte[] payload = "streamed-body-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
StreamingResponseBody body = out -> out.write(payload);
|
||||
String id = fileStorage.storeFromStreamingBody(body, "body.bin");
|
||||
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writer IOException propagates and leaves no lingering file")
|
||||
void writerErrorPropagatesAndCleansUp() throws IOException {
|
||||
long before = countFiles();
|
||||
StreamingResponseBody body =
|
||||
out -> {
|
||||
out.write("partial".getBytes(StandardCharsets.UTF_8));
|
||||
throw new IOException("boom mid-write");
|
||||
};
|
||||
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad.bin"))
|
||||
.isInstanceOf(IOException.class);
|
||||
assertThat(countFiles()).isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unchecked writer failure is wrapped as IOException")
|
||||
void uncheckedWriterErrorWrapped() {
|
||||
StreamingResponseBody body =
|
||||
out -> {
|
||||
throw new IllegalStateException("unchecked boom");
|
||||
};
|
||||
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad2.bin"))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
private long countFiles() throws IOException {
|
||||
try (var s = Files.list(storageDir)) {
|
||||
return s.count();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
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.springframework.http.ContentDisposition;
|
||||
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 org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
/** Additional coverage for JobExecutorService branches not exercised by JobExecutorServiceTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobExecutorServiceMoreTest {
|
||||
|
||||
private JobExecutorService service;
|
||||
|
||||
@Mock private TaskManager taskManager;
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
@Mock private JobQueue jobQueue;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// request is null on purpose to exercise the request==null guard.
|
||||
service =
|
||||
new JobExecutorService(
|
||||
taskManager, fileStorage, null, resourceMonitor, jobQueue, 30000L, "30m");
|
||||
}
|
||||
|
||||
/** Concrete validation exception so we can drive the BaseValidationException rethrow branch. */
|
||||
private static class TestValidationException extends ExceptionUtils.BaseValidationException {
|
||||
TestValidationException(String message) {
|
||||
super(message, "E999");
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete app exception so we can drive the BaseAppException rethrow branch. */
|
||||
private static class TestAppException extends ExceptionUtils.BaseAppException {
|
||||
TestAppException(String message) {
|
||||
super(message, null, "E998");
|
||||
}
|
||||
}
|
||||
|
||||
/** Bean exposing getFileId/getOriginalFilename/getContentType for the reflection branch. */
|
||||
public static class FileIdBean {
|
||||
private final String fileId;
|
||||
private final String originalFilename;
|
||||
private final String contentType;
|
||||
|
||||
FileIdBean(String fileId, String originalFilename, String contentType) {
|
||||
this.fileId = fileId;
|
||||
this.originalFilename = originalFilename;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getFileId() {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
public String getOriginalFilename() {
|
||||
return originalFilename;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FileIdBean{fileId=" + fileId + "}";
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronous error mapping")
|
||||
class SyncErrors {
|
||||
|
||||
@Test
|
||||
@DisplayName("IllegalArgumentException is rethrown, not wrapped in a 500 body")
|
||||
void illegalArgumentRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new IllegalArgumentException("bad input");
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("bad input");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cause of BaseValidationException is rethrown")
|
||||
void validationCauseRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException(new TestValidationException("invalid"));
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(ExceptionUtils.BaseValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cause of BaseAppException is rethrown")
|
||||
void appCauseRethrown() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException(new TestAppException("app error"));
|
||||
};
|
||||
assertThatThrownBy(() -> service.runJobGeneric(false, work))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasCauseInstanceOf(ExceptionUtils.BaseAppException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronous result handling")
|
||||
class SyncResults {
|
||||
|
||||
@Test
|
||||
@DisplayName("byte[] result becomes a PDF attachment response")
|
||||
void byteArrayBecomesAttachment() {
|
||||
byte[] payload = "pdf-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> payload);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo(payload);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("result.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile result is streamed back with its own content type")
|
||||
void multipartBecomesResponse() {
|
||||
MultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"f", "orig.txt", MediaType.TEXT_PLAIN_VALUE, "hi".getBytes());
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> file);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_PLAIN);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("orig.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ResponseEntity result is returned verbatim")
|
||||
void responseEntityReturnedVerbatim() {
|
||||
ResponseEntity<String> inner = ResponseEntity.status(HttpStatus.ACCEPTED).body("ok");
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> inner);
|
||||
assertThat(response).isSameAs(inner);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("asynchronous error handling")
|
||||
class AsyncErrors {
|
||||
|
||||
@Test
|
||||
@DisplayName("a thrown exception is recorded via TaskManager.setError")
|
||||
void asyncErrorRecorded() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
throw new RuntimeException("async boom");
|
||||
};
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, work);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
verify(taskManager, timeout(5000)).setError(anyString(), eq("async boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job that exceeds its timeout is recorded as timed out")
|
||||
void asyncTimeoutRecorded() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond the 1ms timeout
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
// 1ms custom timeout, async, non-queueable.
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, work, 1L, false, 10);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
verify(taskManager, timeout(5000)).setError(anyString(), eq("Job timed out"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processJobResult branches (via async execution)")
|
||||
class ProcessJobResult {
|
||||
|
||||
@Test
|
||||
@DisplayName("raw byte[] result is stored and recorded as a file")
|
||||
void rawBytesStored() throws Exception {
|
||||
byte[] payload = "raw".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileStorage.storeBytes(any(byte[].class), eq("result.pdf")))
|
||||
.thenReturn("bytes-id");
|
||||
|
||||
service.runJobGeneric(true, () -> payload);
|
||||
|
||||
verify(fileStorage, timeout(5000)).storeBytes(any(byte[].class), eq("result.pdf"));
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("bytes-id"),
|
||||
eq("result.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity<byte[]> is stored with the filename from headers")
|
||||
void responseEntityBytesStored() throws Exception {
|
||||
byte[] payload = "rebytes".getBytes(StandardCharsets.UTF_8);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDisposition(
|
||||
ContentDisposition.formData().name("a").filename("out.pdf").build());
|
||||
Supplier<Object> work = () -> new ResponseEntity<>(payload, headers, HttpStatus.OK);
|
||||
when(fileStorage.storeBytes(any(byte[].class), eq("out.pdf"))).thenReturn("re-id");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("re-id"),
|
||||
eq("out.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity<StreamingResponseBody> is stored via storeFromStreamingBody")
|
||||
void responseEntityStreamingStored() throws Exception {
|
||||
StreamingResponseBody body = out -> out.write("stream".getBytes());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
Supplier<Object> work = () -> new ResponseEntity<>(body, headers, HttpStatus.OK);
|
||||
when(fileStorage.storeFromStreamingBody(any(StreamingResponseBody.class), anyString()))
|
||||
.thenReturn("stream-id");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(fileStorage, timeout(5000))
|
||||
.storeFromStreamingBody(any(StreamingResponseBody.class), eq("result.pdf"));
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("stream-id"), eq("result.pdf"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResponseEntity body exposing getFileId is recorded via reflection")
|
||||
void responseEntityFileIdBean() {
|
||||
FileIdBean bean = new FileIdBean("bean-file", "bean.pdf", "text/custom");
|
||||
Supplier<Object> work = () -> ResponseEntity.ok(bean);
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("bean-file"), eq("bean.pdf"), eq("text/custom"));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain ResponseEntity body without fileId is stored as a generic result")
|
||||
void responseEntityPlainBody() {
|
||||
Supplier<Object> work = () -> ResponseEntity.ok("plain-string");
|
||||
|
||||
service.runJobGeneric(true, work);
|
||||
|
||||
verify(taskManager, timeout(5000)).setResult(anyString(), eq("plain-string"));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MultipartFile result is stored via storeFile")
|
||||
void multipartStored() throws Exception {
|
||||
MultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"f", "m.pdf", MediaType.APPLICATION_PDF_VALUE, "m".getBytes());
|
||||
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("mp-id");
|
||||
|
||||
service.runJobGeneric(true, () -> file);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("mp-id"),
|
||||
eq("m.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain object result exposing getFileId is recorded via reflection")
|
||||
void plainObjectFileIdBean() {
|
||||
FileIdBean bean = new FileIdBean("plain-bean", "p.pdf", "app/p");
|
||||
|
||||
service.runJobGeneric(true, () -> bean);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(anyString(), eq("plain-bean"), eq("p.pdf"), eq("app/p"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a generic non-file object is stored via setResult")
|
||||
void genericObjectStored() {
|
||||
service.runJobGeneric(true, () -> "just-text");
|
||||
verify(taskManager, timeout(5000)).setResult(anyString(), eq("just-text"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a storage failure is recorded as an error on the task")
|
||||
void storageFailureRecordsError() throws Exception {
|
||||
byte[] payload = "x".getBytes(StandardCharsets.UTF_8);
|
||||
when(fileStorage.storeBytes(any(byte[].class), anyString()))
|
||||
.thenThrow(new java.io.IOException("disk full"));
|
||||
|
||||
service.runJobGeneric(true, () -> payload);
|
||||
|
||||
verify(taskManager, timeout(5000))
|
||||
.setError(anyString(), org.mockito.ArgumentMatchers.contains("disk full"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("queued execution")
|
||||
class QueuedExecution {
|
||||
|
||||
@Test
|
||||
@DisplayName("queued wrapped work stores its result through processJobResult on success")
|
||||
void queuedWorkSuccess() {
|
||||
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
|
||||
// Capture the wrapped supplier so we can run it as the queue would.
|
||||
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
|
||||
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
|
||||
.thenReturn(new CompletableFuture<>());
|
||||
|
||||
ResponseEntity<?> response =
|
||||
service.runJobGeneric(true, () -> "queued-ok", 5000, true, 80);
|
||||
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
|
||||
|
||||
// Execute the wrapped work and assert it routed the result to TaskManager.
|
||||
Object result = workCaptor.getValue().get();
|
||||
assertThat(result).isEqualTo("queued-ok");
|
||||
verify(taskManager).setResult(anyString(), eq("queued-ok"));
|
||||
verify(taskManager).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("queued wrapped work records and rethrows on failure")
|
||||
void queuedWorkFailure() {
|
||||
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
|
||||
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
|
||||
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
|
||||
.thenReturn(new CompletableFuture<>());
|
||||
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new RuntimeException("queued-boom");
|
||||
};
|
||||
service.runJobGeneric(true, failing, 5000, true, 80);
|
||||
|
||||
assertThatThrownBy(() -> workCaptor.getValue().get())
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("queued-boom");
|
||||
verify(taskManager).setError(anyString(), eq("queued-boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job is not queued when it is synchronous even if queueable")
|
||||
void syncJobNeverQueued() {
|
||||
// queueable=true but async=false -> shouldQueue is false, runs inline.
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, () -> "inline", 0, true, 90);
|
||||
assertThat(response.getBody()).isEqualTo("inline");
|
||||
verify(jobQueue, org.mockito.Mockito.never())
|
||||
.queueJob(anyString(), anyInt(), any(), anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("job ownership scoping")
|
||||
class JobOwnership {
|
||||
|
||||
@Test
|
||||
@DisplayName("scoped job key and owner come from JobOwnershipService when present")
|
||||
void scopedKeyUsed() {
|
||||
JobOwnershipService ownership = org.mockito.Mockito.mock(JobOwnershipService.class);
|
||||
when(ownership.createScopedJobKey(anyString())).thenReturn("user1:scoped");
|
||||
lenient().when(ownership.getCurrentUserId()).thenReturn(Optional.of("user1"));
|
||||
ReflectionTestUtils.setField(service, "jobOwnershipService", ownership);
|
||||
|
||||
ResponseEntity<?> response = service.runJobGeneric(true, () -> "owned");
|
||||
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
|
||||
assertThat(jobResponse.getJobId()).isEqualTo("user1:scoped");
|
||||
verify(taskManager).createTask("user1:scoped");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("session timeout parsing")
|
||||
class SessionTimeoutParsing {
|
||||
|
||||
private long parse(String value) {
|
||||
JobExecutorService s =
|
||||
new JobExecutorService(
|
||||
taskManager,
|
||||
fileStorage,
|
||||
null,
|
||||
resourceMonitor,
|
||||
jobQueue,
|
||||
999_999_999L,
|
||||
value);
|
||||
return (long) ReflectionTestUtils.getField(s, "effectiveTimeoutMs");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("seconds, hours and days units are parsed")
|
||||
void parsesUnits() {
|
||||
assertThat(parse("45s")).isEqualTo(45_000L);
|
||||
assertThat(parse("2h")).isEqualTo(2L * 60 * 60 * 1000);
|
||||
assertThat(parse("1d")).isEqualTo(24L * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unrecognised unit defaults to minutes")
|
||||
void unknownUnitDefaultsToMinutes() {
|
||||
assertThat(parse("5x")).isEqualTo(5L * 60 * 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/empty and unparseable values fall back to 30 minutes")
|
||||
void fallbackToThirtyMinutes() {
|
||||
long thirtyMin = 30L * 60 * 1000;
|
||||
assertThat(parse("")).isEqualTo(thirtyMin);
|
||||
assertThat(parse("garbage")).isEqualTo(thirtyMin);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("sync timeout")
|
||||
class SyncTimeout {
|
||||
|
||||
@Test
|
||||
@DisplayName("a synchronous job that exceeds its timeout returns a 500 with a timeout body")
|
||||
void syncTimeoutReturns500() {
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond 1ms timeout
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
ResponseEntity<?> response = service.runJobGeneric(false, work, 1L);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> body = (Map<String, String>) response.getBody();
|
||||
assertThat(body.get("error")).contains("timed out");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
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.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
/** Additional coverage for JobQueue branches not exercised by JobQueueTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobQueueMoreTest {
|
||||
|
||||
private JobQueue jobQueue;
|
||||
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
|
||||
private final AtomicReference<ResourceStatus> statusRef =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient()
|
||||
.when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt()))
|
||||
.thenReturn(10);
|
||||
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
|
||||
jobQueue = new JobQueue(resourceMonitor);
|
||||
}
|
||||
|
||||
private void invokeProcessQueue() {
|
||||
ReflectionTestUtils.invokeMethod(jobQueue, "processQueue");
|
||||
}
|
||||
|
||||
// Bounded wait: block up to 5s for the queued job's future to settle on the executor.
|
||||
private static void awaitDone(CompletableFuture<?> future) {
|
||||
try {
|
||||
future.handle((r, e) -> null).get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new AssertionError("future did not complete within 5s", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("SmartLifecycle")
|
||||
class Lifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("start/stop toggles running and start is idempotent")
|
||||
void startStopToggle() {
|
||||
assertThat(jobQueue.isRunning()).isFalse();
|
||||
|
||||
jobQueue.start();
|
||||
assertThat(jobQueue.isRunning()).isTrue();
|
||||
|
||||
// Second start is a no-op (already running).
|
||||
jobQueue.start();
|
||||
assertThat(jobQueue.isRunning()).isTrue();
|
||||
|
||||
jobQueue.stop();
|
||||
assertThat(jobQueue.isRunning()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("phase and auto-startup expose lifecycle ordering")
|
||||
void phaseAndAutoStartup() {
|
||||
assertThat(jobQueue.getPhase()).isEqualTo(10);
|
||||
assertThat(jobQueue.isAutoStartup()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stop completes any still-pending futures exceptionally")
|
||||
void stopCompletesPendingFutures() {
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("pending", 50, () -> "x", 1000);
|
||||
assertThat(future.isDone()).isFalse();
|
||||
|
||||
// Drive shutdown without starting the scheduler so no processor races us to the job.
|
||||
jobQueue.stop();
|
||||
|
||||
assertThat(future).isCompletedExceptionally();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("queueJob capacity")
|
||||
class QueueCapacity {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a job when the queue is full")
|
||||
void rejectsWhenFull() {
|
||||
// Capacity-1 queue whose timed offer rejects instantly (no 5s block) when full.
|
||||
BlockingQueue<Object> instaReject =
|
||||
new LinkedBlockingQueue<>(1) {
|
||||
@Override
|
||||
public boolean offer(Object e, long timeout, TimeUnit unit) {
|
||||
return super.offer(e);
|
||||
}
|
||||
};
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", instaReject);
|
||||
jobQueue.queueJob("first", 50, () -> "a", 1000);
|
||||
|
||||
CompletableFuture<ResponseEntity<?>> rejected =
|
||||
jobQueue.queueJob("second", 50, () -> "b", 1000);
|
||||
|
||||
assertThat(rejected).isCompletedExceptionally();
|
||||
assertThat(jobQueue.getRejectedJobs()).isEqualTo(1);
|
||||
assertThat(jobQueue.isJobQueued("second")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getQueueCapacity reflects remaining capacity plus current size")
|
||||
void getQueueCapacityReports() {
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(5));
|
||||
jobQueue.queueJob("c1", 50, () -> "a", 1000);
|
||||
assertThat(jobQueue.getQueueCapacity()).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("job position")
|
||||
class JobPosition {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 0 for the first queued job and -1 for an unknown job")
|
||||
void positionAndUnknown() {
|
||||
jobQueue.queueJob("p1", 50, () -> "a", 1000);
|
||||
jobQueue.queueJob("p2", 50, () -> "b", 1000);
|
||||
|
||||
assertThat(jobQueue.getJobPosition("p1")).isEqualTo(0);
|
||||
assertThat(jobQueue.getJobPosition("p2")).isEqualTo(1);
|
||||
assertThat(jobQueue.getJobPosition("missing")).isEqualTo(-1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cancelJob")
|
||||
class CancelJob {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when the job id is unknown")
|
||||
void cancelUnknownReturnsFalse() {
|
||||
assertThat(jobQueue.cancelJob("nope")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processQueue")
|
||||
class ProcessQueue {
|
||||
|
||||
@Test
|
||||
@DisplayName("does nothing when shutting down")
|
||||
void noopWhenShuttingDown() {
|
||||
jobQueue.queueJob("s1", 50, () -> "a", 1000);
|
||||
ReflectionTestUtils.setField(jobQueue, "shuttingDown", true);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
// Still queued: the shutdown guard returned before polling.
|
||||
assertThat(jobQueue.isJobQueued("s1")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("delays execution while the system is under critical load")
|
||||
void delaysUnderCriticalLoad() {
|
||||
statusRef.set(ResourceStatus.CRITICAL);
|
||||
jobQueue.queueJob("crit", 50, () -> "a", 1000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
// Critical load: job remains queued, nothing executed.
|
||||
assertThat(jobQueue.isJobQueued("crit")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("executes a queued job and completes its future when resources are OK")
|
||||
void executesWhenOk() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("ok", 50, () -> "done", 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(jobQueue.isJobQueued("ok")).isFalse();
|
||||
assertThat(future).isCompleted();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a job past the max wait time still executes and adds a timeout note")
|
||||
void overdueJobExecutesAndNotes() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
ReflectionTestUtils.setField(jobQueue, "maxWaitTimeMs", 1L);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("overdue", 50, () -> "late-done", 5000);
|
||||
|
||||
// Backdate the queuedAt so wait-time exceeds maxWaitTimeMs.
|
||||
backdateQueuedAt("overdue");
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future).isCompleted();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void backdateQueuedAt(String jobId) {
|
||||
var jobMap =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(jobQueue, "jobMap");
|
||||
Object job = jobMap.get(jobId);
|
||||
ReflectionTestUtils.setField(job, "queuedAt", Instant.now().minusSeconds(60));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("executeJob")
|
||||
class ExecuteJob {
|
||||
|
||||
@Test
|
||||
@DisplayName("a cancelled job is skipped by executeJob without running its work")
|
||||
@SuppressWarnings("unchecked")
|
||||
void cancelledJobSkipped() throws Exception {
|
||||
java.util.concurrent.atomic.AtomicBoolean ran =
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob(
|
||||
"cancelled",
|
||||
50,
|
||||
() -> {
|
||||
ran.set(true);
|
||||
return "should-not-run";
|
||||
},
|
||||
1000);
|
||||
|
||||
// Grab the real QueuedJob instance, mark it cancelled, then drive executeJob directly.
|
||||
var jobMap =
|
||||
(java.util.Map<String, Object>)
|
||||
ReflectionTestUtils.getField(jobQueue, "jobMap");
|
||||
Object job = jobMap.get("cancelled");
|
||||
ReflectionTestUtils.setField(job, "cancelled", true);
|
||||
|
||||
var executeJob = JobQueue.class.getDeclaredMethod("executeJob", job.getClass());
|
||||
executeJob.setAccessible(true);
|
||||
executeJob.invoke(jobQueue, job);
|
||||
|
||||
// The early return means the work supplier never ran.
|
||||
assertThat(ran.get()).isFalse();
|
||||
assertThat(future.isDone()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-ResponseEntity result is wrapped in ResponseEntity.ok")
|
||||
void nonResponseEntityWrapped() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("wrap", 50, () -> "plain", 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
ResponseEntity<?> response = future.join();
|
||||
assertThat(response.getBody()).isEqualTo("plain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ResponseEntity result is forwarded as-is")
|
||||
void responseEntityForwarded() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
ResponseEntity<String> inner = ResponseEntity.ok("inner");
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("forward", 50, () -> inner, 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future.join()).isSameAs(inner);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a failing job completes its future exceptionally")
|
||||
void failingJobCompletesExceptionally() {
|
||||
statusRef.set(ResourceStatus.OK);
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new RuntimeException("exec-boom");
|
||||
};
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob("fail", 50, failing, 5000);
|
||||
|
||||
invokeProcessQueue();
|
||||
|
||||
awaitDone(future);
|
||||
assertThat(future).isCompletedExceptionally();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("executeWithTimeout")
|
||||
class ExecuteWithTimeout {
|
||||
|
||||
@Test
|
||||
@DisplayName("with no timeout it joins and returns the value")
|
||||
void noTimeoutJoins() {
|
||||
Object result =
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", (Supplier<Object>) () -> "joined", 0L);
|
||||
assertThat(result).isEqualTo("joined");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an execution failure is unwrapped to its cause")
|
||||
void executionFailureUnwrapped() {
|
||||
Supplier<Object> failing =
|
||||
() -> {
|
||||
throw new IllegalStateException("inner-cause");
|
||||
};
|
||||
Throwable thrown =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Throwable.class,
|
||||
() ->
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", failing, 1000L));
|
||||
assertThat(messageChain(thrown)).contains("inner-cause");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a slow job exceeds the timeout and throws TimeoutException")
|
||||
void slowJobTimesOut() {
|
||||
Supplier<Object> slow =
|
||||
() -> {
|
||||
long start = System.nanoTime();
|
||||
while (System.nanoTime() - start < 200_000_000L) {
|
||||
// busy wait beyond 1ms
|
||||
}
|
||||
return "late";
|
||||
};
|
||||
Throwable thrown =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Throwable.class,
|
||||
() ->
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
jobQueue, "executeWithTimeout", slow, 1L));
|
||||
assertThat(messageChain(thrown)).contains("timed out");
|
||||
}
|
||||
|
||||
// Spring's ReflectionTestUtils wraps checked exceptions, so inspect the whole cause chain.
|
||||
private String messageChain(Throwable t) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Throwable c = t; c != null; c = c.getCause()) {
|
||||
if (c.getMessage() != null) {
|
||||
sb.append(c.getMessage()).append('|');
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateQueueCapacity")
|
||||
class UpdateQueueCapacity {
|
||||
|
||||
@Test
|
||||
@DisplayName("resizes the queue and preserves queued jobs when capacity changes")
|
||||
void resizesQueue() {
|
||||
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(10));
|
||||
jobQueue.queueJob("keep", 50, () -> "a", 1000);
|
||||
|
||||
// Force a new, smaller capacity on the next recalculation.
|
||||
when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(4);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(jobQueue, "updateQueueCapacity");
|
||||
|
||||
assertThat(jobQueue.getQueueCapacity()).isEqualTo(4);
|
||||
// The previously queued job survived the drain into the new queue.
|
||||
assertThat(jobQueue.getCurrentQueueSize()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getQueueStats")
|
||||
class QueueStats {
|
||||
|
||||
@Test
|
||||
@DisplayName("includes the current resource status name")
|
||||
void includesResourceStatus() {
|
||||
statusRef.set(ResourceStatus.WARNING);
|
||||
var stats = jobQueue.getQueueStats();
|
||||
assertThat(stats.get("resourceStatus")).isEqualTo("WARNING");
|
||||
assertThat(stats).containsKeys("queuedJobs", "queueCapacity", "rejectedJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.MemoryUsage;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
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.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceMetrics;
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
/** Additional coverage for ResourceMonitor branches not exercised by ResourceMonitorTest. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ResourceMonitorMoreTest {
|
||||
|
||||
private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock private OperatingSystemMXBean osMXBean;
|
||||
@Mock private MemoryMXBean memoryMXBean;
|
||||
@Mock private MemoryUsage heapUsage;
|
||||
@Mock private MemoryUsage nonHeapUsage;
|
||||
|
||||
private final AtomicReference<ResourceStatus> currentStatus =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
private final AtomicReference<ResourceMetrics> latestMetrics =
|
||||
new AtomicReference<>(new ResourceMetrics());
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
resourceMonitor = new ResourceMonitor();
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics);
|
||||
}
|
||||
|
||||
private void stubMemory(long heapUsed, long nonHeapUsed) {
|
||||
lenient().when(heapUsage.getUsed()).thenReturn(heapUsed);
|
||||
lenient().when(nonHeapUsage.getUsed()).thenReturn(nonHeapUsed);
|
||||
lenient().when(memoryMXBean.getHeapMemoryUsage()).thenReturn(heapUsage);
|
||||
lenient().when(memoryMXBean.getNonHeapMemoryUsage()).thenReturn(nonHeapUsage);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateResourceMetrics status transitions")
|
||||
class UpdateMetrics {
|
||||
|
||||
@Test
|
||||
@DisplayName("high CPU load drives the status to CRITICAL")
|
||||
void criticalOnHighCpu() {
|
||||
// load average / processors = 4 / 2 = 2.0 -> well over critical threshold.
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(4.0);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.CRITICAL);
|
||||
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(2.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moderately high CPU load drives the status to WARNING")
|
||||
void warningOnModerateCpu() {
|
||||
// 1.6 / 2 = 0.8 -> above high (0.75) but below critical (0.9).
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(1.6);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.WARNING);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("low load keeps the status at OK")
|
||||
void okOnLowLoad() {
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(0.2);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(4);
|
||||
stubMemory(1L, 1L);
|
||||
currentStatus.set(ResourceStatus.WARNING); // ensure a transition log path is hit
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a negative load average triggers the alternative CPU fallback")
|
||||
void negativeLoadUsesFallback() {
|
||||
// getSystemLoadAverage returns -1 on platforms (e.g. Windows) where it is unsupported.
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(-1.0);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(4);
|
||||
stubMemory(1L, 1L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
// The mock OS bean has no getProcessCpuLoad/getSystemCpuLoad, so fallback yields 0.5.
|
||||
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(0.5);
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an exception while sampling is swallowed and status is unchanged")
|
||||
void samplingExceptionSwallowed() {
|
||||
when(osMXBean.getSystemLoadAverage()).thenReturn(0.1);
|
||||
when(osMXBean.getAvailableProcessors()).thenReturn(2);
|
||||
when(memoryMXBean.getHeapMemoryUsage())
|
||||
.thenThrow(new RuntimeException("jmx unavailable"));
|
||||
currentStatus.set(ResourceStatus.OK);
|
||||
|
||||
// Must not propagate; the catch in updateResourceMetrics handles it.
|
||||
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
|
||||
|
||||
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAlternativeCpuLoad")
|
||||
class AlternativeCpuLoad {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses getProcessCpuLoad via reflection when present")
|
||||
void usesProcessCpuLoad() {
|
||||
// A bean exposing getProcessCpuLoad lets the reflective fallback return its value.
|
||||
OperatingSystemMXBean withCpuLoad = new OsBeanWithProcessCpuLoad(0.42);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", withCpuLoad);
|
||||
|
||||
double load =
|
||||
(double)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
resourceMonitor, "getAlternativeCpuLoad");
|
||||
assertThat(load).isEqualTo(0.42);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("defaults to 0.5 when no CPU-load method is available")
|
||||
void defaultsWhenUnavailable() {
|
||||
double load =
|
||||
(double)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
resourceMonitor, "getAlternativeCpuLoad");
|
||||
assertThat(load).isEqualTo(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("calculateDynamicQueueCapacity memory pressure")
|
||||
class MemoryPressure {
|
||||
|
||||
@Test
|
||||
@DisplayName("high memory usage halves the computed capacity")
|
||||
void highMemoryHalvesCapacity() {
|
||||
currentStatus.set(ResourceStatus.OK);
|
||||
// memoryUsage > 0.8 triggers the additional 0.5 multiplier.
|
||||
latestMetrics.set(new ResourceMetrics(0.1, 0.85, 1, 1, 1, Instant.now()));
|
||||
|
||||
int capacity = resourceMonitor.calculateDynamicQueueCapacity(10, 2);
|
||||
// OK factor 1.0 * 0.5 = 0.5; ceil(10 * 0.5) = 5.
|
||||
assertThat(capacity).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ResourceMetrics")
|
||||
class Metrics {
|
||||
|
||||
@Test
|
||||
@DisplayName("getAge returns a non-negative duration")
|
||||
void getAgeNonNegative() {
|
||||
ResourceMetrics m = new ResourceMetrics(0, 0, 0, 0, 0, Instant.now().minusSeconds(1));
|
||||
assertThat(m.getAge().toMillis()).isGreaterThanOrEqualTo(1000L);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("lifecycle")
|
||||
class Lifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize schedules sampling and shutdown stops the scheduler")
|
||||
void initializeAndShutdown() {
|
||||
// Real bean so initialize() schedules against a live virtual-thread scheduler.
|
||||
ResourceMonitor live = new ResourceMonitor();
|
||||
ReflectionTestUtils.setField(live, "monitorIntervalMs", 60000L);
|
||||
live.initialize();
|
||||
|
||||
ScheduledExecutorService scheduler =
|
||||
(ScheduledExecutorService) ReflectionTestUtils.getField(live, "scheduler");
|
||||
assertThat(scheduler.isShutdown()).isFalse();
|
||||
|
||||
live.shutdown();
|
||||
assertThat(scheduler.isShutdown()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal OS bean stub exposing getProcessCpuLoad so the reflective fallback can find it. */
|
||||
private static class OsBeanWithProcessCpuLoad implements OperatingSystemMXBean {
|
||||
private final double cpuLoad;
|
||||
|
||||
OsBeanWithProcessCpuLoad(double cpuLoad) {
|
||||
this.cpuLoad = cpuLoad;
|
||||
}
|
||||
|
||||
// Reflectively located by getAlternativeCpuLoad.
|
||||
public double getProcessCpuLoad() {
|
||||
return cpuLoad;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getArch() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVersion() {
|
||||
return "stub";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAvailableProcessors() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getSystemLoadAverage() {
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public javax.management.ObjectName getObjectName() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Html.UrlSecurity;
|
||||
import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel;
|
||||
|
||||
class SsrfProtectionServiceTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private UrlSecurity config;
|
||||
private SsrfProtectionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
// Real config object: drill down to the live UrlSecurity instance and mutate it.
|
||||
config = applicationProperties.getSystem().getHtml().getUrlSecurity();
|
||||
service = new SsrfProtectionService(applicationProperties);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Protection disabled / always-allowed inputs")
|
||||
class AlwaysAllowed {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true for any URL when protection disabled")
|
||||
void disabledAllowsEverything() {
|
||||
config.setEnabled(false);
|
||||
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("not a url")).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\t"})
|
||||
@DisplayName("returns false for null/blank when enabled")
|
||||
void blankRejected(String url) {
|
||||
config.setEnabled(true);
|
||||
assertThat(service.isUrlAllowed(url)).isFalse();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"data:text/plain;base64,SGVsbG8=",
|
||||
"DATA:image/png;base64,iVBOR",
|
||||
"#section",
|
||||
"#"
|
||||
})
|
||||
@DisplayName("data: URLs and fragments are always allowed")
|
||||
void dataAndFragmentAllowed(String url) {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MAX);
|
||||
assertThat(service.isUrlAllowed(url)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("OFF level")
|
||||
class OffLevel {
|
||||
|
||||
@Test
|
||||
@DisplayName("allows external and internal hosts alike")
|
||||
void offAllowsAll() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.OFF);
|
||||
assertThat(service.isUrlAllowed("http://10.0.0.1/secret")).isTrue();
|
||||
assertThat(service.isUrlAllowed("https://example.com")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MAX level - allowlist only")
|
||||
class MaxLevel {
|
||||
|
||||
@BeforeEach
|
||||
void max() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MAX);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows only whitelisted hosts (case-insensitive)")
|
||||
void allowsWhitelistedHost() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("https://EXAMPLE.com/path")).isTrue();
|
||||
assertThat(service.isUrlAllowed("https://other.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks when allowlist is empty")
|
||||
void emptyAllowlistBlocks() {
|
||||
assertThat(service.isUrlAllowed("https://example.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks URL with no host")
|
||||
void noHostBlocked() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("file:///etc/passwd")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks malformed URL (parse exception path)")
|
||||
void malformedBlocked() {
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("http://exa mple.com")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - host parsing and lists")
|
||||
class MediumHostAndLists {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows a normal public literal IP")
|
||||
void allowsPublicIp() {
|
||||
assertThat(service.isUrlAllowed("http://93.184.216.34/page")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks URL with no host")
|
||||
void noHostBlocked() {
|
||||
assertThat(service.isUrlAllowed("mailto:test@example.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks malformed URL (parse exception path)")
|
||||
void malformedBlocked() {
|
||||
assertThat(service.isUrlAllowed("ht!tp://%%%")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks explicitly blocked domain (case-insensitive)")
|
||||
void blockedDomain() {
|
||||
config.setBlockedDomains(List.of("evil.com"));
|
||||
assertThat(service.isUrlAllowed("http://EVIL.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks internal TLD suffixes")
|
||||
void internalTld() {
|
||||
// default internalTlds include .local, .internal, .corp, .home
|
||||
assertThat(service.isUrlAllowed("http://server.local")).isFalse();
|
||||
assertThat(service.isUrlAllowed("http://host.internal")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allowlist present: host not in list is blocked before any DNS lookup")
|
||||
void allowlistRejectsUnlisted() {
|
||||
// notexample.com is rejected by the allowlist check, which runs before DNS resolution,
|
||||
// so this stays deterministic offline.
|
||||
config.setAllowedDomains(List.of("example.com"));
|
||||
assertThat(service.isUrlAllowed("http://notexample.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allowlist present: exact host and subdomain pass the allowlist gate")
|
||||
void allowlistAcceptsExactAndSubdomain() {
|
||||
// Allow a literal IP so the subsequent DNS resolution is the identity and network
|
||||
// checks are disabled, keeping the allow path deterministic without external DNS.
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLocalhost(false);
|
||||
config.setBlockLinkLocal(false);
|
||||
config.setBlockCloudMetadata(false);
|
||||
config.setAllowedDomains(List.of("93.184.216.34"));
|
||||
assertThat(service.isUrlAllowed("http://93.184.216.34")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - network based blocking via literal IPs")
|
||||
class MediumNetworkBlocking {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks loopback when blockLocalhost enabled")
|
||||
void blocksLoopback() {
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1/admin")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows loopback when blockLocalhost disabled and private/link checks off")
|
||||
void allowsLoopbackWhenAllChecksOff() {
|
||||
config.setBlockLocalhost(false);
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLinkLocal(false);
|
||||
config.setBlockCloudMetadata(false);
|
||||
assertThat(service.isUrlAllowed("http://127.0.0.1/ok")).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"http://10.1.2.3",
|
||||
"http://192.168.0.5",
|
||||
"http://172.16.0.9",
|
||||
"http://172.31.255.1",
|
||||
"http://100.64.0.1"
|
||||
})
|
||||
@DisplayName("blocks RFC1918 / CGNAT private ranges")
|
||||
void blocksPrivateRanges(String url) {
|
||||
assertThat(service.isUrlAllowed(url)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("172.x and 100.x outside private sub-range are not private")
|
||||
void boundaryRangesNotPrivate() {
|
||||
// 172.15/172.32 outside 16-31; 100.63/100.128 outside 64-127.
|
||||
assertThat(service.isUrlAllowed("http://172.15.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://172.32.0.1")).isTrue();
|
||||
assertThat(service.isUrlAllowed("http://100.63.0.1")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allows private range when blockPrivateNetworks disabled")
|
||||
void allowsPrivateWhenDisabled() {
|
||||
config.setBlockPrivateNetworks(false);
|
||||
config.setBlockLocalhost(false);
|
||||
assertThat(service.isUrlAllowed("http://10.1.2.3")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks link-local 169.254.x via private-network check")
|
||||
void blocksLinkLocal() {
|
||||
assertThat(service.isUrlAllowed("http://169.254.1.1")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks AWS cloud-metadata IP 169.254.169.254")
|
||||
void blocksCloudMetadata() {
|
||||
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data/")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks unspecified address 0.0.0.0")
|
||||
void blocksUnspecified() {
|
||||
assertThat(service.isUrlAllowed("http://0.0.0.0")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks unresolvable host (UnknownHostException path)")
|
||||
void blocksUnresolvableHost() {
|
||||
assertThat(service.isUrlAllowed("http://nonexistent-host-stirling-test.invalid/page"))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("MEDIUM level - IPv6 literal handling")
|
||||
class MediumIpv6 {
|
||||
|
||||
@BeforeEach
|
||||
void medium() {
|
||||
config.setEnabled(true);
|
||||
config.setLevel(SsrfProtectionLevel.MEDIUM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 loopback ::1")
|
||||
void blocksIpv6Loopback() {
|
||||
assertThat(service.isUrlAllowed("http://[::1]/path")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 unique-local fc00::/7")
|
||||
void blocksIpv6UniqueLocal() {
|
||||
assertThat(service.isUrlAllowed("http://[fc00::1]")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv6 link-local fe80::/10")
|
||||
void blocksIpv6LinkLocal() {
|
||||
assertThat(service.isUrlAllowed("http://[fe80::1]")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blocks IPv4-mapped IPv6 of a private address")
|
||||
void blocksIpv4MappedPrivate() {
|
||||
assertThat(service.isUrlAllowed("http://[::ffff:10.0.0.1]")).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
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.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
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.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/** Additional coverage for TaskManager branches not exercised by TaskManagerTest. */
|
||||
class TaskManagerMoreTest {
|
||||
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private JobStore jobStore;
|
||||
@Mock private ClusterBackplane clusterBackplane;
|
||||
|
||||
@InjectMocks private TaskManager taskManager;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
|
||||
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
|
||||
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
private static byte[] buildZip(String... entryNames) throws Exception {
|
||||
var baos = new java.io.ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (String name : entryNames) {
|
||||
zos.putNextEntry(new ZipEntry(name));
|
||||
zos.write(("content-of-" + name).getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setFileResult ZIP handling")
|
||||
class ZipHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("extracts a ZIP into individual file results and deletes the original")
|
||||
void extractsZipIntoIndividualFiles() throws Exception {
|
||||
String jobId = "zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
byte[] zipBytes = buildZip("a.pdf", "b.txt");
|
||||
when(fileStorage.retrieveInputStream("zip-file-id"))
|
||||
.thenReturn(new ByteArrayInputStream(zipBytes));
|
||||
// Each extracted entry is stored, returning a distinct StoredFile.
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenReturn(new FileStorage.StoredFile("extracted-a", 11L))
|
||||
.thenReturn(new FileStorage.StoredFile("extracted-b", 22L));
|
||||
when(fileStorage.deleteFile("zip-file-id")).thenReturn(true);
|
||||
|
||||
taskManager.setFileResult(jobId, "zip-file-id", "bundle.zip", "application/zip");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.isComplete()).isTrue();
|
||||
assertThat(result.hasMultipleFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles()).hasSize(2);
|
||||
// Content type is derived from the entry extension, not the ZIP content type.
|
||||
assertThat(result.getAllResultFiles().get(0).getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_PDF_VALUE);
|
||||
assertThat(result.getAllResultFiles().get(1).getContentType())
|
||||
.isEqualTo(MediaType.TEXT_PLAIN_VALUE);
|
||||
verify(fileStorage).deleteFile("zip-file-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty ZIP falls back to a single-file result")
|
||||
void emptyZipFallsBackToSingleFile() throws Exception {
|
||||
String jobId = "empty-zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
byte[] emptyZip = buildZip();
|
||||
when(fileStorage.retrieveInputStream("empty-zip-id"))
|
||||
.thenReturn(new ByteArrayInputStream(emptyZip));
|
||||
when(fileStorage.getFileSize("empty-zip-id")).thenReturn(7L);
|
||||
|
||||
taskManager.setFileResult(jobId, "empty-zip-id", "empty.zip", "application/zip");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasMultipleFiles()).isFalse();
|
||||
assertThat(result.getAllResultFiles()).hasSize(1);
|
||||
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("empty-zip-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ZIP extraction failure falls back to a single-file result")
|
||||
void zipExtractionFailureFallsBackToSingleFile() throws Exception {
|
||||
String jobId = "bad-zip-job";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// retrieveInputStream throws so extractZipToIndividualFiles fails and we fall back.
|
||||
when(fileStorage.retrieveInputStream("bad-zip-id"))
|
||||
.thenThrow(new java.io.IOException("boom"));
|
||||
when(fileStorage.getFileSize("bad-zip-id")).thenReturn(99L);
|
||||
|
||||
taskManager.setFileResult(
|
||||
jobId, "bad-zip-id", "broken.zip", "application/x-zip-compressed");
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("bad-zip-id");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setFileResult size fallback")
|
||||
class SizeFallback {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses size 0 when getFileSize throws for a non-zip file")
|
||||
void usesZeroSizeWhenGetFileSizeThrows() throws Exception {
|
||||
String jobId = "size-fail-job";
|
||||
taskManager.createTask(jobId);
|
||||
when(fileStorage.getFileSize("file-x")).thenThrow(new java.io.IOException("no stat"));
|
||||
|
||||
taskManager.setFileResult(jobId, "file-x", "doc.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.getAllResultFiles().get(0).getFileSize()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setMultipleFileResults")
|
||||
class MultipleFileResults {
|
||||
|
||||
@Test
|
||||
@DisplayName("stores the provided list directly")
|
||||
void storesProvidedList() {
|
||||
String jobId = "multi-job";
|
||||
taskManager.createTask(jobId);
|
||||
List<ResultFile> files =
|
||||
List.of(
|
||||
ResultFile.builder().fileId("f1").fileName("1.pdf").build(),
|
||||
ResultFile.builder().fileId("f2").fileName("2.pdf").build());
|
||||
|
||||
taskManager.setMultipleFileResults(jobId, files);
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertThat(result.hasMultipleFiles()).isTrue();
|
||||
assertThat(result.getAllResultFiles()).hasSize(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getJobStats edge cases")
|
||||
class StatsEdgeCases {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty manager reports zero average processing time")
|
||||
void emptyManagerZeroAverage() {
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
assertThat(stats.getTotalJobs()).isZero();
|
||||
assertThat(stats.getAverageProcessingTimeMs()).isZero();
|
||||
assertThat(stats.getOldestActiveJobTime()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accumulates processing time across multiple completed jobs")
|
||||
void accumulatesProcessingTime() {
|
||||
taskManager.createTask("c1");
|
||||
taskManager.setResult("c1", "r1");
|
||||
taskManager.createTask("c2");
|
||||
taskManager.setResult("c2", "r2");
|
||||
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
assertThat(stats.getCompletedJobs()).isEqualTo(2);
|
||||
assertThat(stats.getSuccessfulJobs()).isEqualTo(2);
|
||||
assertThat(stats.getAverageProcessingTimeMs()).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("findResultFileByFileId")
|
||||
class FindResultFile {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns matching ResultFile metadata")
|
||||
void returnsMatch() throws Exception {
|
||||
taskManager.createTask("rf-job");
|
||||
when(fileStorage.getFileSize("target")).thenReturn(5L);
|
||||
taskManager.setFileResult("rf-job", "target", "t.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
ResultFile found = taskManager.findResultFileByFileId("target");
|
||||
assertThat(found).isNotNull();
|
||||
assertThat(found.getFileId()).isEqualTo("target");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when no job owns the file id")
|
||||
void returnsNullWhenAbsent() {
|
||||
assertThat(taskManager.findResultFileByFileId("nope")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("findJobKeyByFileId")
|
||||
class FindJobKey {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the local job key when a job owns the file id")
|
||||
void returnsLocalKey() throws Exception {
|
||||
taskManager.createTask("owner-job");
|
||||
when(fileStorage.getFileSize("owned")).thenReturn(3L);
|
||||
taskManager.setFileResult(
|
||||
"owner-job", "owned", "o.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
|
||||
assertThat(taskManager.findJobKeyByFileId("owned")).isEqualTo("owner-job");
|
||||
// Local hit must not consult the JobStore.
|
||||
verify(jobStore, never()).findJobIdByFileId(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when JobStore also has no match")
|
||||
void returnsNullWhenJobStoreEmpty() {
|
||||
when(jobStore.findJobIdByFileId("ghost")).thenReturn(Optional.empty());
|
||||
assertThat(taskManager.findJobKeyByFileId("ghost")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates JobStore lookup failures instead of returning null")
|
||||
void propagatesJobStoreFailure() {
|
||||
when(jobStore.findJobIdByFileId("blip"))
|
||||
.thenThrow(new RuntimeException("backplane down"));
|
||||
assertThatThrownBy(() -> taskManager.findJobKeyByFileId("blip"))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("backplane down");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupOldJobs resilience")
|
||||
class CleanupResilience {
|
||||
|
||||
@Test
|
||||
@DisplayName("continues when a file deletion throws during cleanup")
|
||||
void continuesWhenDeleteThrows() throws Exception {
|
||||
String jobId = "old-file-job";
|
||||
taskManager.createTask(jobId);
|
||||
JobResult job = taskManager.getJobResult(jobId);
|
||||
ResultFile rf =
|
||||
ResultFile.builder()
|
||||
.fileId("doomed")
|
||||
.fileName("d.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF_VALUE)
|
||||
.fileSize(1L)
|
||||
.build();
|
||||
ReflectionTestUtils.setField(job, "resultFiles", List.of(rf));
|
||||
ReflectionTestUtils.setField(job, "complete", true);
|
||||
ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2));
|
||||
|
||||
when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked"));
|
||||
|
||||
// Must not propagate; the job is still removed afterwards.
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, JobResult> map =
|
||||
(Map<String, JobResult>)
|
||||
ReflectionTestUtils.getField(taskManager, "jobResults");
|
||||
assertThat(map).doesNotContainKey(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("write-through failures")
|
||||
class WriteThroughFailures {
|
||||
|
||||
@Test
|
||||
@DisplayName("a JobStore put failure does not break createTask")
|
||||
void putFailureSwallowed() {
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("store offline"))
|
||||
.when(jobStore)
|
||||
.put(any(), any());
|
||||
// createTask -> writeThrough; the RuntimeException is caught and logged.
|
||||
taskManager.createTask("wt-job");
|
||||
assertThat(taskManager.getJobResult("wt-job")).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("toEntry mapping")
|
||||
class ToEntryMapping {
|
||||
|
||||
@Test
|
||||
@DisplayName("a failed job maps to FAILED state in the JobStore entry")
|
||||
void failedJobMapsToFailedState() {
|
||||
taskManager.createTask("fail-job");
|
||||
taskManager.setError("fail-job", "kaboom");
|
||||
|
||||
var captor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.common.cluster.JobStoreEntry.class);
|
||||
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
|
||||
assertThat(captor.getValue().jobId()).isEqualTo("fail-job");
|
||||
assertThat(captor.getAllValues())
|
||||
.anySatisfy(
|
||||
e ->
|
||||
assertThat(e.state())
|
||||
.isEqualTo(
|
||||
stirling.software.common.cluster.JobStoreEntry
|
||||
.JobState.FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addNote write-through")
|
||||
class AddNoteWriteThrough {
|
||||
|
||||
@Test
|
||||
@DisplayName("note is reflected in JobStore entry metadata")
|
||||
void noteWritesMetadata() {
|
||||
taskManager.createTask("note-job");
|
||||
assertThat(taskManager.addNote("note-job", "hello")).isTrue();
|
||||
|
||||
var captor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.common.cluster.JobStoreEntry.class);
|
||||
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
|
||||
assertThat(captor.getAllValues())
|
||||
.anySatisfy(e -> assertThat(e.resultMeta()).containsKey("notesCount"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
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.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/** Additional coverage for TempFileCleanupService branches not exercised by the base test. */
|
||||
class TempFileCleanupServiceMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private TempFileRegistry registry;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
@Mock private ApplicationProperties.TempFileManagement tempFileManagement;
|
||||
|
||||
@InjectMocks private TempFileCleanupService cleanupService;
|
||||
|
||||
private Path systemTempDir;
|
||||
private Path customTempDir;
|
||||
private Path libreOfficeTempDir;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
|
||||
systemTempDir = tempDir.resolve("systemTemp");
|
||||
customTempDir = tempDir.resolve("customTemp");
|
||||
libreOfficeTempDir = tempDir.resolve("libreOfficeTemp");
|
||||
Files.createDirectories(systemTempDir);
|
||||
Files.createDirectories(customTempDir);
|
||||
Files.createDirectories(libreOfficeTempDir);
|
||||
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(system);
|
||||
lenient().when(system.getTempFileManagement()).thenReturn(tempFileManagement);
|
||||
lenient().when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString());
|
||||
lenient()
|
||||
.when(tempFileManagement.getLibreofficeDir())
|
||||
.thenReturn(libreOfficeTempDir.toString());
|
||||
lenient().when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
lenient().when(tempFileManagement.isStartupCleanup()).thenReturn(false);
|
||||
lenient().when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false);
|
||||
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
|
||||
lenient().when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
private static void backdate(Path file, long millisAgo) throws IOException {
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.fromMillis(System.currentTimeMillis() - millisAgo));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isContainerMode")
|
||||
class ContainerMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("Docker and Kubernetes are container modes; others are not")
|
||||
void detectsContainerMachineTypes() {
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Docker");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isTrue();
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Kubernetes");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isTrue();
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
|
||||
assertThat(
|
||||
(Boolean)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService, "isContainerMode"))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSystemTempPath")
|
||||
class SystemTempPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the configured system temp dir when set")
|
||||
void usesConfiguredDir() {
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
Path path =
|
||||
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
|
||||
assertThat(path).isEqualTo(systemTempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to java.io.tmpdir when unset")
|
||||
void fallsBackToJavaTmpDir() {
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn("");
|
||||
Path path =
|
||||
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
|
||||
assertThat(path).isEqualTo(Path.of(System.getProperty("java.io.tmpdir")));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("init")
|
||||
class Init {
|
||||
|
||||
@Test
|
||||
@DisplayName("creates configured temp directories that do not yet exist")
|
||||
void createsMissingDirectories() {
|
||||
Path newBase = tempDir.resolve("newBase");
|
||||
Path newLo = tempDir.resolve("newLo");
|
||||
when(tempFileManagement.getBaseTmpDir()).thenReturn(newBase.toString());
|
||||
when(tempFileManagement.getLibreofficeDir()).thenReturn(newLo.toString());
|
||||
when(tempFileManagement.isStartupCleanup()).thenReturn(false);
|
||||
|
||||
cleanupService.init();
|
||||
|
||||
assertThat(Files.exists(newBase)).isTrue();
|
||||
assertThat(Files.exists(newLo)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runs startup cleanup when enabled")
|
||||
void runsStartupCleanupWhenEnabled() throws IOException {
|
||||
when(tempFileManagement.isStartupCleanup()).thenReturn(true);
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// An old stirling temp file in the custom dir should be removed by startup cleanup.
|
||||
Path stale = Files.createFile(customTempDir.resolve("stirling-pdf-stale.tmp"));
|
||||
backdate(stale, 48L * 60 * 60 * 1000); // 48h old, beyond non-container 24h cutoff
|
||||
|
||||
cleanupService.init();
|
||||
|
||||
assertThat(Files.exists(stale)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("scheduledCleanup")
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
void deletesRegisteredDirectories() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
|
||||
// The registered directory was removed by GeneralUtils.deleteDirectory.
|
||||
assertThat(Files.exists(regDir)).isFalse();
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips a registered directory that no longer exists")
|
||||
void skipsMissingRegisteredDirectory() {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(tempDir.resolve("ghostDir"));
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// No exception even though the directory does not exist.
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
verify(registry).getTempDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupUnregisteredFiles system-temp inclusion")
|
||||
class CleanupUnregistered {
|
||||
|
||||
@Test
|
||||
@DisplayName("includes the system temp dir when cleanupSystemTemp is enabled")
|
||||
void includesSystemTempDir() throws Exception {
|
||||
when(tempFileManagement.isCleanupSystemTemp()).thenReturn(true);
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// Old stirling file in the system temp dir should be deleted in container mode.
|
||||
Path stale = Files.createFile(systemTempDir.resolve("stirling-pdf-sys.tmp"));
|
||||
backdate(stale, 2L * 60 * 60 * 1000); // 2h old
|
||||
|
||||
int deleted =
|
||||
(int)
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
cleanupService,
|
||||
"cleanupUnregisteredFiles",
|
||||
true,
|
||||
true,
|
||||
3600000L);
|
||||
|
||||
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||
assertThat(Files.exists(stale)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("registered-file skip and recursion depth")
|
||||
class RegistryAndDepth {
|
||||
|
||||
@Test
|
||||
@DisplayName("a registered file is never deleted")
|
||||
void registeredFilePreserved() throws Exception {
|
||||
Path registered = Files.createFile(systemTempDir.resolve("output_registered.pdf"));
|
||||
backdate(registered, 2L * 60 * 60 * 1000);
|
||||
// The registry reports the file as registered, so cleanup must skip it.
|
||||
when(registry.contains(any(File.class))).thenReturn(true);
|
||||
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, 0, false, 3600000L);
|
||||
|
||||
assertThat(Files.exists(registered)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recursion stops once the maximum depth is exceeded")
|
||||
void recursionDepthGuard() throws Exception {
|
||||
// Starting beyond MAX_RECURSION_DEPTH (5) returns immediately without listing.
|
||||
Path deepFile = Files.createFile(systemTempDir.resolve("output_deep.pdf"));
|
||||
backdate(deepFile, 2L * 60 * 60 * 1000);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, 6, false, 3600000L);
|
||||
|
||||
// Depth guard hit: the file was not visited or deleted.
|
||||
assertThat(Files.exists(deepFile)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupLibreOfficeTempFiles")
|
||||
class LibreOfficeCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("clears contents of registered libreoffice directories but keeps the dir")
|
||||
void clearsLibreOfficeContents() throws IOException {
|
||||
Path loDir = Files.createDirectories(tempDir.resolve("libreoffice-conv"));
|
||||
Path inside = Files.createFile(loDir.resolve("output_lo.pdf"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(loDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
cleanupService.cleanupLibreOfficeTempFiles();
|
||||
|
||||
// The file is removed (age ignored), directory itself remains.
|
||||
assertThat(Files.exists(inside)).isFalse();
|
||||
assertThat(Files.exists(loDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ignores registered directories that are not libreoffice dirs")
|
||||
void ignoresNonLibreOfficeDirs() throws IOException {
|
||||
Path other = Files.createDirectories(tempDir.resolve("other-dir"));
|
||||
Path keep = Files.createFile(other.resolve("output_keep.pdf"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(other);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
|
||||
cleanupService.cleanupLibreOfficeTempFiles();
|
||||
|
||||
// Not a libreoffice dir, so its contents are untouched.
|
||||
assertThat(Files.exists(keep)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupPDFBoxCache")
|
||||
class PdfBoxCache {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes an existing .pdfbox.cache file in the user home")
|
||||
void deletesCacheFile() throws IOException {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("home"));
|
||||
Path cache = Files.createFile(fakeHome.resolve(".pdfbox.cache"));
|
||||
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
|
||||
assertThat(Files.exists(cache)).isFalse();
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("is a no-op when no cache file exists")
|
||||
void noOpWhenNoCache() throws IOException {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("home2"));
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
// No exception when the cache file is absent.
|
||||
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
|
||||
assertThat(Files.exists(fakeHome.resolve(".pdfbox.cache"))).isFalse();
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Point user.home at a throwaway dir so the real ~/.pdfbox.cache is never touched.
|
||||
private void withIsolatedUserHome(Runnable action) {
|
||||
String oldHome = System.getProperty("user.home");
|
||||
try {
|
||||
Path fakeHome = Files.createDirectories(tempDir.resolve("isolated-home"));
|
||||
System.setProperty("user.home", fakeHome.toString());
|
||||
action.run();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
System.setProperty("user.home", oldHome);
|
||||
}
|
||||
}
|
||||
|
||||
private void invokeCleanupDirectoryStreaming(
|
||||
Path directory, int depth, boolean containerMode, long maxAgeMillis) {
|
||||
try {
|
||||
Consumer<Path> noop = p -> {};
|
||||
var method =
|
||||
TempFileCleanupService.class.getDeclaredMethod(
|
||||
"cleanupDirectoryStreaming",
|
||||
Path.class,
|
||||
boolean.class,
|
||||
int.class,
|
||||
long.class,
|
||||
boolean.class,
|
||||
Consumer.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(
|
||||
cleanupService, directory, containerMode, depth, maxAgeMillis, false, noop);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link CbrUtils#convertCbrToPdf}. junrar cannot parse synthetic RAR data,
|
||||
* so these exercise the archive-open failure branches (corrupt header / invalid format) by feeding
|
||||
* non-RAR bytes through a real {@link CustomPDFDocumentFactory} and {@link TempFileManager}. No
|
||||
* external tool is launched.
|
||||
*/
|
||||
class CbrUtilsMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomPDFDocumentFactory factory;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-cbr-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
}
|
||||
|
||||
private static MultipartFile cbr(String filename, byte[] bytes) {
|
||||
return new MockMultipartFile("file", filename, "application/x-cbr", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf - invalid archives")
|
||||
class InvalidArchiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-RAR bytes in a .cbr file are rejected as an invalid archive")
|
||||
void nonRarContentCbr() {
|
||||
byte[] junk = "this is not a rar archive at all".getBytes(StandardCharsets.UTF_8);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("comic.cbr", junk), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-RAR bytes in a .rar file are rejected as an invalid archive")
|
||||
void nonRarContentRar() {
|
||||
byte[] junk = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("archive.rar", junk), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bytes carrying the RAR signature but no valid body are rejected")
|
||||
void rarSignatureOnly() {
|
||||
// "Rar!\x1A\x07\x00" is the classic RAR4 signature; body is missing/garbage.
|
||||
byte[] data = {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55};
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbrUtils.convertCbrToPdf(
|
||||
cbr("comic.cbr", data), factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbrToPdf - validation overload")
|
||||
class ValidationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the 3-arg overload delegates and still validates the extension")
|
||||
void threeArgOverloadValidatesExtension() {
|
||||
MultipartFile wrong = cbr("document.pdf", "x".getBytes(StandardCharsets.UTF_8));
|
||||
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(wrong, factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty .cbr file is rejected before archive parsing")
|
||||
void emptyFile() {
|
||||
MultipartFile empty = cbr("comic.cbr", new byte[0]);
|
||||
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(empty, factory, tempFileManager))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isCbrFile additional branches")
|
||||
class IsCbrFileTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a .zip file is not a CBR")
|
||||
void zipIsNotCbr() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
org.mockito.Mockito.when(file.getOriginalFilename()).thenReturn("bundle.zip");
|
||||
assertThat(CbrUtils.isCbrFile(file)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
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.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link CbzUtils#convertCbzToPdf} that build real in-memory CBZ (ZIP)
|
||||
* archives containing real PNG images and convert them with a real {@link
|
||||
* CustomPDFDocumentFactory}. No external process is launched (optimizeForEbook is left off so
|
||||
* Ghostscript is never invoked).
|
||||
*/
|
||||
class CbzUtilsMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomPDFDocumentFactory factory;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-cbz-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
}
|
||||
|
||||
private static byte[] pngBytes(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, "PNG", baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Build a CBZ (ZIP) from name->bytes entries. */
|
||||
private static byte[] buildCbz(String[] names, byte[][] contents) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
zos.putNextEntry(new ZipEntry(names[i]));
|
||||
if (contents[i] != null) {
|
||||
zos.write(contents[i]);
|
||||
}
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static MultipartFile cbz(byte[] bytes) {
|
||||
return new MockMultipartFile("file", "comic.cbz", "application/x-cbz", bytes);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf - happy path")
|
||||
class HappyPathTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a CBZ with two images converts to a two-page PDF, sorted by natural order")
|
||||
void twoImagesToPdf() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"page2.png", "page10.png", "page1.png"},
|
||||
new byte[][] {
|
||||
pngBytes(Color.RED), pngBytes(Color.GREEN), pngBytes(Color.BLUE)
|
||||
});
|
||||
|
||||
try (TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
|
||||
assertThat(resultPdf.exists()).isTrue();
|
||||
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-image entries are ignored, only images become pages")
|
||||
void mixedEntries() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"readme.txt", "cover.png"},
|
||||
new byte[][] {
|
||||
"notes".getBytes(StandardCharsets.UTF_8), pngBytes(Color.CYAN)
|
||||
});
|
||||
|
||||
try (TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
|
||||
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertCbzToPdf - invalid archives")
|
||||
class InvalidArchiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty ZIP (no entries) is rejected")
|
||||
void emptyArchive() throws Exception {
|
||||
byte[] archive = buildCbz(new String[] {}, new byte[][] {});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a ZIP with no image entries is rejected as 'no images'")
|
||||
void noImageEntries() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"a.txt", "b.json"},
|
||||
new byte[][] {
|
||||
"x".getBytes(StandardCharsets.UTF_8),
|
||||
"{}".getBytes(StandardCharsets.UTF_8)
|
||||
});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-ZIP bytes are rejected as an invalid CBZ format")
|
||||
void corruptArchive() {
|
||||
byte[] notAZip = "this is definitely not a zip file".getBytes(StandardCharsets.UTF_8);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(notAZip), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a CBZ whose only image is corrupt produces no pages and is rejected")
|
||||
void corruptImageProducesNoPages() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(
|
||||
new String[] {"broken.png"},
|
||||
new byte[][] {"not a real png".getBytes(StandardCharsets.UTF_8)});
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CbzUtils.convertCbzToPdf(
|
||||
cbz(archive), factory, tempFileManager, false))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("@TempDir cleanup")
|
||||
class CleanupTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the returned TempFile lives under the configured temp dir and closes cleanly")
|
||||
void tempFileCleanup() throws Exception {
|
||||
byte[] archive =
|
||||
buildCbz(new String[] {"p.png"}, new byte[][] {pngBytes(Color.MAGENTA)});
|
||||
|
||||
TempFile resultPdf =
|
||||
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false);
|
||||
Path path = resultPdf.getPath();
|
||||
assertThat(Files.exists(path)).isTrue();
|
||||
resultPdf.close();
|
||||
assertThat(Files.exists(path)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.util.EmlParser.EmailAttachment;
|
||||
import stirling.software.common.util.EmlParser.EmailContent;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link EmlParser#extractEmailContent} driven by small real .eml strings.
|
||||
* These exercise the content-building, recipient-formatting and attachment-mapping branches plus
|
||||
* the nested {@link EmailContent}/{@link EmailAttachment} value types. No network or external tool.
|
||||
*/
|
||||
class EmlParserMoreTest {
|
||||
|
||||
private static final String TS = "Mon, 01 Jan 2024 12:00:00 +0000";
|
||||
|
||||
private static byte[] eml(String content) {
|
||||
return content.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static EmlToPdfRequest requestWithAttachments(int maxMb) {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
request.setMaxAttachmentSizeMB(maxMb);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static String simpleText(String from, String to, String subject, String body) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
TS,
|
||||
body);
|
||||
}
|
||||
|
||||
private static String multipartWithAttachment(
|
||||
String boundary, String body, String filename, String attachmentContent) {
|
||||
String encoded =
|
||||
Base64.getEncoder()
|
||||
.encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: a@example.com\nTo: b@example.com\nCc: c@example.com\n"
|
||||
+ "Subject: Multipart\nDate: %s\n"
|
||||
+ "Content-Type: multipart/mixed; boundary=\"%s\"\n\n"
|
||||
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n%s\n\n"
|
||||
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Disposition: attachment; filename=\"%s\"\n"
|
||||
+ "Content-Transfer-Encoding: base64\n\n%s\n\n--%s--",
|
||||
TS,
|
||||
boundary,
|
||||
boundary,
|
||||
body,
|
||||
boundary,
|
||||
filename,
|
||||
encoded,
|
||||
boundary);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - headers and bodies")
|
||||
class HeaderTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("subject, from, to and plain-text body are extracted")
|
||||
void plainTextEmail() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
simpleText(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Hello Subject",
|
||||
"Body line one")),
|
||||
null,
|
||||
null);
|
||||
|
||||
assertThat(content.getSubject()).isEqualTo("Hello Subject");
|
||||
assertThat(content.getFrom()).contains("sender@example.com");
|
||||
assertThat(content.getTo()).contains("recipient@example.com");
|
||||
assertThat(content.getTextBody()).contains("Body line one");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the sent date is parsed into a UTC ZonedDateTime")
|
||||
void parsesDate() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "Dated", "hi")), null, null);
|
||||
ZonedDateTime date = content.getDate();
|
||||
assertThat(date).isNotNull();
|
||||
assertThat(date.getYear()).isEqualTo(2024);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an HTML body is captured as the html body")
|
||||
void htmlBodyCaptured() throws Exception {
|
||||
String html =
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"From: a@x.com\nTo: b@x.com\nSubject: HtmlMail\nDate: %s\n"
|
||||
+ "Content-Type: text/html; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "<html><body><p>Rich</p></body></html>",
|
||||
TS);
|
||||
|
||||
EmailContent content = EmlParser.extractEmailContent(eml(html), null, null);
|
||||
assertThat(content.getHtmlBody()).contains("Rich");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - attachments")
|
||||
class AttachmentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("attachment metadata is mapped and CC recipients are formatted")
|
||||
void attachmentMappedAndCc() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b1",
|
||||
"see attached",
|
||||
"notes.txt",
|
||||
"attachment payload")),
|
||||
requestWithAttachments(10),
|
||||
null);
|
||||
|
||||
assertThat(content.getCc()).contains("c@example.com");
|
||||
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
|
||||
EmailAttachment att = content.getAttachments().get(0);
|
||||
assertThat(att.getFilename()).isEqualTo("notes.txt");
|
||||
assertThat(att.getData()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("when attachments are not requested the data bytes are omitted")
|
||||
void attachmentDataOmittedWhenNotRequested() throws Exception {
|
||||
EmlToPdfRequest noAttach = new EmlToPdfRequest();
|
||||
noAttach.setIncludeAttachments(false);
|
||||
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b2", "body", "doc.txt", "some content")),
|
||||
noAttach,
|
||||
null);
|
||||
|
||||
// Metadata still present, but the raw bytes are not attached.
|
||||
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(content.getAttachments().get(0).getData()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an attachment over the size limit has its data skipped")
|
||||
void attachmentOverSizeLimitSkipped() throws Exception {
|
||||
// 0 MB limit means any non-empty attachment exceeds it.
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(
|
||||
multipartWithAttachment(
|
||||
"----b3",
|
||||
"body",
|
||||
"big.txt",
|
||||
"this content exceeds the zero-byte limit")),
|
||||
requestWithAttachments(0),
|
||||
null);
|
||||
|
||||
assertThat(content.getAttachments().get(0).getData()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractEmailContent - failure paths")
|
||||
class FailureTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("OLE2 magic bytes that are not a real MSG file raise an IOException")
|
||||
void fakeMsgFile() {
|
||||
// OLE2/MSG magic prefix followed by garbage -> outlookMsgToEmail fails.
|
||||
byte[] fakeMsg = {
|
||||
(byte) 0xD0,
|
||||
(byte) 0xCF,
|
||||
(byte) 0x11,
|
||||
(byte) 0xE0,
|
||||
(byte) 0xA1,
|
||||
(byte) 0xB1,
|
||||
(byte) 0x1A,
|
||||
(byte) 0xE1,
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x04,
|
||||
0x05,
|
||||
0x06,
|
||||
0x07
|
||||
};
|
||||
assertThatThrownBy(() -> EmlParser.extractEmailContent(fakeMsg, null, null))
|
||||
.isInstanceOf(java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EmailContent value type")
|
||||
class EmailContentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("setHtmlBody and setTextBody strip carriage returns")
|
||||
void stripsCarriageReturns() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
|
||||
content.setHtmlBody("line1\r\nline2");
|
||||
content.setTextBody("a\r\nb");
|
||||
assertThat(content.getHtmlBody()).doesNotContain("\r");
|
||||
assertThat(content.getTextBody()).doesNotContain("\r");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null bodies are preserved as null")
|
||||
void nullBodiesPreserved() throws Exception {
|
||||
EmailContent content =
|
||||
EmlParser.extractEmailContent(
|
||||
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
|
||||
content.setHtmlBody(null);
|
||||
assertThat(content.getHtmlBody()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EmailAttachment value type")
|
||||
class EmailAttachmentTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("setData updates the size in bytes")
|
||||
void setDataUpdatesSize() {
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setData(new byte[] {1, 2, 3, 4, 5});
|
||||
assertThat(att.getSizeBytes()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setData with null leaves size unchanged")
|
||||
void setDataNull() {
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setData(null);
|
||||
assertThat(att.getSizeBytes()).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
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.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.util.EmlParser.EmailAttachment;
|
||||
import stirling.software.common.util.EmlParser.EmailContent;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for the HTML-generation and helper methods of {@link EmlProcessingUtils}. All
|
||||
* inputs are built in-memory; no sanitizer, network or external tool is used.
|
||||
*/
|
||||
class EmlProcessingUtilsMoreTest {
|
||||
|
||||
private static EmailContent content(String subject, String from, String to) {
|
||||
EmailContent content = new EmailContent();
|
||||
content.setSubject(subject);
|
||||
content.setFrom(from);
|
||||
content.setTo(to);
|
||||
return content;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("generateEnhancedEmailHtml")
|
||||
class GenerateHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("produces a full HTML document with the subject and core headers")
|
||||
void basicDocument() {
|
||||
EmailContent content = content("My Subject", "from@x.com", "to@x.com");
|
||||
content.setTextBody("plain body text");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("<!DOCTYPE html>")
|
||||
.contains("My Subject")
|
||||
.contains("from@x.com")
|
||||
.contains("to@x.com")
|
||||
.contains("plain body text")
|
||||
.contains("</body></html>");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renders CC, BCC and a formatted date when present")
|
||||
void ccBccAndDate() {
|
||||
EmailContent content = content("Sub", "from@x.com", "to@x.com");
|
||||
content.setCc("cc@x.com");
|
||||
content.setBcc("bcc@x.com");
|
||||
content.setDate(ZonedDateTime.of(2024, 5, 6, 7, 8, 0, 0, ZoneOffset.UTC));
|
||||
content.setTextBody("hi");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("CC:")
|
||||
.contains("cc@x.com")
|
||||
.contains("BCC:")
|
||||
.contains("bcc@x.com")
|
||||
.contains("Date:");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("prefers the HTML body over the text body when both are present")
|
||||
void prefersHtmlBody() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setHtmlBody("<p>html version</p>");
|
||||
content.setTextBody("text version");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("html version");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to a no-content placeholder when both bodies are empty")
|
||||
void noContentPlaceholder() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("No content available");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("renders an attachments section and respects includeAttachments wording")
|
||||
void attachmentsSection() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setTextBody("body");
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setFilename("file.pdf");
|
||||
att.setContentType("application/pdf");
|
||||
att.setData(new byte[] {1, 2, 3});
|
||||
List<EmailAttachment> list = new ArrayList<>();
|
||||
list.add(att);
|
||||
content.setAttachments(list);
|
||||
content.setAttachmentCount(1);
|
||||
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, request, null);
|
||||
|
||||
assertThat(html)
|
||||
.contains("Attachments (1)")
|
||||
.contains("file.pdf")
|
||||
.contains("embedded in the file");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shows the not-included note when attachments are not requested")
|
||||
void attachmentsNotIncludedNote() {
|
||||
EmailContent content = content("Sub", "f@x.com", "t@x.com");
|
||||
content.setTextBody("body");
|
||||
EmailAttachment att = new EmailAttachment();
|
||||
att.setFilename("a.txt");
|
||||
List<EmailAttachment> list = new ArrayList<>();
|
||||
list.add(att);
|
||||
content.setAttachments(list);
|
||||
content.setAttachmentCount(1);
|
||||
|
||||
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
|
||||
|
||||
assertThat(html).contains("files not included in PDF");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createHtmlRequest")
|
||||
class CreateHtmlRequestTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("copies the file input and applies the default zoom")
|
||||
void copiesFileInputAndZoom() {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(request);
|
||||
assertThat(htmlRequest).isNotNull();
|
||||
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tolerates a null request and still sets the zoom")
|
||||
void nullRequest() {
|
||||
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(null);
|
||||
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("simplifyHtmlContent")
|
||||
class SimplifyHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("strips script and style tags")
|
||||
void stripsScriptAndStyle() {
|
||||
String html =
|
||||
"<html><head><style>.a{}</style></head>"
|
||||
+ "<body><script>alert(1)</script><p>keep</p></body></html>";
|
||||
String result = EmlProcessingUtils.simplifyHtmlContent(html);
|
||||
assertThat(result).doesNotContain("<script").doesNotContain("<style").contains("keep");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("decodeMimeHeader - quoted-printable charset handling")
|
||||
class DecodeMimeHeaderTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("decodes a quoted-printable hex sequence into the right characters")
|
||||
void decodesQpHex() {
|
||||
// =E9 in ISO-8859-1 is 'é'.
|
||||
String result = EmlProcessingUtils.decodeMimeHeader("=?ISO-8859-1?Q?caf=E9?=");
|
||||
assertThat(result).isEqualTo("café");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown charset falls back without throwing")
|
||||
void unknownCharsetFallback() {
|
||||
String result = EmlProcessingUtils.decodeMimeHeader("=?MADE-UP-CHARSET?B?SGVsbG8=?=");
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertTextToHtml - sanitizer-less escaping")
|
||||
class ConvertTextToHtmlTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("escapes HTML special characters when no sanitizer is supplied")
|
||||
void escapesSpecialChars() {
|
||||
String result = EmlProcessingUtils.convertTextToHtml("a <b> & c", null);
|
||||
assertThat(result).contains("<b>").contains("&");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectMimeType - extension table")
|
||||
class DetectMimeTypeTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("detects svg, bmp and webp from the filename")
|
||||
void detectsExtraTypes() {
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.svg", null)).isEqualTo("image/svg+xml");
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.bmp", null)).isEqualTo("image/bmp");
|
||||
assertThat(EmlProcessingUtils.detectMimeType("a.webp", null)).isEqualTo("image/webp");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
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 org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils.CbrFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.CbzFormatException;
|
||||
import stirling.software.common.util.ExceptionUtils.ErrorCode;
|
||||
import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException;
|
||||
import stirling.software.common.util.ExceptionUtils.GhostscriptException;
|
||||
|
||||
/**
|
||||
* Remaining-gap tests for {@link ExceptionUtils} not already covered by ExceptionUtilsTest /
|
||||
* ExceptionUtilsGapTest: the two-argument Ghostscript factory, the cause-bearing exception
|
||||
* constructors, and the EPS-multipage Ghostscript diagnostic branch.
|
||||
*/
|
||||
class ExceptionUtilsExtraTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("createGhostscriptCompressionException(processOutput, cause)")
|
||||
class TwoArgGhostscriptTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("both output and cause provided yields a coded exception with the cause")
|
||||
void outputAndCause() {
|
||||
Exception cause = new RuntimeException("boom");
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException(
|
||||
"Some informational chatter", cause);
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("EPS-multipage marker is recognized as a page-drawing error")
|
||||
void epsMultipageMarker() {
|
||||
String output = "Page 1\nEPS files may not contain multiple pages";
|
||||
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
|
||||
assertNotNull(ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-string overload with informational output uses compression code")
|
||||
void singleStringInformational() {
|
||||
GhostscriptException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException("just chatter");
|
||||
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
|
||||
// The fallback informative line is appended to the base message.
|
||||
assertTrue(ex.getMessage().contains("chatter"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cause-bearing exception constructors")
|
||||
class CauseConstructorTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("CbrFormatException(message, cause, code) retains cause and code")
|
||||
void cbrWithCause() {
|
||||
Exception cause = new IllegalStateException("rar");
|
||||
CbrFormatException ex = new CbrFormatException("bad cbr", cause, "E010");
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals("E010", ex.getErrorCode());
|
||||
assertEquals("bad cbr", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CbzFormatException(message, code) leaves cause null")
|
||||
void cbzNoCause() {
|
||||
CbzFormatException ex = new CbzFormatException("bad cbz", "E015");
|
||||
assertEquals("E015", ex.getErrorCode());
|
||||
assertEquals("bad cbz", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FfmpegRequiredException(message, cause, code) retains the cause")
|
||||
void ffmpegWithCause() {
|
||||
Exception cause = new RuntimeException("no ffmpeg");
|
||||
FfmpegRequiredException ex =
|
||||
new FfmpegRequiredException("ffmpeg missing", cause, "E063");
|
||||
assertSame(cause, ex.getCause());
|
||||
assertEquals("E063", ex.getErrorCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
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.configuration.RuntimePathConfig;
|
||||
|
||||
/**
|
||||
* Gap-coverage tests for {@link FileMonitor}, focusing on {@code isFileReadyForProcessing} branches
|
||||
* (stale-timestamp ready path, active file-lock not-ready path) and {@code trackFiles} processing
|
||||
* of real filesystem create/modify events. Timing-sensitive readiness is forced via explicit
|
||||
* last-modified timestamps rather than sleeps to stay non-flaky.
|
||||
*/
|
||||
class FileMonitorMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private FileMonitor monitorWatching(Path watchDir, Predicate<Path> filter) throws IOException {
|
||||
RuntimePathConfig config = mock(RuntimePathConfig.class);
|
||||
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of(watchDir.toString()));
|
||||
return new FileMonitor(filter, config);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isFileReadyForProcessing")
|
||||
class ReadinessTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("file with an old last-modified time and no lock is ready")
|
||||
void staleFileIsReady() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("ready.pdf");
|
||||
Files.writeString(file, "data");
|
||||
// Backdate well beyond the 5000ms freshness window so the timestamp branch marks ready.
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
|
||||
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stale file lock is acquired and released so readiness stays true")
|
||||
void staleUnlockedFileLockRoundTrips() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("roundtrip.pdf");
|
||||
Files.writeString(file, "data");
|
||||
Files.setLastModifiedTime(
|
||||
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
|
||||
|
||||
// First call acquires+releases a lock and returns ready; a second call still works,
|
||||
// proving the lock was released (no lingering handle).
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
assertTrue(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recently modified, unlocked file is not yet ready")
|
||||
void freshFileNotReady() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
Path file = tempDir.resolve("fresh.pdf");
|
||||
Files.writeString(file, "data");
|
||||
// Just-written file is within the freshness window and not in the ready list.
|
||||
assertFalse(monitor.isFileReadyForProcessing(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("trackFiles event processing")
|
||||
class TrackFilesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-existing files are registered during construction")
|
||||
void preExistingFilesRegistered() throws IOException {
|
||||
Files.writeString(tempDir.resolve("existing.txt"), "x");
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
assertNotNull(monitor);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-existing nested directories are registered recursively")
|
||||
void nestedDirectoriesRegistered() throws IOException {
|
||||
Path nested = tempDir.resolve("sub");
|
||||
Files.createDirectories(nested);
|
||||
Files.writeString(nested.resolve("inner.txt"), "y");
|
||||
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
assertNotNull(monitor);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("create then modify then delete cycle is processed without error")
|
||||
void createModifyDeleteCycle() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
|
||||
Path file = tempDir.resolve("cycle.txt");
|
||||
Files.writeString(file, "one");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
|
||||
Files.writeString(file, "two-modified-content");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
|
||||
Files.delete(file);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a rejecting path filter still lets trackFiles run cleanly")
|
||||
void rejectingFilter() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> false);
|
||||
Files.writeString(tempDir.resolve("ignored.txt"), "z");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("subdirectory created after start is handled on the next tick")
|
||||
void subdirectoryCreatedAfterStart() throws IOException {
|
||||
FileMonitor monitor = monitorWatching(tempDir, p -> true);
|
||||
// First tick establishes monitoring; then create a child directory + file.
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
Path newDir = tempDir.resolve("late");
|
||||
Files.createDirectories(newDir);
|
||||
Files.writeString(newDir.resolve("late.txt"), "late");
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("re-registration safety net")
|
||||
class ReRegistrationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("trackFiles re-registers root dirs when nothing is currently mapped")
|
||||
void reRegistersWhenEmpty() throws IOException {
|
||||
// Root directory does not exist at construction, so nothing is registered.
|
||||
Path missing = tempDir.resolve("appears-later");
|
||||
FileMonitor monitor = monitorWatching(missing, p -> true);
|
||||
|
||||
// Now create the directory; the next tick should attempt re-registration.
|
||||
Files.createDirectories(missing);
|
||||
assertDoesNotThrow(monitor::trackFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
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.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link FileToPdf#convertHtmlToPdf}. The WeasyPrint process is fully mocked
|
||||
* via {@link MockedStatic} so the command-building, sanitization and ZIP repacking paths run
|
||||
* without launching any external tool.
|
||||
*/
|
||||
class FileToPdfMoreTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private CustomHtmlSanitizer sanitizer;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-htmlpdf-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
|
||||
sanitizer = mock(CustomHtmlSanitizer.class);
|
||||
// Identity sanitize so content is preserved for assertions.
|
||||
when(sanitizer.sanitize(Mockito.anyString()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
}
|
||||
|
||||
/** Build a real ZIP byte[] from name->content pairs. */
|
||||
private static byte[] buildZip(String[] names, String[] contents) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
zos.putNextEntry(new ZipEntry(names[i]));
|
||||
zos.write(contents[i].getBytes(StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** mockStatic helper returning a captor of the command list passed to the executor. */
|
||||
private ProcessExecutorResult successResult() {
|
||||
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
|
||||
when(result.getRc()).thenReturn(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - HTML input")
|
||||
class HtmlInputTests {
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
@DisplayName("builds the WeasyPrint command and returns the output bytes")
|
||||
void htmlHappyPath() throws Exception {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(commandCaptor.capture());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"/usr/bin/weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8),
|
||||
"page.html",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
List<String> command = commandCaptor.getValue();
|
||||
assertThat(command.get(0)).isEqualTo("/usr/bin/weasyprint");
|
||||
assertThat(command).contains("--pdf-forms", "-e", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the HTML body is passed through the sanitizer before writing")
|
||||
void htmlIsSanitized() throws Exception {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"<b>x</b>".getBytes(StandardCharsets.UTF_8),
|
||||
"doc.HTML",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
Mockito.verify(sanitizer).sanitize("<b>x</b>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - ZIP input")
|
||||
class ZipInputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("html entries inside the ZIP are sanitized and repacked")
|
||||
void zipHtmlEntriesSanitized() throws Exception {
|
||||
byte[] zip =
|
||||
buildZip(
|
||||
new String[] {"index.html", "asset.css"},
|
||||
new String[] {"<p>body</p>", "p{color:red}"});
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
// Only the .html entry should be sanitized, not the .css.
|
||||
Mockito.verify(sanitizer).sanitize("<p>body</p>");
|
||||
Mockito.verify(sanitizer, Mockito.never()).sanitize("p{color:red}");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-html entries inside the ZIP are copied through unchanged")
|
||||
void zipNonHtmlCopied() throws Exception {
|
||||
byte[] zip = buildZip(new String[] {"data.txt"}, new String[] {"plain text content"});
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(successResult())
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
// Drop the identity-stub invocation recorded during setUp.
|
||||
Mockito.clearInvocations(sanitizer);
|
||||
|
||||
byte[] result =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
Mockito.verifyNoInteractions(sanitizer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertHtmlToPdf - invalid input")
|
||||
class InvalidInputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an unsupported extension throws before any process is started")
|
||||
void unsupportedExtension() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
"data".getBytes(StandardCharsets.UTF_8),
|
||||
"document.txt",
|
||||
tempFileManager,
|
||||
sanitizer))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("sanitizeZipFilename additional branches")
|
||||
class SanitizeZipFilenameTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a bare relative name is returned unchanged")
|
||||
void plainName() {
|
||||
assertThat(FileToPdf.sanitizeZipFilename("file.html")).isEqualTo("file.html");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only the .. sequences are stripped, the rest of the path survives")
|
||||
void stripsTraversalKeepsTail() {
|
||||
String result = FileToPdf.sanitizeZipFilename("a/../b/c.html");
|
||||
assertThat(result).doesNotContain("..").endsWith("c.html");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("repacked ZIP integrity")
|
||||
class RepackedZipTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the temp input zip handed to weasyprint still contains the html entry")
|
||||
void repackedZipContainsEntry() throws Exception {
|
||||
byte[] zip = buildZip(new String[] {"a.html"}, new String[] {"<i>hi</i>"});
|
||||
|
||||
// Inspect the repacked zip from inside the command answer, while the temp file is
|
||||
// still on disk (it is auto-deleted once convertHtmlToPdf returns).
|
||||
List<String> entryNames = new java.util.ArrayList<>();
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doAnswer(
|
||||
invocation -> {
|
||||
List<String> command = invocation.getArgument(0);
|
||||
Path inputZip = Path.of(command.get(command.size() - 2));
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(
|
||||
java.nio.file.Files.newInputStream(inputZip))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entryNames.add(entry.getName());
|
||||
}
|
||||
}
|
||||
return successResult();
|
||||
})
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
|
||||
.thenReturn(executor);
|
||||
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"weasyprint",
|
||||
new HTMLToPdfRequest(),
|
||||
zip,
|
||||
"bundle.zip",
|
||||
tempFileManager,
|
||||
sanitizer);
|
||||
|
||||
assertThat(entryNames).anyMatch(name -> name.endsWith("a.html"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.assertThrows;
|
||||
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.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.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
|
||||
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;
|
||||
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
|
||||
/**
|
||||
* Additional branch coverage for {@link FormUtils}, complementing FormUtilsAdditionalTest and
|
||||
* FormUtilsGapTest. Targets the display-label derivation chain, choice/radio value extraction and
|
||||
* application, the modify-form type-change recreation path, and coordinate edge cases.
|
||||
*/
|
||||
class FormUtilsMoreTest {
|
||||
|
||||
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
|
||||
|
||||
private static SetupDocument createBasicDocument(PDDocument document) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
document.addPage(page);
|
||||
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
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());
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>();
|
||||
widgets.add(widget);
|
||||
field.setWidgets(widgets);
|
||||
setup.acroForm().getFields().add(field);
|
||||
setup.page().getAnnotations().add(widget);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// extractFormFields - field-type branches and display labels
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractFormFields metadata")
|
||||
class ExtractFormFieldsMetadata {
|
||||
|
||||
@Test
|
||||
void comboBoxExtractsOptionsAndType() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setPartialName("color");
|
||||
combo.setOptions(List.of("Red", "Green"));
|
||||
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
FormUtils.FormFieldInfo info = fields.get(0);
|
||||
assertEquals("combobox", info.type());
|
||||
assertNotNull(info.options());
|
||||
assertTrue(info.options().contains("Red"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSelectListBoxReportsMultiSelect() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDListBox listBox = new PDListBox(setup.acroForm());
|
||||
listBox.setPartialName("items");
|
||||
listBox.setMultiSelect(true);
|
||||
listBox.setOptions(List.of("A", "B", "C"));
|
||||
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertEquals("listbox", fields.get(0).type());
|
||||
assertTrue(fields.get(0).multiSelect());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fieldWithoutNameIsSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
// No partial name set -> fullyQualifiedName and partialName both null -> skipped.
|
||||
PDTextField nameless = new PDTextField(setup.acroForm());
|
||||
attachWidget(setup, nameless, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertTrue(fields.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void alternateFieldNameBecomesDisplayLabel() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("f1");
|
||||
text.setAlternateFieldName("Customer Email");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals("Customer Email", fields.get(0).label());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooltipBecomesDisplayLabelWhenNoAlternate() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("f1");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
// Set the /TU tooltip on the widget.
|
||||
text.getWidgets().get(0).getCOSObject().setString(COSName.TU, "Phone Number");
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals("Phone Number", fields.get(0).label());
|
||||
assertEquals("Phone Number", fields.get(0).tooltip());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void humanizedNameUsedWhenNoLabelSources() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("first_name");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
// humanizeName turns first_name -> "first name".
|
||||
assertEquals("first name", fields.get(0).label());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericNameFallsBackToTypeLabel() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
// A 32+ hex char name is detected as UUID-like (generic), forcing the fallback.
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("cdc47b7041524571abcd93017fe77bf7");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals("Text field 1", fields.get(0).label());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void choiceFieldCurrentValueIsJoined() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDListBox listBox = new PDListBox(setup.acroForm());
|
||||
listBox.setPartialName("items");
|
||||
listBox.setMultiSelect(true);
|
||||
listBox.setOptions(List.of("A", "B", "C"));
|
||||
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
|
||||
listBox.setValue(List.of("A", "C"));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals("A,C", fields.get(0).value());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fieldsAreSortedByPageThenOrderThenName() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField zebra = new PDTextField(setup.acroForm());
|
||||
zebra.setPartialName("zebra");
|
||||
attachWidget(setup, zebra, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
PDTextField apple = new PDTextField(setup.acroForm());
|
||||
apple.setPartialName("apple");
|
||||
attachWidget(setup, apple, new PDRectangle(50, 660, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(2, fields.size());
|
||||
// pageOrder is assigned in tree order so zebra (added first) keeps order 0.
|
||||
assertEquals("zebra", fields.get(0).name());
|
||||
assertEquals(0, fields.get(0).pageOrder());
|
||||
assertEquals(1, fields.get(1).pageOrder());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// extractFormFieldsWithCoordinates - extra branches
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractFormFieldsWithCoordinates extras")
|
||||
class ExtractWithCoordinatesExtras {
|
||||
|
||||
@Test
|
||||
void multilineAndReadOnlyFlagsAreReported() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("notes");
|
||||
text.setMultiline(true);
|
||||
text.setReadOnly(true);
|
||||
attachWidget(setup, text, new PDRectangle(50, 600, 200, 80));
|
||||
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertTrue(fields.get(0).isMultiline());
|
||||
assertTrue(fields.get(0).isReadOnly());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void comboBoxWithDistinctDisplayValuesPopulatesDisplayOptions() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setPartialName("country");
|
||||
// Distinct export vs display values triggers displayOptions to be sent.
|
||||
combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain"));
|
||||
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(1, fields.size());
|
||||
List<String> displayOptions = fields.get(0).getDisplayOptions();
|
||||
assertNotNull(displayOptions);
|
||||
assertTrue(displayOptions.contains("United States"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fontSizeExtractedFromDefaultAppearance() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("sized");
|
||||
text.setDefaultAppearance("/Helv 14 Tf 0 g");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
FormFieldWithCoordinates.WidgetCoordinates wc = fields.get(0).getWidgets().get(0);
|
||||
assertEquals(14f, wc.getFontSize(), 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("offpage");
|
||||
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
|
||||
// returns null, which is still added to the per-field widget list.
|
||||
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
|
||||
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(1, fields.size());
|
||||
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
|
||||
fields.get(0).getWidgets();
|
||||
assertNotNull(widgets);
|
||||
assertEquals(1, widgets.size());
|
||||
assertNull(widgets.get(0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void widgetWithNullRectangleIsSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("norect");
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setPage(setup.page());
|
||||
// Deliberately leave rectangle unset.
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>();
|
||||
widgets.add(widget);
|
||||
text.setWidgets(widgets);
|
||||
setup.acroForm().getFields().add(text);
|
||||
setup.page().getAnnotations().add(widget);
|
||||
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertNull(fields.get(0).getWidgets());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// applyFieldValues - choice / radio / signature / button branches
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyFieldValues field-type branches")
|
||||
class ApplyFieldValuesBranches {
|
||||
|
||||
@Test
|
||||
void comboBoxValueIsApplied() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setPartialName("color");
|
||||
combo.setOptions(List.of("Red", "Green", "Blue"));
|
||||
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.applyFieldValues(doc, Map.of("color", "Green"), false);
|
||||
assertThat(combo.getValue()).contains("Green");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void comboBoxNullValueClearsSelection() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setPartialName("color");
|
||||
combo.setOptions(List.of("Red", "Green"));
|
||||
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
|
||||
combo.setValue("Red");
|
||||
|
||||
java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
values.put("color", null);
|
||||
FormUtils.applyFieldValues(doc, values, false);
|
||||
// Null value routes to setValue("") which clears the prior "Red" selection.
|
||||
assertFalse(combo.getValue().contains("Red"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSelectListBoxAppliesCommaSeparatedValues() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDListBox listBox = new PDListBox(setup.acroForm());
|
||||
listBox.setPartialName("items");
|
||||
listBox.setMultiSelect(true);
|
||||
listBox.setOptions(List.of("A", "B", "C"));
|
||||
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
|
||||
|
||||
FormUtils.applyFieldValues(doc, Map.of("items", "A, C"), false);
|
||||
assertThat(listBox.getValue()).containsExactlyInAnyOrder("A", "C");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void radioButtonValueIsApplied() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDListBox other = new PDListBox(setup.acroForm());
|
||||
other.setPartialName("dummy");
|
||||
other.setOptions(List.of("x"));
|
||||
attachWidget(setup, other, new PDRectangle(50, 500, 200, 20));
|
||||
|
||||
// Blank radio value path: no exception, value stays unset.
|
||||
org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio =
|
||||
new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton(
|
||||
setup.acroForm());
|
||||
radio.setPartialName("choice");
|
||||
attachWidget(setup, radio, new PDRectangle(50, 700, 20, 20));
|
||||
|
||||
FormUtils.applyFieldValues(doc, Map.of("choice", " "), false);
|
||||
// No widgets configured with on-states, but the blank-skip branch must not throw.
|
||||
assertNotNull(radio.getValueAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureAndPushButtonFieldsAreSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDSignatureField sig = new PDSignatureField(setup.acroForm());
|
||||
sig.setPartialName("sig");
|
||||
attachWidget(setup, sig, new PDRectangle(50, 700, 200, 40));
|
||||
|
||||
PDPushButton button = new PDPushButton(setup.acroForm());
|
||||
button.setPartialName("btn");
|
||||
attachWidget(setup, button, new PDRectangle(50, 640, 200, 40));
|
||||
|
||||
// Must complete without throwing; both branches are no-ops.
|
||||
FormUtils.applyFieldValues(doc, Map.of("sig", "ignored", "btn", "ignored"), false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankKeysAreSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("name");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
java.util.Map<String, Object> values = new java.util.LinkedHashMap<>();
|
||||
values.put(" ", "blankKey");
|
||||
values.put("name", "value");
|
||||
FormUtils.applyFieldValues(doc, values, false);
|
||||
assertEquals("value", text.getValueAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKeyIsSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("name");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.applyFieldValues(doc, Map.of("doesNotExist", "x"), false);
|
||||
assertEquals("", text.getValueAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// modifyFormFields - type change (recreate) and choice in-place edits
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("modifyFormFields advanced")
|
||||
class ModifyFormFieldsAdvanced {
|
||||
|
||||
@Test
|
||||
void changesFieldTypeViaRecreate() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("toCombo");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"toCombo",
|
||||
"toCombo",
|
||||
"Pick one",
|
||||
"combobox",
|
||||
null,
|
||||
null,
|
||||
List.of("One", "Two"),
|
||||
"One",
|
||||
null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertEquals("combobox", fields.get(0).type());
|
||||
assertEquals("toCombo", fields.get(0).name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void inPlaceChoiceOptionAndMultiSelectUpdate() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDListBox listBox = new PDListBox(setup.acroForm());
|
||||
listBox.setPartialName("list");
|
||||
listBox.setOptions(List.of("A", "B"));
|
||||
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"list",
|
||||
null,
|
||||
null,
|
||||
"listbox", // same type -> in-place path
|
||||
null,
|
||||
Boolean.TRUE,
|
||||
List.of("X", "Y", "Z"),
|
||||
null,
|
||||
"Choose items");
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
PDField updated = doc.getDocumentCatalog().getAcroForm().getField("list");
|
||||
assertTrue(updated instanceof PDListBox);
|
||||
assertTrue(((PDListBox) updated).isMultiSelect());
|
||||
assertThat(((PDListBox) updated).getOptions()).contains("X", "Y", "Z");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedTargetTypeIsSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("keep");
|
||||
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"keep", null, null, "bogusType", null, null, null, null, null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
// The field is preserved unchanged because the target type is unsupported.
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
|
||||
assertEquals(1, fields.size());
|
||||
assertEquals("text", fields.get(0).type());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void renameAvoidsCollisionWithExistingField() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField a = new PDTextField(setup.acroForm());
|
||||
a.setPartialName("alpha");
|
||||
attachWidget(setup, a, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
PDTextField b = new PDTextField(setup.acroForm());
|
||||
b.setPartialName("beta");
|
||||
attachWidget(setup, b, new PDRectangle(50, 660, 200, 20));
|
||||
|
||||
// Rename beta -> alpha; should be uniquified to avoid the collision.
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"beta", "alpha", null, null, null, null, null, null, null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
List<String> names = new ArrayList<>();
|
||||
for (FormUtils.FormFieldInfo info : FormUtils.extractFormFields(doc)) {
|
||||
names.add(info.name());
|
||||
}
|
||||
assertEquals(2, names.size());
|
||||
assertTrue(names.contains("alpha"));
|
||||
// The renamed field cannot also be "alpha"; it gets a suffix.
|
||||
assertTrue(names.stream().anyMatch(n -> n.startsWith("alpha_")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentWithoutAcroFormIsNoOp() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"x", null, null, null, null, null, null, null, null);
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// buildFillTemplateRecord - radio default branch
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void buildFillTemplateRadioUsesCurrentValue() {
|
||||
FormUtils.FormFieldInfo info =
|
||||
new FormUtils.FormFieldInfo(
|
||||
"choice", "Choice", "radio", "Yes", null, false, 0, false, null, 0);
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
|
||||
assertEquals("Yes", result.get("choice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildFillTemplateNullEntriesAreSkipped() {
|
||||
List<FormUtils.FormFieldInfo> list = new ArrayList<>();
|
||||
list.add(null);
|
||||
list.add(
|
||||
new FormUtils.FormFieldInfo(
|
||||
"kept", "Kept", "text", "v", null, false, 0, false, null, 0));
|
||||
Map<String, Object> result = FormUtils.buildFillTemplateRecord(list);
|
||||
assertEquals(1, result.size());
|
||||
assertTrue(result.containsKey("kept"));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// resolveDisplayOptions / resolveOptions extra branches
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void resolveDisplayOptionsReturnsDistinctDisplayValues() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setPartialName("c");
|
||||
combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain"));
|
||||
List<String> display = FormUtils.resolveDisplayOptions(combo);
|
||||
assertThat(display).contains("United States", "Britain");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveOptionsRadioUsesExportValues() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio =
|
||||
new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton(setup.acroForm());
|
||||
radio.setExportValues(List.of("opt1", "opt2"));
|
||||
assertEquals(List.of("opt1", "opt2"), FormUtils.resolveOptions(radio));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// applyFieldValues strict mode
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void strictModeWrapsChoiceFailureInIoException() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
// A combo box with no /Opt array: setting a non-empty value triggers the
|
||||
// "missing /Opt" IllegalArgumentException, which strict mode rethrows as IOException.
|
||||
PDComboBox combo = new PDComboBox(setup.acroForm());
|
||||
combo.setPartialName("noOpts");
|
||||
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> FormUtils.applyFieldValues(doc, Map.of("noOpts", "X"), false, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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.PDPushButton;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Branch coverage for {@link GeneralFormCopyUtils#copyAndTransformFormFields} and the {@link
|
||||
* GeneralFormFieldTypeSupport} handlers, complementing GeneralFormCopyUtilsTest which only covers
|
||||
* rotation and the empty-form early returns.
|
||||
*/
|
||||
class GeneralFormCopyUtilsMoreTest {
|
||||
|
||||
private static PDAcroForm newAcroForm(PDDocument document) {
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
PDResources dr = new PDResources();
|
||||
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
acroForm.setDefaultResources(dr);
|
||||
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
return acroForm;
|
||||
}
|
||||
|
||||
private static void addWidget(PDTerminalField field, PDPage page, PDRectangle rect)
|
||||
throws IOException {
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(rect);
|
||||
widget.setPage(page);
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>();
|
||||
widgets.add(widget);
|
||||
field.setWidgets(widgets);
|
||||
page.getAnnotations().add(widget);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// copyAndTransformFormFields - real field copying
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("copyAndTransformFormFields copying")
|
||||
class CopyingFields {
|
||||
|
||||
@Test
|
||||
void copiesTextCheckboxAndComboFields() throws IOException {
|
||||
try (PDDocument source = new PDDocument();
|
||||
PDDocument target = new PDDocument()) {
|
||||
PDPage sourcePage = new PDPage(PDRectangle.A4);
|
||||
source.addPage(sourcePage);
|
||||
target.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
PDAcroForm sourceForm = newAcroForm(source);
|
||||
|
||||
PDTextField text = new PDTextField(sourceForm);
|
||||
text.setPartialName("name");
|
||||
addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20));
|
||||
sourceForm.getFields().add(text);
|
||||
text.setValue("Alice");
|
||||
|
||||
PDCheckBox check = new PDCheckBox(sourceForm);
|
||||
check.setPartialName("agree");
|
||||
check.setExportValues(List.of("Yes"));
|
||||
addWidget(check, sourcePage, new PDRectangle(50, 660, 16, 16));
|
||||
sourceForm.getFields().add(check);
|
||||
|
||||
PDComboBox combo = new PDComboBox(sourceForm);
|
||||
combo.setPartialName("color");
|
||||
addWidget(combo, sourcePage, new PDRectangle(50, 620, 200, 20));
|
||||
sourceForm.getFields().add(combo);
|
||||
combo.setOptions(List.of("Red", "Green"));
|
||||
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
source, target, 1, 1, 1, 1, 612f, 792f);
|
||||
|
||||
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
|
||||
assertNotNull(targetForm);
|
||||
assertEquals(3, targetForm.getFields().size());
|
||||
List<String> names = new ArrayList<>();
|
||||
for (var f : targetForm.getFields()) {
|
||||
names.add(f.getPartialName());
|
||||
}
|
||||
// Names are prefixed with page index during copy.
|
||||
assertThat(names).contains("page0_name", "page0_agree", "page0_color");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void copiesFieldThroughMultiCellGridLayout() throws IOException {
|
||||
try (PDDocument source = new PDDocument();
|
||||
PDDocument target = new PDDocument()) {
|
||||
PDPage sourcePage = new PDPage(PDRectangle.A4);
|
||||
source.addPage(sourcePage);
|
||||
target.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
PDAcroForm sourceForm = newAcroForm(source);
|
||||
PDTextField text = new PDTextField(sourceForm);
|
||||
text.setPartialName("name");
|
||||
addWidget(text, sourcePage, new PDRectangle(100, 100, 200, 20));
|
||||
sourceForm.getFields().add(text);
|
||||
|
||||
// 2x2 layout exercises the scale/offset arithmetic for cell placement.
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
source, target, 1, 4, 2, 2, 300f, 396f);
|
||||
|
||||
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
|
||||
assertEquals(1, targetForm.getFields().size());
|
||||
assertEquals("page0_name", targetForm.getFields().get(0).getPartialName());
|
||||
assertEquals(1, targetForm.getFields().get(0).getWidgets().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsPagesWithoutAnnotations() throws IOException {
|
||||
try (PDDocument source = new PDDocument();
|
||||
PDDocument target = new PDDocument()) {
|
||||
source.addPage(new PDPage(PDRectangle.A4)); // no annotations
|
||||
target.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
// Source has an AcroForm with a field on a different (non-existent here) page,
|
||||
// but page 0 has no annotations -> the per-page copy is skipped.
|
||||
PDAcroForm sourceForm = newAcroForm(source);
|
||||
PDTextField text = new PDTextField(sourceForm);
|
||||
text.setPartialName("ghost");
|
||||
sourceForm.getFields().add(text);
|
||||
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
source, target, 1, 1, 1, 1, 612f, 792f);
|
||||
|
||||
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
|
||||
// Form is created but no widgets were copied.
|
||||
assertNotNull(targetForm);
|
||||
assertTrue(targetForm.getFields().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWhenRowIndexExceedsRows() throws IOException {
|
||||
try (PDDocument source = new PDDocument();
|
||||
PDDocument target = new PDDocument()) {
|
||||
PDPage page0 = new PDPage(PDRectangle.A4);
|
||||
PDPage page1 = new PDPage(PDRectangle.A4);
|
||||
source.addPage(page0);
|
||||
source.addPage(page1);
|
||||
target.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
PDAcroForm sourceForm = newAcroForm(source);
|
||||
PDTextField a = new PDTextField(sourceForm);
|
||||
a.setPartialName("a");
|
||||
addWidget(a, page0, new PDRectangle(10, 10, 100, 20));
|
||||
sourceForm.getFields().add(a);
|
||||
|
||||
PDTextField b = new PDTextField(sourceForm);
|
||||
b.setPartialName("b");
|
||||
addWidget(b, page1, new PDRectangle(10, 10, 100, 20));
|
||||
sourceForm.getFields().add(b);
|
||||
|
||||
// cols=1, rows=1, pagesPerSheet=2 -> second page maps to rowIndex 1 (>= rows) ->
|
||||
// skipped.
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
source, target, 2, 2, 1, 1, 612f, 792f);
|
||||
|
||||
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
|
||||
assertEquals(1, targetForm.getFields().size());
|
||||
assertEquals("page0_a", targetForm.getFields().get(0).getPartialName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWhenDestinationPageMissing() throws IOException {
|
||||
try (PDDocument source = new PDDocument();
|
||||
PDDocument target = new PDDocument()) {
|
||||
PDPage sourcePage = new PDPage(PDRectangle.A4);
|
||||
source.addPage(sourcePage);
|
||||
// Target has NO pages, so destinationPageIndex 0 is out of bounds.
|
||||
|
||||
PDAcroForm sourceForm = newAcroForm(source);
|
||||
PDTextField text = new PDTextField(sourceForm);
|
||||
text.setPartialName("name");
|
||||
addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20));
|
||||
sourceForm.getFields().add(text);
|
||||
|
||||
assertDoesNotThrow(
|
||||
() ->
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
source, target, 1, 1, 1, 1, 612f, 792f));
|
||||
|
||||
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
|
||||
assertTrue(targetForm.getFields().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniquifiesDuplicateFieldNamesAcrossPages() throws IOException {
|
||||
try (PDDocument source = new PDDocument();
|
||||
PDDocument target = new PDDocument()) {
|
||||
PDPage sourcePage = new PDPage(PDRectangle.A4);
|
||||
source.addPage(sourcePage);
|
||||
target.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
PDAcroForm sourceForm = newAcroForm(source);
|
||||
|
||||
// Two separate fields placed on the same source page with the same partial name
|
||||
// would clash; the copier must generate distinct names.
|
||||
PDTextField one = new PDTextField(sourceForm);
|
||||
one.setPartialName("dup");
|
||||
addWidget(one, sourcePage, new PDRectangle(50, 700, 100, 20));
|
||||
sourceForm.getFields().add(one);
|
||||
|
||||
PDTextField two = new PDTextField(sourceForm);
|
||||
two.setPartialName("dup");
|
||||
addWidget(two, sourcePage, new PDRectangle(50, 660, 100, 20));
|
||||
sourceForm.getFields().add(two);
|
||||
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
source, target, 1, 1, 1, 1, 612f, 792f);
|
||||
|
||||
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
|
||||
assertEquals(2, targetForm.getFields().size());
|
||||
List<String> names = new ArrayList<>();
|
||||
for (var f : targetForm.getFields()) {
|
||||
names.add(f.getPartialName());
|
||||
}
|
||||
// First keeps page0_dup; the second is suffixed.
|
||||
assertTrue(names.contains("page0_dup"));
|
||||
assertTrue(names.stream().anyMatch(n -> n.startsWith("page0_dup_")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// GeneralFormFieldTypeSupport - forField / createField / copyFromOriginal
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("GeneralFormFieldTypeSupport")
|
||||
class TypeSupport {
|
||||
|
||||
@Test
|
||||
void forFieldNullReturnsNull() {
|
||||
assertNull(GeneralFormFieldTypeSupport.forField(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forFieldResolvesEachConcreteType() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDAcroForm form = newAcroForm(doc);
|
||||
assertEquals(
|
||||
GeneralFormFieldTypeSupport.TEXT,
|
||||
GeneralFormFieldTypeSupport.forField(new PDTextField(form)));
|
||||
assertEquals(
|
||||
GeneralFormFieldTypeSupport.CHECKBOX,
|
||||
GeneralFormFieldTypeSupport.forField(new PDCheckBox(form)));
|
||||
assertEquals(
|
||||
GeneralFormFieldTypeSupport.COMBOBOX,
|
||||
GeneralFormFieldTypeSupport.forField(new PDComboBox(form)));
|
||||
assertEquals(
|
||||
GeneralFormFieldTypeSupport.BUTTON,
|
||||
GeneralFormFieldTypeSupport.forField(new PDPushButton(form)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFieldProducesMatchingInstance() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDAcroForm form = newAcroForm(doc);
|
||||
PDTerminalField text = GeneralFormFieldTypeSupport.TEXT.createField(form);
|
||||
assertTrue(text instanceof PDTextField);
|
||||
PDTerminalField check = GeneralFormFieldTypeSupport.CHECKBOX.createField(form);
|
||||
assertTrue(check instanceof PDCheckBox);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyFromOriginalTransfersComboOptions() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDAcroForm form = newAcroForm(doc);
|
||||
PDComboBox src = new PDComboBox(form);
|
||||
src.setPartialName("src");
|
||||
src.setOptions(List.of("A", "B"));
|
||||
PDComboBox dst = new PDComboBox(form);
|
||||
dst.setPartialName("dst");
|
||||
|
||||
GeneralFormFieldTypeSupport.COMBOBOX.copyFromOriginal(src, dst);
|
||||
assertThat(dst.getOptions()).contains("A", "B");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyFromOriginalTransfersTextValue() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDAcroForm form = newAcroForm(doc);
|
||||
PDTextField src = new PDTextField(form);
|
||||
src.setPartialName("src");
|
||||
src.setValue("hello");
|
||||
PDTextField dst = new PDTextField(form);
|
||||
dst.setPartialName("dst");
|
||||
dst.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
|
||||
GeneralFormFieldTypeSupport.TEXT.copyFromOriginal(src, dst);
|
||||
assertEquals("hello", dst.getValueAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void typeNameAndFallbackWidgetNameExposed() {
|
||||
assertEquals("text", GeneralFormFieldTypeSupport.TEXT.typeName());
|
||||
assertEquals("textField", GeneralFormFieldTypeSupport.TEXT.fallbackWidgetName());
|
||||
assertEquals("checkbox", GeneralFormFieldTypeSupport.CHECKBOX.typeName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.GeneralUtils.NetworkInterfaceInfo;
|
||||
|
||||
class GeneralUtilsLocalIpTest {
|
||||
|
||||
private static NetworkInterfaceInfo iface(
|
||||
String name, String displayName, int index, boolean virtual, String... ips) {
|
||||
return new NetworkInterfaceInfo(
|
||||
name, displayName, index, true, false, false, virtual, true, List.of(ips));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersPhysicalWifiOverVmwareNatAdapter() {
|
||||
NetworkInterfaceInfo vmware =
|
||||
iface("eth5", "VMware Virtual Ethernet Adapter for VMnet8", 5, false, "172.16.1.1");
|
||||
NetworkInterfaceInfo wifi =
|
||||
iface("wlan0", "Intel(R) Wi-Fi 6 AX201", 12, false, "192.168.1.50");
|
||||
|
||||
assertEquals("192.168.1.50", GeneralUtils.selectBestSiteLocalIp(List.of(vmware, wifi)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesHyperVVethernetAdapter() {
|
||||
NetworkInterfaceInfo hyperv =
|
||||
iface("ethernet_32770", "Hyper-V Virtual Ethernet Adapter", 3, false, "172.28.0.1");
|
||||
NetworkInterfaceInfo ethernet =
|
||||
iface("eth0", "Realtek PCIe GbE Family Controller", 8, false, "192.168.0.20");
|
||||
|
||||
assertEquals("192.168.0.20", GeneralUtils.selectBestSiteLocalIp(List.of(hyperv, ethernet)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesWslAndDockerBridges() {
|
||||
NetworkInterfaceInfo wsl =
|
||||
iface("eth1", "Hyper-V Virtual Ethernet Adapter (WSL)", 70, false, "172.20.0.1");
|
||||
NetworkInterfaceInfo docker = iface("docker0", "docker0", 4, false, "172.17.0.1");
|
||||
NetworkInterfaceInfo lan =
|
||||
iface("eth0", "Intel(R) Ethernet Connection", 2, false, "10.0.0.5");
|
||||
|
||||
assertEquals("10.0.0.5", GeneralUtils.selectBestSiteLocalIp(List.of(wsl, docker, lan)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefers192Over10WhenBothPhysical() {
|
||||
NetworkInterfaceInfo ten = iface("eth0", "Ethernet", 2, false, "10.1.2.3");
|
||||
NetworkInterfaceInfo home = iface("wlan0", "Wi-Fi", 6, false, "192.168.1.10");
|
||||
|
||||
assertEquals("192.168.1.10", GeneralUtils.selectBestSiteLocalIp(List.of(ten, home)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void breaksTiesByLowestInterfaceIndex() {
|
||||
NetworkInterfaceInfo first = iface("eth0", "Ethernet", 2, false, "192.168.1.2");
|
||||
NetworkInterfaceInfo second = iface("eth1", "Ethernet", 9, false, "192.168.1.3");
|
||||
|
||||
assertEquals("192.168.1.2", GeneralUtils.selectBestSiteLocalIp(List.of(second, first)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullWhenOnlyVirtualOrDownInterfaces() {
|
||||
NetworkInterfaceInfo vbox =
|
||||
iface("vboxnet0", "VirtualBox Host-Only Network", 1, false, "192.168.56.1");
|
||||
NetworkInterfaceInfo flaggedVirtual =
|
||||
new NetworkInterfaceInfo(
|
||||
"eth9",
|
||||
"Ethernet",
|
||||
9,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
List.of("192.168.1.9"));
|
||||
NetworkInterfaceInfo down =
|
||||
new NetworkInterfaceInfo(
|
||||
"eth0",
|
||||
"Ethernet",
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
List.of("192.168.1.2"));
|
||||
|
||||
assertNull(GeneralUtils.selectBestSiteLocalIp(List.of(vbox, flaggedVirtual, down)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLikelyVirtualInterfaceFlagsKnownAdaptersButNotRealNics() {
|
||||
assertTrue(
|
||||
GeneralUtils.isLikelyVirtualInterface(
|
||||
"vEthernet", "Hyper-V Virtual Ethernet Adapter"));
|
||||
assertTrue(GeneralUtils.isLikelyVirtualInterface("docker0", "docker0"));
|
||||
assertTrue(
|
||||
GeneralUtils.isLikelyVirtualInterface("eth0", "VMware Virtual Ethernet Adapter"));
|
||||
assertTrue(GeneralUtils.isLikelyVirtualInterface("tun0", "WireGuard tunnel"));
|
||||
|
||||
assertFalse(GeneralUtils.isLikelyVirtualInterface("wlan0", "Intel(R) Wi-Fi 6 AX201"));
|
||||
assertFalse(
|
||||
GeneralUtils.isLikelyVirtualInterface(
|
||||
"eth0", "Realtek PCIe GbE Family Controller"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
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.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/**
|
||||
* Branch-coverage gap tests for {@link GeneralUtils}. Targets size parsing/formatting, page-list
|
||||
* and range handling, version comparison, URL validation, script/pipeline extraction validation,
|
||||
* and the Ghostscript optimize failure paths not exercised by the existing GeneralUtils*Test files.
|
||||
*/
|
||||
class GeneralUtilsMoreTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertSizeToBytes with explicit default unit")
|
||||
class ConvertSizeWithDefaultUnitTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid default unit throws IllegalArgumentException")
|
||||
void invalidDefaultUnitThrows() {
|
||||
assertThatThrownBy(() -> GeneralUtils.convertSizeToBytes("100", "ZB"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid default unit");
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "value \"5\" with default unit {0} -> {1} bytes")
|
||||
@CsvSource({"B, 5", "KB, 5120", "MB, 5242880", "GB, 5368709120", "TB, 5497558138880"})
|
||||
@DisplayName("numeric value uses the supplied default unit")
|
||||
void numericValueUsesDefaultUnit(String unit, long expected) {
|
||||
assertEquals(expected, GeneralUtils.convertSizeToBytes("5", unit));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lowercase default unit is normalized")
|
||||
void lowercaseDefaultUnit() {
|
||||
assertEquals(5L * 1024 * 1024, GeneralUtils.convertSizeToBytes("5", "mb"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicit suffix overrides default unit")
|
||||
void explicitSuffixOverridesDefault() {
|
||||
// "2KB" should parse as KB even though default unit is GB.
|
||||
assertEquals(2048L, GeneralUtils.convertSizeToBytes("2KB", "GB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null default unit falls back to MB")
|
||||
void nullDefaultUnitFallsBackToMb() {
|
||||
assertEquals(3L * 1024 * 1024, GeneralUtils.convertSizeToBytes("3", null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertSizeToBytes suffix and edge parsing")
|
||||
class ConvertSizeSuffixTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("comma decimal separator and embedded spaces are handled")
|
||||
void commaAndSpaces() {
|
||||
// "2,5 GB" -> "2.5GB" after normalization.
|
||||
assertEquals(2684354560L, GeneralUtils.convertSizeToBytes("2,5 GB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bare B suffix parses as bytes")
|
||||
void bareBytes() {
|
||||
assertEquals(42L, GeneralUtils.convertSizeToBytes("42B"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-numeric body returns null")
|
||||
void nonNumericReturnsNull() {
|
||||
assertNull(GeneralUtils.convertSizeToBytes("abcMB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative value returns null")
|
||||
void negativeReturnsNull() {
|
||||
assertNull(GeneralUtils.convertSizeToBytes("-1KB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero is a valid size")
|
||||
void zeroIsValid() {
|
||||
assertEquals(0L, GeneralUtils.convertSizeToBytes("0MB"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("formatBytes boundaries")
|
||||
class FormatBytesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("negative bytes report invalid size")
|
||||
void negativeInvalid() {
|
||||
assertEquals("Invalid size", GeneralUtils.formatBytes(-1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("terabyte range uses TB suffix")
|
||||
void terabyteRange() {
|
||||
long oneTb = 1024L * 1024L * 1024L * 1024L;
|
||||
assertEquals("1.00 TB", GeneralUtils.formatBytes(oneTb));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upper KB boundary just below a megabyte")
|
||||
void kbBoundary() {
|
||||
assertThat(GeneralUtils.formatBytes(1024L * 1024L - 1)).endsWith("KB");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("parsePageList String overload")
|
||||
class ParsePageListStringTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null pages defaults to first page")
|
||||
void nullDefaultsToFirst() {
|
||||
// Cast disambiguates the String vs String[] overloads for a null literal.
|
||||
assertEquals(List.of(1), GeneralUtils.parsePageList((String) null, 5, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("comma-separated list expands across tokens")
|
||||
void commaSeparated() {
|
||||
assertEquals(List.of(1, 3, 5), GeneralUtils.parsePageList("1,3,5", 5, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'all' keyword via String overload returns every page")
|
||||
void allKeyword() {
|
||||
assertEquals(List.of(1, 2, 3), GeneralUtils.parsePageList("all", 3, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two-argument overload defaults to zero-based output")
|
||||
void twoArgOverloadZeroBased() {
|
||||
assertEquals(List.of(0, 1, 2), GeneralUtils.parsePageList(new String[] {"1-3"}, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("large in-range request stays within the max-size guard")
|
||||
void largeRequestWithinGuard() {
|
||||
// Pages are clamped to [1, total], so a wide range never trips the maxSize guard.
|
||||
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1-500"}, 500, true);
|
||||
assertEquals(500, result.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("range and single-page handling")
|
||||
class RangeHandlingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("open-ended range extends to the last page")
|
||||
void openEndedRange() {
|
||||
assertEquals(
|
||||
List.of(3, 4, 5), GeneralUtils.parsePageList(new String[] {"3-"}, 5, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid range bounds are skipped, valid tokens remain")
|
||||
void invalidRangeSkipped() {
|
||||
List<Integer> result = GeneralUtils.parsePageList(new String[] {"x-y", "2"}, 5, true);
|
||||
assertEquals(List.of(2), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("out-of-range single page is dropped")
|
||||
void outOfRangeSinglePage() {
|
||||
assertTrue(GeneralUtils.parsePageList(new String[] {"99"}, 5, true).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-numeric single page is dropped")
|
||||
void nonNumericSinglePage() {
|
||||
assertTrue(GeneralUtils.parsePageList(new String[] {"abc"}, 5, true).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("range partially outside the document keeps in-bounds pages")
|
||||
void rangePartlyOutOfBounds() {
|
||||
assertEquals(List.of(4, 5), GeneralUtils.parsePageList(new String[] {"4-99"}, 5, true));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isVersionHigher")
|
||||
class VersionTests {
|
||||
|
||||
@ParameterizedTest(name = "{0} > {1} == {2}")
|
||||
@CsvSource({
|
||||
"2.0.0, 1.9.9, true",
|
||||
"1.0.0, 1.0.0, false",
|
||||
"1.0, 1.0.1, false",
|
||||
"1.0.1, 1.0, true",
|
||||
"1.2, 1.10, false"
|
||||
})
|
||||
@DisplayName("compares version components numerically")
|
||||
void comparesComponents(String a, String b, boolean expected) {
|
||||
assertEquals(expected, GeneralUtils.isVersionHigher(a, b));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null arguments yield false")
|
||||
void nullArgs() {
|
||||
assertFalse(GeneralUtils.isVersionHigher(null, "1.0"));
|
||||
assertFalse(GeneralUtils.isVersionHigher("1.0", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-numeric component throws NumberFormatException")
|
||||
void nonNumericComponentThrows() {
|
||||
assertThrows(
|
||||
NumberFormatException.class, () -> GeneralUtils.isVersionHigher("1.x", "1.0"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isValidURL")
|
||||
class ValidUrlTests {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"https://example.com", "http://example.com/path?q=1"})
|
||||
@DisplayName("well-formed external URLs are valid")
|
||||
void validUrls(String url) {
|
||||
assertTrue(GeneralUtils.isValidURL(url));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"htp:/bad", "not a url", "://missing-scheme"})
|
||||
@DisplayName("malformed URLs are rejected")
|
||||
void invalidUrls(String url) {
|
||||
assertFalse(GeneralUtils.isValidURL(url));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isValidUUID")
|
||||
class UuidTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null is not a valid UUID")
|
||||
void nullUuid() {
|
||||
assertFalse(GeneralUtils.isValidUUID(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("well-formed UUID is accepted")
|
||||
void validUuid() {
|
||||
assertTrue(GeneralUtils.isValidUUID("123e4567-e89b-12d3-a456-426614174000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("garbage string is rejected")
|
||||
void garbageUuid() {
|
||||
assertFalse(GeneralUtils.isValidUUID("xyz"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("createDir failure path")
|
||||
class CreateDirFailureTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when directory creation throws IOException")
|
||||
void createDirIoFailure(@TempDir Path tempDir) throws IOException {
|
||||
// A regular file at the target path makes createDirectories fail.
|
||||
Path asFile = tempDir.resolve("not-a-dir");
|
||||
Files.writeString(asFile, "blocker");
|
||||
Path child = asFile.resolve("child");
|
||||
assertFalse(GeneralUtils.createDir(child.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractScript validation")
|
||||
class ExtractScriptTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("null or blank name is rejected")
|
||||
void nullOrBlank() {
|
||||
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(null));
|
||||
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(" "));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"../evil.py", "dir/script.py"})
|
||||
@DisplayName("path-traversal characters are rejected")
|
||||
void pathTraversalRejected(String name) {
|
||||
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(name));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("name outside the allow-list is rejected")
|
||||
void notInAllowList() {
|
||||
assertThatThrownBy(() -> GeneralUtils.extractScript("random.py"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("png_to_webp.py");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractPipeline invalid configuration")
|
||||
class ExtractPipelineTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("missing classpath resource surfaces as IOException")
|
||||
void missingResource(@TempDir Path tempDir) {
|
||||
// Point the pipeline path at a temp dir; default pipeline JSONs are absent from
|
||||
// the common module test classpath, so extraction fails with an IOException.
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
Mockito.mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getPipelinePath).thenReturn(tempDir.toString());
|
||||
assertThrows(IOException.class, GeneralUtils::extractPipeline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("optimizePdfWithGhostscript failure handling")
|
||||
class OptimizeGhostscriptTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-zero return code raises a Ghostscript exception")
|
||||
void nonZeroReturnCode() throws Exception {
|
||||
ProcessExecutor.ProcessExecutorResult result =
|
||||
mock(ProcessExecutor.ProcessExecutorResult.class);
|
||||
when(result.getMessages()).thenReturn("some ghostscript chatter");
|
||||
when(result.getRc()).thenReturn(1);
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
// doReturn avoids referencing the checked-exception-declaring method during stubbing
|
||||
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(
|
||||
() ->
|
||||
ProcessExecutor.getInstance(
|
||||
ProcessExecutor.Processes.GHOSTSCRIPT))
|
||||
.thenReturn(executor);
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3}));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("detected critical Ghostscript error is rethrown")
|
||||
void criticalErrorDetected() throws Exception {
|
||||
ProcessExecutor.ProcessExecutorResult result =
|
||||
mock(ProcessExecutor.ProcessExecutorResult.class);
|
||||
when(result.getMessages()).thenReturn("Page 1\ncould not draw this page");
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(
|
||||
() ->
|
||||
ProcessExecutor.getInstance(
|
||||
ProcessExecutor.Processes.GHOSTSCRIPT))
|
||||
.thenReturn(executor);
|
||||
assertThatThrownBy(
|
||||
() -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3}))
|
||||
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("selectBestSiteLocalIp edge cases")
|
||||
class SelectBestIpTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("empty interface list returns null")
|
||||
void emptyList() {
|
||||
assertNull(GeneralUtils.selectBestSiteLocalIp(List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-private routable-style site-local IP still scores and is selected")
|
||||
void otherRangeStillSelected() {
|
||||
GeneralUtils.NetworkInterfaceInfo other =
|
||||
new GeneralUtils.NetworkInterfaceInfo(
|
||||
"eth0",
|
||||
"Realtek PCIe GbE Family Controller",
|
||||
2,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
List.of("172.16.5.5"));
|
||||
assertEquals("172.16.5.5", GeneralUtils.selectBestSiteLocalIp(List.of(other)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
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.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
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.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link PdfToCbrUtils#convertPdfToCbr} that drive the real PDFBox render
|
||||
* loop with a tiny one-page PDF and mock the external {@code rar} process so the archive-creation
|
||||
* branch is exercised without any external tool.
|
||||
*/
|
||||
class PdfToCbrUtilsMoreTest {
|
||||
|
||||
/** A one-page PDF containing a small embedded image so the renderer produces a PNG. */
|
||||
private static byte[] onePageImagePdf() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(new PDRectangle(72, 72));
|
||||
doc.addPage(page);
|
||||
|
||||
BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(Color.BLUE);
|
||||
g.fillRect(0, 0, 16, 16);
|
||||
g.dispose();
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(pdImage, 0, 0, 72, 72);
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static MultipartFile pdfMultipart(byte[] bytes) {
|
||||
return new MockMultipartFile("file", "comic.pdf", "application/pdf", bytes);
|
||||
}
|
||||
|
||||
private static CustomPDFDocumentFactory factoryReturning(PDDocument document)
|
||||
throws IOException {
|
||||
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
|
||||
when(factory.load(any(MultipartFile.class))).thenReturn(document);
|
||||
return factory;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToCbr - rar process branches")
|
||||
class RarProcessTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-zero rar exit code surfaces as a processing exception")
|
||||
void rarNonZeroExit() throws Exception {
|
||||
PDDocument doc = Loader.loadPDF(onePageImagePdf());
|
||||
CustomPDFDocumentFactory factory = factoryReturning(doc);
|
||||
|
||||
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
|
||||
when(result.getRc()).thenReturn(1);
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(
|
||||
() ->
|
||||
ProcessExecutor.getInstance(
|
||||
ProcessExecutor.Processes.INSTALL_APP))
|
||||
.thenReturn(executor);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
pdfMultipart(onePageImagePdf()), 72, factory))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
doc.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rc=0 but missing rar output file raises 'RAR file was not created'")
|
||||
void rarFileNotCreated() throws Exception {
|
||||
PDDocument doc = Loader.loadPDF(onePageImagePdf());
|
||||
CustomPDFDocumentFactory factory = factoryReturning(doc);
|
||||
|
||||
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
|
||||
when(result.getRc()).thenReturn(0);
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
// No real rar runs, so the expected output.cbr is never produced.
|
||||
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(
|
||||
() ->
|
||||
ProcessExecutor.getInstance(
|
||||
ProcessExecutor.Processes.INSTALL_APP))
|
||||
.thenReturn(executor);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
pdfMultipart(onePageImagePdf()), 72, factory))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("RAR");
|
||||
}
|
||||
doc.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an interrupted rar process is wrapped and the thread interrupt is restored")
|
||||
void rarInterrupted() throws Exception {
|
||||
PDDocument doc = Loader.loadPDF(onePageImagePdf());
|
||||
CustomPDFDocumentFactory factory = factoryReturning(doc);
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
Mockito.doThrow(new InterruptedException("boom"))
|
||||
.when(executor)
|
||||
.runCommandWithOutputHandling(anyList(), any());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
mocked.when(
|
||||
() ->
|
||||
ProcessExecutor.getInstance(
|
||||
ProcessExecutor.Processes.INSTALL_APP))
|
||||
.thenReturn(executor);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
pdfMultipart(onePageImagePdf()), 72, factory))
|
||||
.isInstanceOf(Exception.class);
|
||||
} finally {
|
||||
// Clear the interrupt flag set by the handler so it doesn't leak into later tests.
|
||||
Thread.interrupted();
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToCbr - document validation")
|
||||
class DocumentValidationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a zero-page document raises a no-pages exception before rendering")
|
||||
void zeroPageDocument() throws Exception {
|
||||
try (PDDocument empty = new PDDocument()) {
|
||||
CustomPDFDocumentFactory factory = factoryReturning(empty);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PdfToCbrUtils.convertPdfToCbr(
|
||||
pdfMultipart(onePageImagePdf()), 72, factory))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isPdfFile")
|
||||
class IsPdfFileTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a .cbr file is not a PDF")
|
||||
void cbrIsNotPdf() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("comic.cbr");
|
||||
assertThat(PdfToCbrUtils.isPdfFile(file)).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
|
||||
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.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
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.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
/**
|
||||
* Further gap-filling tests for {@link PdfUtils}, complementing {@code PdfUtilsTest} and {@code
|
||||
* PdfUtilsGapTest}: the form-XObject recursion in image discovery, the found-text branch, the
|
||||
* ApplicationProperties-present DPI lookups, the rotated/duplicate page-size paths, and the
|
||||
* multi-frame TIFF input path of imageToPdf.
|
||||
*/
|
||||
class PdfUtilsMoreTest {
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/** Builds a PDF whose pages each show 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;
|
||||
}
|
||||
|
||||
/** A small one-page PDF 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 an ApplicationProperties whose system reports the given max DPI. */
|
||||
private static ApplicationProperties propsWithMaxDpi(int dpi) {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().setMaxDPI(dpi);
|
||||
return props;
|
||||
}
|
||||
|
||||
/** Encodes a multi-frame TIFF (two solid-colour frames) to bytes. */
|
||||
private static byte[] multiFrameTiff() throws IOException {
|
||||
ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
|
||||
writer.setOutput(ios);
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
writer.prepareWriteSequence(null);
|
||||
for (Color c : new Color[] {Color.RED, Color.BLUE}) {
|
||||
BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(c);
|
||||
g.fillRect(0, 0, 16, 16);
|
||||
g.dispose();
|
||||
writer.writeToSequence(new IIOImage(img, null, null), param);
|
||||
}
|
||||
writer.endWriteSequence();
|
||||
}
|
||||
writer.dispose();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ---- getAllImages recursion --------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAllImages with form XObjects")
|
||||
class GetAllImagesForm {
|
||||
|
||||
@Test
|
||||
@DisplayName("images nested inside a form XObject are discovered recursively")
|
||||
void recursesIntoFormXObject() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
// Build a form XObject that itself holds an image in its resources.
|
||||
PDFormXObject form = new PDFormXObject(doc);
|
||||
form.setResources(new PDResources());
|
||||
BufferedImage bi = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject nested = LosslessFactory.createFromImage(doc, bi);
|
||||
form.getResources().add(nested);
|
||||
|
||||
PDResources pageResources = new PDResources();
|
||||
pageResources.add(form);
|
||||
|
||||
assertThat(PdfUtils.getAllImages(pageResources)).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hasText found branch ----------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("hasText found branch")
|
||||
class HasTextFound {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true when the phrase is present on a searched page")
|
||||
void findsPhrase() throws IOException {
|
||||
try (PDDocument doc = docWithText("NeedleInHaystack")) {
|
||||
assertThat(PdfUtils.hasText(doc, "all", "NeedleInHaystack")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true when the phrase is on the requested page only")
|
||||
void findsPhraseOnSecondPage() throws IOException {
|
||||
try (PDDocument doc = docWithText("first", "SecondMarker")) {
|
||||
assertThat(PdfUtils.hasText(doc, "2", "SecondMarker")).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- convertFromPdf with ApplicationProperties present ------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertFromPdf honouring configured max DPI")
|
||||
class ConvertFromPdfWithProps {
|
||||
|
||||
@Test
|
||||
@DisplayName("DPI under the configured limit renders; properties branch is taken")
|
||||
void underConfiguredLimitRenders() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(new PDRectangle(20f, 20f)));
|
||||
when(factory.load(bytes)).thenReturn(doc);
|
||||
|
||||
try (MockedStatic<ApplicationContextProvider> ctx =
|
||||
Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(propsWithMaxDpi(200));
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, bytes, "png", ImageType.RGB, true, 72, "doc", true);
|
||||
assertThat(out).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DPI above the configured limit throws using the configured maximum")
|
||||
void aboveConfiguredLimitThrows() {
|
||||
byte[] bytes = new byte[] {1, 2, 3};
|
||||
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
|
||||
|
||||
try (MockedStatic<ApplicationContextProvider> ctx =
|
||||
Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(propsWithMaxDpi(100));
|
||||
|
||||
// 150 exceeds the configured limit of 100, so the limit check fires before loading.
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
factory,
|
||||
bytes,
|
||||
"png",
|
||||
ImageType.RGB,
|
||||
true,
|
||||
150,
|
||||
"doc",
|
||||
true));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("combined-image mode reuses the cached size for duplicate pages")
|
||||
void combinedImageReusesDuplicatePageSize() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
|
||||
PDDocument doc = new PDDocument();
|
||||
// Two identically-sized pages: the second hits the size cache.
|
||||
doc.addPage(new PDPage(new PDRectangle(20f, 30f)));
|
||||
doc.addPage(new PDPage(new PDRectangle(20f, 30f)));
|
||||
when(factory.load(bytes)).thenReturn(doc);
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, bytes, "png", ImageType.RGB, true, 36, "doc", true);
|
||||
assertThat(out).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("combined-image mode swaps dimensions for a rotated page")
|
||||
void combinedImageRotatedPage() throws Exception {
|
||||
byte[] bytes = simplePdfBytes();
|
||||
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage rotated = new PDPage(new PDRectangle(20f, 30f));
|
||||
rotated.setRotation(90);
|
||||
doc.addPage(rotated);
|
||||
when(factory.load(bytes)).thenReturn(doc);
|
||||
|
||||
byte[] out =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, bytes, "png", ImageType.RGB, true, 36, "doc", true);
|
||||
assertThat(out).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- convertPdfToPdfImage with ApplicationProperties present ------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToPdfImage honouring configured DPI")
|
||||
class ConvertPdfToPdfImageWithProps {
|
||||
|
||||
@Test
|
||||
@DisplayName("renders using the configured max DPI when properties are present")
|
||||
void usesConfiguredDpi() throws IOException {
|
||||
try (MockedStatic<ApplicationContextProvider> ctx =
|
||||
Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(propsWithMaxDpi(72));
|
||||
|
||||
try (PDDocument source = new PDDocument()) {
|
||||
source.addPage(new PDPage(new PDRectangle(12f, 18f)));
|
||||
try (PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- imageToPdf with a multi-frame TIFF --------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("imageToPdf with TIFF input")
|
||||
class ImageToPdfTiff {
|
||||
|
||||
@Test
|
||||
@DisplayName("a multi-frame TIFF produces one page per frame")
|
||||
void multiFrameTiffBecomesMultiplePages() throws IOException {
|
||||
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
|
||||
when(factory.createNewDocument()).thenReturn(new PDDocument());
|
||||
|
||||
MockMultipartFile tiff =
|
||||
new MockMultipartFile("file", "scan.tiff", "image/tiff", multiFrameTiff());
|
||||
|
||||
byte[] pdfOut =
|
||||
PdfUtils.imageToPdf(
|
||||
new MultipartFile[] {tiff}, "fillPage", false, "color", factory);
|
||||
|
||||
assertThat(pdfOut).isNotEmpty();
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a .tif extension is also handled by the TIFF reader path")
|
||||
void tifExtensionHandled() throws IOException {
|
||||
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
|
||||
when(factory.createNewDocument()).thenReturn(new PDDocument());
|
||||
|
||||
MockMultipartFile tif =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"scan.tif",
|
||||
MediaType.APPLICATION_OCTET_STREAM_VALUE,
|
||||
multiFrameTiff());
|
||||
|
||||
byte[] pdfOut =
|
||||
PdfUtils.imageToPdf(
|
||||
new MultipartFile[] {tif}, "fillPage", false, "color", factory);
|
||||
|
||||
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
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.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
/**
|
||||
* Tests that drive {@link ProcessExecutor#runCommandWithOutputHandling} through its full
|
||||
* output-handling logic by intercepting {@link ProcessBuilder} construction with {@link
|
||||
* MockedConstruction}. The {@link Process} is mocked, so no real OS process is ever started.
|
||||
*/
|
||||
class ProcessExecutorMoreTest {
|
||||
|
||||
private ProcessExecutor qpdfExecutor() {
|
||||
return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
|
||||
}
|
||||
|
||||
private ProcessExecutor ghostscriptExecutor() {
|
||||
return ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
}
|
||||
|
||||
/** Configure a mocked Process with given streams, completion flag and exit code. */
|
||||
private static Process mockedProcess(
|
||||
String stdout, String stderr, boolean finished, int exitCode)
|
||||
throws InterruptedException {
|
||||
Process process = mock(Process.class);
|
||||
when(process.getInputStream())
|
||||
.thenReturn(new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)));
|
||||
when(process.getErrorStream())
|
||||
.thenReturn(new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8)));
|
||||
when(process.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(finished);
|
||||
when(process.exitValue()).thenReturn(exitCode);
|
||||
when(process.descendants()).thenReturn(Stream.empty());
|
||||
return process;
|
||||
}
|
||||
|
||||
/** Stub every constructed ProcessBuilder so start() returns the supplied process. */
|
||||
private MockedConstruction<ProcessBuilder> stubProcessBuilder(Process process) {
|
||||
return Mockito.mockConstruction(
|
||||
ProcessBuilder.class,
|
||||
(mockBuilder, context) -> {
|
||||
when(mockBuilder.start()).thenReturn(process);
|
||||
when(mockBuilder.directory(any())).thenReturn(mockBuilder);
|
||||
});
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("runCommandWithOutputHandling - exit code handling")
|
||||
class ExitCodeTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a successful command (exit 0) returns rc=0 and captured output")
|
||||
void successReturnsZero() throws Exception {
|
||||
Process process = mockedProcess("hello output", "", true, 0);
|
||||
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
|
||||
ProcessExecutorResult result =
|
||||
qpdfExecutor().runCommandWithOutputHandling(List.of("qpdf", "--version"));
|
||||
assertThat(result.getRc()).isEqualTo(0);
|
||||
assertThat(result.getMessages()).contains("hello output");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-zero exit code with error output throws an IOException")
|
||||
void nonZeroExitThrows() throws Exception {
|
||||
Process process = mockedProcess("", "fatal: boom", true, 2);
|
||||
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
ghostscriptExecutor()
|
||||
.runCommandWithOutputHandling(
|
||||
List.of("gs", "-bad")))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("exit code 2");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-zero exit code without error output still throws with the log tail")
|
||||
void nonZeroExitNoStderrThrows() throws Exception {
|
||||
Process process = mockedProcess("some stdout only", "", true, 5);
|
||||
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
ghostscriptExecutor()
|
||||
.runCommandWithOutputHandling(List.of("gs", "x")))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("exit code 5");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("runCommandWithOutputHandling - qpdf special-casing")
|
||||
class QpdfTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("qpdf exit code 3 is treated as success-with-warnings, not a failure")
|
||||
void qpdfExitThreeIsWarning() throws Exception {
|
||||
Process process = mockedProcess("", "WARNING: minor issue", true, 3);
|
||||
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
|
||||
ProcessExecutorResult result =
|
||||
qpdfExecutor()
|
||||
.runCommandWithOutputHandling(List.of("qpdf", "--check", "in.pdf"));
|
||||
assertThat(result.getRc()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("qpdf exit code 2 is still a hard failure")
|
||||
void qpdfExitTwoFails() throws Exception {
|
||||
Process process = mockedProcess("", "ERROR: broken", true, 2);
|
||||
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
qpdfExecutor()
|
||||
.runCommandWithOutputHandling(
|
||||
List.of("qpdf", "in.pdf")))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("runCommandWithOutputHandling - timeout")
|
||||
class TimeoutTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a process that never finishes is destroyed and an IOException is thrown")
|
||||
void timeoutThrows() throws Exception {
|
||||
Process process = mockedProcess("", "", false, 0);
|
||||
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
qpdfExecutor()
|
||||
.runCommandWithOutputHandling(
|
||||
List.of("qpdf", "slow")))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("timeout");
|
||||
Mockito.verify(process).destroyForcibly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("runCommandWithOutputHandling - working directory overload")
|
||||
class WorkingDirectoryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("the working-directory overload runs the command and applies the directory")
|
||||
void withWorkingDirectory() throws Exception {
|
||||
Process process = mockedProcess("ok", "", true, 0);
|
||||
try (MockedConstruction<ProcessBuilder> construction = stubProcessBuilder(process)) {
|
||||
ProcessExecutorResult result =
|
||||
qpdfExecutor()
|
||||
.runCommandWithOutputHandling(
|
||||
List.of("qpdf", "--version"),
|
||||
new java.io.File(System.getProperty("java.io.tmpdir")));
|
||||
assertThat(result.getRc()).isEqualTo(0);
|
||||
// directory(...) must have been applied to the single constructed builder.
|
||||
ProcessBuilder built = construction.constructed().get(0);
|
||||
Mockito.verify(built).directory(any(java.io.File.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Gap-coverage tests for {@link RegexPatternUtils}. The existing RegexPatternUtilsTest covers
|
||||
* caching mechanics; this file exercises the many lazily-built named accessor patterns, the static
|
||||
* regex string getters, flag-aware cache operations, and the invalid-regex compile path.
|
||||
*/
|
||||
class RegexPatternUtilsMoreTest {
|
||||
|
||||
private final RegexPatternUtils utils = RegexPatternUtils.getInstance();
|
||||
|
||||
@Nested
|
||||
@DisplayName("static regex string getters")
|
||||
class StaticRegexTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("whitespace and extension regex strings are returned")
|
||||
void staticStrings() {
|
||||
assertEquals("\\s++", RegexPatternUtils.getWhitespaceRegex());
|
||||
assertEquals("\\.(?:[^.]*+)?$", RegexPatternUtils.getExtensionRegex());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("supported new field types contains the documented set")
|
||||
void supportedFieldTypes() {
|
||||
Set<String> types = utils.getSupportedNewFieldTypes();
|
||||
assertThat(types)
|
||||
.contains(
|
||||
"text",
|
||||
"checkbox",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"radio",
|
||||
"button",
|
||||
"signature");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("flag-aware cache operations")
|
||||
class FlagCacheTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("removeFromCache with flags removes the flagged entry only")
|
||||
void removeWithFlags() {
|
||||
String regex = "moreflagcache\\d+";
|
||||
utils.getPattern(regex, Pattern.CASE_INSENSITIVE);
|
||||
assertTrue(utils.isCached(regex, Pattern.CASE_INSENSITIVE));
|
||||
|
||||
assertTrue(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE));
|
||||
assertFalse(utils.isCached(regex, Pattern.CASE_INSENSITIVE));
|
||||
// Removing again returns false.
|
||||
assertFalse(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isCached with flags is false for null regex")
|
||||
void isCachedNullWithFlags() {
|
||||
assertFalse(utils.isCached(null, Pattern.CASE_INSENSITIVE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removeFromCache with flags is false for null regex")
|
||||
void removeNullWithFlags() {
|
||||
assertFalse(utils.removeFromCache(null, Pattern.CASE_INSENSITIVE));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("invalid regex compilation")
|
||||
class InvalidRegexTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("an invalid pattern propagates PatternSyntaxException")
|
||||
void invalidPattern() {
|
||||
assertThrows(PatternSyntaxException.class, () -> utils.getPattern("[unclosed"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("path and filename patterns")
|
||||
class PathFilenameTests {
|
||||
|
||||
@Test
|
||||
void driveLetterPattern() {
|
||||
assertTrue(utils.getDriveLetterPattern().matcher("C:\\Users\\x").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leadingSlashesPattern() {
|
||||
assertTrue(utils.getLeadingSlashesPattern().matcher("//leading").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backslashPattern() {
|
||||
assertTrue(utils.getBackslashPattern().matcher("a\\b").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filenameSafePattern() {
|
||||
assertTrue(utils.getFilenameSafePattern().matcher("a!b").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAlnumUnderscorePattern() {
|
||||
assertTrue(utils.getNonAlnumUnderscorePattern().matcher("a-b").find());
|
||||
assertFalse(utils.getNonAlnumUnderscorePattern().matcher("a_b").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void underscoreCollapsePatterns() {
|
||||
assertTrue(utils.getMultipleUnderscoresPattern().matcher("a__b").find());
|
||||
assertTrue(utils.getLeadingUnderscoresPattern().matcher("__a").find());
|
||||
assertTrue(utils.getTrailingUnderscoresPattern().matcher("a__").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadDownloadPathPattern() {
|
||||
assertTrue(utils.getUploadDownloadPathPattern().matcher("/api/UPLOAD/file").matches());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("whitespace, newline and word patterns")
|
||||
class WhitespaceNewlineTests {
|
||||
|
||||
@Test
|
||||
void whitespaceAndWordSplit() {
|
||||
assertEquals(2, utils.getWordSplitPattern().split("a b").length);
|
||||
assertTrue(utils.getWhitespacePattern().matcher("a b").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void punctuationPattern() {
|
||||
assertTrue(utils.getPunctuationPattern().matcher("a!b").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlineVariants() {
|
||||
assertTrue(utils.getNewlinesPattern().matcher("a\r\nb").find());
|
||||
assertTrue(utils.getNewlineSplitPattern().matcher("a\nb").find());
|
||||
assertTrue(utils.getCarriageReturnPattern().matcher("a\rb").find());
|
||||
assertTrue(utils.getNewlineCharsPattern().matcher("a\nb").find());
|
||||
assertTrue(utils.getMultiFormatNewlinePattern().matcher("a\r\nb").find());
|
||||
assertTrue(utils.getEncodedPayloadNewlinePattern().matcher("a\nb").find());
|
||||
assertTrue(utils.getLineSeparatorPattern().matcher("a\nb").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapedNewlinePattern() {
|
||||
assertTrue(utils.getEscapedNewlinePattern().matcher("line\\nbreak").find());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("sanitization and field-name patterns")
|
||||
class SanitizationTests {
|
||||
|
||||
@Test
|
||||
void inputSanitizePattern() {
|
||||
assertTrue(utils.getInputSanitizePattern().matcher("a@b").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void formFieldBracketPattern() {
|
||||
assertEquals(
|
||||
"field", utils.getFormFieldBracketPattern().matcher("field[0]").replaceAll(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void underscoreHyphenPattern() {
|
||||
assertTrue(utils.getUnderscoreHyphenPattern().matcher("a-_b").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void camelCaseBoundaryPattern() {
|
||||
assertEquals(
|
||||
"first Name",
|
||||
utils.getCamelCaseBoundaryPattern().matcher("firstName").replaceAll(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void angleBracketsAndQuotes() {
|
||||
assertTrue(utils.getAngleBracketsPattern().matcher("a<b>c").find());
|
||||
assertTrue(utils.getQuotesRemovalPattern().matcher("\"q\"").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void plusAndPipe() {
|
||||
assertTrue(utils.getPlusSignPattern().matcher("a+b").find());
|
||||
assertEquals(2, utils.getPipeDelimiterPattern().split("a|b").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usernameValidationPattern() {
|
||||
assertTrue(utils.getUsernameValidationPattern().matcher("john_doe1").matches());
|
||||
assertFalse(utils.getUsernameValidationPattern().matcher("a--b").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericAndSimpleFieldPatterns() {
|
||||
assertTrue(utils.getGenericFieldNamePattern().matcher("Field 1").matches());
|
||||
assertTrue(utils.getSimpleFormFieldPattern().matcher("t1").matches());
|
||||
assertTrue(utils.getOptionalTNumericPattern().matcher("t 12").matches());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("number and math patterns")
|
||||
class NumberMathTests {
|
||||
|
||||
@Test
|
||||
void numericExtractionAndDigitPatterns() {
|
||||
assertTrue(utils.getNumericExtractionPattern().matcher("a1").find());
|
||||
assertTrue(utils.getNonDigitDotPattern().matcher("1a").find());
|
||||
assertTrue(utils.getDigitDotPattern().matcher("1.0").find());
|
||||
assertTrue(utils.getContainsDigitsPattern().matcher("ab12cd").matches());
|
||||
assertTrue(utils.getNumberRangePattern().matcher("250").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mathExpressionPatterns() {
|
||||
assertTrue(utils.getMathExpressionPattern().matcher("2n+1").matches());
|
||||
assertTrue(utils.getNumberBeforeNPattern().matcher("4n").find());
|
||||
assertTrue(utils.getConsecutiveNPattern().matcher("annb").matches());
|
||||
assertTrue(utils.getConsecutiveNReplacementPattern().matcher("nn").find());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("url, email and html patterns")
|
||||
class UrlEmailHtmlTests {
|
||||
|
||||
@Test
|
||||
void httpAndLinkPatterns() {
|
||||
assertTrue(utils.getHttpUrlPattern().matcher("https://x.com").matches());
|
||||
assertTrue(utils.getUrlLinkPattern().matcher("see http://x.com/a").find());
|
||||
assertTrue(utils.getEmailLinkPattern().matcher("a@b.com").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emailValidationPattern() {
|
||||
assertTrue(utils.getEmailValidationPattern().matcher("user@example.com").matches());
|
||||
assertFalse(utils.getEmailValidationPattern().matcher("not-an-email").matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scriptStyleAndCssPatterns() {
|
||||
assertTrue(utils.getScriptTagPattern().matcher("<script>x()</script>").find());
|
||||
assertTrue(utils.getStyleTagPattern().matcher("<style>a{}</style>").find());
|
||||
assertTrue(utils.getFixedPositionCssPattern().matcher("position: fixed;").find());
|
||||
assertTrue(utils.getAbsolutePositionCssPattern().matcher("position: absolute;").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void inlineCidAndImagePatterns() {
|
||||
assertTrue(utils.getInlineCidImagePattern().matcher("<img src=\"cid:abc\">").find());
|
||||
assertTrue(utils.getImageFilePattern().matcher("photo.JPG").matches());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("size, temp-file and mime patterns")
|
||||
class SizeTempMimeTests {
|
||||
|
||||
@Test
|
||||
void sizeUnitPattern() {
|
||||
assertTrue(utils.getSizeUnitPattern().matcher("MB").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemTempFilePatterns() {
|
||||
assertTrue(utils.getSystemTempFile1Pattern().matcher("lu123abc.tmp").find());
|
||||
assertTrue(utils.getSystemTempFile2Pattern().matcher("ocr_process42").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whitespaceParensSplit() {
|
||||
assertTrue(utils.getWhitespaceParenthesesSplitPattern().matcher("a (b)").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mimeHeaderAndEncodedWord() {
|
||||
assertTrue(utils.getMimeHeaderWhitespacePattern().matcher("a =?utf-8").find());
|
||||
assertTrue(utils.getMimeEncodedWordPattern().matcher("=?utf-8?B?abc?=").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fontNamePattern() {
|
||||
assertTrue(utils.getFontNamePattern().matcher("ABCDEF+Arial").matches());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("xml, attachment and api-doc patterns")
|
||||
class XmlAttachmentApiTests {
|
||||
|
||||
@Test
|
||||
void accessReadOnlyAndXmpPatterns() {
|
||||
assertTrue(utils.getAccessReadOnlyPattern().matcher("access=\"readOnly\"").find());
|
||||
assertTrue(utils.getPdfAidPartPattern().matcher("pdfaid:part=\"2\"").find());
|
||||
assertTrue(
|
||||
utils.getPdfAidConformancePattern().matcher("pdfaid:conformance=\"B\"").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void attachmentPatterns() {
|
||||
assertTrue(utils.getAttachmentSectionPattern().matcher("Attachments (3)").find());
|
||||
assertTrue(utils.getAttachmentFilenamePattern().matcher("@ file.txt").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageModeAndApiDocPatterns() {
|
||||
assertTrue(utils.getPageModePattern().matcher("a/b").find());
|
||||
assertTrue(utils.getApiDocOutputTypePattern().matcher("Output: PDF").find());
|
||||
assertTrue(utils.getApiDocInputTypePattern().matcher("Input: PDF").find());
|
||||
assertTrue(utils.getApiDocTypePattern().matcher("Type: WEB").find());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileExtensionValidationAndLeadingAsterisks() {
|
||||
assertTrue(utils.getFileExtensionValidationPattern().matcher("pdf").matches());
|
||||
assertFalse(utils.getFileExtensionValidationPattern().matcher("a").matches());
|
||||
assertEquals(
|
||||
"text",
|
||||
utils.getLeadingAsterisksWhitespacePattern()
|
||||
.matcher("** text")
|
||||
.replaceFirst(""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every cached accessor returns a non-null pattern")
|
||||
void accessorsNeverNull() {
|
||||
assertNotNull(utils.getTrailingSlashesPattern());
|
||||
assertNotNull(utils.getSafeFilenamePattern());
|
||||
assertNotNull(utils.getWordSplitPattern());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
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.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
class YamlHelperMoreTest {
|
||||
|
||||
private static final LoadSettings LOAD_SETTINGS =
|
||||
LoadSettings.builder()
|
||||
.setUseMarks(true)
|
||||
.setMaxAliasesForCollections(Integer.MAX_VALUE)
|
||||
.setAllowRecursiveKeys(true)
|
||||
.setParseComments(true)
|
||||
.build();
|
||||
|
||||
private YamlHelper helper(String yaml) {
|
||||
return new YamlHelper(LOAD_SETTINGS, yaml);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateValue value-type handling")
|
||||
class UpdateValueTypes {
|
||||
|
||||
@Test
|
||||
@DisplayName("updates an integer value with INT tag")
|
||||
void integerValue() {
|
||||
YamlHelper h = helper("server:\n port: 80\n");
|
||||
assertThat(h.updateValue(List.of("server", "port"), 8080)).isTrue();
|
||||
assertThat(h.getValueByExactKeyPath("server", "port")).isEqualTo("8080");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updates a float value")
|
||||
void floatValue() {
|
||||
YamlHelper h = helper("scale:\n factor: 1.0\n");
|
||||
assertThat(h.updateValue(List.of("scale", "factor"), 2.5f)).isTrue();
|
||||
assertThat(String.valueOf(h.getValueByExactKeyPath("scale", "factor")))
|
||||
.startsWith("2.5");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updates a boolean value via string literal")
|
||||
void booleanValue() {
|
||||
YamlHelper h = helper("flags:\n on: false\n");
|
||||
assertThat(h.updateValue(List.of("flags", "on"), "true")).isTrue();
|
||||
assertThat(h.getValueByExactKeyPath("flags", "on")).isEqualTo("true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces a scalar with a Map value (MappingNode)")
|
||||
void mapValue() {
|
||||
YamlHelper h = helper("meta:\n data: placeholder\n");
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("author", "alice");
|
||||
map.put("year", 2024);
|
||||
assertThat(h.updateValue(List.of("meta", "data"), map)).isTrue();
|
||||
assertThat(h.getValueByExactKeyPath("meta", "data", "author")).isEqualTo("alice");
|
||||
assertThat(h.getValueByExactKeyPath("meta", "data", "year")).isEqualTo("2024");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces a scalar with a List value (SequenceNode)")
|
||||
void listValue() {
|
||||
YamlHelper h = helper("cfg:\n items: x\n");
|
||||
assertThat(h.updateValue(List.of("cfg", "items"), List.of("a", "b", "c"))).isTrue();
|
||||
Object value = h.getValueByExactKeyPath("cfg", "items");
|
||||
assertThat(value).isInstanceOf(List.class);
|
||||
List<?> list = (List<?>) value;
|
||||
assertThat(list).hasSize(3);
|
||||
assertThat(list.toString()).contains("a").contains("b").contains("c");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("list with mixed scalar element types is converted")
|
||||
void mixedListValue() {
|
||||
YamlHelper h = helper("cfg:\n vals: x\n");
|
||||
assertThat(h.updateValue(List.of("cfg", "vals"), List.of("s", 1, 2.5, "true")))
|
||||
.isTrue();
|
||||
Object value = h.getValueByExactKeyPath("cfg", "vals");
|
||||
assertThat((List<?>) value).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updates a previously null scalar")
|
||||
void nullScalarBecomesValue() {
|
||||
YamlHelper h = helper("opt:\n value:\n");
|
||||
assertThat(h.updateValue(List.of("opt", "value"), "set")).isTrue();
|
||||
assertThat(h.getValueByExactKeyPath("opt", "value")).isEqualTo("set");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updates a null scalar to a boolean (BOOL tag promotion)")
|
||||
void nullScalarBecomesBoolean() {
|
||||
YamlHelper h = helper("opt:\n enabled:\n");
|
||||
assertThat(h.updateValue(List.of("opt", "enabled"), Boolean.TRUE)).isTrue();
|
||||
assertThat(h.getValueByExactKeyPath("opt", "enabled")).isEqualTo("true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when intermediate key path is not a mapping")
|
||||
void nonMappingPathReturnsFalse() {
|
||||
YamlHelper h = helper("server:\n port: 80\n");
|
||||
// 'port' is a scalar, so descending into it cannot update.
|
||||
assertThat(h.updateValue(List.of("server", "port", "deeper"), "x")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateValuesFromYaml")
|
||||
class UpdateFromYaml {
|
||||
|
||||
@Test
|
||||
@DisplayName("copies differing existing keys from source into target")
|
||||
void copiesChangedValues() {
|
||||
YamlHelper target = helper("server:\n port: 80\n host: localhost\n");
|
||||
YamlHelper source = helper("server:\n port: 9090\n host: localhost\n");
|
||||
boolean updated = target.updateValuesFromYaml(source, target);
|
||||
assertThat(updated).isTrue();
|
||||
assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("9090");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("source keys absent from target are not added (no update)")
|
||||
void unknownKeysIgnored() {
|
||||
YamlHelper target = helper("server:\n port: 80\n");
|
||||
YamlHelper source = helper("server:\n port: 80\n");
|
||||
boolean updated = target.updateValuesFromYaml(source, target);
|
||||
assertThat(updated).isFalse();
|
||||
assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("80");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("save / saveOverride / node tracking")
|
||||
class SaveAndNodes {
|
||||
|
||||
@Test
|
||||
@DisplayName("save to the original path is a no-op write but returns the mapping")
|
||||
void saveSamePathNoRewrite(@TempDir Path tempDir) throws IOException {
|
||||
Path file = tempDir.resolve("orig.yaml");
|
||||
Files.writeString(file, "a:\n b: 1\n");
|
||||
YamlHelper h = new YamlHelper(file);
|
||||
h.updateValue(List.of("a", "b"), 2);
|
||||
// Same path: method must not rewrite the file but still return a MappingNode.
|
||||
assertThat(h.save(file)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("saveOverride writes to disk")
|
||||
void saveOverrideWrites(@TempDir Path tempDir) throws IOException {
|
||||
YamlHelper h = helper("a:\n b: 1\n");
|
||||
h.updateValue(List.of("a", "b"), 42);
|
||||
Path out = tempDir.resolve("out.yaml");
|
||||
h.saveOverride(out);
|
||||
assertThat(Files.readString(out)).contains("42");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setNewNode then getUpdatedRootNode returns the set node")
|
||||
void setAndGetNode() {
|
||||
YamlHelper h = helper("a:\n b: 1\n");
|
||||
var root = h.getUpdatedRootNode();
|
||||
h.setNewNode(root);
|
||||
assertThat(h.getUpdatedRootNode()).isSameAs(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("static numeric type checks")
|
||||
class NumericChecks {
|
||||
|
||||
@Test
|
||||
@DisplayName("isShort / isByte accept Long and parsable strings")
|
||||
void shortAndByte() {
|
||||
assertThat(YamlHelper.isShort(5L)).isTrue();
|
||||
assertThat(YamlHelper.isShort("100")).isTrue();
|
||||
assertThat(YamlHelper.isShort("notNumeric")).isFalse();
|
||||
assertThat(YamlHelper.isByte(1L)).isTrue();
|
||||
assertThat(YamlHelper.isByte("7")).isTrue();
|
||||
assertThat(YamlHelper.isByte("999999")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isInteger rejects null and non-numeric, accepts boxed integers")
|
||||
void integerEdges() {
|
||||
assertThat(YamlHelper.isInteger(null)).isFalse();
|
||||
assertThat(YamlHelper.isInteger((byte) 3)).isTrue();
|
||||
assertThat(YamlHelper.isInteger((short) 9)).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
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.io.TempDir;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Tests for {@link ZipExtractionUtils} that build real in-memory ZIP byte streams and exercise
|
||||
* detection, flat extraction, nested-ZIP recursion, directory skipping and corrupt-input handling.
|
||||
* No external process is launched.
|
||||
*/
|
||||
class ZipExtractionUtilsTest {
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
private final List<TempFile> created = new ArrayList<>();
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("test-zip-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
for (TempFile tf : created) {
|
||||
tf.close();
|
||||
}
|
||||
created.clear();
|
||||
}
|
||||
|
||||
// ----- helpers -----------------------------------------------------------
|
||||
|
||||
/** Build a flat ZIP from name->bytes entries. */
|
||||
private static byte[] buildZip(String[] names, byte[][] contents) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
zos.putNextEntry(new ZipEntry(names[i]));
|
||||
if (contents[i] != null) {
|
||||
zos.write(contents[i]);
|
||||
}
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] bytes(String s) {
|
||||
return s.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Resource resource(byte[] data) {
|
||||
return new ByteArrayResource(data);
|
||||
}
|
||||
|
||||
private static String drain(Resource r) throws IOException {
|
||||
try (InputStream is = r.getInputStream()) {
|
||||
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isZip")
|
||||
class IsZipTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("real ZIP magic bytes are detected")
|
||||
void detectsRealZip() throws IOException {
|
||||
byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("hi")});
|
||||
assertThat(ZipExtractionUtils.isZip(resource(zip))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-ZIP content is rejected")
|
||||
void rejectsNonZip() throws IOException {
|
||||
assertThat(ZipExtractionUtils.isZip(resource(bytes("not a zip at all")))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null resource is not a ZIP")
|
||||
void nullResource() throws IOException {
|
||||
assertThat(ZipExtractionUtils.isZip(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("content shorter than the magic prefix is not a ZIP")
|
||||
void tooShort() throws IOException {
|
||||
assertThat(ZipExtractionUtils.isZip(resource(new byte[] {0x50, 0x4B}))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(".cbz filename is explicitly excluded even with ZIP magic bytes")
|
||||
void cbzExcluded() throws IOException {
|
||||
byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")});
|
||||
assertThat(ZipExtractionUtils.isZip(resource(zip), "comic.cbz")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(".cbz exclusion is case-insensitive")
|
||||
void cbzExcludedUppercase() throws IOException {
|
||||
byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")});
|
||||
assertThat(ZipExtractionUtils.isZip(resource(zip), "COMIC.CBZ")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-cbz filename does not suppress detection")
|
||||
void nonCbzFilenameStillDetected() throws IOException {
|
||||
byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("x")});
|
||||
assertThat(ZipExtractionUtils.isZip(resource(zip), "bundle.zip")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("first four bytes that differ from the magic are rejected")
|
||||
void wrongMagicBytes() throws IOException {
|
||||
byte[] data = {0x50, 0x4B, 0x05, 0x06, 0x00, 0x00};
|
||||
assertThat(ZipExtractionUtils.isZip(resource(data))).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractZip")
|
||||
class ExtractZipTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("flat ZIP extracts one resource per file entry with filenames preserved")
|
||||
void flatExtraction() throws IOException {
|
||||
byte[] zip =
|
||||
buildZip(
|
||||
new String[] {"first.txt", "second.txt"},
|
||||
new byte[][] {bytes("one"), bytes("two")});
|
||||
|
||||
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result)
|
||||
.extracting(Resource::getFilename)
|
||||
.containsExactlyInAnyOrder("first.txt", "second.txt");
|
||||
assertThat(drain(result.get(0)) + drain(result.get(1))).contains("one").contains("two");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("directory entries are skipped")
|
||||
void directoriesSkipped() throws IOException {
|
||||
byte[] zip =
|
||||
buildZip(
|
||||
new String[] {"dir/", "dir/file.txt"},
|
||||
new byte[][] {null, bytes("payload")});
|
||||
|
||||
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getFilename()).isEqualTo("dir/file.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty ZIP yields no resources")
|
||||
void emptyZip() throws IOException {
|
||||
byte[] zip = buildZip(new String[] {}, new byte[][] {});
|
||||
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("nested ZIP entries are recursively expanded")
|
||||
void nestedExtraction() throws IOException {
|
||||
byte[] inner =
|
||||
buildZip(new String[] {"inner.txt"}, new byte[][] {bytes("nested-content")});
|
||||
byte[] outer =
|
||||
buildZip(
|
||||
new String[] {"top.txt", "child.zip"},
|
||||
new byte[][] {bytes("top-content"), inner});
|
||||
|
||||
List<Resource> result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager);
|
||||
|
||||
// top.txt + the single file inside child.zip => 2 flat resources
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result)
|
||||
.extracting(Resource::getFilename)
|
||||
.containsExactlyInAnyOrder("top.txt", "inner.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tempFileConsumer receives every created temp file")
|
||||
void consumerInvoked() throws IOException {
|
||||
byte[] zip =
|
||||
buildZip(
|
||||
new String[] {"a.txt", "b.txt"}, new byte[][] {bytes("a"), bytes("b")});
|
||||
|
||||
List<TempFile> seen = new ArrayList<>();
|
||||
List<Resource> result =
|
||||
ZipExtractionUtils.extractZip(
|
||||
resource(zip),
|
||||
tempFileManager,
|
||||
tf -> {
|
||||
seen.add(tf);
|
||||
created.add(tf);
|
||||
});
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(seen).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a .cbz entry inside the ZIP is kept as a single file, not recursed")
|
||||
void cbzEntryNotRecursed() throws IOException {
|
||||
byte[] innerZip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("imgdata")});
|
||||
byte[] outer = buildZip(new String[] {"book.cbz"}, new byte[][] {innerZip});
|
||||
|
||||
List<Resource> result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getFilename()).isEqualTo("book.cbz");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a truncated ZIP entry stream surfaces as an IOException")
|
||||
void corruptZip() throws IOException {
|
||||
// Build a real ZIP with compressible content, then truncate it mid-stream so the
|
||||
// deflate entry cannot be fully read and extraction fails.
|
||||
byte[] valid =
|
||||
buildZip(new String[] {"big.txt"}, new byte[][] {bytes("A".repeat(8192))});
|
||||
byte[] truncated = new byte[valid.length / 2];
|
||||
System.arraycopy(valid, 0, truncated, 0, truncated.length);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
ZipExtractionUtils.extractZip(
|
||||
resource(truncated), tempFileManager))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package stirling.software.common.util.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
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;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.api.misc.HighContrastColorCombination;
|
||||
import stirling.software.common.model.api.misc.ReplaceAndInvert;
|
||||
|
||||
/**
|
||||
* Gap-filling tests for {@link CustomColorReplaceStrategy#replace()} that run the full restyle loop
|
||||
* against real, tiny PDFs built in-memory with PDFBox. No external process is launched.
|
||||
*/
|
||||
class CustomColorReplaceStrategyMoreTest {
|
||||
|
||||
/**
|
||||
* A one-page PDF that draws a line of text so the restyle loop has TextPositions to process.
|
||||
*/
|
||||
private static byte[] pdfWithText(String text) throws IOException {
|
||||
try (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(text);
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] emptyPagePdf() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static MultipartFile pdf(byte[] bytes) {
|
||||
return new MockMultipartFile("file", "input.pdf", "application/pdf", bytes);
|
||||
}
|
||||
|
||||
private static int pageCount(InputStreamResource resource) throws IOException {
|
||||
try (InputStream is = resource.getInputStream();
|
||||
PDDocument doc = Loader.loadPDF(is.readAllBytes())) {
|
||||
return doc.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("replace - custom colours")
|
||||
class CustomColourTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("restyles text and overlays a background, returning a valid PDF")
|
||||
void customColoursProduceValidPdf() throws Exception {
|
||||
CustomColorReplaceStrategy strategy =
|
||||
new CustomColorReplaceStrategy(
|
||||
pdf(pdfWithText("Hello World")),
|
||||
ReplaceAndInvert.CUSTOM_COLOR,
|
||||
"#000000",
|
||||
"#FFFFFF",
|
||||
null);
|
||||
|
||||
InputStreamResource result = strategy.replace();
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(pageCount(result)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a page without any text still gets the background overlay")
|
||||
void emptyPageStillProcessed() throws Exception {
|
||||
CustomColorReplaceStrategy strategy =
|
||||
new CustomColorReplaceStrategy(
|
||||
pdf(emptyPagePdf()),
|
||||
ReplaceAndInvert.CUSTOM_COLOR,
|
||||
"#112233",
|
||||
"#AABBCC",
|
||||
null);
|
||||
|
||||
InputStreamResource result = strategy.replace();
|
||||
assertThat(pageCount(result)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text restyling runs through the font-encoding path without failing")
|
||||
void fontEncodingPathExercised() throws Exception {
|
||||
CustomColorReplaceStrategy strategy =
|
||||
new CustomColorReplaceStrategy(
|
||||
pdf(pdfWithText("Hi there 123")),
|
||||
ReplaceAndInvert.CUSTOM_COLOR,
|
||||
"#101010",
|
||||
"#FFFFFF",
|
||||
null);
|
||||
|
||||
InputStreamResource result = strategy.replace();
|
||||
assertThat(pageCount(result)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("replace - high contrast colours")
|
||||
class HighContrastTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("high-contrast mode resolves colours from the combination and produces a PDF")
|
||||
void highContrastProducesValidPdf() throws Exception {
|
||||
CustomColorReplaceStrategy strategy =
|
||||
new CustomColorReplaceStrategy(
|
||||
pdf(pdfWithText("Contrast")),
|
||||
ReplaceAndInvert.HIGH_CONTRAST_COLOR,
|
||||
null,
|
||||
null,
|
||||
HighContrastColorCombination.WHITE_TEXT_ON_BLACK);
|
||||
|
||||
InputStreamResource result = strategy.replace();
|
||||
assertThat(pageCount(result)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("replace - invalid input")
|
||||
class InvalidInputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-PDF payload causes replace() to throw")
|
||||
void nonPdfThrows() {
|
||||
CustomColorReplaceStrategy strategy =
|
||||
new CustomColorReplaceStrategy(
|
||||
pdf("not a pdf".getBytes()),
|
||||
ReplaceAndInvert.CUSTOM_COLOR,
|
||||
"000000",
|
||||
"FFFFFF",
|
||||
null);
|
||||
|
||||
assertThatThrownBy(strategy::replace).isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,12 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
group = 'frontend'
|
||||
description = 'Remove previously generated frontend assets from static resources'
|
||||
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 and mobile-upload.html are real backend source files, not generated artifacts.
|
||||
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
|
||||
// Nested prerendered route pages (e.g. settings/people.html)
|
||||
delete new File(resourcesStaticDir, 'settings')
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.PdfMetricsService;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PdfMetricsInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final PdfMetricsService pdfMetricsService;
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex) {
|
||||
try {
|
||||
if (!pdfMetricsService.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) {
|
||||
return;
|
||||
}
|
||||
String path = request.getServletPath();
|
||||
if (path == null || path.isBlank()) {
|
||||
path = request.getRequestURI();
|
||||
}
|
||||
if (path == null || !path.contains("/api/v1/")) {
|
||||
return;
|
||||
}
|
||||
if (!(request instanceof MultipartHttpServletRequest multipart)) {
|
||||
return;
|
||||
}
|
||||
if (isFromEditor(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> bucket : multipart.getMultiFileMap().values()) {
|
||||
fileCount += bucket.size();
|
||||
}
|
||||
if (fileCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
pdfMetricsService.recordOperation(fileCount);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to record PDF metrics", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT.
|
||||
// JWTs start "eyJ" and have two dots; API keys do not, so they still count as API.
|
||||
private boolean isFromEditor(HttpServletRequest request) {
|
||||
String browserId = request.getHeader("X-Browser-Id");
|
||||
if (browserId != null && !browserId.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return false;
|
||||
}
|
||||
String token = auth.substring(7).trim();
|
||||
return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final EndpointInterceptor endpointInterceptor;
|
||||
private final PdfMetricsInterceptor pdfMetricsInterceptor;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
|
||||
@@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(endpointInterceptor);
|
||||
registry.addInterceptor(pdfMetricsInterceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+41
@@ -42,6 +42,8 @@ public class ReactRoutingController {
|
||||
private boolean loggedMissingIndex = false;
|
||||
private String cachedSaasLandingHtml;
|
||||
private boolean saasLandingExists = false;
|
||||
private String cachedMobileUploadHtml;
|
||||
private boolean mobileUploadHtmlExists = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
@@ -64,6 +66,12 @@ public class ReactRoutingController {
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't
|
||||
// load the React /mobile-scanner route from the local backend. Cache the self-contained
|
||||
// static upload page to serve at that route in desktop mode instead.
|
||||
this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html");
|
||||
this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null;
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
@@ -144,6 +152,28 @@ public class ReactRoutingController {
|
||||
return new ClassPathResource("static/index.html");
|
||||
}
|
||||
|
||||
private String readStaticHtml(String filename) {
|
||||
try {
|
||||
Path external = Path.of(InstallationPathConfig.getStaticPath(), filename);
|
||||
if (Files.exists(external) && Files.isReadable(external)) {
|
||||
return Files.readString(external, StandardCharsets.UTF_8);
|
||||
}
|
||||
ClassPathResource resource = new ClassPathResource("static/" + filename);
|
||||
if (resource.exists()) {
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to read static HTML {}", filename, ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isDesktopMode() {
|
||||
return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
@@ -191,6 +221,17 @@ public class ReactRoutingController {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/mobile-scanner", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveMobileScanner(HttpServletRequest request) {
|
||||
if (isDesktopMode() && mobileUploadHtmlExists) {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache().mustRevalidate())
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(cachedMobileUploadHtml);
|
||||
}
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
|
||||
// cachedCallbackHtml is always initialized in @PostConstruct
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
|
||||
@Service
|
||||
public class PdfMetricsService {
|
||||
|
||||
private final PostHogService postHogService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private final AtomicLong operations = new AtomicLong();
|
||||
private final AtomicLong pdfs = new AtomicLong();
|
||||
private long lastOperations;
|
||||
private long lastPdfs;
|
||||
|
||||
public PdfMetricsService(
|
||||
PostHogService postHogService, ApplicationProperties applicationProperties) {
|
||||
this.postHogService = postHogService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return applicationProperties.getSystem().isPosthogEnabled();
|
||||
}
|
||||
|
||||
public void recordOperation(int pdfCount) {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
operations.incrementAndGet();
|
||||
if (pdfCount > 0) {
|
||||
pdfs.addAndGet(pdfCount);
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 7200000)
|
||||
public void flushMetrics() {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
long curOps = operations.get();
|
||||
long curPdfs = pdfs.get();
|
||||
long opsDelta = curOps - lastOperations;
|
||||
long pdfsDelta = curPdfs - lastPdfs;
|
||||
if (opsDelta <= 0 && pdfsDelta <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> props = new HashMap<>();
|
||||
props.put("source", "api");
|
||||
props.put("operations", opsDelta);
|
||||
props.put("pdfs", pdfsDelta);
|
||||
postHogService.captureEvent("pdf_operation_metrics", props);
|
||||
|
||||
lastOperations = curOps;
|
||||
lastPdfs = curPdfs;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,333 @@
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
|
||||
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.Mockito.mock;
|
||||
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.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.Security;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.security.auth.x500.X500Principal;
|
||||
|
||||
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.DigestCalculator;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
import org.bouncycastle.tsp.TimeStampRequest;
|
||||
import org.bouncycastle.tsp.TimeStampResponse;
|
||||
import org.bouncycastle.tsp.TimeStampResponseGenerator;
|
||||
import org.bouncycastle.tsp.TimeStampTokenGenerator;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Exercises the vendored PDFBox {@link TSAClient}. The TSA HTTP boundary is replaced with a mocked
|
||||
* {@link URL}/{@link URLConnection} so no real network is ever contacted. A real BouncyCastle TSA
|
||||
* response is generated in-process to drive the success path.
|
||||
*/
|
||||
@DisplayName("TSAClient (vendored PDFBox) Tests")
|
||||
class TSAClientTest {
|
||||
|
||||
private static KeyPair tsaKeyPair;
|
||||
private static X509Certificate tsaCert;
|
||||
|
||||
@BeforeAll
|
||||
static void setUpProviderAndCert() throws Exception {
|
||||
if (Security.getProvider("BC") == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
tsaKeyPair = kpg.generateKeyPair();
|
||||
tsaCert = selfSignedCert(tsaKeyPair);
|
||||
}
|
||||
|
||||
private static X509Certificate selfSignedCert(KeyPair kp) throws Exception {
|
||||
X500Principal dn = new X500Principal("CN=Test TSA");
|
||||
long now = System.currentTimeMillis();
|
||||
Date from = new Date(now - 1000L);
|
||||
Date to = new Date(now + 365L * 24 * 60 * 60 * 1000);
|
||||
BigInteger serial = BigInteger.valueOf(now);
|
||||
ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA").build(kp.getPrivate());
|
||||
JcaX509v3CertificateBuilder builder =
|
||||
new JcaX509v3CertificateBuilder(dn, serial, from, to, dn, kp.getPublic());
|
||||
// RFC 3161 requires the TSA signing cert to carry a critical id-kp-timeStamping EKU.
|
||||
builder.addExtension(
|
||||
Extension.extendedKeyUsage,
|
||||
true,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_timeStamping));
|
||||
X509CertificateHolder holder = builder.build(signer);
|
||||
return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
|
||||
}
|
||||
|
||||
/** Builds a valid RFC 3161 timestamp response matching the supplied request bytes. */
|
||||
private static byte[] buildTsaResponse(byte[] requestBytes) throws Exception {
|
||||
TimeStampRequest request = new TimeStampRequest(requestBytes);
|
||||
|
||||
// SHA-1 digest calculator for the token's messageImprint of the signer cert.
|
||||
DigestCalculator sha1 =
|
||||
new JcaDigestCalculatorProviderBuilder()
|
||||
.setProvider("BC")
|
||||
.build()
|
||||
.get(
|
||||
new AlgorithmIdentifier(
|
||||
org.bouncycastle.asn1.oiw.OIWObjectIdentifiers.idSHA1));
|
||||
|
||||
ContentSigner signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA").build(tsaKeyPair.getPrivate());
|
||||
|
||||
TimeStampTokenGenerator tokenGen =
|
||||
new TimeStampTokenGenerator(
|
||||
new org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder(
|
||||
new JcaDigestCalculatorProviderBuilder()
|
||||
.setProvider("BC")
|
||||
.build())
|
||||
.build(signer, tsaCert),
|
||||
sha1,
|
||||
new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.2.3.4.1"));
|
||||
// Embed the signer certificate so the response token validates standalone.
|
||||
tokenGen.addCertificates(
|
||||
new org.bouncycastle.cert.jcajce.JcaCertStore(java.util.List.of(tsaCert)));
|
||||
|
||||
// Accept the SHA-256 digest used by the request's messageImprint.
|
||||
Set<String> acceptedAlgorithms =
|
||||
Set.of(org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256.getId());
|
||||
TimeStampResponseGenerator responseGen =
|
||||
new TimeStampResponseGenerator(tokenGen, acceptedAlgorithms);
|
||||
TimeStampResponse response = responseGen.generate(request, BigInteger.ONE, new Date());
|
||||
return response.getEncoded();
|
||||
}
|
||||
|
||||
private TSAClient newClient(URL url, String username, String password) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return new TSAClient(url, username, password, digest);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Successful timestamp request")
|
||||
class SuccessTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Returns a parsed time stamp token from a valid TSA response")
|
||||
void returnsTokenOnValidResponse() throws Exception {
|
||||
URL url = mock(URL.class);
|
||||
URLConnection connection = mock(URLConnection.class);
|
||||
when(url.openConnection()).thenReturn(connection);
|
||||
|
||||
CapturingOutputStream sink = new CapturingOutputStream();
|
||||
when(connection.getOutputStream()).thenReturn(sink);
|
||||
// Lazily build the response based on the request actually written to the connection.
|
||||
ResponseSupplierInputStream responseStream = new ResponseSupplierInputStream(sink);
|
||||
when(connection.getInputStream()).thenReturn(responseStream);
|
||||
|
||||
TSAClient client = newClient(url, null, null);
|
||||
var token = client.getTimeStampToken(new ByteArrayInputStream("hello pdf".getBytes()));
|
||||
|
||||
assertThat(token).isNotNull();
|
||||
assertThat(token.getTimeStampInfo()).isNotNull();
|
||||
// Content-Type header is always set for the timestamp query.
|
||||
verify(connection).setRequestProperty("Content-Type", "application/timestamp-query");
|
||||
verify(connection).setDoOutput(true);
|
||||
verify(connection).setDoInput(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Sends a Basic Authorization header when credentials are supplied")
|
||||
void addsBasicAuthHeaderWhenCredentialsPresent() throws Exception {
|
||||
URL url = mock(URL.class);
|
||||
URLConnection connection = mock(URLConnection.class);
|
||||
when(url.openConnection()).thenReturn(connection);
|
||||
when(connection.getContentEncoding()).thenReturn(null);
|
||||
|
||||
CapturingOutputStream sink = new CapturingOutputStream();
|
||||
when(connection.getOutputStream()).thenReturn(sink);
|
||||
when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink));
|
||||
|
||||
TSAClient client = newClient(url, "user", "secret");
|
||||
client.getTimeStampToken(new ByteArrayInputStream("data".getBytes()));
|
||||
|
||||
verify(connection)
|
||||
.setRequestProperty(
|
||||
org.mockito.ArgumentMatchers.eq("Authorization"),
|
||||
org.mockito.ArgumentMatchers.startsWith("Basic "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Does not send Authorization header when username is empty")
|
||||
void noAuthHeaderWhenUsernameEmpty() throws Exception {
|
||||
URL url = mock(URL.class);
|
||||
URLConnection connection = mock(URLConnection.class);
|
||||
when(url.openConnection()).thenReturn(connection);
|
||||
|
||||
CapturingOutputStream sink = new CapturingOutputStream();
|
||||
when(connection.getOutputStream()).thenReturn(sink);
|
||||
when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink));
|
||||
|
||||
TSAClient client = newClient(url, "", "secret");
|
||||
client.getTimeStampToken(new ByteArrayInputStream("data".getBytes()));
|
||||
|
||||
verify(connection, never())
|
||||
.setRequestProperty(org.mockito.ArgumentMatchers.eq("Authorization"), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error handling")
|
||||
class ErrorTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Propagates IOException raised while writing the request")
|
||||
void propagatesWriteFailure() throws Exception {
|
||||
URL url = mock(URL.class);
|
||||
URLConnection connection = mock(URLConnection.class);
|
||||
when(url.openConnection()).thenReturn(connection);
|
||||
OutputStream failing =
|
||||
new OutputStream() {
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
throw new IOException("write boom");
|
||||
}
|
||||
};
|
||||
when(connection.getOutputStream()).thenReturn(failing);
|
||||
|
||||
TSAClient client = newClient(url, null, null);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
client.getTimeStampToken(
|
||||
new ByteArrayInputStream("data".getBytes())))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("write boom");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Propagates IOException raised while reading the response")
|
||||
void propagatesReadFailure() throws Exception {
|
||||
URL url = mock(URL.class);
|
||||
URLConnection connection = mock(URLConnection.class);
|
||||
when(url.openConnection()).thenReturn(connection);
|
||||
when(connection.getOutputStream()).thenReturn(new CapturingOutputStream());
|
||||
when(connection.getInputStream()).thenThrow(new IOException("read boom"));
|
||||
|
||||
TSAClient client = newClient(url, null, null);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
client.getTimeStampToken(
|
||||
new ByteArrayInputStream("data".getBytes())))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("read boom");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Wraps a malformed (non-TSP) response as an IOException")
|
||||
void wrapsMalformedResponse() throws Exception {
|
||||
URL url = mock(URL.class);
|
||||
URLConnection connection = mock(URLConnection.class);
|
||||
when(url.openConnection()).thenReturn(connection);
|
||||
when(connection.getOutputStream()).thenReturn(new CapturingOutputStream());
|
||||
when(connection.getInputStream())
|
||||
.thenReturn(new ByteArrayInputStream("not a tsp response".getBytes()));
|
||||
|
||||
TSAClient client = newClient(url, null, null);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
client.getTimeStampToken(
|
||||
new ByteArrayInputStream("data".getBytes())))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Throws when the connection cannot be opened")
|
||||
void throwsWhenConnectionFails() throws Exception {
|
||||
URL url = mock(URL.class);
|
||||
when(url.openConnection()).thenThrow(new IOException("no route"));
|
||||
|
||||
TSAClient client = newClient(url, null, null);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
client.getTimeStampToken(
|
||||
new ByteArrayInputStream("data".getBytes())))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("no route");
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects everything written so the matching TSA response can be generated afterward. */
|
||||
private static final class CapturingOutputStream extends OutputStream {
|
||||
private final ByteArrayOutputStream delegate = new ByteArrayOutputStream();
|
||||
|
||||
@Override
|
||||
public void write(int b) {
|
||||
delegate.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) {
|
||||
delegate.write(b, off, len);
|
||||
}
|
||||
|
||||
byte[] toByteArray() {
|
||||
return delegate.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazily builds the TSA response from the request captured by the sink on first read. */
|
||||
private static final class ResponseSupplierInputStream extends java.io.InputStream {
|
||||
private final CapturingOutputStream sink;
|
||||
private ByteArrayInputStream delegate;
|
||||
|
||||
ResponseSupplierInputStream(CapturingOutputStream sink) {
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
private ByteArrayInputStream delegate() {
|
||||
if (delegate == null) {
|
||||
try {
|
||||
delegate = new ByteArrayInputStream(buildTsaResponse(sink.toByteArray()));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
return delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
return delegate().read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) {
|
||||
return delegate().read(b, off, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ValidationTimeStamp}. Only the constructor is exercised; it builds a
|
||||
* TSAClient object without performing any network I/O.
|
||||
*/
|
||||
class ValidationTimeStampTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("null tsaUrl leaves the client unset and constructs cleanly")
|
||||
void nullUrl() throws Exception {
|
||||
ValidationTimeStamp vts = new ValidationTimeStamp(null);
|
||||
assertThat(vts).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("valid tsaUrl builds the timestamp client without contacting the network")
|
||||
void validUrl() throws Exception {
|
||||
ValidationTimeStamp vts = new ValidationTimeStamp("http://timestamp.example.com/tsa");
|
||||
assertThat(vts).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed tsaUrl is rejected with an exception")
|
||||
void malformedUrl() {
|
||||
assertThatThrownBy(() -> new ValidationTimeStamp("http:// bad host/tsa"))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package stirling.software.SPDF;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.Socket;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
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.mockito.MockedConstruction;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import io.github.pixee.security.SystemCommand;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LibreOfficeListener}. The process, socket and SystemCommand boundaries are
|
||||
* mocked so no real soffice/unoconv process or network connection is ever created.
|
||||
*/
|
||||
@DisplayName("LibreOfficeListener")
|
||||
class LibreOfficeListenerTest {
|
||||
|
||||
private LibreOfficeListener listener;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
listener = LibreOfficeListener.getInstance();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
// Reset the singleton's mutable state so tests stay independent.
|
||||
ExecutorService es = readExecutor();
|
||||
if (es != null) {
|
||||
es.shutdownNow();
|
||||
}
|
||||
setField("process", null);
|
||||
setField("executorService", null);
|
||||
}
|
||||
|
||||
private void setField(String name, Object value) throws Exception {
|
||||
Field f = LibreOfficeListener.class.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
f.set(listener, value);
|
||||
}
|
||||
|
||||
private Object readField(String name) throws Exception {
|
||||
Field f = LibreOfficeListener.class.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f.get(listener);
|
||||
}
|
||||
|
||||
private ExecutorService readExecutor() throws Exception {
|
||||
return (ExecutorService) readField("executorService");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getInstance")
|
||||
class GetInstance {
|
||||
|
||||
@Test
|
||||
@DisplayName("always returns the same singleton instance")
|
||||
void singletonIsStable() {
|
||||
assertThat(LibreOfficeListener.getInstance())
|
||||
.isSameAs(LibreOfficeListener.getInstance());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("start")
|
||||
class Start {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns immediately when a live process already exists")
|
||||
void alreadyRunningShortCircuits() throws Exception {
|
||||
Process alive = Mockito.mock(Process.class);
|
||||
Mockito.when(alive.isAlive()).thenReturn(true);
|
||||
setField("process", alive);
|
||||
|
||||
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class)) {
|
||||
listener.start();
|
||||
|
||||
// No new process is spawned when one is already alive.
|
||||
sys.verifyNoInteractions();
|
||||
assertThat(readField("process")).isSameAs(alive);
|
||||
assertThat(readExecutor()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("spawns the listener and returns once the socket connects")
|
||||
void spawnsAndDetectsRunningListener() throws Exception {
|
||||
Process spawned = Mockito.mock(Process.class);
|
||||
|
||||
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
|
||||
MockedConstruction<Socket> socket = Mockito.mockConstruction(Socket.class)) {
|
||||
|
||||
sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString()))
|
||||
.thenReturn(spawned);
|
||||
|
||||
listener.start();
|
||||
|
||||
// The spawned process is retained and the monitor executor is created.
|
||||
assertThat(readField("process")).isSameAs(spawned);
|
||||
assertThat(readExecutor()).isNotNull();
|
||||
// A socket was constructed and connected exactly once on the first poll.
|
||||
assertThat(socket.constructed()).hasSize(1);
|
||||
Mockito.verify(socket.constructed().get(0)).connect(any(), eq(1000));
|
||||
sys.verify(
|
||||
() ->
|
||||
SystemCommand.runCommand(
|
||||
any(Runtime.class), eq("unoconv --listener")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("retries until the listener socket becomes reachable")
|
||||
void retriesUntilSocketReachable() throws Exception {
|
||||
Process spawned = Mockito.mock(Process.class);
|
||||
|
||||
// First socket fails to connect, second succeeds; start() should poll twice.
|
||||
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
|
||||
MockedConstruction<Socket> socket =
|
||||
Mockito.mockConstruction(
|
||||
Socket.class,
|
||||
(mock, ctx) -> {
|
||||
if (ctx.getCount() == 1) {
|
||||
Mockito.doThrow(new java.io.IOException("refused"))
|
||||
.when(mock)
|
||||
.connect(any(), eq(1000));
|
||||
}
|
||||
})) {
|
||||
|
||||
sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString()))
|
||||
.thenReturn(spawned);
|
||||
|
||||
listener.start();
|
||||
|
||||
// At least two sockets were attempted before one connected.
|
||||
assertThat(socket.constructed().size()).isGreaterThanOrEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("stop")
|
||||
class Stop {
|
||||
|
||||
@Test
|
||||
@DisplayName("shuts down the monitor and destroys a live process")
|
||||
void destroysLiveProcess() throws Exception {
|
||||
Process alive = Mockito.mock(Process.class);
|
||||
Mockito.when(alive.isAlive()).thenReturn(true);
|
||||
ExecutorService es = Mockito.mock(ExecutorService.class);
|
||||
setField("process", alive);
|
||||
setField("executorService", es);
|
||||
|
||||
listener.stop();
|
||||
|
||||
Mockito.verify(es).shutdownNow();
|
||||
Mockito.verify(alive).destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not destroy a process that is already dead")
|
||||
void skipsDestroyWhenProcessDead() throws Exception {
|
||||
Process dead = Mockito.mock(Process.class);
|
||||
Mockito.when(dead.isAlive()).thenReturn(false);
|
||||
ExecutorService es = Mockito.mock(ExecutorService.class);
|
||||
setField("process", dead);
|
||||
setField("executorService", es);
|
||||
|
||||
listener.stop();
|
||||
|
||||
Mockito.verify(es).shutdownNow();
|
||||
Mockito.verify(dead, Mockito.never()).destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package stirling.software.SPDF;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
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.env.Environment;
|
||||
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Remaining static-helper and lifecycle coverage for {@link SPDFApplication} that the existing
|
||||
* {@code SPDFApplicationMoreTest} does not reach: profile selection, classpath probing, the
|
||||
* setServerPortStatic auto/explicit branches and the non-Tauri init path.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("SPDFApplication remaining coverage")
|
||||
class SPDFApplicationExtraTest {
|
||||
|
||||
@Mock private AppConfig appConfig;
|
||||
@Mock private Environment env;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
|
||||
private static Object invokeStatic(String name, Class<?>[] sig, Object... args)
|
||||
throws Exception {
|
||||
Method m = SPDFApplication.class.getDeclaredMethod(name, sig);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, args);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getActiveProfile")
|
||||
class GetActiveProfile {
|
||||
|
||||
private String[] activeProfile(String[] args) throws Exception {
|
||||
return (String[])
|
||||
invokeStatic(
|
||||
"getActiveProfile", new Class<?>[] {String[].class}, (Object) args);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicit --spring.profiles.active wins over classpath detection")
|
||||
void explicitProfile() throws Exception {
|
||||
String[] result = activeProfile(new String[] {"--spring.profiles.active=foo,bar"});
|
||||
assertThat(result).containsExactly("foo", "bar");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single explicit profile is honoured")
|
||||
void singleExplicitProfile() throws Exception {
|
||||
String[] result = activeProfile(new String[] {"--spring.profiles.active=custom"});
|
||||
assertThat(result).containsExactly("custom");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null args fall through to classpath-based detection")
|
||||
void nullArgsFallThrough() throws Exception {
|
||||
// Falls through to classpath detection; exact profile depends on the build flavor.
|
||||
String[] result = activeProfile(null);
|
||||
assertThat(result).isNotNull().isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no profile arg falls through to classpath-based detection")
|
||||
void noProfileArgFallThrough() throws Exception {
|
||||
String[] result = activeProfile(new String[] {"--server.port=9090"});
|
||||
assertThat(result).isNotNull().isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isClassPresent")
|
||||
class IsClassPresent {
|
||||
|
||||
private boolean present(String className) throws Exception {
|
||||
return (boolean)
|
||||
invokeStatic("isClassPresent", new Class<?>[] {String.class}, className);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true for a class on the classpath")
|
||||
void existingClass() throws Exception {
|
||||
assertThat(present("stirling.software.SPDF.SPDFApplication")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false for a missing class")
|
||||
void missingClass() throws Exception {
|
||||
assertThat(present("com.example.totally.Missing")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("setServerPortStatic")
|
||||
class SetServerPortStatic {
|
||||
|
||||
@Test
|
||||
@DisplayName("'auto' maps to Spring's 0 (auto-assign) port")
|
||||
void autoMapsToZero() {
|
||||
SPDFApplication.setServerPortStatic("auto");
|
||||
assertThat(SPDFApplication.getStaticPort()).isEqualTo("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("'AUTO' is matched case-insensitively")
|
||||
void autoCaseInsensitive() {
|
||||
SPDFApplication.setServerPortStatic("AUTO");
|
||||
assertThat(SPDFApplication.getStaticPort()).isEqualTo("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an explicit port is stored verbatim")
|
||||
void explicitPort() {
|
||||
SPDFApplication.setServerPortStatic("8443");
|
||||
assertThat(SPDFApplication.getStaticPort()).isEqualTo("8443");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("init (non-Tauri, browser disabled)")
|
||||
class InitNonTauri {
|
||||
|
||||
@Test
|
||||
@DisplayName("populates the static URL fields without opening a browser")
|
||||
void initBrowserDisabled() {
|
||||
System.clearProperty("STIRLING_PDF_TAURI_MODE");
|
||||
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
|
||||
when(appConfig.getContextPath()).thenReturn("/app");
|
||||
when(appConfig.getServerPort()).thenReturn("9000");
|
||||
when(env.getProperty("BROWSER_OPEN")).thenReturn(null);
|
||||
|
||||
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
|
||||
app.init();
|
||||
|
||||
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:9000");
|
||||
assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/app");
|
||||
assertThat(SPDFApplication.getStaticPort()).isEqualTo("9000");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getStaticBaseUrl reflects the most recent init")
|
||||
void staticBaseUrlReflectsInit() {
|
||||
AppConfig cfg = mock(AppConfig.class);
|
||||
when(cfg.getBackendUrl()).thenReturn("https://example.org");
|
||||
when(cfg.getContextPath()).thenReturn("/");
|
||||
when(cfg.getServerPort()).thenReturn("443");
|
||||
Environment e = mock(Environment.class);
|
||||
when(e.getProperty("BROWSER_OPEN")).thenReturn("false");
|
||||
|
||||
new SPDFApplication(cfg, e, applicationProperties).init();
|
||||
|
||||
// default https port 443 is omitted from the normalized base url
|
||||
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("https://example.org");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package stirling.software.SPDF;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
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 org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Static URL helpers and lifecycle branches of SPDFApplication that need no Spring context. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("SPDFApplication extra coverage")
|
||||
class SPDFApplicationMoreTest {
|
||||
|
||||
private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE";
|
||||
private static final String BROWSER_OPEN = "BROWSER_OPEN";
|
||||
|
||||
@Mock private AppConfig appConfig;
|
||||
@Mock private Environment env;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
|
||||
private String originalTauri;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
originalTauri = System.getProperty(TAURI_PROP);
|
||||
System.clearProperty(TAURI_PROP);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (originalTauri == null) {
|
||||
System.clearProperty(TAURI_PROP);
|
||||
} else {
|
||||
System.setProperty(TAURI_PROP, originalTauri);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object invokeStatic(String name, Class<?>[] sig, Object... args)
|
||||
throws Exception {
|
||||
Method m = SPDFApplication.class.getDeclaredMethod(name, sig);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, args);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("normalizeBackendUrl")
|
||||
class NormalizeBackendUrl {
|
||||
|
||||
private String normalize(String url, String port) throws Exception {
|
||||
return (String)
|
||||
invokeStatic(
|
||||
"normalizeBackendUrl",
|
||||
new Class<?>[] {String.class, String.class},
|
||||
url,
|
||||
port);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank backend url defaults to localhost with port")
|
||||
void blankDefaultsLocalhost() throws Exception {
|
||||
assertThat(normalize("", "8080")).isEqualTo("http://localhost:8080");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("adds scheme when missing and appends non-default port")
|
||||
void addsSchemeAndPort() throws Exception {
|
||||
assertThat(normalize("example.com", "9000")).isEqualTo("http://example.com:9000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits default http port 80")
|
||||
void omitsDefaultHttpPort() throws Exception {
|
||||
assertThat(normalize("http://example.com", "80")).isEqualTo("http://example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits default https port 443")
|
||||
void omitsDefaultHttpsPort() throws Exception {
|
||||
assertThat(normalize("https://example.com", "443")).isEqualTo("https://example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("strips trailing slashes")
|
||||
void stripsTrailingSlash() throws Exception {
|
||||
assertThat(normalize("http://example.com///", "80")).isEqualTo("http://example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps an explicit port already in the url")
|
||||
void keepsExplicitPort() throws Exception {
|
||||
assertThat(normalize("http://example.com:1234", null))
|
||||
.isEqualTo("http://example.com:1234");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("buildFullUrl")
|
||||
class BuildFullUrl {
|
||||
|
||||
private String build(String base, String port, String ctx) throws Exception {
|
||||
return (String)
|
||||
invokeStatic(
|
||||
"buildFullUrl",
|
||||
new Class<?>[] {String.class, String.class, String.class},
|
||||
base,
|
||||
port,
|
||||
ctx);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("root context path yields a single trailing slash")
|
||||
void rootContext() throws Exception {
|
||||
assertThat(build("http://localhost", "8080", "/")).isEqualTo("http://localhost:8080/");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-root context path is prefixed with a slash")
|
||||
void prefixesContext() throws Exception {
|
||||
assertThat(build("http://localhost", "8080", "app"))
|
||||
.isEqualTo("http://localhost:8080/app");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank context path treated as root")
|
||||
void blankContext() throws Exception {
|
||||
assertThat(build("http://localhost", "8080", "")).isEqualTo("http://localhost:8080/");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("parsePort")
|
||||
class ParsePort {
|
||||
|
||||
private Integer parse(String port) throws Exception {
|
||||
return (Integer) invokeStatic("parsePort", new Class<?>[] {String.class}, port);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses a positive port")
|
||||
void positive() throws Exception {
|
||||
assertThat(parse("8080")).isEqualTo(8080);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null for blank, non-numeric, zero, and negative")
|
||||
void invalidInputs() throws Exception {
|
||||
assertThat(parse("")).isNull();
|
||||
assertThat(parse("abc")).isNull();
|
||||
assertThat(parse("0")).isNull();
|
||||
assertThat(parse("-5")).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("appendPortFallback")
|
||||
class AppendPortFallback {
|
||||
|
||||
private String append(String base, Integer port) throws Exception {
|
||||
return (String)
|
||||
invokeStatic(
|
||||
"appendPortFallback",
|
||||
new Class<?>[] {String.class, Integer.class},
|
||||
base,
|
||||
port);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null port returns base unchanged")
|
||||
void nullPort() throws Exception {
|
||||
assertThat(append("http://host", null)).isEqualTo("http://host");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("base already ending in a port is unchanged")
|
||||
void alreadyHasPort() throws Exception {
|
||||
assertThat(append("http://host:1234", 80)).isEqualTo("http://host:1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appends the port otherwise")
|
||||
void appendsPort() throws Exception {
|
||||
assertThat(append("http://host", 8080)).isEqualTo("http://host:8080");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("lifecycle")
|
||||
class Lifecycle {
|
||||
|
||||
@Test
|
||||
@DisplayName("onApplicationReady picks up the runtime local.server.port")
|
||||
void onApplicationReadyUsesRuntimePort() {
|
||||
ApplicationReadyEvent event = mock(ApplicationReadyEvent.class, RETURNS_DEEP_STUBS);
|
||||
when(event.getApplicationContext().getEnvironment().getProperty("local.server.port"))
|
||||
.thenReturn("44444");
|
||||
|
||||
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
|
||||
app.onApplicationReady(event);
|
||||
|
||||
assertThat(SPDFApplication.getStaticPort()).isEqualTo("44444");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("init in Tauri mode logs parent pid and sets static URLs")
|
||||
void initTauriMode() {
|
||||
System.setProperty(TAURI_PROP, "true");
|
||||
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
|
||||
when(appConfig.getContextPath()).thenReturn("/");
|
||||
when(appConfig.getServerPort()).thenReturn("8080");
|
||||
when(env.getProperty(BROWSER_OPEN)).thenReturn("false");
|
||||
|
||||
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
|
||||
app.init();
|
||||
|
||||
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:8080");
|
||||
assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
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 org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
@DisplayName("EndpointInspector (additional coverage)")
|
||||
class EndpointInspectorMoreTest {
|
||||
|
||||
private ApplicationContext applicationContext;
|
||||
private EndpointInspector inspector;
|
||||
|
||||
// Simple controller bean providing a handler method for HandlerMethod construction.
|
||||
static class DummyController {
|
||||
public String handle() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationContext = mock(ApplicationContext.class);
|
||||
inspector = new EndpointInspector(applicationContext);
|
||||
}
|
||||
|
||||
private HandlerMethod handlerMethod() throws Exception {
|
||||
Method method = DummyController.class.getMethod("handle");
|
||||
return new HandlerMethod(new DummyController(), method);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Set<String> validEndpoints() throws Exception {
|
||||
Field field = EndpointInspector.class.getDeclaredField("validGetEndpoints");
|
||||
field.setAccessible(true);
|
||||
return (Set<String>) field.get(inspector);
|
||||
}
|
||||
|
||||
private void stubMapping(Map<RequestMappingInfo, HandlerMethod> handlerMethods) {
|
||||
RequestMappingHandlerMapping mapping = mock(RequestMappingHandlerMapping.class);
|
||||
when(mapping.getHandlerMethods()).thenReturn(handlerMethods);
|
||||
Map<String, RequestMappingHandlerMapping> beans = new HashMap<>();
|
||||
beans.put("requestMappingHandlerMapping", mapping);
|
||||
when(applicationContext.getBeansOfType(RequestMappingHandlerMapping.class))
|
||||
.thenReturn(beans);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("onApplicationEvent")
|
||||
class OnApplicationEvent {
|
||||
|
||||
@Test
|
||||
@DisplayName("discovers endpoints exactly once across repeated events")
|
||||
void discoversOnce() throws Exception {
|
||||
stubMapping(new LinkedHashMap<>());
|
||||
|
||||
ContextRefreshedEvent event = new ContextRefreshedEvent(new StaticApplicationContext());
|
||||
inspector.onApplicationEvent(event);
|
||||
inspector.onApplicationEvent(event);
|
||||
|
||||
Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered");
|
||||
discovered.setAccessible(true);
|
||||
assertThat(discovered.getBoolean(inspector)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("discoverEndpoints")
|
||||
class DiscoverEndpoints {
|
||||
|
||||
@Test
|
||||
@DisplayName("collects direct paths from a GET mapping")
|
||||
void collectsDirectPaths() throws Exception {
|
||||
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
|
||||
RequestMappingInfo getInfo =
|
||||
RequestMappingInfo.paths("/dashboard").methods(RequestMethod.GET).build();
|
||||
methods.put(getInfo, handlerMethod());
|
||||
stubMapping(methods);
|
||||
|
||||
Set<String> endpoints = inspector.getValidGetEndpoints();
|
||||
|
||||
assertThat(endpoints).contains("/dashboard");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("treats a mapping with no explicit method as a GET handler")
|
||||
void noMethodCountsAsGet() throws Exception {
|
||||
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
|
||||
RequestMappingInfo anyInfo = RequestMappingInfo.paths("/anything").build();
|
||||
methods.put(anyInfo, handlerMethod());
|
||||
stubMapping(methods);
|
||||
|
||||
assertThat(inspector.getValidGetEndpoints()).contains("/anything");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ignores non-GET only mappings")
|
||||
void ignoresPostOnly() throws Exception {
|
||||
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
|
||||
RequestMappingInfo postInfo =
|
||||
RequestMappingInfo.paths("/save").methods(RequestMethod.POST).build();
|
||||
methods.put(postInfo, handlerMethod());
|
||||
stubMapping(methods);
|
||||
|
||||
assertThat(inspector.getValidGetEndpoints()).doesNotContain("/save");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to string parsing for pattern-only mappings")
|
||||
void fallsBackToStringParsing() throws Exception {
|
||||
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
|
||||
// Wildcard patterns are not direct paths, forcing the toString() fallback branch.
|
||||
RequestMappingInfo patternInfo =
|
||||
RequestMappingInfo.paths("/files/**").methods(RequestMethod.GET).build();
|
||||
methods.put(patternInfo, handlerMethod());
|
||||
stubMapping(methods);
|
||||
|
||||
Set<String> endpoints = inspector.getValidGetEndpoints();
|
||||
|
||||
assertThat(endpoints).anySatisfy(p -> assertThat(p).contains("/files"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getValidGetEndpoints")
|
||||
class GetValidGetEndpoints {
|
||||
|
||||
@Test
|
||||
@DisplayName("triggers discovery when not yet discovered")
|
||||
void triggersDiscovery() throws Exception {
|
||||
stubMapping(new LinkedHashMap<>());
|
||||
|
||||
Set<String> endpoints = inspector.getValidGetEndpoints();
|
||||
|
||||
// Empty discovery installs the fallback set.
|
||||
assertThat(endpoints).contains("/", "/**", "/api/**");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("matching helpers")
|
||||
class MatchingHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("matchesPathSegments rejects a URI shorter than the pattern")
|
||||
void shorterUriDoesNotMatch() throws Exception {
|
||||
validEndpoints().clear();
|
||||
validEndpoints().add("/api/v1/convert");
|
||||
markDiscovered();
|
||||
|
||||
assertThat(inspector.isValidGetEndpoint("/api")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("wildcard with a star prefix matches the static portion")
|
||||
void starPrefixMatches() throws Exception {
|
||||
validEndpoints().clear();
|
||||
validEndpoints().add("/static/*");
|
||||
markDiscovered();
|
||||
|
||||
assertThat(inspector.isValidGetEndpoint("/static/app.js")).isTrue();
|
||||
}
|
||||
|
||||
private void markDiscovered() throws Exception {
|
||||
Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered");
|
||||
discovered.setAccessible(true);
|
||||
discovered.setBoolean(inspector, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
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.MockedConstruction;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
/**
|
||||
* Pure-helper coverage for {@link ExternalAppDepConfig}: the inner Version comparator, the
|
||||
* weasyprint/qpdf command recognisers, feature-name formatting and the findFirstAvailable probe
|
||||
* loop. No real binaries run; ProcessBuilder is intercepted where a probe is required.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ExternalAppDepConfig helper coverage")
|
||||
class ExternalAppDepConfigExtraTest {
|
||||
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private ExternalAppDepConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/opt/weasyprint");
|
||||
when(runtimePathConfig.getUnoConvertPath()).thenReturn("/opt/unoconvert");
|
||||
when(runtimePathConfig.getCalibrePath()).thenReturn("/opt/calibre");
|
||||
when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/opt/ocrmypdf");
|
||||
when(runtimePathConfig.getSOfficePath()).thenReturn("/opt/soffice");
|
||||
lenient()
|
||||
.when(endpointConfiguration.getEndpointsForGroup(anyString()))
|
||||
.thenReturn(Set.of());
|
||||
config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig);
|
||||
}
|
||||
|
||||
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
|
||||
Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(config, args);
|
||||
}
|
||||
|
||||
private static Process processReturning(int exitCode, String stdout) {
|
||||
Process p = mock(Process.class);
|
||||
try {
|
||||
doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class));
|
||||
} catch (InterruptedException ignored) {
|
||||
// mock never throws
|
||||
}
|
||||
doReturn(exitCode).when(p).exitValue();
|
||||
doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)))
|
||||
.when(p)
|
||||
.getInputStream();
|
||||
doAnswer(inv -> new ByteArrayInputStream(new byte[0])).when(p).getErrorStream();
|
||||
return p;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Version comparator")
|
||||
class VersionComparator {
|
||||
|
||||
private Comparable<Object> version(String v) throws Exception {
|
||||
Class<?> versionClass =
|
||||
Class.forName("stirling.software.SPDF.config.ExternalAppDepConfig$Version");
|
||||
Constructor<?> ctor = versionClass.getDeclaredConstructor(String.class);
|
||||
ctor.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Comparable<Object> instance = (Comparable<Object>) ctor.newInstance(v);
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("orders by major, then minor, then patch")
|
||||
void ordersBySegments() throws Exception {
|
||||
assertThat(version("1.2.3").compareTo(version("1.2.4"))).isNegative();
|
||||
assertThat(version("2.0.0").compareTo(version("1.9.9"))).isPositive();
|
||||
assertThat(version("1.2.0").compareTo(version("1.2"))).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("treats equal versions as equal")
|
||||
void equalVersions() throws Exception {
|
||||
assertThat(version("58.0").compareTo(version("58.0.0"))).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-numeric segments are treated as zero")
|
||||
void nonNumericSegmentsAsZero() throws Exception {
|
||||
// "12.x" -> 12.0.0, equal to "12"
|
||||
assertThat(version("12.x").compareTo(version("12"))).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toString renders the three-segment form")
|
||||
void toStringThreeSegments() throws Exception {
|
||||
assertThat(version("11.9").toString()).isEqualTo("11.9.0");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("command recognisers")
|
||||
class CommandRecognisers {
|
||||
|
||||
private boolean isWeasyprint(String command) throws Exception {
|
||||
return (boolean) invoke("isWeasyprint", new Class<?>[] {String.class}, command);
|
||||
}
|
||||
|
||||
private boolean isQpdf(String command) throws Exception {
|
||||
return (boolean) invoke("isQpdf", new Class<?>[] {String.class}, command);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isWeasyprint matches the configured path and any name containing weasyprint")
|
||||
void weasyprintMatches() throws Exception {
|
||||
assertThat(isWeasyprint("/opt/weasyprint")).isTrue();
|
||||
assertThat(isWeasyprint("WeasyPrint")).isTrue();
|
||||
assertThat(isWeasyprint("gs")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isQpdf matches any name containing qpdf, case-insensitively")
|
||||
void qpdfMatches() throws Exception {
|
||||
assertThat(isQpdf("qpdf")).isTrue();
|
||||
assertThat(isQpdf("/usr/bin/QPDF")).isTrue();
|
||||
assertThat(isQpdf("tesseract")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("feature-name formatting")
|
||||
class FeatureFormatting {
|
||||
|
||||
private String capitalizeWord(String word) throws Exception {
|
||||
return (String) invoke("capitalizeWord", new Class<?>[] {String.class}, word);
|
||||
}
|
||||
|
||||
private String formatEndpointAsFeature(String endpoint) throws Exception {
|
||||
return (String)
|
||||
invoke("formatEndpointAsFeature", new Class<?>[] {String.class}, endpoint);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("capitalizeWord upper-cases the first letter and lowers the rest")
|
||||
void capitalizes() throws Exception {
|
||||
assertThat(capitalizeWord("hello")).isEqualTo("Hello");
|
||||
assertThat(capitalizeWord("WORLD")).isEqualTo("World");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("capitalizeWord keeps pdf fully upper-cased")
|
||||
void capitalizesPdf() throws Exception {
|
||||
assertThat(capitalizeWord("pdf")).isEqualTo("PDF");
|
||||
assertThat(capitalizeWord("PDF")).isEqualTo("PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("capitalizeWord returns null/empty unchanged")
|
||||
void capitalizeEmpty() throws Exception {
|
||||
assertThat(capitalizeWord("")).isEmpty();
|
||||
assertThat(capitalizeWord((String) null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("formatEndpointAsFeature humanises a hyphenated endpoint with pdf/img mapping")
|
||||
void humanisesEndpoint() throws Exception {
|
||||
// "pdf-to-img" -> tokens pdf,to,image -> "PDF To Image"
|
||||
assertThat(formatEndpointAsFeature("pdf-to-img")).isEqualTo("PDF To Image");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("formatEndpointAsFeature title-cases a plain endpoint")
|
||||
void titleCasesPlain() throws Exception {
|
||||
assertThat(formatEndpointAsFeature("merge-pdfs")).contains("Merge");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("findFirstAvailable")
|
||||
class FindFirstAvailable {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Optional<String> findFirstAvailable(List<String> commands) throws Exception {
|
||||
return (Optional<String>)
|
||||
invoke("findFirstAvailable", new Class<?>[] {List.class}, commands);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the first command whose lookup probe succeeds")
|
||||
void returnsFirstSuccessful() throws Exception {
|
||||
// python3 lookup fails (exit 1), python lookup succeeds (exit 0)
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
mockConstruction(
|
||||
ProcessBuilder.class,
|
||||
(pbMock, ctx) -> {
|
||||
List<?> cmd = (List<?>) ctx.arguments().get(0);
|
||||
boolean python = cmd.contains("python");
|
||||
doReturn(processReturning(python ? 0 : 1, "")).when(pbMock).start();
|
||||
})) {
|
||||
Optional<String> result = findFirstAvailable(List.of("python3", "python"));
|
||||
assertThat(result).contains("python");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns empty when no command is available")
|
||||
void emptyWhenNoneAvailable() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
mockConstruction(
|
||||
ProcessBuilder.class,
|
||||
(pbMock, ctx) ->
|
||||
doReturn(processReturning(1, "")).when(pbMock).start())) {
|
||||
Optional<String> result = findFirstAvailable(List.of("nope1", "nope2"));
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
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.MockedConstruction;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
/**
|
||||
* Covers the process-probing paths of ExternalAppDepConfig by intercepting ProcessBuilder
|
||||
* construction. No real external binaries are ever executed. Process mocks are wired with
|
||||
* doReturn/doAnswer to avoid nested-when stubbing inside the mockConstruction initializer.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ExternalAppDepConfig extra coverage")
|
||||
class ExternalAppDepConfigMoreTest {
|
||||
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private ExternalAppDepConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/custom/weasyprint");
|
||||
when(runtimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
|
||||
when(runtimePathConfig.getCalibrePath()).thenReturn("/custom/calibre");
|
||||
when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/custom/ocrmypdf");
|
||||
when(runtimePathConfig.getSOfficePath()).thenReturn("/custom/soffice");
|
||||
lenient()
|
||||
.when(endpointConfiguration.getEndpointsForGroup(anyString()))
|
||||
.thenReturn(Set.of());
|
||||
config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Process mock that finishes with the given exit code and stream text. Uses doReturn so
|
||||
* it can be safely called inside a mockConstruction initializer.
|
||||
*/
|
||||
private static Process processReturning(int exitCode, String stdout, String stderr) {
|
||||
Process p = mock(Process.class);
|
||||
try {
|
||||
doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class));
|
||||
} catch (InterruptedException ignored) {
|
||||
// mock never actually throws
|
||||
}
|
||||
doReturn(exitCode).when(p).exitValue();
|
||||
// Fresh stream per call so concurrent/repeated reads never race on a consumed buffer.
|
||||
doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)))
|
||||
.when(p)
|
||||
.getInputStream();
|
||||
doAnswer(inv -> new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8)))
|
||||
.when(p)
|
||||
.getErrorStream();
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Make every ProcessBuilder built in scope return the supplied process from start(). */
|
||||
private static MockedConstruction<ProcessBuilder> alwaysReturn(Process p) {
|
||||
return mockConstruction(
|
||||
ProcessBuilder.class,
|
||||
(pbMock, ctx) -> {
|
||||
try {
|
||||
doReturn(p).when(pbMock).start();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
|
||||
Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(config, args);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isCommandAvailable")
|
||||
class IsCommandAvailable {
|
||||
|
||||
@Test
|
||||
@DisplayName("true when OS lookup (where/which) exits 0")
|
||||
void availableViaLookup() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) {
|
||||
boolean available =
|
||||
(boolean) invoke("isCommandAvailable", new Class<?>[] {String.class}, "gs");
|
||||
assertThat(available).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to --version when lookup fails, then true")
|
||||
void availableViaVersionFallback() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
mockConstruction(
|
||||
ProcessBuilder.class,
|
||||
(pbMock, ctx) -> {
|
||||
List<?> cmd = (List<?>) ctx.arguments().get(0);
|
||||
boolean isVersion = cmd.contains("--version");
|
||||
doReturn(processReturning(isVersion ? 0 : 1, "1.0", ""))
|
||||
.when(pbMock)
|
||||
.start();
|
||||
})) {
|
||||
boolean available =
|
||||
(boolean)
|
||||
invoke(
|
||||
"isCommandAvailable",
|
||||
new Class<?>[] {String.class},
|
||||
"weirdcmd");
|
||||
assertThat(available).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false when both lookup and --version fail")
|
||||
void unavailable() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(1, "", ""))) {
|
||||
boolean available =
|
||||
(boolean)
|
||||
invoke("isCommandAvailable", new Class<?>[] {String.class}, "nope");
|
||||
assertThat(available).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getVersionSafe")
|
||||
class GetVersionSafe {
|
||||
|
||||
@Test
|
||||
@DisplayName("extracts a version number from combined output")
|
||||
@SuppressWarnings("unchecked")
|
||||
void extractsVersion() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(0, "qpdf version 11.9.0", ""))) {
|
||||
Optional<String> version =
|
||||
(Optional<String>)
|
||||
invoke(
|
||||
"getVersionSafe",
|
||||
new Class<?>[] {String.class, String.class},
|
||||
"qpdf",
|
||||
"--version");
|
||||
assertThat(version).contains("11.9.0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty when command exits non-zero")
|
||||
@SuppressWarnings("unchecked")
|
||||
void emptyOnNonZero() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(2, "", ""))) {
|
||||
Optional<String> version =
|
||||
(Optional<String>)
|
||||
invoke(
|
||||
"getVersionSafe",
|
||||
new Class<?>[] {String.class, String.class},
|
||||
"qpdf",
|
||||
"--version");
|
||||
assertThat(version).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("runAndWait")
|
||||
class RunAndWait {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns timeout code 124 and destroys the process when it does not finish")
|
||||
void timeoutDestroysProcess() throws Exception {
|
||||
Process p = mock(Process.class);
|
||||
doReturn(false).when(p).waitFor(anyLong(), any(TimeUnit.class));
|
||||
try (MockedConstruction<ProcessBuilder> ignored = alwaysReturn(p)) {
|
||||
Object result =
|
||||
invoke(
|
||||
"runAndWait",
|
||||
new Class<?>[] {List.class, Duration.class},
|
||||
List.of("sleep", "100"),
|
||||
Duration.ofMillis(10));
|
||||
Method ec = result.getClass().getDeclaredMethod("exitCode");
|
||||
ec.setAccessible(true);
|
||||
assertThat((int) ec.invoke(result)).isEqualTo(124);
|
||||
verify(p).destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns code 127 when ProcessBuilder.start throws IOException")
|
||||
void ioExceptionYields127() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
mockConstruction(
|
||||
ProcessBuilder.class,
|
||||
(pbMock, ctx) ->
|
||||
when(pbMock.start())
|
||||
.thenThrow(new java.io.IOException("cannot run")))) {
|
||||
Object result =
|
||||
invoke(
|
||||
"runAndWait",
|
||||
new Class<?>[] {List.class, Duration.class},
|
||||
List.of("bogus"),
|
||||
Duration.ofSeconds(1));
|
||||
Method ec = result.getClass().getDeclaredMethod("exitCode");
|
||||
ec.setAccessible(true);
|
||||
assertThat((int) ec.invoke(result)).isEqualTo(127);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* checkDependencyAndDisableGroup is tested directly (single-threaded). The public
|
||||
* checkDependencies() fans probes out over virtual threads where Mockito's thread-confined
|
||||
* MockedConstruction would not intercept, so it is not exercised here.
|
||||
*/
|
||||
@Nested
|
||||
@DisplayName("checkDependencyAndDisableGroup")
|
||||
class CheckDependencyAndDisableGroup {
|
||||
|
||||
@Test
|
||||
@DisplayName("disables the affected group when the command is missing")
|
||||
void disablesMissingGroup() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(1, "", ""))) {
|
||||
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "gs");
|
||||
}
|
||||
|
||||
verify(endpointConfiguration)
|
||||
.disableGroup("Ghostscript", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("present command with no version gate leaves the group enabled")
|
||||
void presentCommandNotDisabled() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) {
|
||||
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "gs");
|
||||
}
|
||||
|
||||
verify(endpointConfiguration, never())
|
||||
.disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("qpdf below the required version disables the qpdf group")
|
||||
void qpdfBelowMinimumDisabled() throws Exception {
|
||||
// Lookup succeeds (exit 0) and --version reports an old release -> version gate fires.
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(0, "qpdf version 10.0.0", ""))) {
|
||||
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "qpdf");
|
||||
}
|
||||
|
||||
verify(endpointConfiguration)
|
||||
.disableGroup("qpdf", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("qpdf at or above the required version stays enabled")
|
||||
void qpdfMeetsMinimumNotDisabled() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(0, "qpdf version 12.5.0", ""))) {
|
||||
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "qpdf");
|
||||
}
|
||||
|
||||
verify(endpointConfiguration, never())
|
||||
.disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("weasyprint below the required version disables the weasyprint group")
|
||||
void weasyprintBelowMinimumDisabled() throws Exception {
|
||||
try (MockedConstruction<ProcessBuilder> ignored =
|
||||
alwaysReturn(processReturning(0, "WeasyPrint 50.0", ""))) {
|
||||
invoke(
|
||||
"checkDependencyAndDisableGroup",
|
||||
new Class<?>[] {String.class},
|
||||
"/custom/weasyprint");
|
||||
}
|
||||
|
||||
verify(endpointConfiguration)
|
||||
.disableGroup("Weasyprint", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 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.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
|
||||
class InitialSetupTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ApplicationProperties.AutomaticallyGenerated autoGen;
|
||||
private ApplicationProperties.Legal legal;
|
||||
private InitialSetup initialSetup;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
autoGen = applicationProperties.getAutomaticallyGenerated();
|
||||
legal = applicationProperties.getLegal();
|
||||
initialSetup = new InitialSetup(applicationProperties);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("initUUIDKey")
|
||||
class UuidKey {
|
||||
|
||||
@Test
|
||||
@DisplayName("generates and persists a UUID when missing")
|
||||
void generatesWhenMissing() throws Exception {
|
||||
autoGen.setUUID(null);
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false);
|
||||
|
||||
initialSetup.initUUIDKey();
|
||||
|
||||
assertThat(autoGen.getUUID()).isNotBlank();
|
||||
util.verify(
|
||||
() ->
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
eq("AutomaticallyGenerated.UUID"), any()),
|
||||
times(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps an existing valid UUID")
|
||||
void keepsValid() throws Exception {
|
||||
autoGen.setUUID("existing");
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
util.when(() -> GeneralUtils.isValidUUID("existing")).thenReturn(true);
|
||||
|
||||
initialSetup.initUUIDKey();
|
||||
|
||||
assertThat(autoGen.getUUID()).isEqualTo("existing");
|
||||
util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("initSecretKey")
|
||||
class SecretKey {
|
||||
|
||||
@Test
|
||||
@DisplayName("generates a key when invalid")
|
||||
void generatesWhenInvalid() throws Exception {
|
||||
autoGen.setKey(null);
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false);
|
||||
|
||||
initialSetup.initSecretKey();
|
||||
|
||||
assertThat(autoGen.getKey()).isNotBlank();
|
||||
util.verify(
|
||||
() ->
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
eq("AutomaticallyGenerated.key"), any()),
|
||||
times(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps an existing valid key")
|
||||
void keepsValid() throws Exception {
|
||||
autoGen.setKey("secret");
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
util.when(() -> GeneralUtils.isValidUUID("secret")).thenReturn(true);
|
||||
|
||||
initialSetup.initSecretKey();
|
||||
|
||||
assertThat(autoGen.getKey()).isEqualTo("secret");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("initLegalUrls")
|
||||
class LegalUrls {
|
||||
|
||||
@Test
|
||||
@DisplayName("sets defaults when both URLs are empty")
|
||||
void setsDefaults() throws Exception {
|
||||
legal.setTermsAndConditions(null);
|
||||
legal.setPrivacyPolicy("");
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
initialSetup.initLegalUrls();
|
||||
|
||||
assertThat(legal.getTermsAndConditions()).contains("stirlingpdf.com/terms");
|
||||
assertThat(legal.getPrivacyPolicy()).contains("privacy-policy");
|
||||
util.verify(
|
||||
() -> GeneralUtils.saveKeyToSettings(eq("legal.termsAndConditions"), any()),
|
||||
times(1));
|
||||
util.verify(
|
||||
() -> GeneralUtils.saveKeyToSettings(eq("legal.privacyPolicy"), any()),
|
||||
times(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preserves already-configured URLs")
|
||||
void preservesExisting() throws Exception {
|
||||
legal.setTermsAndConditions("https://example.com/t");
|
||||
legal.setPrivacyPolicy("https://example.com/p");
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
initialSetup.initLegalUrls();
|
||||
|
||||
assertThat(legal.getTermsAndConditions()).isEqualTo("https://example.com/t");
|
||||
util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("initSetAppVersion")
|
||||
class AppVersion {
|
||||
|
||||
@Test
|
||||
@DisplayName("flags new server when version is missing")
|
||||
void newServerWhenMissing() throws Exception {
|
||||
autoGen.setAppVersion(null);
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
initialSetup.initSetAppVersion();
|
||||
|
||||
assertThat(autoGen.getIsNewServer()).isTrue();
|
||||
assertThat(autoGen.getAppVersion()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("flags new server when version is 0.0.0")
|
||||
void newServerWhenZero() throws Exception {
|
||||
autoGen.setAppVersion("0.0.0");
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
initialSetup.initSetAppVersion();
|
||||
|
||||
assertThat(autoGen.getIsNewServer()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("existing server keeps not-new flag")
|
||||
void existingServer() throws Exception {
|
||||
autoGen.setAppVersion("1.2.3");
|
||||
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
|
||||
initialSetup.initSetAppVersion();
|
||||
|
||||
assertThat(autoGen.getIsNewServer()).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
||||
class MetricsFilterTest {
|
||||
|
||||
private SimpleMeterRegistry registry;
|
||||
private MetricsFilter filter;
|
||||
private HttpServletRequest request;
|
||||
private HttpServletResponse response;
|
||||
private FilterChain chain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = new SimpleMeterRegistry();
|
||||
filter = new MetricsFilter(registry);
|
||||
request = mock(HttpServletRequest.class);
|
||||
response = mock(HttpServletResponse.class);
|
||||
chain = mock(FilterChain.class);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("trackable requests")
|
||||
class Trackable {
|
||||
|
||||
@Test
|
||||
@DisplayName("increments a counter for a trackable URI with session")
|
||||
void countsWithSession() throws Exception {
|
||||
HttpSession session = mock(HttpSession.class);
|
||||
when(session.getId()).thenReturn("sess-1");
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general/rotate-pdf");
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(request.getMethod()).thenReturn("POST");
|
||||
when(request.getSession(false)).thenReturn(session);
|
||||
|
||||
filter.doFilterInternal(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses no-session tag when session is absent")
|
||||
void countsWithoutSession() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general/merge-pdfs");
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(request.getMethod()).thenReturn("POST");
|
||||
when(request.getSession(false)).thenReturn(null);
|
||||
|
||||
filter.doFilterInternal(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("non-trackable requests")
|
||||
class NonTrackable {
|
||||
|
||||
@Test
|
||||
@DisplayName("static resource is not counted but chain continues")
|
||||
void staticResourceSkipped() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/css/style.css");
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
filter.doFilterInternal(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
verify(request, never()).getMethod();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
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 jakarta.servlet.MultipartConfigElement;
|
||||
|
||||
import stirling.software.SPDF.controller.web.UploadLimitService;
|
||||
|
||||
class MultipartConfigurationTest {
|
||||
|
||||
private UploadLimitService uploadLimitService;
|
||||
private MultipartConfiguration configuration;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Manually constructed config with a mocked service, so Spring env overrides do not apply.
|
||||
uploadLimitService = mock(UploadLimitService.class);
|
||||
configuration = new MultipartConfiguration();
|
||||
Field field = MultipartConfiguration.class.getDeclaredField("uploadLimitService");
|
||||
field.setAccessible(true);
|
||||
field.set(configuration, uploadLimitService);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("multipartConfigElement")
|
||||
class ConfigElement {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the configured upload limit when positive")
|
||||
void usesConfiguredLimit() {
|
||||
long limit = 50L * 1024 * 1024;
|
||||
when(uploadLimitService.getUploadLimit()).thenReturn(limit);
|
||||
when(uploadLimitService.getReadableUploadLimit()).thenReturn("50.0 MB");
|
||||
|
||||
MultipartConfigElement element = configuration.multipartConfigElement();
|
||||
|
||||
assertThat(element.getMaxFileSize()).isEqualTo(limit);
|
||||
assertThat(element.getMaxRequestSize()).isEqualTo(limit);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to 2000MB default when no limit configured")
|
||||
void usesDefaultWhenZero() {
|
||||
when(uploadLimitService.getUploadLimit()).thenReturn(0L);
|
||||
|
||||
MultipartConfigElement element = configuration.multipartConfigElement();
|
||||
|
||||
long expectedDefault = 2000L * 1024 * 1024;
|
||||
assertThat(element.getMaxFileSize()).isEqualTo(expectedDefault);
|
||||
assertThat(element.getMaxRequestSize()).isEqualTo(expectedDefault);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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.springdoc.core.customizers.OpenApiCustomizer;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.media.ComposedSchema;
|
||||
import io.swagger.v3.oas.models.media.Schema;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@DisplayName("OpenApiConfig")
|
||||
class OpenApiConfigTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private OpenApiConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
config = new OpenApiConfig(applicationProperties);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("customOpenAPI")
|
||||
class CustomOpenAPI {
|
||||
|
||||
@Test
|
||||
@DisplayName("builds OpenAPI with title, version and 3.0.3 spec")
|
||||
void buildsBaseOpenApi() {
|
||||
OpenAPI openAPI = config.customOpenAPI();
|
||||
|
||||
assertThat(openAPI).isNotNull();
|
||||
assertThat(openAPI.getOpenapi()).isEqualTo("3.0.3");
|
||||
assertThat(openAPI.getInfo()).isNotNull();
|
||||
assertThat(openAPI.getInfo().getTitle()).isEqualTo("Stirling PDF API");
|
||||
// Version falls back to 1.0.0 when no implementation version on the package.
|
||||
assertThat(openAPI.getInfo().getVersion()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sets license, contact and terms of service")
|
||||
void setsLicenseAndContact() {
|
||||
OpenAPI openAPI = config.customOpenAPI();
|
||||
|
||||
assertThat(openAPI.getInfo().getLicense()).isNotNull();
|
||||
assertThat(openAPI.getInfo().getLicense().getName()).contains("MIT");
|
||||
assertThat(openAPI.getInfo().getTermsOfService())
|
||||
.isEqualTo("https://www.stirlingpdf.com/terms");
|
||||
assertThat(openAPI.getInfo().getContact()).isNotNull();
|
||||
assertThat(openAPI.getInfo().getContact().getEmail())
|
||||
.isEqualTo("contact@stirlingpdf.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("registers the global AI tag")
|
||||
void registersAiTag() {
|
||||
OpenAPI openAPI = config.customOpenAPI();
|
||||
|
||||
assertThat(openAPI.getTags()).isNotNull();
|
||||
assertThat(openAPI.getTags())
|
||||
.anySatisfy(tag -> assertThat(tag.getName()).isEqualTo("AI"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("adds a server item and an ErrorResponse schema")
|
||||
void addsServerAndErrorSchema() {
|
||||
OpenAPI openAPI = config.customOpenAPI();
|
||||
|
||||
assertThat(openAPI.getServers()).isNotEmpty();
|
||||
assertThat(openAPI.getServers().get(0).getUrl()).isNotBlank();
|
||||
|
||||
Components components = openAPI.getComponents();
|
||||
assertThat(components).isNotNull();
|
||||
assertThat(components.getSchemas()).containsKey("ErrorResponse");
|
||||
Schema<?> errorSchema = components.getSchemas().get("ErrorResponse");
|
||||
assertThat(errorSchema.getProperties())
|
||||
.containsKeys("timestamp", "status", "error", "message", "path");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses relative server URL when SWAGGER_SERVER_URL env var is absent")
|
||||
void usesRelativeServerWhenEnvAbsent() {
|
||||
// The test JVM does not set SWAGGER_SERVER_URL so the relative branch runs.
|
||||
if (System.getenv("SWAGGER_SERVER_URL") == null) {
|
||||
OpenAPI openAPI = config.customOpenAPI();
|
||||
assertThat(openAPI.getServers().get(0).getUrl()).isEqualTo("/");
|
||||
assertThat(openAPI.getServers().get(0).getDescription())
|
||||
.isEqualTo("Current Server");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits API-key security scheme when login is disabled")
|
||||
void noSecuritySchemeWhenLoginDisabled() {
|
||||
applicationProperties.getSecurity().setEnableLogin(false);
|
||||
|
||||
OpenAPI openAPI = config.customOpenAPI();
|
||||
|
||||
assertThat(openAPI.getComponents().getSecuritySchemes()).isNullOrEmpty();
|
||||
assertThat(openAPI.getSecurity()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("adds API-key security scheme when login is enabled")
|
||||
void addsSecuritySchemeWhenLoginEnabled() {
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
|
||||
OpenAPI openAPI = config.customOpenAPI();
|
||||
|
||||
assertThat(openAPI.getComponents().getSecuritySchemes()).containsKey("apiKey");
|
||||
assertThat(openAPI.getSecurity()).isNotEmpty();
|
||||
assertThat(openAPI.getSecurity().get(0)).containsKey("apiKey");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("pdfFileOneOfCustomizer")
|
||||
class PdfFileOneOfCustomizer {
|
||||
|
||||
@Test
|
||||
@DisplayName("replaces PDFFile schema with a oneOf and registers upload/ref shapes")
|
||||
void replacesPdfFileSchema() {
|
||||
OpenApiCustomizer customizer = config.pdfFileOneOfCustomizer();
|
||||
assertThat(customizer).isNotNull();
|
||||
|
||||
// Seed an OpenAPI with an existing PDFFile schema to be replaced.
|
||||
OpenAPI openApi = new OpenAPI().components(new Components());
|
||||
openApi.getComponents().addSchemas("PDFFile", new Schema<>().type("string"));
|
||||
|
||||
customizer.customise(openApi);
|
||||
|
||||
var schemas = openApi.getComponents().getSchemas();
|
||||
assertThat(schemas).containsKeys("PDFFileUpload", "PDFFileRef", "PDFFile");
|
||||
assertThat(schemas.get("PDFFile")).isInstanceOf(ComposedSchema.class);
|
||||
|
||||
ComposedSchema oneOf = (ComposedSchema) schemas.get("PDFFile");
|
||||
assertThat(oneOf.getOneOf()).hasSize(2);
|
||||
assertThat(schemas.get("PDFFileUpload").getRequired()).contains("fileInput");
|
||||
assertThat(schemas.get("PDFFileRef").getRequired()).contains("fileId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.SPDF.service.PdfMetricsService;
|
||||
|
||||
class PdfMetricsInterceptorTest {
|
||||
|
||||
private PdfMetricsService service;
|
||||
private PdfMetricsInterceptor interceptor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(PdfMetricsService.class);
|
||||
when(service.isEnabled()).thenReturn(true);
|
||||
interceptor = new PdfMetricsInterceptor(service);
|
||||
}
|
||||
|
||||
private MultipartHttpServletRequest editRequest(int fileParts, String... headers) {
|
||||
MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class);
|
||||
when(request.getMethod()).thenReturn("POST");
|
||||
when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf");
|
||||
for (int i = 0; i + 1 < headers.length; i += 2) {
|
||||
when(request.getHeader(headers[i])).thenReturn(headers[i + 1]);
|
||||
}
|
||||
MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<>();
|
||||
for (int i = 0; i < fileParts; i++) {
|
||||
files.add("fileInput", mock(MultipartFile.class));
|
||||
}
|
||||
when(request.getMultiFileMap()).thenReturn(files);
|
||||
return request;
|
||||
}
|
||||
|
||||
private HttpServletResponse response(int status, String contentType) {
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
when(response.getStatus()).thenReturn(status);
|
||||
when(response.getContentType()).thenReturn(contentType);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiRequestIsCounted() {
|
||||
interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null);
|
||||
verify(service).recordOperation(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsEveryFilePartUnderOneFieldName() {
|
||||
interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null);
|
||||
verify(service).recordOperation(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsRegardlessOfResponseType() {
|
||||
interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null);
|
||||
verify(service).recordOperation(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editorRequestWithBrowserIdIsNotCounted() {
|
||||
interceptor.afterCompletion(
|
||||
editRequest(1, "X-Browser-Id", "abc-123"),
|
||||
response(200, "application/pdf"),
|
||||
null,
|
||||
null);
|
||||
verify(service, never()).recordOperation(anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editorJwtWithoutBrowserIdIsNotCounted() {
|
||||
interceptor.afterCompletion(
|
||||
editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"),
|
||||
response(200, "application/pdf"),
|
||||
null,
|
||||
null);
|
||||
verify(service, never()).recordOperation(anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bearerApiKeyIsCounted() {
|
||||
interceptor.afterCompletion(
|
||||
editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"),
|
||||
response(200, "application/pdf"),
|
||||
null,
|
||||
null);
|
||||
verify(service).recordOperation(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorResponseIsNotCounted() {
|
||||
interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null);
|
||||
verify(service, never()).recordOperation(anyInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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.springdoc.core.customizers.OpenApiCustomizer;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
|
||||
@DisplayName("SpringDocConfig")
|
||||
class SpringDocConfigTest {
|
||||
|
||||
private SpringDocConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
config = new SpringDocConfig();
|
||||
}
|
||||
|
||||
// Applies every registered customizer against a fresh OpenAPI carrying an Info instance.
|
||||
private OpenAPI applyCustomizers(GroupedOpenApi api) {
|
||||
OpenAPI openApi = new OpenAPI().info(new Info());
|
||||
for (OpenApiCustomizer customizer : api.getOpenApiCustomizers()) {
|
||||
customizer.customise(openApi);
|
||||
}
|
||||
return openApi;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("pdfProcessingApi")
|
||||
class PdfProcessingApi {
|
||||
|
||||
@Test
|
||||
@DisplayName("builds the file-processing group with match and exclude paths")
|
||||
void buildsGroup() {
|
||||
OpenApiCustomizer pdfFileOneOfCustomizer = openApi -> {};
|
||||
|
||||
GroupedOpenApi api = config.pdfProcessingApi(pdfFileOneOfCustomizer);
|
||||
|
||||
assertThat(api).isNotNull();
|
||||
assertThat(api.getGroup()).isEqualTo("file-processing");
|
||||
assertThat(api.getDisplayName()).isEqualTo("File Processing");
|
||||
assertThat(api.getPathsToMatch()).contains("/api/v1/**");
|
||||
assertThat(api.getPathsToExclude()).contains("/api/v1/admin/**", "/api/v1/auth/**");
|
||||
// The injected oneOf customizer plus the inline info customizer are both registered.
|
||||
assertThat(api.getOpenApiCustomizers()).hasSizeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("info customizer sets the processing title and description")
|
||||
void infoCustomizerSetsTitle() {
|
||||
GroupedOpenApi api = config.pdfProcessingApi(openApi -> {});
|
||||
|
||||
OpenAPI openApi = applyCustomizers(api);
|
||||
|
||||
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Processing API");
|
||||
assertThat(openApi.getInfo().getDescription()).contains("PDF");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("adminApi")
|
||||
class AdminApi {
|
||||
|
||||
@Test
|
||||
@DisplayName("builds the management group with admin/user/auth paths")
|
||||
void buildsGroup() {
|
||||
GroupedOpenApi api = config.adminApi();
|
||||
|
||||
assertThat(api.getGroup()).isEqualTo("management");
|
||||
assertThat(api.getDisplayName()).isEqualTo("Management");
|
||||
assertThat(api.getPathsToMatch())
|
||||
.contains("/api/v1/admin/**", "/api/v1/user/**", "/api/v1/auth/**");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("info customizer sets the management title")
|
||||
void infoCustomizerSetsTitle() {
|
||||
GroupedOpenApi api = config.adminApi();
|
||||
|
||||
OpenAPI openApi = applyCustomizers(api);
|
||||
|
||||
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Management API");
|
||||
assertThat(openApi.getInfo().getDescription()).isNotBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("systemApi")
|
||||
class SystemApi {
|
||||
|
||||
@Test
|
||||
@DisplayName("builds the system group with ui-data/info paths")
|
||||
void buildsGroup() {
|
||||
GroupedOpenApi api = config.systemApi();
|
||||
|
||||
assertThat(api.getGroup()).isEqualTo("system");
|
||||
assertThat(api.getDisplayName()).isEqualTo("System & UI API");
|
||||
assertThat(api.getPathsToMatch())
|
||||
.contains("/api/v1/ui-data/**", "/api/v1/info/**", "/api/v1/general/job/**");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("info customizer sets the system title")
|
||||
void infoCustomizerSetsTitle() {
|
||||
GroupedOpenApi api = config.systemApi();
|
||||
|
||||
OpenAPI openApi = applyCustomizers(api);
|
||||
|
||||
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - System API");
|
||||
assertThat(openApi.getInfo().getDescription()).isNotBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
|
||||
class StartupApplicationListenerTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("onApplicationEvent records a startup time")
|
||||
void recordsStartTime() {
|
||||
StartupApplicationListener listener = new StartupApplicationListener();
|
||||
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
|
||||
|
||||
listener.onApplicationEvent(new ContextRefreshedEvent(new EmptyContext()));
|
||||
|
||||
assertThat(StartupApplicationListener.startTime).isNotNull();
|
||||
assertThat(StartupApplicationListener.startTime).isAfterOrEqualTo(before);
|
||||
}
|
||||
|
||||
// Minimal ApplicationContext to satisfy ContextRefreshedEvent construction.
|
||||
private static class EmptyContext
|
||||
extends org.springframework.context.support.StaticApplicationContext {}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
@DisplayName("TauriProcessMonitor")
|
||||
class TauriProcessMonitorTest {
|
||||
|
||||
private static Object invokePrivate(TauriProcessMonitor monitor, String name, Object... args)
|
||||
throws Exception {
|
||||
Method method = findMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method.invoke(monitor, args);
|
||||
}
|
||||
|
||||
private static Method findMethod(String name) {
|
||||
for (Method m : TauriProcessMonitor.class.getDeclaredMethods()) {
|
||||
if (m.getName().equals(name)) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Method not found: " + name);
|
||||
}
|
||||
|
||||
private static void setField(TauriProcessMonitor monitor, String name, Object value)
|
||||
throws Exception {
|
||||
Field field = TauriProcessMonitor.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(monitor, value);
|
||||
}
|
||||
|
||||
private static Object getField(TauriProcessMonitor monitor, String name) throws Exception {
|
||||
Field field = TauriProcessMonitor.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(monitor);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getCurrentProcessId")
|
||||
class CurrentProcessId {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a non-blank PID string")
|
||||
void returnsPid() {
|
||||
assertThat(TauriProcessMonitor.getCurrentProcessId()).isNotBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("init")
|
||||
class Init {
|
||||
|
||||
// System.getenv cannot be mocked (java.base), so init() is exercised against the real
|
||||
// environment, which has no TAURI_PARENT_PID; the present-PID path is driven directly
|
||||
// through startMonitoring().
|
||||
|
||||
@Test
|
||||
@DisplayName("startMonitoring flips monitoring on and creates a scheduler")
|
||||
void startMonitoringSchedulesTask() throws Exception {
|
||||
ApplicationContext ctx = mock(ApplicationContext.class);
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
|
||||
setField(monitor, "parentProcessId", "12345");
|
||||
|
||||
try {
|
||||
invokePrivate(monitor, "startMonitoring");
|
||||
|
||||
assertThat((Boolean) getField(monitor, "monitoring")).isTrue();
|
||||
assertThat(getField(monitor, "scheduler")).isNotNull();
|
||||
assertThat((String) getField(monitor, "parentProcessId")).isEqualTo("12345");
|
||||
} finally {
|
||||
// Stop the scheduler thread created by startMonitoring.
|
||||
monitor.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isProcessAlive")
|
||||
class IsProcessAlive {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns true when ProcessHandle reports the PID present")
|
||||
void aliveWhenPresent() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
|
||||
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
|
||||
ph.when(() -> ProcessHandle.of(999L))
|
||||
.thenReturn(Optional.of(mock(ProcessHandle.class)));
|
||||
Object result = invokePrivate(monitor, "isProcessAlive", "999");
|
||||
assertThat((Boolean) result).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when ProcessHandle reports the PID absent")
|
||||
void deadWhenAbsent() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
|
||||
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
|
||||
ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty());
|
||||
Object result = invokePrivate(monitor, "isProcessAlive", "999");
|
||||
assertThat((Boolean) result).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false for a non-numeric PID")
|
||||
void falseForInvalidPid() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
|
||||
Object result = invokePrivate(monitor, "isProcessAlive", "not-a-number");
|
||||
assertThat((Boolean) result).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("checkParentProcess")
|
||||
class CheckParentProcess {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns early when monitoring is off")
|
||||
void earlyReturnWhenNotMonitoring() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
setField(monitor, "monitoring", false);
|
||||
|
||||
// Should not throw even though parentProcessId is null.
|
||||
invokePrivate(monitor, "checkParentProcess");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("triggers graceful shutdown when the parent process is dead")
|
||||
void shutsDownWhenParentDead() throws Exception {
|
||||
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
|
||||
setField(monitor, "monitoring", true);
|
||||
setField(monitor, "parentProcessId", "999");
|
||||
|
||||
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
|
||||
ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty());
|
||||
|
||||
invokePrivate(monitor, "checkParentProcess");
|
||||
|
||||
// initiateGracefulShutdown flips monitoring off and spawns an async close.
|
||||
// The async close runs after a hardcoded 1s sleep, so we assert only the
|
||||
// immediate, deterministic effect to keep the test fast.
|
||||
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does nothing when the parent process is still alive")
|
||||
void noShutdownWhenParentAlive() throws Exception {
|
||||
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
|
||||
setField(monitor, "monitoring", true);
|
||||
setField(monitor, "parentProcessId", "999");
|
||||
|
||||
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
|
||||
ph.when(() -> ProcessHandle.of(999L))
|
||||
.thenReturn(Optional.of(mock(ProcessHandle.class)));
|
||||
|
||||
invokePrivate(monitor, "checkParentProcess");
|
||||
|
||||
assertThat((Boolean) getField(monitor, "monitoring")).isTrue();
|
||||
}
|
||||
verify(ctx, never()).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("initiateGracefulShutdown")
|
||||
class InitiateGracefulShutdown {
|
||||
|
||||
@Test
|
||||
@DisplayName("closes a ConfigurableApplicationContext asynchronously")
|
||||
void closesConfigurableContext() throws Exception {
|
||||
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
|
||||
setField(monitor, "monitoring", true);
|
||||
|
||||
invokePrivate(monitor, "initiateGracefulShutdown");
|
||||
|
||||
// The async close runs after a hardcoded 1s sleep; assert only the immediate effect.
|
||||
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanup")
|
||||
class Cleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("is a no-op when no scheduler was created")
|
||||
void noOpWithoutScheduler() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
|
||||
// scheduler is null by default; cleanup must not throw.
|
||||
monitor.cleanup();
|
||||
|
||||
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shuts down an active scheduler")
|
||||
void shutsDownActiveScheduler() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
|
||||
when(scheduler.isShutdown()).thenReturn(false);
|
||||
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(true);
|
||||
setField(monitor, "scheduler", scheduler);
|
||||
setField(monitor, "monitoring", true);
|
||||
|
||||
monitor.cleanup();
|
||||
|
||||
verify(scheduler, times(1)).shutdown();
|
||||
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("forces shutdownNow when awaitTermination times out")
|
||||
void forcesShutdownNowOnTimeout() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
|
||||
when(scheduler.isShutdown()).thenReturn(false);
|
||||
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(false);
|
||||
setField(monitor, "scheduler", scheduler);
|
||||
|
||||
monitor.cleanup();
|
||||
|
||||
verify(scheduler, times(1)).shutdown();
|
||||
verify(scheduler, times(1)).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("restores interrupt flag when awaitTermination is interrupted")
|
||||
void handlesInterruptedException() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
|
||||
when(scheduler.isShutdown()).thenReturn(false);
|
||||
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS)))
|
||||
.thenThrow(new InterruptedException("boom"));
|
||||
setField(monitor, "scheduler", scheduler);
|
||||
|
||||
monitor.cleanup();
|
||||
|
||||
verify(scheduler, times(1)).shutdownNow();
|
||||
// Clear the interrupt flag we just set so it does not leak to other tests.
|
||||
assertThat(Thread.interrupted()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips shutdown when scheduler already terminated")
|
||||
void skipsAlreadyShutdownScheduler() throws Exception {
|
||||
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
|
||||
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
|
||||
when(scheduler.isShutdown()).thenReturn(true);
|
||||
setField(monitor, "scheduler", scheduler);
|
||||
|
||||
monitor.cleanup();
|
||||
|
||||
verify(scheduler, never()).shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
|
||||
class WAUTrackingFilterTest {
|
||||
|
||||
private WeeklyActiveUsersService wauService;
|
||||
private WAUTrackingFilter filter;
|
||||
private jakarta.servlet.ServletResponse response;
|
||||
private FilterChain chain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
wauService = mock(WeeklyActiveUsersService.class);
|
||||
filter = new WAUTrackingFilter(wauService);
|
||||
response = mock(jakarta.servlet.ServletResponse.class);
|
||||
chain = mock(FilterChain.class);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("browser id handling")
|
||||
class BrowserId {
|
||||
|
||||
@Test
|
||||
@DisplayName("records access when header present")
|
||||
void recordsWhenPresent() throws Exception {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("X-Browser-Id")).thenReturn("browser-42");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(wauService).recordBrowserAccess("browser-42");
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not record when header is null")
|
||||
void skipsWhenNull() throws Exception {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("X-Browser-Id")).thenReturn(null);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(wauService, never()).recordBrowserAccess(anyString());
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not record when header is blank")
|
||||
void skipsWhenBlank() throws Exception {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("X-Browser-Id")).thenReturn(" ");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(wauService, never()).recordBrowserAccess(anyString());
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("non-http requests")
|
||||
class NonHttp {
|
||||
|
||||
@Test
|
||||
@DisplayName("passes through without recording")
|
||||
void nonHttpPassThrough() throws Exception {
|
||||
ServletRequest request = mock(ServletRequest.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(wauService, never()).recordBrowserAccess(anyString());
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
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.util.List;
|
||||
|
||||
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.web.servlet.config.annotation.CorsRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("WebMvcConfig")
|
||||
class WebMvcConfigTest {
|
||||
|
||||
private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE";
|
||||
|
||||
@Mock private EndpointInterceptor endpointInterceptor;
|
||||
@Mock private PdfMetricsInterceptor pdfMetricsInterceptor;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.System system;
|
||||
|
||||
private WebMvcConfig config;
|
||||
private String originalTauriProp;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
originalTauriProp = System.getProperty(TAURI_PROP);
|
||||
System.clearProperty(TAURI_PROP);
|
||||
config =
|
||||
new WebMvcConfig(endpointInterceptor, pdfMetricsInterceptor, applicationProperties);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (originalTauriProp == null) {
|
||||
System.clearProperty(TAURI_PROP);
|
||||
} else {
|
||||
System.setProperty(TAURI_PROP, originalTauriProp);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addInterceptors")
|
||||
class AddInterceptors {
|
||||
|
||||
@Test
|
||||
@DisplayName("registers both interceptors in order")
|
||||
void registersBothInterceptors() {
|
||||
InterceptorRegistry registry = mock(InterceptorRegistry.class);
|
||||
InterceptorRegistration registration = mock(InterceptorRegistration.class);
|
||||
when(registry.addInterceptor(any())).thenReturn(registration);
|
||||
|
||||
config.addInterceptors(registry);
|
||||
|
||||
verify(registry).addInterceptor(endpointInterceptor);
|
||||
verify(registry).addInterceptor(pdfMetricsInterceptor);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addResourceHandlers")
|
||||
class AddResourceHandlers {
|
||||
|
||||
@Test
|
||||
@DisplayName("registers all five resource handler groups")
|
||||
void registersFiveHandlerGroups() {
|
||||
ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class);
|
||||
ResourceHandlerRegistration registration =
|
||||
mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS);
|
||||
when(registry.addResourceHandler(any(String[].class))).thenReturn(registration);
|
||||
|
||||
config.addResourceHandlers(registry);
|
||||
|
||||
// SW/PWA, assets, media+fonts, branding, catch-all = 5 handler registrations.
|
||||
verify(registry, times(5)).addResourceHandler(any(String[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes the SPA catch-all and assets patterns")
|
||||
void includesKnownPatterns() {
|
||||
ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class);
|
||||
ResourceHandlerRegistration registration =
|
||||
mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS);
|
||||
when(registry.addResourceHandler(any(String[].class))).thenReturn(registration);
|
||||
|
||||
config.addResourceHandlers(registry);
|
||||
|
||||
ArgumentCaptor<String[]> captor = ArgumentCaptor.forClass(String[].class);
|
||||
verify(registry, atLeastOnce()).addResourceHandler(captor.capture());
|
||||
List<String> allPatterns =
|
||||
captor.getAllValues().stream().flatMap(java.util.Arrays::stream).toList();
|
||||
assertThat(allPatterns).contains("/**", "/assets/**", "/sw.js");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addCorsMappings")
|
||||
class AddCorsMappings {
|
||||
|
||||
private CorsRegistry registry;
|
||||
private CorsRegistration registration;
|
||||
|
||||
@BeforeEach
|
||||
void initRegistry() {
|
||||
registry = mock(CorsRegistry.class);
|
||||
registration = mock(CorsRegistration.class, RETURNS_DEEP_STUBS);
|
||||
when(registry.addMapping(anyString())).thenReturn(registration);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tauri mode adds a mapping with Tauri origin patterns")
|
||||
void tauriModeBranch() {
|
||||
System.setProperty(TAURI_PROP, "true");
|
||||
// hasConfiguredOrigins is evaluated before the Tauri check, so getSystem() is
|
||||
// consulted.
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getCorsAllowedOrigins()).thenReturn(List.of());
|
||||
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
verify(registry).addMapping("/**");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses configured origins and appends Tauri origins when present")
|
||||
void configuredOriginsBranch() {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getCorsAllowedOrigins())
|
||||
.thenReturn(new java.util.ArrayList<>(List.of("https://app.example.com")));
|
||||
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
verify(registry).addMapping("/**");
|
||||
// origins consulted twice (presence check + value use)
|
||||
verify(system, atLeastOnce()).getCorsAllowedOrigins();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("configured origins keep an already-present Tauri origin unduplicated")
|
||||
void configuredOriginsAlreadyContainTauri() {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getCorsAllowedOrigins())
|
||||
.thenReturn(
|
||||
new java.util.ArrayList<>(
|
||||
List.of(
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")));
|
||||
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
verify(registry).addMapping("/**");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default branch allows all origins when nothing configured")
|
||||
void defaultBranchAllowsAll() {
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getCorsAllowedOrigins()).thenReturn(List.of());
|
||||
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
verify(registry).addMapping("/**");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default branch also triggers when system is null")
|
||||
void defaultBranchWhenSystemNull() {
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(null);
|
||||
|
||||
config.addCorsMappings(registry);
|
||||
|
||||
verify(registry).addMapping("/**");
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
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.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.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.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Additional branch coverage for {@link CropController}: the Ghostscript routing decision (enabled
|
||||
* vs disabled when removeDataOutsideCrop is set), the Ghostscript execution path with the external
|
||||
* process mocked, and the large-image sampling-step branch of detectContentBounds. The gs binary is
|
||||
* never invoked.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("CropController additional branch tests")
|
||||
class CropControllerMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@InjectMocks private CropController cropController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile(
|
||||
tempDir, "crop", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
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());
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
private MockMultipartFile pdf(int pages) throws IOException {
|
||||
Path p = tempDir.resolve("crop-src.pdf");
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.LETTER));
|
||||
}
|
||||
doc.save(p.toFile());
|
||||
}
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "src.pdf", MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(p));
|
||||
}
|
||||
|
||||
private CropPdfForm form(MockMultipartFile file, boolean removeOutside) {
|
||||
CropPdfForm f = new CropPdfForm();
|
||||
f.setFileInput(file);
|
||||
f.setX(20f);
|
||||
f.setY(20f);
|
||||
f.setWidth(200f);
|
||||
f.setHeight(300f);
|
||||
f.setAutoCrop(false);
|
||||
f.setRemoveDataOutsideCrop(removeOutside);
|
||||
return f;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Ghostscript routing")
|
||||
class GhostscriptRouting {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"removeDataOutsideCrop with Ghostscript disabled falls back to the PDFBox path")
|
||||
void disabledFallsBackToPdfBox() throws Exception {
|
||||
MockMultipartFile file = pdf(1);
|
||||
CropPdfForm request = form(file, true);
|
||||
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
PDDocument source = mock(PDDocument.class);
|
||||
PDDocument out = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(source);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)).thenReturn(out);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// PDFBox path constructs a new document; gs path would not.
|
||||
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(source);
|
||||
verify(source).close();
|
||||
verify(out).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removeDataOutsideCrop with Ghostscript enabled runs the gs command path")
|
||||
void enabledRunsGhostscript() throws Exception {
|
||||
MockMultipartFile file = pdf(2);
|
||||
CropPdfForm request = form(file, true);
|
||||
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
// Real document so setCropBox + save() succeed inside the gs branch.
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT))
|
||||
.thenReturn(executor);
|
||||
when(executor.runCommandWithOutputHandling(any())).thenReturn(null);
|
||||
|
||||
ResponseEntity<Resource> response = cropController.cropPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
verify(executor).runCommandWithOutputHandling(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ghostscript interruption is wrapped and surfaced")
|
||||
void interruptedIsWrapped() throws Exception {
|
||||
MockMultipartFile file = pdf(1);
|
||||
CropPdfForm request = form(file, true);
|
||||
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes()));
|
||||
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT))
|
||||
.thenReturn(executor);
|
||||
when(executor.runCommandWithOutputHandling(any()))
|
||||
.thenThrow(new InterruptedException("stop"));
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
Exception.class, () -> cropController.cropPdf(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectContentBounds sampling step")
|
||||
class SamplingStep {
|
||||
|
||||
private Method detect;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
detect =
|
||||
CropController.class.getDeclaredMethod(
|
||||
"detectContentBounds", BufferedImage.class);
|
||||
detect.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("large images (>2000px) use a step of 2 and still locate content")
|
||||
void largeImageUsesStep2() throws Exception {
|
||||
// Width > 2000 triggers the step=2 sampling branch.
|
||||
BufferedImage image = new BufferedImage(2100, 50, BufferedImage.TYPE_INT_RGB);
|
||||
for (int x = 0; x < 2100; x++) {
|
||||
for (int y = 0; y < 50; y++) {
|
||||
image.setRGB(x, y, 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
// Dark block on even coordinates so the step-2 scan can see it.
|
||||
for (int x = 1000; x < 1040; x += 2) {
|
||||
for (int y = 10; y < 30; y += 2) {
|
||||
image.setRGB(x, y, 0x000000);
|
||||
}
|
||||
}
|
||||
|
||||
int[] bounds = (int[]) detect.invoke(null, image);
|
||||
assertThat(bounds).hasSize(4);
|
||||
assertThat(bounds[0]).isGreaterThanOrEqualTo(0);
|
||||
assertThat(bounds[2]).isGreaterThan(bounds[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero-size image returns degenerate bounds")
|
||||
void zeroSizeImage() throws Exception {
|
||||
BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
|
||||
image.setRGB(0, 0, 0xFFFFFF);
|
||||
int[] bounds = (int[]) detect.invoke(null, image);
|
||||
assertThat(bounds).containsExactly(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
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.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Extra coverage for {@link MergeController} helpers that the mock-based and end-to-end suites do
|
||||
* not reach directly: the PDFBox {@code addTableOfContents} outline builder and {@code
|
||||
* mergeDocuments} page-copy path, both driven over real in-memory documents.
|
||||
*/
|
||||
class MergeControllerExtraTest {
|
||||
|
||||
private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private MergeController mergeController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
TempFileManager tempFileManager =
|
||||
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
mergeController = new MergeController(pdfDocumentFactory, tempFileManager);
|
||||
}
|
||||
|
||||
private static byte[] pdfBytes(int pages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdf(String name, int pages) throws IOException {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, pdfBytes(pages));
|
||||
}
|
||||
|
||||
private void addTableOfContents(PDDocument merged, MultipartFile[] files) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(mergeController, merged, files);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("mergeDocuments")
|
||||
class MergeDocuments {
|
||||
|
||||
@Test
|
||||
@DisplayName("copies all pages from every source into a single document")
|
||||
void copiesAllPages() throws Exception {
|
||||
try (PDDocument a = loadPdf(pdfBytes(2));
|
||||
PDDocument b = loadPdf(pdfBytes(3))) {
|
||||
try (PDDocument merged = mergeController.mergeDocuments(List.of(a, b))) {
|
||||
assertThat(merged.getNumberOfPages()).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty source list yields an empty merged document")
|
||||
void emptyListEmptyDoc() throws Exception {
|
||||
try (PDDocument merged = mergeController.mergeDocuments(List.of())) {
|
||||
assertThat(merged.getNumberOfPages()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single source is copied verbatim")
|
||||
void singleSource() throws Exception {
|
||||
try (PDDocument only = loadPdf(pdfBytes(4))) {
|
||||
try (PDDocument merged = mergeController.mergeDocuments(List.of(only))) {
|
||||
assertThat(merged.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PDDocument loadPdf(byte[] bytes) throws IOException {
|
||||
return org.apache.pdfbox.Loader.loadPDF(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addTableOfContents")
|
||||
class AddTableOfContents {
|
||||
|
||||
@Test
|
||||
@DisplayName("adds one outline entry per input file titled by filename without extension")
|
||||
void oneEntryPerFile() throws Exception {
|
||||
MultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)};
|
||||
try (PDDocument merged = new PDDocument()) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
merged.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
|
||||
addTableOfContents(merged, files);
|
||||
|
||||
PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline();
|
||||
assertThat(outline).isNotNull();
|
||||
List<String> titles = outlineTitles(outline);
|
||||
assertThat(titles).containsExactly("intro", "body");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("outline destinations advance by each source's page count")
|
||||
void destinationsAdvance() throws Exception {
|
||||
MultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 1)};
|
||||
try (PDDocument merged = new PDDocument()) {
|
||||
merged.addPage(new PDPage(PDRectangle.A4));
|
||||
merged.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
addTableOfContents(merged, files);
|
||||
|
||||
PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline();
|
||||
Iterator<PDOutlineItem> it = outline.children().iterator();
|
||||
// both items resolve and the outline is non-empty
|
||||
assertThat(it.hasNext()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("handles a single-file table of contents")
|
||||
void singleFileToc() throws Exception {
|
||||
MultipartFile[] files = {pdf("solo.pdf", 1)};
|
||||
try (PDDocument merged = new PDDocument()) {
|
||||
merged.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
addTableOfContents(merged, files);
|
||||
|
||||
assertThat(outlineTitles(merged.getDocumentCatalog().getDocumentOutline()))
|
||||
.containsExactly("solo");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> outlineTitles(PDDocumentOutline outline) {
|
||||
List<String> titles = new ArrayList<>();
|
||||
for (PDOutlineItem item : outline.children()) {
|
||||
titles.add(item.getTitle());
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Calendar;
|
||||
import java.util.GregorianCalendar;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.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.documentnavigation.outline.PDDocumentOutline;
|
||||
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.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
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.MergePdfsRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* End-to-end coverage for {@link MergeController#mergePdfs} using real in-memory PDFs and the real
|
||||
* JPDFium merge pipeline plus a real {@link CustomPDFDocumentFactory}/{@link TempFileManager}.
|
||||
* Exercises the sort modes, the fileOrder reordering branch, table-of-contents generation, the
|
||||
* removeCertSign signature pre-check, single/empty file handling and the corrupted-input path that
|
||||
* the mock-based {@code MergeControllerTest}/{@code MergeControllerGapTest} do not reach.
|
||||
*/
|
||||
class MergeControllerMoreTest {
|
||||
|
||||
private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private TempFileManager tempFileManager;
|
||||
private MergeController mergeController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
mergeController = new MergeController(pdfDocumentFactory, tempFileManager);
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
private static byte[] buildPdf(int pageCount, String title, Long modMillis) throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
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(72, 720);
|
||||
cs.showText("Body " + (i + 1));
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
if (title != null) {
|
||||
info.setTitle(title);
|
||||
}
|
||||
if (modMillis != null) {
|
||||
Calendar cal = new GregorianCalendar();
|
||||
cal.setTimeInMillis(modMillis);
|
||||
info.setModificationDate(cal);
|
||||
}
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdf(String name, int pages) throws IOException {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, buildPdf(pages, null, null));
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdf(String name, int pages, String title, Long modMillis)
|
||||
throws IOException {
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
name,
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdf(pages, title, modMillis));
|
||||
}
|
||||
|
||||
private static MergePdfsRequest request(
|
||||
MockMultipartFile[] files, String sortType, boolean removeCertSign, boolean toc) {
|
||||
MergePdfsRequest req = new MergePdfsRequest();
|
||||
req.setFileInput(files);
|
||||
req.setSortType(sortType);
|
||||
req.setRemoveCertSign(removeCertSign);
|
||||
req.setGenerateToc(toc);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static PDDocument readResponse(ResponseEntity<Resource> response) throws IOException {
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
byte[] out;
|
||||
try (InputStream is = response.getBody().getInputStream()) {
|
||||
out = is.readAllBytes();
|
||||
}
|
||||
assertThat(out.length).isGreaterThan(0);
|
||||
return Loader.loadPDF(out);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Basic merge")
|
||||
class BasicMerge {
|
||||
|
||||
@Test
|
||||
@DisplayName("merges two PDFs and sums the page counts")
|
||||
void mergesTwoFiles() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 3)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(5);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("merges three PDFs preserving total pages")
|
||||
void mergesThreeFiles() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 2), pdf("c.pdf", 1)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("merging a single file returns its pages")
|
||||
void mergesSingleFile() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("solo.pdf", 4)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null fileInput is treated as an empty set and still returns OK")
|
||||
void nullFileInput() throws Exception {
|
||||
MergePdfsRequest req = new MergePdfsRequest();
|
||||
req.setFileInput(null);
|
||||
req.setSortType("orderProvided");
|
||||
req.setRemoveCertSign(false);
|
||||
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty file array returns an empty (zero-byte) body")
|
||||
void emptyFileArray() throws Exception {
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(
|
||||
request(new MockMultipartFile[0], "orderProvided", false, false), null);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Sort modes")
|
||||
class SortModes {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"orderProvided",
|
||||
"byFileName",
|
||||
"byDateModified",
|
||||
"byDateCreated",
|
||||
"byPDFTitle",
|
||||
"unknownSortType"
|
||||
})
|
||||
@DisplayName("every sort mode produces a valid merged document")
|
||||
void allSortModes(String sortType) throws Exception {
|
||||
MockMultipartFile[] files = {
|
||||
pdf("charlie.pdf", 1, "Gamma", 3_000L),
|
||||
pdf("alpha.pdf", 1, "Alpha", 1_000L),
|
||||
pdf("bravo.pdf", 1, "Beta", 2_000L)
|
||||
};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, sortType, false, false), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byFileName orders the first output filename deterministically")
|
||||
void byFileNameUsesFirstAlphabetical() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("zebra.pdf", 1), pdf("apple.pdf", 1)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "byFileName", false, false), null);
|
||||
String disposition =
|
||||
response.getHeaders()
|
||||
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
|
||||
// apple.pdf sorts first so it seeds the generated merged filename
|
||||
assertThat(disposition).contains("apple");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("fileOrder reordering")
|
||||
class FileOrder {
|
||||
|
||||
@Test
|
||||
@DisplayName("fileOrder param overrides sortType and drives the merge order")
|
||||
void fileOrderOverridesSort() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 2)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(
|
||||
request(files, "byFileName", false, false), "second.pdf\nfirst.pdf");
|
||||
String disposition =
|
||||
response.getHeaders()
|
||||
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
|
||||
assertThat(disposition).contains("second");
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank fileOrder falls through to the sortType branch")
|
||||
void blankFileOrderUsesSort() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", false, false), " ");
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Table of contents and bookmarks")
|
||||
class TableOfContents {
|
||||
|
||||
@Test
|
||||
@DisplayName("generateToc adds a document outline keyed by filename")
|
||||
void generatesToc() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", false, true), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
PDDocumentOutline outline = result.getDocumentCatalog().getDocumentOutline();
|
||||
assertThat(outline).isNotNull();
|
||||
assertThat(outline.children().iterator().hasNext()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a blank filename falls back to a generated Document N title")
|
||||
void tocBlankFilenameFallback() throws Exception {
|
||||
MockMultipartFile blankName =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
buildPdf(1, null, null));
|
||||
MockMultipartFile[] files = {blankName, pdf("named.pdf", 1)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", false, true), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("removeCertSign branch")
|
||||
class RemoveCertSign {
|
||||
|
||||
@Test
|
||||
@DisplayName("removeCertSign with no signatures skips the flatten pass but still merges")
|
||||
void removeCertSignNoSignatures() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", true, false), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removeCertSign=false copies the merged output directly")
|
||||
void removeCertSignFalse() throws Exception {
|
||||
MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 1)};
|
||||
ResponseEntity<Resource> response =
|
||||
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
|
||||
try (PDDocument result = readResponse(response)) {
|
||||
assertThat(result.getNumberOfPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Corrupted input handling")
|
||||
class CorruptedInput {
|
||||
|
||||
// Drives the PDF pre-validate loop and the merge error path. JPDFium may either reject the
|
||||
// garbage payload (throw) or salvage a degenerate document; both outcomes are acceptable,
|
||||
// so
|
||||
// we only assert the code path runs and any thrown error is an Exception (logged +
|
||||
// rethrown).
|
||||
@Test
|
||||
@DisplayName("a non-PDF payload exercises the pre-validate and merge error branch")
|
||||
void corruptedPayloadHandled() throws Exception {
|
||||
MockMultipartFile good = pdf("good.pdf", 1);
|
||||
MockMultipartFile bad =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"broken.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"this is not a pdf at all".getBytes());
|
||||
MergePdfsRequest req =
|
||||
request(new MockMultipartFile[] {good, bad}, "orderProvided", false, false);
|
||||
try {
|
||||
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
} catch (Exception expected) {
|
||||
assertThat(expected).isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an entirely empty payload still runs through the merge pipeline")
|
||||
void emptyPayloadHandled() throws Exception {
|
||||
MockMultipartFile empty =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "empty.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[0]);
|
||||
MergePdfsRequest req =
|
||||
request(new MockMultipartFile[] {empty}, "orderProvided", false, false);
|
||||
try {
|
||||
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
} catch (Exception expected) {
|
||||
assertThat(expected).isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.when;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
|
||||
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.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Branch coverage for {@link MultiPageLayoutController#mergeMultiplePagesIntoOne} options:
|
||||
* orientation/arrangement/reading-direction validation, margin validation, landscape, RTL and
|
||||
* by-column layouts, all exercised against real multi-page source documents.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("MultiPageLayoutController options")
|
||||
class MultiPageLayoutControllerMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private MultiPageLayoutController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
controller = new MultiPageLayoutController(pdfDocumentFactory, tempFileManager);
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("mpl", 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;
|
||||
});
|
||||
}
|
||||
|
||||
/** Wires the factory to load real source pages and return a real target document. */
|
||||
private void wireDocuments(int pages) throws Exception {
|
||||
PDDocument source = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
source.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class)))
|
||||
.thenReturn(source);
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source))
|
||||
.thenReturn(new PDDocument());
|
||||
}
|
||||
|
||||
private static MockMultipartFile file() {
|
||||
return new MockMultipartFile("fileInput", "in.pdf", "application/pdf", new byte[] {1});
|
||||
}
|
||||
|
||||
private static MergeMultiplePagesRequest base() {
|
||||
MergeMultiplePagesRequest req = new MergeMultiplePagesRequest();
|
||||
req.setFileInput(file());
|
||||
req.setPagesPerSheet(4);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("validation failures")
|
||||
class Validation {
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown mode is rejected")
|
||||
void unknownModeThrows() {
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setMode("WEIRD");
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("custom mode with non-positive rows/cols is rejected")
|
||||
void customNonPositiveThrows() {
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setMode("CUSTOM");
|
||||
req.setRows(0);
|
||||
req.setCols(2);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid orientation is rejected")
|
||||
void invalidOrientationThrows() {
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setOrientation("DIAGONAL");
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid arrangement is rejected")
|
||||
void invalidArrangementThrows() {
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setArrangement("SPIRAL");
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid reading direction is rejected")
|
||||
void invalidReadingDirectionThrows() {
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setReadingDirection("DIAGONAL");
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative margins are rejected")
|
||||
void negativeMarginsThrows() {
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setTopMargin(-1);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("outer margins that consume the whole page yield a non-positive cell error")
|
||||
void outerMarginsTooLargeThrows() throws Exception {
|
||||
wireDocuments(1);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
// A4 width is ~595pt; 600 left margin alone makes cell width non-positive.
|
||||
req.setLeftMargin(600);
|
||||
req.setRightMargin(600);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("inner margin larger than the cell yields a non-positive inner-area error")
|
||||
void innerMarginTooLargeThrows() throws Exception {
|
||||
wireDocuments(1);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setInnerMargin(1000);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.mergeMultiplePagesIntoOne(req));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("layout option branches")
|
||||
class LayoutOptions {
|
||||
|
||||
@Test
|
||||
@DisplayName("landscape orientation succeeds and skips form copying")
|
||||
void landscapeSucceeds() throws Exception {
|
||||
wireDocuments(4);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setOrientation("LANDSCAPE");
|
||||
req.setAddBorder(Boolean.TRUE);
|
||||
|
||||
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(response.getBody().contentLength() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("right-to-left reading direction succeeds")
|
||||
void rtlSucceeds() throws Exception {
|
||||
wireDocuments(4);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setReadingDirection("RTL");
|
||||
|
||||
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("by-columns arrangement with RTL succeeds")
|
||||
void byColumnsRtlSucceeds() throws Exception {
|
||||
wireDocuments(4);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setArrangement("BY_COLUMNS");
|
||||
req.setReadingDirection("RTL");
|
||||
|
||||
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("by-columns arrangement with LTR succeeds")
|
||||
void byColumnsLtrSucceeds() throws Exception {
|
||||
wireDocuments(4);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setArrangement("BY_COLUMNS");
|
||||
|
||||
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("custom mode with explicit borderWidth succeeds")
|
||||
void customModeWithBorderSucceeds() throws Exception {
|
||||
wireDocuments(6);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setMode("CUSTOM");
|
||||
req.setRows(2);
|
||||
req.setCols(3);
|
||||
req.setAddBorder(Boolean.TRUE);
|
||||
req.setBorderWidth(3);
|
||||
|
||||
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank mode string defaults to DEFAULT and succeeds")
|
||||
void blankModeDefaults() throws Exception {
|
||||
wireDocuments(2);
|
||||
MergeMultiplePagesRequest req = base();
|
||||
req.setMode(" ");
|
||||
req.setPagesPerSheet(2);
|
||||
|
||||
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
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 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.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageFitDestination;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
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.SplitPdfByChaptersRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Additional branch coverage for {@link SplitPdfByChaptersController}: nested-bookmark depth
|
||||
* limiting, the same-page bookmark merge path, and the AcroForm-bearing branch that routes per
|
||||
* chapter through PDFBox instead of JPDFium. All PDFs are built in memory.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("SplitPdfByChaptersController additional branch tests")
|
||||
class SplitPdfByChaptersControllerMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private PdfMetadataService pdfMetadataService;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private SplitPdfByChaptersController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv ->
|
||||
Files.createTempFile(tempDir, "ch", inv.<String>getArgument(0))
|
||||
.toFile());
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
}
|
||||
|
||||
private PDOutlineItem item(PDDocument doc, String title, int pageIndex) {
|
||||
PDOutlineItem oi = new PDOutlineItem();
|
||||
oi.setTitle(title);
|
||||
PDPageFitDestination dest = new PDPageFitDestination();
|
||||
dest.setPage(doc.getPage(pageIndex));
|
||||
oi.setDestination(dest);
|
||||
return oi;
|
||||
}
|
||||
|
||||
private MockMultipartFile asFile(byte[] bytes) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private SplitPdfByChaptersRequest request(byte[] bytes, int level, boolean dupes) {
|
||||
SplitPdfByChaptersRequest req = new SplitPdfByChaptersRequest();
|
||||
req.setFileInput(asFile(bytes));
|
||||
req.setBookmarkLevel(level);
|
||||
req.setIncludeMetadata(false);
|
||||
req.setAllowDuplicates(dupes);
|
||||
return req;
|
||||
}
|
||||
|
||||
private List<byte[]> unzip(Resource zip) throws IOException {
|
||||
List<byte[]> out = new ArrayList<>();
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(zip.getContentAsByteArray()))) {
|
||||
ZipEntry e;
|
||||
while ((e = zis.getNextEntry()) != null) {
|
||||
out.add(zis.readAllBytes());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private int totalPages(List<byte[]> entries) throws IOException {
|
||||
int total = 0;
|
||||
for (byte[] data : entries) {
|
||||
try (PDDocument doc = Loader.loadPDF(data)) {
|
||||
total += doc.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Nested bookmarks")
|
||||
class NestedBookmarks {
|
||||
|
||||
/** Builds a doc with top-level chapters, each carrying one child bookmark. */
|
||||
private byte[] nestedDoc() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
doc.getDocumentCatalog().setDocumentOutline(outline);
|
||||
|
||||
PDOutlineItem chapter1 = item(doc, "Chapter 1", 0);
|
||||
chapter1.addLast(item(doc, "Section 1.1", 2));
|
||||
outline.addLast(chapter1);
|
||||
|
||||
PDOutlineItem chapter2 = item(doc, "Chapter 2", 4);
|
||||
chapter2.addLast(item(doc, "Section 2.1", 6));
|
||||
outline.addLast(chapter2);
|
||||
|
||||
Path p = tempDir.resolve("nested.pdf");
|
||||
doc.save(p.toFile());
|
||||
return Files.readAllBytes(p);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("level 0 collects only top-level chapters")
|
||||
void levelZeroTopLevelOnly() throws Exception {
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request(nestedDoc(), 0, true));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
// Only the 2 top-level chapters become split points at level 0.
|
||||
assertThat(outputs).hasSize(2);
|
||||
assertThat(totalPages(outputs)).isEqualTo(8);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a deeper level descends into child bookmarks")
|
||||
void deeperLevelIncludesChildren() throws Exception {
|
||||
int topLevelCount =
|
||||
unzip(controller.splitPdf(request(nestedDoc(), 0, true)).getBody()).size();
|
||||
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request(nestedDoc(), 2, true));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
// Descending into children yields at least as many split points as the top level.
|
||||
assertThat(outputs.size()).isGreaterThanOrEqualTo(topLevelCount);
|
||||
assertThat(totalPages(outputs)).isEqualTo(8);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Same-page bookmark merge")
|
||||
class SamePageMerge {
|
||||
|
||||
@Test
|
||||
@DisplayName("bookmarks on the same page are merged when duplicates are disallowed")
|
||||
void mergesSamePageBookmarks() throws Exception {
|
||||
byte[] bytes;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
doc.getDocumentCatalog().setDocumentOutline(outline);
|
||||
// Two bookmarks both pointing at page index 0 -> same start/end -> merged.
|
||||
outline.addLast(item(doc, "Intro A", 0));
|
||||
outline.addLast(item(doc, "Intro B", 0));
|
||||
outline.addLast(item(doc, "Body", 2));
|
||||
Path p = tempDir.resolve("samepage.pdf");
|
||||
doc.save(p.toFile());
|
||||
bytes = Files.readAllBytes(p);
|
||||
}
|
||||
|
||||
ResponseEntity<Resource> response = controller.splitPdf(request(bytes, 0, false));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
// The two same-page intros collapse, leaving fewer outputs than bookmarks.
|
||||
assertThat(outputs.size()).isLessThan(3);
|
||||
assertThat(totalPages(outputs)).isGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Form-bearing PDFs route through PDFBox")
|
||||
class FormBearing {
|
||||
|
||||
private byte[] formDocWithBookmarks() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDAcroForm acroForm = new PDAcroForm(doc);
|
||||
doc.getDocumentCatalog().setAcroForm(acroForm);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDTextField field = new PDTextField(acroForm);
|
||||
field.setPartialName("text_p" + (i + 1));
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(new PDRectangle(100, 700, 200, 20));
|
||||
widget.setPage(page);
|
||||
field.setWidgets(List.of(widget));
|
||||
page.getAnnotations().add(widget);
|
||||
acroForm.getFields().add(field);
|
||||
}
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
doc.getDocumentCatalog().setDocumentOutline(outline);
|
||||
outline.addLast(item(doc, "Chapter 1", 0));
|
||||
outline.addLast(item(doc, "Chapter 2", 2));
|
||||
|
||||
Path p = tempDir.resolve("form.pdf");
|
||||
doc.save(p.toFile());
|
||||
return Files.readAllBytes(p);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a PDF with an AcroForm still splits into the expected chapters")
|
||||
void formPdfSplits() throws Exception {
|
||||
ResponseEntity<Resource> response =
|
||||
controller.splitPdf(request(formDocWithBookmarks(), 0, true));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<byte[]> outputs = unzip(response.getBody());
|
||||
assertThat(outputs).hasSize(2);
|
||||
assertThat(totalPages(outputs)).isEqualTo(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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.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.ConvertToImageRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Extra branch coverage for {@link ConvertImgPDFController#convertToImage} not exercised by the
|
||||
* existing tests: the null-result logging branch, the octet-stream media-type fallback, and the
|
||||
* webp-with-Python path that produces no output files. The Python/ProcessExecutor boundary is
|
||||
* mocked so no interpreter or external binary ever runs.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ConvertImgPDFController extra convertToImage branches")
|
||||
class ConvertImgPDFControllerExtraTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@InjectMocks private ConvertImgPDFController controller;
|
||||
|
||||
private static byte[] tinyPdfBytes(int pages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
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(byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", "source.pdf", "application/pdf", bytes);
|
||||
}
|
||||
|
||||
private ConvertToImageRequest baseRequest(byte[] pdf, String format) {
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(pdfFile(pdf));
|
||||
request.setImageFormat(format);
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("all");
|
||||
request.setIncludeAnnotations(false);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("non-webp branches")
|
||||
class NonWebp {
|
||||
|
||||
@Test
|
||||
@DisplayName("null render result still produces a single-image response")
|
||||
void nullResultStillResponds() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
ConvertToImageRequest request = baseRequest(pdfBytes, "png");
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<byte[]> expected = Mockito.mock(ResponseEntity.class);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
// Null bytes hit the "resultant bytes is null" log branch but still respond.
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("PNG"),
|
||||
eq(ImageType.RGB),
|
||||
eq(true),
|
||||
eq(72),
|
||||
any(String.class),
|
||||
eq(false)))
|
||||
.thenReturn(null);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.bytesToWebResponse(
|
||||
any(), any(String.class), any(MediaType.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<?> response = controller.convertToImage(request);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("webp-with-Python branch")
|
||||
class WebpWithPython {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when the Python conversion yields no webp files")
|
||||
void noWebpFilesProducedThrows() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(1);
|
||||
ConvertToImageRequest request = baseRequest(pdfBytes, "webp");
|
||||
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDocument(1));
|
||||
|
||||
// ProcessExecutor instance + result are mocked; the empty output dir drives the
|
||||
// "No WebP files were created" IOException without invoking Python.
|
||||
ProcessExecutorResult procResult = Mockito.mock(ProcessExecutorResult.class);
|
||||
Mockito.when(procResult.getMessages()).thenReturn("no output");
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
Mockito.when(executor.runCommandWithOutputHandling(anyList())).thenReturn(procResult);
|
||||
|
||||
// parsePageList/generateFilename are stubbed so rearrangePdfPages runs without
|
||||
// touching the real installation path for script extraction.
|
||||
java.util.List<Integer> pageOrder = java.util.List.of(0);
|
||||
Path scriptPath = Path.of("png_to_webp.py");
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<CheckProgramInstall> cpi =
|
||||
Mockito.mockStatic(CheckProgramInstall.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.convertFromPdf(
|
||||
eq(pdfDocumentFactory),
|
||||
any(byte[].class),
|
||||
eq("png"),
|
||||
any(ImageType.class),
|
||||
anyBoolean(),
|
||||
anyInt(),
|
||||
any(String.class),
|
||||
anyBoolean()))
|
||||
.thenReturn("png-image".getBytes());
|
||||
gu.when(
|
||||
() ->
|
||||
GeneralUtils.parsePageList(
|
||||
any(String[].class), anyInt(), anyBoolean()))
|
||||
.thenReturn(pageOrder);
|
||||
gu.when(() -> GeneralUtils.generateFilename(any(), any(String.class)))
|
||||
.thenReturn("out");
|
||||
gu.when(() -> GeneralUtils.extractScript("png_to_webp.py")).thenReturn(scriptPath);
|
||||
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
|
||||
cpi.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
|
||||
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(executor);
|
||||
|
||||
// Output directory is empty after the (mocked) run, so the controller throws.
|
||||
assertThatThrownBy(() -> controller.convertToImage(request))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("No WebP files were created");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
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.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.ConvertToImageRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Additional branch coverage for {@link ConvertImgPDFController}: the getMediaType fallback, the
|
||||
* convertToPdf null/blank colorType and fitOption defaults, multi-image input, and the explicit
|
||||
* page-selection path of convertToImage. The PdfUtils boundary is mocked so no real rendering or
|
||||
* external binary runs.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ConvertImgPDFController additional branch tests")
|
||||
class ConvertImgPDFControllerMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@InjectMocks private ConvertImgPDFController controller;
|
||||
|
||||
private static byte[] tinyPdfBytes(int pages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static PDDocument tinyDoc(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getMediaType")
|
||||
class GetMediaType {
|
||||
|
||||
private String invoke(String format) throws Exception {
|
||||
Method m =
|
||||
ConvertImgPDFController.class.getDeclaredMethod("getMediaType", String.class);
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(controller, format);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("known image extension resolves to a concrete mime type")
|
||||
void knownExtension() throws Exception {
|
||||
assertThat(invoke("png")).isEqualTo("image/png");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown extension does not resolve to a concrete image mime type")
|
||||
void unknownExtensionNotConcrete() throws Exception {
|
||||
// guessContentTypeFromName yields null/octet-stream for an unrecognised extension;
|
||||
// either way it must not masquerade as a real image type.
|
||||
String result = invoke("zzz");
|
||||
assertThat(result).isNotEqualTo("image/png");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertToPdf defaults")
|
||||
class ConvertToPdfDefaults {
|
||||
|
||||
@Test
|
||||
@DisplayName("blank colorType and empty fitOption fall back to color/fillPage")
|
||||
void blankDefaults() throws Exception {
|
||||
MockMultipartFile img =
|
||||
new MockMultipartFile("fileInput", "p.jpg", "image/jpeg", "x".getBytes());
|
||||
ConvertToPdfRequest request = new ConvertToPdfRequest();
|
||||
request.setFileInput(new MockMultipartFile[] {img});
|
||||
request.setColorType(" ");
|
||||
request.setFitOption("");
|
||||
request.setAutoRotate(null);
|
||||
|
||||
byte[] pdfBytes = "pdf".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.imageToPdf(
|
||||
any(MockMultipartFile[].class),
|
||||
eq("fillPage"),
|
||||
eq(false),
|
||||
eq("color"),
|
||||
eq(pdfDocumentFactory)))
|
||||
.thenReturn(pdfBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("p.jpg", "_converted.pdf"))
|
||||
.thenReturn("p_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "p_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<byte[]> response = controller.convertToPdf(request);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
// Blank colorType -> "color"; empty fitOption -> "fillPage".
|
||||
pu.verify(
|
||||
() ->
|
||||
PdfUtils.imageToPdf(
|
||||
any(MockMultipartFile[].class),
|
||||
eq("fillPage"),
|
||||
eq(false),
|
||||
eq("color"),
|
||||
eq(pdfDocumentFactory)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple images use the first filename for the output")
|
||||
void multipleImagesUseFirstName() throws Exception {
|
||||
MockMultipartFile a =
|
||||
new MockMultipartFile("fileInput", "first.png", "image/png", "a".getBytes());
|
||||
MockMultipartFile b =
|
||||
new MockMultipartFile("fileInput", "second.png", "image/png", "b".getBytes());
|
||||
ConvertToPdfRequest request = new ConvertToPdfRequest();
|
||||
request.setFileInput(new MockMultipartFile[] {a, b});
|
||||
request.setColorType("color");
|
||||
request.setFitOption("fillPage");
|
||||
request.setAutoRotate(false);
|
||||
|
||||
byte[] pdfBytes = "pdf".getBytes();
|
||||
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
|
||||
|
||||
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
pu.when(
|
||||
() ->
|
||||
PdfUtils.imageToPdf(
|
||||
any(MockMultipartFile[].class),
|
||||
eq("fillPage"),
|
||||
eq(false),
|
||||
eq("color"),
|
||||
eq(pdfDocumentFactory)))
|
||||
.thenReturn(pdfBytes);
|
||||
gu.when(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf"))
|
||||
.thenReturn("first_converted.pdf");
|
||||
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "first_converted.pdf"))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<byte[]> response = controller.convertToPdf(request);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
gu.verify(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertToImage explicit page selection")
|
||||
class ExplicitPages {
|
||||
|
||||
@Test
|
||||
@DisplayName("a specific page-number list is parsed and rendered")
|
||||
void specificPageList() throws Exception {
|
||||
byte[] pdfBytes = tinyPdfBytes(3);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "src.pdf", "application/pdf", pdfBytes);
|
||||
|
||||
ConvertToImageRequest request = new ConvertToImageRequest();
|
||||
request.setFileInput(file);
|
||||
request.setImageFormat("png");
|
||||
request.setSingleOrMultiple("single");
|
||||
request.setColorType("color");
|
||||
request.setDpi(72);
|
||||
request.setPageNumbers("1,3");
|
||||
request.setIncludeAnnotations(false);
|
||||
|
||||
// rearrangePdfPages loads a real document and selects pages 1 and 3.
|
||||
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenReturn(tinyDoc(3));
|
||||
|
||||
byte[] imageBytes = "img".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);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
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.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.commonmark.node.Node;
|
||||
import org.commonmark.parser.Parser;
|
||||
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.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
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.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.GeneralFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.FileToPdf;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Additional coverage for {@link ConvertMarkdownToPdf}. Real commonmark parsing is exercised; the
|
||||
* external WeasyPrint boundary ({@link FileToPdf#convertHtmlToPdf}) and the response writer are
|
||||
* mocked so no external tool is launched. The ZIP branch uses a real in-memory ZIP archive.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ConvertMarkdownToPdfMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
|
||||
|
||||
@InjectMocks private ConvertMarkdownToPdf controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient().when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(any(byte[].class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
// A real managed temp file so Files.write succeeds before the (mocked) response build.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("md-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;
|
||||
});
|
||||
// A real temp directory backing the ZIP-extraction branch.
|
||||
lenient()
|
||||
.when(tempFileManager.createTempDirectory())
|
||||
.thenAnswer(inv -> Files.createTempDirectory("md-zip-test"));
|
||||
}
|
||||
|
||||
private static ResponseEntity<Resource> cannedResponse() {
|
||||
return ResponseEntity.ok(new ByteArrayResource("pdf".getBytes()));
|
||||
}
|
||||
|
||||
private GeneralFile generalFileOf(String name, String contentType, byte[] bytes) {
|
||||
GeneralFile gf = new GeneralFile();
|
||||
gf.setFileInput(new MockMultipartFile("fileInput", name, contentType, bytes));
|
||||
return gf;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("plain markdown branch")
|
||||
class PlainMarkdown {
|
||||
|
||||
@Test
|
||||
@DisplayName("converts a markdown file with a GFM table to PDF")
|
||||
void markdownWithTable() throws Exception {
|
||||
String md = "# Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nSome **bold** body text.\n";
|
||||
GeneralFile gf = generalFileOf("doc.md", "text/markdown", md.getBytes());
|
||||
|
||||
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
|
||||
ArgumentCaptor<byte[]> htmlBytes = ArgumentCaptor.forClass(byte[].class);
|
||||
ftp.when(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
eq("/usr/bin/weasyprint"),
|
||||
isNull(),
|
||||
htmlBytes.capture(),
|
||||
eq("converted.html"),
|
||||
eq(tempFileManager),
|
||||
eq(customHtmlSanitizer)))
|
||||
.thenReturn("pdf".getBytes());
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(cannedResponse());
|
||||
|
||||
ResponseEntity<Resource> response = controller.markdownToPdf(gf);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
String html = new String(htmlBytes.getValue(), StandardCharsets.UTF_8);
|
||||
// commonmark + tables extension produced a styled table and a heading.
|
||||
assertThat(html).contains("table table-striped");
|
||||
assertThat(html).contains("<h1>");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates a WeasyPrint conversion failure")
|
||||
void weasyPrintFailurePropagates() throws Exception {
|
||||
GeneralFile gf = generalFileOf("doc.md", "text/markdown", "# Hi".getBytes());
|
||||
|
||||
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class)) {
|
||||
ftp.when(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
anyString(),
|
||||
isNull(),
|
||||
any(byte[].class),
|
||||
anyString(),
|
||||
any(TempFileManager.class),
|
||||
any(CustomHtmlSanitizer.class)))
|
||||
.thenThrow(new java.io.IOException("weasyprint failed"));
|
||||
|
||||
assertThatThrownBy(() -> controller.markdownToPdf(gf))
|
||||
.isInstanceOf(java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("zip markdown branch")
|
||||
class ZipMarkdown {
|
||||
|
||||
private byte[] zipWith(Map<String, byte[]> entries) throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
|
||||
zos.putNextEntry(new ZipEntry(e.getKey()));
|
||||
zos.write(e.getValue());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extracts markdown plus an image and converts via the zip path")
|
||||
void zipWithImageConverts() throws Exception {
|
||||
Map<String, byte[]> entries = new HashMap<>();
|
||||
entries.put("index.md", "# Zip Doc\n\n\n".getBytes());
|
||||
entries.put("pic.png", new byte[] {(byte) 0x89, 'P', 'N', 'G'});
|
||||
byte[] zip = zipWith(entries);
|
||||
|
||||
GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip);
|
||||
|
||||
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
ftp.when(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
eq("/usr/bin/weasyprint"),
|
||||
isNull(),
|
||||
any(byte[].class),
|
||||
eq("package.zip"),
|
||||
eq(tempFileManager),
|
||||
eq(customHtmlSanitizer)))
|
||||
.thenReturn("pdf".getBytes());
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(cannedResponse());
|
||||
|
||||
ResponseEntity<Resource> response = controller.markdownToPdf(gf);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
ftp.verify(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
anyString(),
|
||||
isNull(),
|
||||
any(byte[].class),
|
||||
eq("package.zip"),
|
||||
any(TempFileManager.class),
|
||||
any(CustomHtmlSanitizer.class)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when the zip contains no markdown file")
|
||||
void zipWithoutMarkdownThrows() throws Exception {
|
||||
Map<String, byte[]> entries = new HashMap<>();
|
||||
entries.put("readme.txt", "not markdown".getBytes());
|
||||
byte[] zip = zipWith(entries);
|
||||
|
||||
GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip);
|
||||
|
||||
assertThatThrownBy(() -> controller.markdownToPdf(gf))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("input validation")
|
||||
class Validation {
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when fileInput is null")
|
||||
void nullFileInput() {
|
||||
GeneralFile gf = new GeneralFile();
|
||||
gf.setFileInput(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.markdownToPdf(gf))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws for a non-markdown, non-zip extension")
|
||||
void wrongExtension() {
|
||||
GeneralFile gf = generalFileOf("notes.txt", "text/plain", "hello".getBytes());
|
||||
|
||||
assertThatThrownBy(() -> controller.markdownToPdf(gf))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TableAttributeProvider adds the table class only to table blocks")
|
||||
void tableAttributeProviderBehaviour() {
|
||||
Parser parser = Parser.builder().build();
|
||||
Node paragraph = parser.parse("plain paragraph");
|
||||
TableAttributeProvider provider = new TableAttributeProvider();
|
||||
|
||||
Map<String, String> attrs = new HashMap<>();
|
||||
// A non-table node leaves the attribute map untouched.
|
||||
provider.setAttributes(paragraph.getFirstChild(), "p", attrs);
|
||||
assertThat(attrs).doesNotContainKey("class");
|
||||
}
|
||||
}
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
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.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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.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.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.GeneralFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.OfficeDocumentSanitizer;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.ProcessExecutor.Processes;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ConvertOfficeController}. The external LibreOffice/unoconvert boundary is
|
||||
* mocked via mockStatic(ProcessExecutor) so no real process is spawned.
|
||||
*/
|
||||
@DisplayName("ConvertOfficeController tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertOfficeControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
|
||||
@Mock private OfficeDocumentSanitizer officeDocumentSanitizer;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ConvertOfficeController controller;
|
||||
|
||||
private ConvertOfficeController newController() {
|
||||
return new ConvertOfficeController(
|
||||
pdfDocumentFactory,
|
||||
runtimePathConfig,
|
||||
customHtmlSanitizer,
|
||||
officeDocumentSanitizer,
|
||||
endpointConfiguration,
|
||||
tempFileManager);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = newController();
|
||||
lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice");
|
||||
lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn("unoconvert");
|
||||
}
|
||||
|
||||
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
|
||||
return ResponseEntity.ok(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
// ---- reflection helper for the private convertToPdf-supporting methods --------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T invokeInstance(String methodName, Object... args) throws Exception {
|
||||
for (Method method : ConvertOfficeController.class.getDeclaredMethods()) {
|
||||
if (method.getName().equals(methodName) && method.getParameterCount() == args.length) {
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (T) method.invoke(controller, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw new RuntimeException(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"No method " + methodName + " with " + args.length + " args");
|
||||
}
|
||||
|
||||
private MockMultipartFile docxFile(byte[] content) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
"report.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the static ProcessExecutor so the LibreOffice/uno call returns rc and writes a pdf
|
||||
* to the outdir if requested.
|
||||
*/
|
||||
private ProcessExecutorResult mockExecutor(MockedStatic<ProcessExecutor> pe, int rc) {
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
pe.when(() -> ProcessExecutor.getInstance(Processes.LIBRE_OFFICE)).thenReturn(executor);
|
||||
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
|
||||
when(result.getRc()).thenReturn(rc);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isValidFileExtension")
|
||||
class ExtensionValidation {
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts 2-4 char alphanumeric extensions")
|
||||
void acceptsValid() throws Exception {
|
||||
assertThat((boolean) invokeInstance("isValidFileExtension", "docx")).isTrue();
|
||||
assertThat((boolean) invokeInstance("isValidFileExtension", "odt")).isTrue();
|
||||
assertThat((boolean) invokeInstance("isValidFileExtension", "xls")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects too-long or symbol extensions")
|
||||
void rejectsInvalid() throws Exception {
|
||||
assertThat((boolean) invokeInstance("isValidFileExtension", "toolongext")).isFalse();
|
||||
assertThat((boolean) invokeInstance("isValidFileExtension", "a")).isFalse();
|
||||
assertThat((boolean) invokeInstance("isValidFileExtension", "d.x")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertToPdf input validation")
|
||||
class InputValidation {
|
||||
|
||||
@Test
|
||||
@DisplayName("blank filename throws file-no-name exception")
|
||||
void blankFilename() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "", "application/octet-stream", "x".getBytes());
|
||||
assertThatThrownBy(() -> controller.convertToPdf(file))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unsupported/invalid extension throws invalid-extension exception")
|
||||
void invalidExtension() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"archive.toolong",
|
||||
"application/octet-stream",
|
||||
"x".getBytes());
|
||||
assertThatThrownBy(() -> controller.convertToPdf(file))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertToPdf conversion paths")
|
||||
class ConversionPaths {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses unoconvert when available and returns produced pdf")
|
||||
void unoconvertSuccess() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutorResult result = mockExecutor(pe, 0);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
ArgumentCaptor<List<String>> cmd = ArgumentCaptor.forClass(List.class);
|
||||
when(executor.runCommandWithOutputHandling(cmd.capture()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
// unoconvert writes directly to the output path (last arg)
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path out = Path.of(command.get(command.size() - 1));
|
||||
Files.writeString(out, "%PDF-1.4 produced");
|
||||
return result;
|
||||
});
|
||||
|
||||
File pdf = controller.convertToPdf(docxFile("real-docx".getBytes()));
|
||||
|
||||
assertThat(pdf).exists();
|
||||
assertThat(Files.size(pdf.toPath())).isGreaterThan(0L);
|
||||
assertThat(cmd.getValue().get(0)).isEqualTo("unoconvert");
|
||||
// sanitizer must have been consulted for the docx
|
||||
Mockito.verify(officeDocumentSanitizer).sanitize(any(byte[].class), anyString());
|
||||
|
||||
deleteWorkdir(pdf);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to soffice when unoconvert is unavailable")
|
||||
void sofficeFallbackWhenUnoUnavailable() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
|
||||
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutorResult result = mockExecutor(pe, 0);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
ArgumentCaptor<List<String>> cmd = ArgumentCaptor.forClass(List.class);
|
||||
when(executor.runCommandWithOutputHandling(cmd.capture()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
// soffice writes <basename>.pdf into the --outdir (workDir)
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.writeString(out, "%PDF soffice");
|
||||
return result;
|
||||
});
|
||||
|
||||
File pdf = controller.convertToPdf(docxFile("real-docx".getBytes()));
|
||||
|
||||
assertThat(pdf).exists();
|
||||
assertThat(cmd.getValue().get(0)).isEqualTo("soffice");
|
||||
assertThat(cmd.getValue()).contains("--headless", "--convert-to", "pdf");
|
||||
|
||||
deleteWorkdir(pdf);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-zero exit code throws IllegalStateException")
|
||||
void nonZeroExit() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
|
||||
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutorResult result = mockExecutor(pe, 3);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
|
||||
|
||||
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("exit 3");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no produced pdf (rc 0 but no file) throws IllegalStateException")
|
||||
void noProducedPdf() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
|
||||
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutorResult result = mockExecutor(pe, 0);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
// rc 0 but nothing written to workDir -> "No PDF produced."
|
||||
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
|
||||
|
||||
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty produced pdf throws IllegalStateException")
|
||||
void emptyProducedPdf() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
|
||||
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutorResult result = mockExecutor(pe, 0);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.write(out, new byte[0]);
|
||||
return result;
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("empty");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("html input is routed through the html sanitizer")
|
||||
void htmlSanitized() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
|
||||
when(customHtmlSanitizer.sanitize(anyString())).thenReturn("<html>clean</html>");
|
||||
|
||||
MockMultipartFile html =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"page.html",
|
||||
"text/html",
|
||||
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutorResult result = mockExecutor(pe, 0);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("page.pdf");
|
||||
Files.writeString(out, "%PDF html");
|
||||
return result;
|
||||
});
|
||||
|
||||
File pdf = controller.convertToPdf(html);
|
||||
|
||||
assertThat(pdf).exists();
|
||||
Mockito.verify(customHtmlSanitizer).sanitize(anyString());
|
||||
Mockito.verifyNoInteractions(officeDocumentSanitizer);
|
||||
|
||||
deleteWorkdir(pdf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("processFileToPDF endpoint")
|
||||
class EndpointTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("happy path loads, saves and cleans up the work directory")
|
||||
void happyPath() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
|
||||
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
File tempOutFile = Files.createTempFile(tempDir, "out", ".pdf").toFile();
|
||||
TempFile tempOut = mock(TempFile.class);
|
||||
when(tempOut.getFile()).thenReturn(tempOutFile);
|
||||
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(tempOut);
|
||||
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage());
|
||||
when(pdfDocumentFactory.load(any(File.class))).thenReturn(doc);
|
||||
|
||||
GeneralFile generalFile = new GeneralFile();
|
||||
generalFile.setFileInput(docxFile("docx-bytes".getBytes()));
|
||||
|
||||
ResponseEntity<Resource> expected = streamingOk("pdf".getBytes());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
|
||||
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
|
||||
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
|
||||
|
||||
ProcessExecutorResult result = mockExecutor(pe, 0);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.writeString(out, "%PDF produced");
|
||||
return result;
|
||||
});
|
||||
|
||||
gu.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
|
||||
.thenReturn("report_convertedToPDF.pdf");
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.processFileToPDF(generalFile);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
wr.verify(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()));
|
||||
}
|
||||
|
||||
doc.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("conversion failure propagates and does not return a response")
|
||||
void conversionFailurePropagates() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
|
||||
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
|
||||
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
|
||||
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeneralFile generalFile = new GeneralFile();
|
||||
generalFile.setFileInput(docxFile("docx".getBytes()));
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutorResult result = mockExecutor(pe, 1);
|
||||
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
|
||||
|
||||
assertThatThrownBy(() -> controller.processFileToPDF(generalFile))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
// a failed conversion never reaches the document factory
|
||||
Mockito.verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteWorkdir(File producedPdf) throws IOException {
|
||||
if (producedPdf != null && producedPdf.getParentFile() != null) {
|
||||
org.apache.commons.io.FileUtils.deleteDirectory(producedPdf.getParentFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
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.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
|
||||
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.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.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.PDFWithPageNums;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Additional coverage for {@link ConvertPDFToExcelController}. Tabula runs in-process, so documents
|
||||
* are built in-memory: an empty page exercises the no-content branch, a multi-page document drives
|
||||
* the page loop, and a bordered-grid page drives the workbook-writing path. The managed temp file
|
||||
* is a real file so the workbook is written to disk.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ConvertPDFToExcelControllerMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private ConvertPDFToExcelController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("xlsx-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;
|
||||
});
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdf(String name) {
|
||||
return new MockMultipartFile("fileInput", name, "application/pdf", "pdf".getBytes());
|
||||
}
|
||||
|
||||
private static PDDocument blankPages(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Build a single-page document with a drawn bordered grid that Tabula can detect. */
|
||||
private static PDDocument borderedTableDoc() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
|
||||
float left = 60f;
|
||||
float top = 700f;
|
||||
float colW = 120f;
|
||||
float rowH = 30f;
|
||||
int cols = 3;
|
||||
int rows = 3;
|
||||
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setLineWidth(1f);
|
||||
// Horizontal rules.
|
||||
for (int r = 0; r <= rows; r++) {
|
||||
float y = top - r * rowH;
|
||||
cs.moveTo(left, y);
|
||||
cs.lineTo(left + cols * colW, y);
|
||||
cs.stroke();
|
||||
}
|
||||
// Vertical rules.
|
||||
for (int c = 0; c <= cols; c++) {
|
||||
float x = left + c * colW;
|
||||
cs.moveTo(x, top);
|
||||
cs.lineTo(x, top - rows * rowH);
|
||||
cs.stroke();
|
||||
}
|
||||
// Cell text.
|
||||
PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
for (int c = 0; c < cols; c++) {
|
||||
cs.beginText();
|
||||
cs.setFont(font, 10);
|
||||
cs.newLineAtOffset(left + c * colW + 5, top - r * rowH - 20);
|
||||
cs.showText("R" + r + "C" + c);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("no-content branches")
|
||||
class NoContent {
|
||||
|
||||
@Test
|
||||
@DisplayName("single blank page yields no content")
|
||||
void blankPageNoContent() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("data.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(blankPages(1));
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToExcel(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multi-page blank document iterates all pages then yields no content")
|
||||
void multiBlankPagesNoContent() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("multi.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(blankPages(3));
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToExcel(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("workbook-writing branch")
|
||||
class WorkbookWriting {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"a bordered table page produces an xlsx response or, if undetected, no content")
|
||||
void borderedTableProducesXlsx() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("table.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(borderedTableDoc());
|
||||
|
||||
ResponseEntity<Resource> response = controller.pdfToExcel(request);
|
||||
|
||||
// Lattice detection depends on the Tabula build; accept either outcome but assert the
|
||||
// success path produced a real, non-empty xlsx body.
|
||||
if (response.getStatusCode() == HttpStatus.OK) {
|
||||
assertThat(response.getHeaders().getContentType().toString())
|
||||
.contains("spreadsheetml.sheet");
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("table.xlsx");
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
} else {
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("error propagation")
|
||||
class Errors {
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates a document load failure and closes the temp file")
|
||||
void loadFailurePropagates() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("corrupt.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom"));
|
||||
|
||||
assertThatThrownBy(() -> controller.pdfToExcel(request))
|
||||
.isInstanceOf(java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sanity: blank pages produce a parseable empty document")
|
||||
void blankDocumentSanity() throws Exception {
|
||||
try (PDDocument doc = blankPages(1);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
doc.save(out);
|
||||
assertThat(out.size()).isPositive();
|
||||
}
|
||||
}
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
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.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.mockito.InjectMocks;
|
||||
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.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.converters.PdfToPresentationRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Additional tests for {@link ConvertPDFToOffice}. The office-format conversions delegate to {@code
|
||||
* PDFToFile.processPdfToOfficeFormat}, which shells out to LibreOffice through the static {@link
|
||||
* ProcessExecutor} factory. Here that factory is mocked with {@code mockStatic}; the mocked
|
||||
* command-runner writes the expected output file into the LibreOffice {@code --outdir} so the real
|
||||
* {@code PDFToFile} flow completes and a file-backed response is produced. No real LibreOffice
|
||||
* runs.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertPDFToOfficeMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@InjectMocks private ConvertPDFToOffice controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Real temp files backing TempFileManager so the file-backed response can be read back.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("conv-out", 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;
|
||||
});
|
||||
|
||||
// PDFToFile creates its own TempFile(manager, suffix) which calls manager.createTempFile.
|
||||
lenient()
|
||||
.when(tempFileManager.createTempFile(any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("conv-in", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
f.deleteOnExit();
|
||||
return f;
|
||||
});
|
||||
|
||||
// PDFToFile also creates a TempDirectory for LibreOffice output.
|
||||
lenient()
|
||||
.when(tempFileManager.createTempDirectory())
|
||||
.thenAnswer(inv -> Files.createTempDirectory("conv-dir"));
|
||||
|
||||
// Force the soffice fallback path (uno disabled) and a deterministic soffice binary name.
|
||||
lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn("");
|
||||
lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice");
|
||||
}
|
||||
|
||||
private MockMultipartFile pdfFile() {
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
"document.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"%PDF-1.4".getBytes());
|
||||
}
|
||||
|
||||
private MockMultipartFile nonPdfFile() {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "document.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
|
||||
}
|
||||
|
||||
private static byte[] readResource(Resource resource) throws IOException {
|
||||
try (InputStream in = resource.getInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
in.transferTo(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubs the LibreOffice executor so that running the soffice command writes a fake output file
|
||||
* (named {@code document.<ext>}) into the directory that follows {@code --outdir}.
|
||||
*/
|
||||
private void stubLibreOfficeWritesOutput(
|
||||
MockedStatic<ProcessExecutor> mockedFactory, String primaryExt) throws Exception {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
ProcessExecutorResult okResult = mock(ProcessExecutorResult.class);
|
||||
lenient().when(okResult.getRc()).thenReturn(0);
|
||||
|
||||
when(executor.runCommandWithOutputHandling(any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
int outDirIdx = cmd.indexOf("--outdir");
|
||||
Path outDir = Path.of(cmd.get(outDirIdx + 1));
|
||||
Path outFile = outDir.resolve("document." + primaryExt);
|
||||
Files.write(
|
||||
outFile, "converted-bytes".getBytes(StandardCharsets.UTF_8));
|
||||
return okResult;
|
||||
});
|
||||
|
||||
mockedFactory
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE))
|
||||
.thenReturn(executor);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Presentation conversion")
|
||||
class PresentationConversion {
|
||||
|
||||
@Test
|
||||
@DisplayName("pptx output streams the converted file back with 200")
|
||||
void presentationPptxSuccess() throws Exception {
|
||||
PdfToPresentationRequest request = new PdfToPresentationRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("pptx");
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
|
||||
stubLibreOfficeWritesOutput(mockedFactory, "pptx");
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToPresentation(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(readResource(response.getBody()).length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-PDF input returns 400 without invoking LibreOffice")
|
||||
void presentationNonPdfReturnsBadRequest() throws Exception {
|
||||
PdfToPresentationRequest request = new PdfToPresentationRequest();
|
||||
request.setFileInput(nonPdfFile());
|
||||
request.setOutputFormat("pptx");
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToPresentation(request);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("LibreOffice IOException propagates from presentation conversion")
|
||||
void presentationLibreOfficeFailurePropagates() throws Exception {
|
||||
PdfToPresentationRequest request = new PdfToPresentationRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("pptx");
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutor executor = mock(ProcessExecutor.class);
|
||||
when(executor.runCommandWithOutputHandling(any()))
|
||||
.thenThrow(new IOException("soffice crashed"));
|
||||
mockedFactory
|
||||
.when(
|
||||
() ->
|
||||
ProcessExecutor.getInstance(
|
||||
ProcessExecutor.Processes.LIBRE_OFFICE))
|
||||
.thenReturn(executor);
|
||||
|
||||
assertThrows(IOException.class, () -> controller.processPdfToPresentation(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Word conversion")
|
||||
class WordConversion {
|
||||
|
||||
@Test
|
||||
@DisplayName("docx output streams the converted file back with 200")
|
||||
void wordDocxSuccess() throws Exception {
|
||||
PdfToWordRequest request = new PdfToWordRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("docx");
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
|
||||
stubLibreOfficeWritesOutput(mockedFactory, "docx");
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToWord(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(readResource(response.getBody()).length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unsupported output format returns 400")
|
||||
void wordUnsupportedFormatReturnsBadRequest() throws Exception {
|
||||
PdfToWordRequest request = new PdfToWordRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("bogus");
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToWord(request);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Text / RTF conversion")
|
||||
class TextRtfConversion {
|
||||
|
||||
@Test
|
||||
@DisplayName("txt output uses PDFBox stripper, not LibreOffice")
|
||||
void txtUsesStripper() throws Exception {
|
||||
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("txt");
|
||||
|
||||
PDDocument realDoc = new PDDocument();
|
||||
realDoc.addPage(new PDPage());
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(realDoc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToRTForTXT(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.TEXT_PLAIN, response.getHeaders().getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rtf output delegates to LibreOffice and streams back")
|
||||
void rtfDelegatesToLibreOffice() throws Exception {
|
||||
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("rtf");
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
|
||||
stubLibreOfficeWritesOutput(mockedFactory, "rtf");
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToRTForTXT(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(readResource(response.getBody()).length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("txt branch closes temp file and rethrows when stripper load fails")
|
||||
void txtLoadFailurePropagates() throws Exception {
|
||||
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("txt");
|
||||
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
|
||||
.thenThrow(new IOException("cannot parse pdf"));
|
||||
|
||||
IOException thrown =
|
||||
assertThrows(IOException.class, () -> controller.processPdfToRTForTXT(request));
|
||||
assertEquals("cannot parse pdf", thrown.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("XML conversion")
|
||||
class XmlConversion {
|
||||
|
||||
@Test
|
||||
@DisplayName("xml output streams the converted file back with 200")
|
||||
void xmlSuccess() throws Exception {
|
||||
PDFFile file = new PDFFile();
|
||||
file.setFileInput(pdfFile());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
|
||||
stubLibreOfficeWritesOutput(mockedFactory, "xml");
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToXML(file);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(readResource(response.getBody()).length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-PDF input returns 400 for xml conversion")
|
||||
void xmlNonPdfReturnsBadRequest() throws Exception {
|
||||
PDFFile file = new PDFFile();
|
||||
file.setFileInput(nonPdfFile());
|
||||
|
||||
ResponseEntity<Resource> response = controller.processPdfToXML(file);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
+591
@@ -0,0 +1,591 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
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.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.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;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
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.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Additional coverage for {@link ConvertPDFToPDFA} focusing on the end-to-end conversion flows
|
||||
* (handlePdfAConversion / handlePdfXConversion / convertPDDocumentToPDFA) and the Ghostscript
|
||||
* command builders. The external ghostscript/qpdf boundary is mocked with mockStatic, so no real
|
||||
* binary is ever executed.
|
||||
*/
|
||||
@DisplayName("ConvertPDFToPDFA additional flow tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConvertPDFToPDFAMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private VeraPDFService veraPDFService;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ConvertPDFToPDFA newController() {
|
||||
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
|
||||
}
|
||||
|
||||
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
|
||||
return ResponseEntity.ok(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
// ---- reflection helpers ----------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <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) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw new RuntimeException(cause);
|
||||
}
|
||||
}
|
||||
|
||||
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 " + methodName + " with " + argCount + " params");
|
||||
}
|
||||
|
||||
private static 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);
|
||||
}
|
||||
|
||||
private static Object resolvePdfXProfile(String token) throws Exception {
|
||||
Class<?> enumClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("PdfXProfile")) {
|
||||
enumClass = inner;
|
||||
}
|
||||
}
|
||||
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(null, token);
|
||||
}
|
||||
|
||||
// ---- 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()) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private MockMultipartFile pdfFile() throws IOException {
|
||||
return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", simplePdfBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a single ProcessExecutor mock returned for every Processes value. The command list
|
||||
* decides the result: ghostscript conversion commands write a valid output pdf, version/probe
|
||||
* commands return rc 0, everything else returns rc 0 without side effects.
|
||||
*/
|
||||
private ProcessExecutor wireProcessExecutor(MockedStatic<ProcessExecutor> pe, int gsConvertRc)
|
||||
throws Exception {
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
|
||||
.thenReturn(executor);
|
||||
pe.when(
|
||||
() ->
|
||||
ProcessExecutor.getInstance(
|
||||
any(ProcessExecutor.Processes.class), Mockito.anyBoolean()))
|
||||
.thenReturn(executor);
|
||||
|
||||
ProcessExecutorResult okResult = mock(ProcessExecutorResult.class);
|
||||
lenient().when(okResult.getRc()).thenReturn(0);
|
||||
|
||||
ProcessExecutorResult gsResult = mock(ProcessExecutorResult.class);
|
||||
lenient().when(gsResult.getRc()).thenReturn(gsConvertRc);
|
||||
lenient().when(gsResult.getMessages()).thenReturn("gs output");
|
||||
|
||||
lenient()
|
||||
.when(executor.runCommandWithOutputHandling(any(List.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> command = invocation.getArgument(0);
|
||||
// The real ghostscript conversion command contains -sOutputFile=...
|
||||
String outFileArg =
|
||||
command.stream()
|
||||
.filter(a -> a.startsWith("-sOutputFile="))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (outFileArg != null) {
|
||||
Path out = Path.of(outFileArg.substring("-sOutputFile=".length()));
|
||||
if (gsConvertRc == 0) {
|
||||
Files.write(out, simplePdfBytes());
|
||||
}
|
||||
return gsResult;
|
||||
}
|
||||
// qpdf normalize/clean writes its (last-arg) output file
|
||||
if (command.contains("--normalize-content=y")) {
|
||||
// qpdf produced file is the last argument
|
||||
Path out = Path.of(command.get(command.size() - 1));
|
||||
Files.write(out, simplePdfBytes());
|
||||
}
|
||||
return okResult;
|
||||
});
|
||||
return executor;
|
||||
}
|
||||
|
||||
private TempFile managedTempFile() throws IOException {
|
||||
File f = Files.createTempFile(tempDir, "managed", ".pdf").toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
return tf;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("isGhostscriptAvailable")
|
||||
class GhostscriptAvailability {
|
||||
|
||||
@Test
|
||||
@DisplayName("true when gs --version returns rc 0")
|
||||
void availableWhenRcZero() throws Exception {
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
wireProcessExecutor(pe, 0);
|
||||
boolean available = invokeInstance(newController(), "isGhostscriptAvailable");
|
||||
assertThat(available).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false when probe throws")
|
||||
void unavailableWhenThrows() throws Exception {
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
|
||||
.thenReturn(executor);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class)))
|
||||
.thenThrow(new IOException("gs missing"));
|
||||
|
||||
boolean available = invokeInstance(newController(), "isGhostscriptAvailable");
|
||||
assertThat(available).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("handlePdfAConversion via pdfToPdfA endpoint")
|
||||
class PdfAConversion {
|
||||
|
||||
@Test
|
||||
@DisplayName("Ghostscript success path returns the produced PDF/A-2b file")
|
||||
void ghostscriptSuccess() throws Exception {
|
||||
TempFile managed = managedTempFile();
|
||||
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
|
||||
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("pdfa-2b");
|
||||
|
||||
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wireProcessExecutor(pe, 0);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
wr.verify(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class),
|
||||
org.mockito.ArgumentMatchers.contains("_PDFA-2b.pdf")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/A-1b exercises the part-1 CIDSet/qpdf branch and succeeds")
|
||||
void pdfA1Success() throws Exception {
|
||||
TempFile managed = managedTempFile();
|
||||
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
|
||||
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("pdfa-1");
|
||||
|
||||
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wireProcessExecutor(pe, 0);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
|
||||
assertThat(response).isSameAs(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("strict mode runs VeraPDF and a compliant result still returns the file")
|
||||
void strictCompliantSucceeds() throws Exception {
|
||||
TempFile managed = managedTempFile();
|
||||
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
|
||||
|
||||
stirling.software.SPDF.model.api.security.PDFVerificationResult ok =
|
||||
new stirling.software.SPDF.model.api.security.PDFVerificationResult();
|
||||
ok.setCompliant(true);
|
||||
ok.setStandard("2b");
|
||||
ok.setComplianceSummary("PDF/A-2b compliant");
|
||||
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
|
||||
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("pdfa-2b");
|
||||
request.setStrict(true);
|
||||
|
||||
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wireProcessExecutor(pe, 0);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
Mockito.verify(veraPDFService).validatePDF(any());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("handlePdfXConversion via pdfToPdfA endpoint")
|
||||
class PdfXConversion {
|
||||
|
||||
@Test
|
||||
@DisplayName("Ghostscript success path returns the produced PDF/X file")
|
||||
void pdfXSuccess() throws Exception {
|
||||
TempFile managed = managedTempFile();
|
||||
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
|
||||
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("pdfx");
|
||||
|
||||
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
|
||||
MockedStatic<WebResponseUtils> wr =
|
||||
Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wireProcessExecutor(pe, 0);
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
|
||||
|
||||
assertThat(response).isSameAs(expected);
|
||||
wr.verify(
|
||||
() ->
|
||||
WebResponseUtils.pdfFileToWebResponse(
|
||||
any(TempFile.class),
|
||||
org.mockito.ArgumentMatchers.contains("_PDFX.pdf")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF/X with Ghostscript unavailable throws the conversion-failed exception")
|
||||
void pdfXNoGhostscript() throws Exception {
|
||||
PdfToPdfARequest request = new PdfToPdfARequest();
|
||||
request.setFileInput(pdfFile());
|
||||
request.setOutputFormat("pdfx");
|
||||
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
// gs --version returns non-zero -> not available
|
||||
wireProcessExecutor(pe, 0);
|
||||
ProcessExecutor executor =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class);
|
||||
when(notAvail.getRc()).thenReturn(127);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail);
|
||||
|
||||
assertThatThrownBy(() -> newController().pdfToPdfA(request))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPDDocumentToPDFA")
|
||||
class ConvertDocument {
|
||||
|
||||
@Test
|
||||
@DisplayName("Ghostscript success returns converted bytes")
|
||||
void documentConversionSuccess() throws Exception {
|
||||
try (PDDocument document = simplePdf();
|
||||
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
wireProcessExecutor(pe, 0);
|
||||
|
||||
byte[] converted = newController().convertPDDocumentToPDFA(document, "pdfa-2b");
|
||||
|
||||
assertThat(converted).isNotNull();
|
||||
assertThat(converted.length).isGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Ghostscript command builders")
|
||||
class CommandBuilders {
|
||||
|
||||
@Test
|
||||
@DisplayName("buildGhostscriptCommand wires PDF/A part, devices and IO files")
|
||||
void buildsPdfACommand() throws Exception {
|
||||
Path workingDir = Files.createDirectories(tempDir.resolve("gs"));
|
||||
Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes());
|
||||
Path output = workingDir.resolve("out.pdf");
|
||||
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
|
||||
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
|
||||
Path defFile = Files.write(workingDir.resolve("def.ps"), new byte[] {1});
|
||||
|
||||
Object colorProfiles = newColorProfiles(rgb, gray);
|
||||
Object profile = resolvePdfaProfile("pdfa-1");
|
||||
|
||||
Method m =
|
||||
ConvertPDFToPDFA.class.getDeclaredMethod(
|
||||
"buildGhostscriptCommand",
|
||||
Path.class,
|
||||
Path.class,
|
||||
colorProfiles.getClass(),
|
||||
Path.class,
|
||||
profile.getClass(),
|
||||
Path.class);
|
||||
m.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> command =
|
||||
(List<String>)
|
||||
m.invoke(
|
||||
null,
|
||||
input,
|
||||
output,
|
||||
colorProfiles,
|
||||
workingDir,
|
||||
profile,
|
||||
defFile);
|
||||
|
||||
assertThat(command).isNotEmpty();
|
||||
assertThat(command.get(0)).isEqualTo("gs");
|
||||
assertThat(command).contains("-dPDFA=1", "-sDEVICE=pdfwrite", "-dEmbedAllFonts=true");
|
||||
assertThat(command).anyMatch(a -> a.startsWith("-sOutputFile="));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("buildGhostscriptCommandX wires PDF/X version and image tuning")
|
||||
void buildsPdfXCommand() throws Exception {
|
||||
Path workingDir = Files.createDirectories(tempDir.resolve("gsx"));
|
||||
Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes());
|
||||
Path output = workingDir.resolve("out.pdf");
|
||||
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
|
||||
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
|
||||
|
||||
Object colorProfiles = newColorProfiles(rgb, gray);
|
||||
Object profile = resolvePdfXProfile("pdfx");
|
||||
|
||||
Method m =
|
||||
ConvertPDFToPDFA.class.getDeclaredMethod(
|
||||
"buildGhostscriptCommandX",
|
||||
Path.class,
|
||||
Path.class,
|
||||
colorProfiles.getClass(),
|
||||
Path.class,
|
||||
profile.getClass());
|
||||
m.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> command =
|
||||
(List<String>)
|
||||
m.invoke(null, input, output, colorProfiles, workingDir, profile);
|
||||
|
||||
assertThat(command).contains("-dPDFX=2008", "-sDEVICE=pdfwrite");
|
||||
assertThat(command).anyMatch(a -> a.startsWith("-dColorImageResolution="));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createPdfaDefFile writes a PDFA_def.ps with the profile title")
|
||||
void createsPdfaDef() throws Exception {
|
||||
Path workingDir = Files.createDirectories(tempDir.resolve("def"));
|
||||
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
|
||||
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
|
||||
|
||||
Object colorProfiles = newColorProfiles(rgb, gray);
|
||||
Object profile = resolvePdfaProfile("pdfa-2b");
|
||||
|
||||
Method m =
|
||||
ConvertPDFToPDFA.class.getDeclaredMethod(
|
||||
"createPdfaDefFile",
|
||||
Path.class,
|
||||
colorProfiles.getClass(),
|
||||
profile.getClass());
|
||||
m.setAccessible(true);
|
||||
Path defFile = (Path) m.invoke(null, workingDir, colorProfiles, profile);
|
||||
|
||||
assertThat(defFile).exists();
|
||||
String content = Files.readString(defFile);
|
||||
assertThat(content).contains("PDF/A-2b");
|
||||
assertThat(content).contains("OutputIntent");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("prepareColorProfiles copies the sRGB icc and writes a gray profile")
|
||||
void preparesColorProfiles() throws Exception {
|
||||
Path workingDir = Files.createDirectories(tempDir.resolve("colors"));
|
||||
Object colorProfiles =
|
||||
invokeInstance(newController(), "prepareColorProfiles", workingDir);
|
||||
assertThat(colorProfiles).isNotNull();
|
||||
|
||||
Method rgbAccessor = colorProfiles.getClass().getDeclaredMethod("rgb");
|
||||
rgbAccessor.setAccessible(true);
|
||||
Path rgb = (Path) rgbAccessor.invoke(colorProfiles);
|
||||
assertThat(rgb).exists();
|
||||
assertThat(Files.size(rgb)).isGreaterThan(0L);
|
||||
}
|
||||
|
||||
private Object newColorProfiles(Path rgb, Path gray) throws Exception {
|
||||
Class<?> recordClass = null;
|
||||
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
|
||||
if (inner.getSimpleName().equals("ColorProfiles")) {
|
||||
recordClass = inner;
|
||||
}
|
||||
}
|
||||
var ctor = recordClass.getDeclaredConstructor(Path.class, Path.class);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(rgb, gray);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("qpdf helpers")
|
||||
class QpdfHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("normalizePdfWithQpdf returns null when qpdf is unavailable")
|
||||
void normalizeUnavailable() throws Exception {
|
||||
Path input = Files.write(tempDir.resolve("n.pdf"), simplePdfBytes());
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
|
||||
.thenReturn(executor);
|
||||
ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class);
|
||||
when(notAvail.getRc()).thenReturn(1);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail);
|
||||
|
||||
Path result = invokeInstance(newController(), "normalizePdfWithQpdf", input);
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cleanCidSetWithQpdf returns null on exception")
|
||||
void cleanCidSetThrows() throws Exception {
|
||||
Path input = Files.write(tempDir.resolve("c.pdf"), simplePdfBytes());
|
||||
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
|
||||
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
|
||||
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
|
||||
.thenReturn(executor);
|
||||
when(executor.runCommandWithOutputHandling(any(List.class)))
|
||||
.thenThrow(new IOException("qpdf boom"));
|
||||
|
||||
Path result = invokeInstance(newController(), "cleanCidSetWithQpdf", input);
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
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 stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Coverage for the remaining {@link ConvertPdfJsonController} diagnostic helpers and the job-access
|
||||
* guard on the GET endpoints. The conversion service boundary is mocked so nothing is rendered.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ConvertPdfJsonController remaining branch coverage")
|
||||
class ConvertPdfJsonControllerExtraTest {
|
||||
|
||||
@Mock private PdfJsonConversionService pdfJsonConversionService;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
|
||||
@InjectMocks private ConvertPdfJsonController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService");
|
||||
f.setAccessible(true);
|
||||
f.set(controller, jobOwnershipService);
|
||||
}
|
||||
|
||||
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
|
||||
Method m = ConvertPdfJsonController.class.getDeclaredMethod(name, sig);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(controller, args);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("looksLikeBase64")
|
||||
class LooksLikeBase64 {
|
||||
|
||||
private boolean looksLikeBase64(String value) throws Exception {
|
||||
return (boolean) invoke("looksLikeBase64", new Class<?>[] {String.class}, value);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("short strings are never treated as base64")
|
||||
void shortNotBase64() throws Exception {
|
||||
assertThat(looksLikeBase64("short")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a long base64-like string is detected")
|
||||
void longBase64Detected() throws Exception {
|
||||
String b64 = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5";
|
||||
assertThat(looksLikeBase64(b64)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a long natural-language string is not base64")
|
||||
void longProseNotBase64() throws Exception {
|
||||
String prose = "this is a long sentence with spaces and punctuation, not base64!!";
|
||||
assertThat(looksLikeBase64(prose)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("truncateForLog")
|
||||
class TruncateForLog {
|
||||
|
||||
private String truncate(String value) throws Exception {
|
||||
return (String) invoke("truncateForLog", new Class<?>[] {String.class}, value);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("short values are returned with whitespace normalised")
|
||||
void shortNormalised() throws Exception {
|
||||
assertThat(truncate("a\tb\nc")).isEqualTo("a b c");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("long values are truncated with an ellipsis suffix")
|
||||
void longTruncated() throws Exception {
|
||||
String value = "x".repeat(100);
|
||||
String result = truncate(value);
|
||||
assertThat(result).endsWith("...");
|
||||
assertThat(result).hasSize(67); // 64 chars + "..."
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("debug/repeat-scan flag readers")
|
||||
class FlagReaders {
|
||||
|
||||
private boolean dumpEnabled() throws Exception {
|
||||
return (boolean) invoke("isPdfJsonDebugDumpEnabled", new Class<?>[] {});
|
||||
}
|
||||
|
||||
private boolean repeatScanEnabled() throws Exception {
|
||||
return (boolean) invoke("isPdfJsonRepeatScanEnabled", new Class<?>[] {});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dump flag reflects the system property")
|
||||
void dumpFlagFromProperty() throws Exception {
|
||||
String prev = System.getProperty("spdf.pdfjson.dump");
|
||||
try {
|
||||
System.clearProperty("spdf.pdfjson.dump");
|
||||
assertThat(dumpEnabled()).isFalse();
|
||||
System.setProperty("spdf.pdfjson.dump", "true");
|
||||
assertThat(dumpEnabled()).isTrue();
|
||||
} finally {
|
||||
restore("spdf.pdfjson.dump", prev);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("repeat-scan flag reflects the system property")
|
||||
void repeatScanFromProperty() throws Exception {
|
||||
String prev = System.getProperty("spdf.pdfjson.repeatScan");
|
||||
try {
|
||||
System.clearProperty("spdf.pdfjson.repeatScan");
|
||||
assertThat(repeatScanEnabled()).isFalse();
|
||||
System.setProperty("spdf.pdfjson.repeatScan", "true");
|
||||
assertThat(repeatScanEnabled()).isTrue();
|
||||
} finally {
|
||||
restore("spdf.pdfjson.repeatScan", prev);
|
||||
}
|
||||
}
|
||||
|
||||
private void restore(String key, String prev) {
|
||||
if (prev == null) {
|
||||
System.clearProperty(key);
|
||||
} else {
|
||||
System.setProperty(key, prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("logJsonResponse")
|
||||
class LogJsonResponse {
|
||||
|
||||
@Test
|
||||
@DisplayName("a null path is logged without throwing")
|
||||
void nullPathHandled() throws Exception {
|
||||
// exercises the early null-path guard branch
|
||||
invoke(
|
||||
"logJsonResponse",
|
||||
new Class<?>[] {String.class, java.nio.file.Path.class},
|
||||
"x",
|
||||
null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("happy path with no debug flags returns without reading the file")
|
||||
void noFlagsNoRead() throws Exception {
|
||||
String dumpPrev = System.getProperty("spdf.pdfjson.dump");
|
||||
String scanPrev = System.getProperty("spdf.pdfjson.repeatScan");
|
||||
try {
|
||||
System.clearProperty("spdf.pdfjson.dump");
|
||||
System.clearProperty("spdf.pdfjson.repeatScan");
|
||||
// a non-existent path must not be read because all flags are off
|
||||
java.nio.file.Path missing = java.nio.file.Path.of("does-not-exist-123.json");
|
||||
invoke(
|
||||
"logJsonResponse",
|
||||
new Class<?>[] {String.class, java.nio.file.Path.class},
|
||||
"label",
|
||||
missing);
|
||||
} finally {
|
||||
if (dumpPrev != null) System.setProperty("spdf.pdfjson.dump", dumpPrev);
|
||||
if (scanPrev != null) System.setProperty("spdf.pdfjson.repeatScan", scanPrev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("GET endpoint job-access guard")
|
||||
class JobAccessGuard {
|
||||
|
||||
@Test
|
||||
@DisplayName("extractSinglePage rejects an unauthorized job before doing work")
|
||||
void singlePageRejected() {
|
||||
doThrow(new SecurityException("denied"))
|
||||
.when(jobOwnershipService)
|
||||
.validateJobAccess("bad");
|
||||
|
||||
assertThrows(SecurityException.class, () -> controller.extractSinglePage("bad", 1));
|
||||
verifyNoConversion();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractPageFonts rejects an unauthorized job before doing work")
|
||||
void pageFontsRejected() {
|
||||
doThrow(new SecurityException("denied"))
|
||||
.when(jobOwnershipService)
|
||||
.validateJobAccess("bad");
|
||||
|
||||
assertThrows(SecurityException.class, () -> controller.extractPageFonts("bad", 2));
|
||||
verifyNoConversion();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("exportPartialPdf rejects an unauthorized job before doing work")
|
||||
void exportPartialRejected() {
|
||||
when(jobOwnershipService.validateJobAccess(anyString()))
|
||||
.thenThrow(new SecurityException("denied"));
|
||||
|
||||
assertThrows(
|
||||
SecurityException.class,
|
||||
() ->
|
||||
controller.exportPartialPdf(
|
||||
"bad",
|
||||
new stirling.software.SPDF.model.json.PdfJsonDocument(),
|
||||
"out.pdf"));
|
||||
}
|
||||
|
||||
private void verifyNoConversion() {
|
||||
try {
|
||||
verify(pdfJsonConversionService, never())
|
||||
.extractSinglePage(
|
||||
anyString(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
verify(pdfJsonConversionService, never())
|
||||
.extractPageFonts(
|
||||
anyString(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
} catch (Exception e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+606
@@ -0,0 +1,606 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
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.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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.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.json.PdfJsonDocument;
|
||||
import stirling.software.SPDF.model.json.PdfJsonMetadata;
|
||||
import stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.model.api.GeneralFile;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ConvertPdfJsonController additional branch coverage")
|
||||
class ConvertPdfJsonControllerMoreTest {
|
||||
|
||||
@Mock private PdfJsonConversionService pdfJsonConversionService;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
|
||||
@InjectMocks private ConvertPdfJsonController controller;
|
||||
|
||||
private final java.util.List<TempFile> createdTempFiles = new java.util.ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// @InjectMocks uses the @RequiredArgsConstructor, so the @Autowired field is not
|
||||
// auto-injected; wire the JobOwnershipService mock in by reflection.
|
||||
setJobOwnershipService(jobOwnershipService);
|
||||
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("more-test", inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile tf = mock(TempFile.class);
|
||||
lenient().when(tf.getFile()).thenReturn(f);
|
||||
lenient().when(tf.getPath()).thenReturn(f.toPath());
|
||||
createdTempFiles.add(tf);
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
private void setJobOwnershipService(JobOwnershipService service) throws Exception {
|
||||
Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService");
|
||||
f.setAccessible(true);
|
||||
f.set(controller, service);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
for (TempFile tf : createdTempFiles) {
|
||||
try {
|
||||
if (tf.getFile() != null) {
|
||||
tf.getFile().delete();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
createdTempFiles.clear();
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (java.io.InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// Disable the @Autowired(required=false) JobOwnershipService for no-auth code paths.
|
||||
private void clearJobOwnershipService() throws Exception {
|
||||
setJobOwnershipService(null);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertPdfToJson filename handling")
|
||||
class ConvertPdfToJsonFilename {
|
||||
|
||||
@Test
|
||||
@DisplayName("Null original filename falls back to document.json")
|
||||
void nullOriginalFilename() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile("fileInput", null, "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doAnswer(writeBytes(2, "{}".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
String cd = response.getHeaders().getFirst("Content-Disposition");
|
||||
assertThat(cd).contains("document.json");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Blank original filename falls back to document.json")
|
||||
void blankOriginalFilename() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile("fileInput", " ", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doAnswer(writeBytes(2, "{}".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("document.json");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Named file strips extension and appends .json")
|
||||
void namedFileStripsExtension() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "report.final.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doAnswer(writeBytes(2, "{}".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("report.final.json");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Service exception closes temp file and propagates")
|
||||
void serviceExceptionClosesTempFile() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doThrow(new IllegalStateException("boom"))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class, () -> controller.convertPdfToJson(request, false));
|
||||
verify(createdTempFiles.get(0)).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convertJsonToPdf filename handling")
|
||||
class ConvertJsonToPdfFilename {
|
||||
|
||||
@Test
|
||||
@DisplayName("Null original filename falls back to document.pdf")
|
||||
void nullOriginalFilename() throws Exception {
|
||||
MockMultipartFile jsonFile =
|
||||
new MockMultipartFile("fileInput", null, "application/json", "{}".getBytes());
|
||||
GeneralFile request = new GeneralFile();
|
||||
request.setFileInput(jsonFile);
|
||||
|
||||
doAnswer(writeBytes(1, "pdf".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertJsonToPdf(request);
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("document.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Filename already ending in .pdf is preserved")
|
||||
void filenameAlreadyPdf() throws Exception {
|
||||
// toSimpleFileName keeps base name; extension pattern strips the trailing .pdf,
|
||||
// so a base name that itself ends in .pdf exercises the endsWith branch.
|
||||
MockMultipartFile jsonFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "weird.pdf.json", "application/json", "{}".getBytes());
|
||||
GeneralFile request = new GeneralFile();
|
||||
request.setFileInput(jsonFile);
|
||||
|
||||
doAnswer(writeBytes(1, "pdf".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertJsonToPdf(request);
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition")).contains("weird.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Service exception closes temp file and propagates")
|
||||
void serviceExceptionClosesTempFile() throws Exception {
|
||||
MockMultipartFile jsonFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.json", "application/json", "{}".getBytes());
|
||||
GeneralFile request = new GeneralFile();
|
||||
request.setFileInput(jsonFile);
|
||||
|
||||
doThrow(new IllegalStateException("boom"))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> controller.convertJsonToPdf(request));
|
||||
verify(createdTempFiles.get(0)).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractPdfMetadata job-key scoping")
|
||||
class ExtractMetadataScoping {
|
||||
|
||||
@Test
|
||||
@DisplayName("Uses scoped job key when JobOwnershipService present")
|
||||
void usesScopedKey() throws Exception {
|
||||
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("user:scoped-id");
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doAnswer(writeBytes(2, "{}".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.extractDocumentMetadata(
|
||||
eq(pdfFile), eq("user:scoped-id"), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractPdfMetadata(request);
|
||||
|
||||
assertEquals("user:scoped-id", response.getHeaders().getFirst("X-Job-Id"));
|
||||
verify(jobOwnershipService).createScopedJobKey(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Uses raw job key when no JobOwnershipService")
|
||||
void usesRawKeyWhenNoService() throws Exception {
|
||||
clearJobOwnershipService();
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doAnswer(writeBytes(2, "{}".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractPdfMetadata(request);
|
||||
|
||||
assertNotNull(response.getHeaders().getFirst("X-Job-Id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Service exception closes temp file and propagates")
|
||||
void serviceExceptionClosesTempFile() throws Exception {
|
||||
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("k");
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doThrow(new IllegalStateException("boom"))
|
||||
.when(pdfJsonConversionService)
|
||||
.extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> controller.extractPdfMetadata(request));
|
||||
verify(createdTempFiles.get(0)).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("exportPartialPdf")
|
||||
class ExportPartialPdf {
|
||||
|
||||
@Test
|
||||
@DisplayName("Null document throws")
|
||||
void nullDocumentThrows() {
|
||||
assertThrows(
|
||||
Exception.class, () -> controller.exportPartialPdf("job", null, "out.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Explicit filename param wins over metadata title")
|
||||
void filenameParamWins() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setMetadata(PdfJsonMetadata.builder().title("titleName").build());
|
||||
|
||||
doAnswer(writeBytes(2, "pdf".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response =
|
||||
controller.exportPartialPdf("job", doc, "custom.pdf");
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("custom.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Falls back to metadata title when filename blank")
|
||||
void fallbackToMetadataTitle() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setMetadata(PdfJsonMetadata.builder().title("MyTitle").build());
|
||||
|
||||
doAnswer(writeBytes(2, "pdf".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, " ");
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("MyTitle.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Falls back to document when title null/blank and no filename")
|
||||
void fallbackToDocumentName() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setMetadata(PdfJsonMetadata.builder().title(" ").build());
|
||||
|
||||
doAnswer(writeBytes(2, "pdf".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, null);
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("document.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null metadata falls back to document name")
|
||||
void nullMetadataFallback() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
doc.setMetadata(null);
|
||||
|
||||
doAnswer(writeBytes(2, "pdf".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, null);
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("document.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Service exception closes temp file and propagates")
|
||||
void serviceExceptionClosesTempFile() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
PdfJsonDocument doc = new PdfJsonDocument();
|
||||
|
||||
doThrow(new IllegalStateException("boom"))
|
||||
.when(pdfJsonConversionService)
|
||||
.exportUpdatedPages(
|
||||
anyString(), any(PdfJsonDocument.class), any(OutputStream.class));
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> controller.exportPartialPdf("job", doc, "out.pdf"));
|
||||
verify(createdTempFiles.get(0)).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractSinglePage and extractPageFonts errors")
|
||||
class GetPageErrors {
|
||||
|
||||
@Test
|
||||
@DisplayName("extractSinglePage service exception closes temp file")
|
||||
void singlePageException() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
doThrow(new IllegalStateException("boom"))
|
||||
.when(pdfJsonConversionService)
|
||||
.extractSinglePage(anyString(), anyInt(), any(OutputStream.class));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> controller.extractSinglePage("job", 1));
|
||||
verify(createdTempFiles.get(0)).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractPageFonts service exception closes temp file")
|
||||
void pageFontsException() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
doThrow(new IllegalStateException("boom"))
|
||||
.when(pdfJsonConversionService)
|
||||
.extractPageFonts(anyString(), anyInt(), any(OutputStream.class));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> controller.extractPageFonts("job", 1));
|
||||
verify(createdTempFiles.get(0)).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Page docName carries page number")
|
||||
void singlePageDocName() throws Exception {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
doAnswer(writeBytes(2, "{}".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.extractSinglePage(eq("job"), eq(7), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractSinglePage("job", 7);
|
||||
|
||||
assertThat(response.getHeaders().getFirst("Content-Disposition"))
|
||||
.contains("page_7.json");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("validateJobAccess and clearCache delegation")
|
||||
class JobAccessDelegation {
|
||||
|
||||
@Test
|
||||
@DisplayName("clearCache validates and delegates when service present")
|
||||
void clearCacheWithService() {
|
||||
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
|
||||
|
||||
ResponseEntity<Void> response = controller.clearCache("job-1");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(jobOwnershipService).validateJobAccess("job-1");
|
||||
verify(pdfJsonConversionService).clearCachedDocument("job-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("clearCache skips validation when no service")
|
||||
void clearCacheNoService() throws Exception {
|
||||
clearJobOwnershipService();
|
||||
|
||||
ResponseEntity<Void> response = controller.clearCache("job-2");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(pdfJsonConversionService).clearCachedDocument("job-2");
|
||||
verify(jobOwnershipService, never()).validateJobAccess(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("validateJobAccess propagates SecurityException from service")
|
||||
void validateThrows() {
|
||||
doThrow(new SecurityException("denied"))
|
||||
.when(jobOwnershipService)
|
||||
.validateJobAccess("bad");
|
||||
|
||||
assertThrows(SecurityException.class, () -> controller.clearCache("bad"));
|
||||
verify(pdfJsonConversionService, never()).clearCachedDocument(anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("logJsonResponse diagnostic paths")
|
||||
class LogJsonResponseDiagnostics {
|
||||
|
||||
@Test
|
||||
@DisplayName("Debug dump writes a copy to configured dir")
|
||||
void debugDumpWritesCopy(@org.junit.jupiter.api.io.TempDir Path dumpDir) throws Exception {
|
||||
String previous = System.getProperty("spdf.pdfjson.dump");
|
||||
System.setProperty("spdf.pdfjson.dump", "true");
|
||||
String prevDir = System.getProperty("java.io.tmpdir");
|
||||
// SPDF_PDFJSON_DUMP_DIR env may be unset; controller falls back to java.io.tmpdir.
|
||||
System.setProperty("java.io.tmpdir", dumpDir.toString());
|
||||
try {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
doAnswer(writeBytes(2, "{\"k\":\"value-string-here\"}".getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
|
||||
try (var stream = Files.list(dumpDir)) {
|
||||
boolean dumped =
|
||||
stream.anyMatch(p -> p.getFileName().toString().startsWith("pdfjson_"));
|
||||
assertThat(dumped).isTrue();
|
||||
}
|
||||
} finally {
|
||||
restoreProp("spdf.pdfjson.dump", previous);
|
||||
restoreProp("java.io.tmpdir", prevDir);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Repeat scan runs without error on repeated strings")
|
||||
void repeatScanRuns() throws Exception {
|
||||
String previous = System.getProperty("spdf.pdfjson.repeatScan");
|
||||
System.setProperty("spdf.pdfjson.repeatScan", "true");
|
||||
try {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
// Two repeated >=12 char strings plus a long base64-like one to exercise filters.
|
||||
String json =
|
||||
"{\"a\":\"repeated-string-value\",\"b\":\"repeated-string-value\","
|
||||
+ "\"c\":\"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5\"}";
|
||||
doAnswer(writeBytes(2, json.getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertThat(drainBody(response)).isNotEmpty();
|
||||
} finally {
|
||||
restoreProp("spdf.pdfjson.repeatScan", previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Repeat scan handles no repeated strings")
|
||||
void repeatScanNoRepeats() throws Exception {
|
||||
String previous = System.getProperty("spdf.pdfjson.repeatScan");
|
||||
System.setProperty("spdf.pdfjson.repeatScan", "true");
|
||||
try {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
String json = "{\"only-key-here-unique\":\"only-value-here-unique\"}";
|
||||
doAnswer(writeBytes(2, json.getBytes()))
|
||||
.when(pdfJsonConversionService)
|
||||
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
} finally {
|
||||
restoreProp("spdf.pdfjson.repeatScan", previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static org.mockito.stubbing.Answer<Object> writeBytes(int argIndex, byte[] data) {
|
||||
return inv -> {
|
||||
OutputStream os = inv.getArgument(argIndex, OutputStream.class);
|
||||
os.write(data);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
private static void restoreProp(String key, String previous) {
|
||||
if (previous == null) {
|
||||
System.clearProperty(key);
|
||||
} else {
|
||||
System.setProperty(key, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.exception.CacheUnavailableException;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ConvertPdfJsonExceptionHandler}. A real ObjectMapper exercises the happy
|
||||
* path; a throwing mock drives the serialization-failure fallback branches.
|
||||
*/
|
||||
@DisplayName("ConvertPdfJsonExceptionHandler")
|
||||
class ConvertPdfJsonExceptionHandlerTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("handleCacheUnavailable - success")
|
||||
class Success {
|
||||
|
||||
@Test
|
||||
@DisplayName("serializes a 410 GONE JSON body with the error details")
|
||||
void serializesGoneResponse() {
|
||||
ConvertPdfJsonExceptionHandler handler =
|
||||
new ConvertPdfJsonExceptionHandler(new ObjectMapper());
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
handler.handleCacheUnavailable(new CacheUnavailableException("cache is gone"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_JSON);
|
||||
String json = new String(response.getBody());
|
||||
assertThat(json).contains("cache_unavailable");
|
||||
assertThat(json).contains("reupload");
|
||||
assertThat(json).contains("cache is gone");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tolerates a null exception message")
|
||||
void toleratesNullMessage() {
|
||||
ConvertPdfJsonExceptionHandler handler =
|
||||
new ConvertPdfJsonExceptionHandler(new ObjectMapper());
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
handler.handleCacheUnavailable(new CacheUnavailableException(null));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
|
||||
assertThat(response.getBody()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("handleCacheUnavailable - fallback")
|
||||
class Fallback {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the literal JSON fallback when every serialization attempt fails")
|
||||
void literalFallbackWhenAllSerializationFails() {
|
||||
ObjectMapper mapper = Mockito.mock(ObjectMapper.class);
|
||||
// Both the primary and the secondary writeValueAsBytes calls fail.
|
||||
Mockito.when(mapper.writeValueAsBytes(any())).thenThrow(new RuntimeException("boom"));
|
||||
ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper);
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
handler.handleCacheUnavailable(new CacheUnavailableException("nope"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_JSON);
|
||||
// Last-ditch hand-written JSON literal.
|
||||
String json = new String(response.getBody());
|
||||
assertThat(json).contains("cache_unavailable");
|
||||
assertThat(json).contains("Cache unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recovers via the second serialization attempt when only the first fails")
|
||||
void secondAttemptSucceeds() {
|
||||
ObjectMapper mapper = Mockito.mock(ObjectMapper.class);
|
||||
byte[] fallbackJson = "{\"error\":\"cache_unavailable\"}".getBytes();
|
||||
// First call throws, second returns serialized bytes.
|
||||
Mockito.when(mapper.writeValueAsBytes(any()))
|
||||
.thenThrow(new RuntimeException("first fails"))
|
||||
.thenReturn(fallbackJson);
|
||||
ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper);
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
handler.handleCacheUnavailable(new CacheUnavailableException("retry"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
|
||||
assertThat(response.getBody()).isEqualTo(fallbackJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
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.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.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.SvgToPdfRequest;
|
||||
import stirling.software.SPDF.utils.SvgToPdf;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.SvgSanitizer;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Gap coverage for {@link ConvertSvgToPDF}: the multi-file zip path and the empty-output failure
|
||||
* branches not covered by ConvertSvgToPDFTest.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ConvertSvgToPDF zip and failure branches")
|
||||
class ConvertSvgToPDFMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private SvgSanitizer svgSanitizer;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private ConvertSvgToPDF controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Real backing files so the zip/pdf streams can actually be written.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("svg", 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;
|
||||
});
|
||||
}
|
||||
|
||||
private static MockMultipartFile svg(String name, String content) {
|
||||
return new MockMultipartFile("fileInput", name, "image/svg+xml", content.getBytes());
|
||||
}
|
||||
|
||||
private static List<String> zipNames(Resource resource) throws Exception {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(resource.getContentAsByteArray()))) {
|
||||
ZipEntry e;
|
||||
while ((e = zis.getNextEntry()) != null) {
|
||||
names.add(e.getName());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple SVGs in separate mode are returned as a zip")
|
||||
void multipleSeparateZipsOutput() throws Exception {
|
||||
byte[] sanitized1 = "<svg>a</svg>".getBytes();
|
||||
byte[] sanitized2 = "<svg>b</svg>".getBytes();
|
||||
byte[] pdf1 = "pdf1".getBytes();
|
||||
byte[] pdf2 = "pdf2".getBytes();
|
||||
|
||||
SvgToPdfRequest request = new SvgToPdfRequest();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile[] {
|
||||
svg("a.svg", "<svg>1</svg>"), svg("b.svg", "<svg>2</svg>")
|
||||
});
|
||||
request.setCombineIntoSinglePdf(false);
|
||||
|
||||
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized1);
|
||||
when(svgSanitizer.sanitize("<svg>2</svg>".getBytes())).thenReturn(sanitized2);
|
||||
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf1)).thenReturn(pdf1);
|
||||
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf2)).thenReturn(pdf2);
|
||||
|
||||
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
|
||||
svg.when(() -> SvgToPdf.convert(sanitized1)).thenReturn(pdf1);
|
||||
svg.when(() -> SvgToPdf.convert(sanitized2)).thenReturn(pdf2);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<String> names = zipNames(response.getBody());
|
||||
assertEquals(2, names.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("empty-output failures")
|
||||
class EmptyOutputs {
|
||||
|
||||
@Test
|
||||
@DisplayName("combined mode returns 500 when the combined PDF is empty")
|
||||
void combinedEmptyOutput() throws Exception {
|
||||
byte[] sanitized = "<svg>s</svg>".getBytes();
|
||||
SvgToPdfRequest request = new SvgToPdfRequest();
|
||||
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
|
||||
request.setCombineIntoSinglePdf(true);
|
||||
|
||||
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
|
||||
|
||||
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
|
||||
svg.when(() -> SvgToPdf.combineIntoPdf(any())).thenReturn(new byte[0]);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
|
||||
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("separate mode returns 500 when no file converts successfully")
|
||||
void separateAllEmpty() throws Exception {
|
||||
byte[] sanitized = "<svg>s</svg>".getBytes();
|
||||
SvgToPdfRequest request = new SvgToPdfRequest();
|
||||
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
|
||||
request.setCombineIntoSinglePdf(false);
|
||||
|
||||
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
|
||||
|
||||
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
|
||||
// Empty conversion output -> the file is skipped -> no successful conversions.
|
||||
svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(new byte[0]);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
|
||||
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("combined mode returns 500 when conversion throws IOException")
|
||||
void combinedConversionThrows() throws Exception {
|
||||
byte[] sanitized = "<svg>s</svg>".getBytes();
|
||||
SvgToPdfRequest request = new SvgToPdfRequest();
|
||||
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
|
||||
request.setCombineIntoSinglePdf(true);
|
||||
|
||||
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
|
||||
|
||||
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
|
||||
svg.when(() -> SvgToPdf.combineIntoPdf(any()))
|
||||
.thenThrow(new IOException("convert failure"));
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
|
||||
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a single converted SVG uses the pdf-file response path")
|
||||
void singleConvertedUsesPdfResponse() throws Exception {
|
||||
byte[] sanitized = "<svg>s</svg>".getBytes();
|
||||
byte[] pdf = "pdf".getBytes();
|
||||
SvgToPdfRequest request = new SvgToPdfRequest();
|
||||
request.setFileInput(new MockMultipartFile[] {svg("only.svg", "<svg>1</svg>")});
|
||||
request.setCombineIntoSinglePdf(false);
|
||||
|
||||
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
|
||||
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf)).thenReturn(pdf);
|
||||
|
||||
ResponseEntity<Resource> stub =
|
||||
ResponseEntity.ok(new org.springframework.core.io.ByteArrayResource(pdf));
|
||||
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class);
|
||||
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(pdf);
|
||||
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(TempFile.class), anyString()))
|
||||
.thenReturn(stub);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
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.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Branch coverage for the disallowed-scheme detection helpers of {@link ConvertWebsiteToPDF}. These
|
||||
* are pure string transforms exercised by reflection, so no network call or external WeasyPrint
|
||||
* invocation occurs.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ConvertWebsiteToPDF scheme-detection helpers")
|
||||
class ConvertWebsiteToPDFExtraTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private ConvertWebsiteToPDF sut;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sut =
|
||||
new ConvertWebsiteToPDF(
|
||||
pdfDocumentFactory,
|
||||
runtimePathConfig,
|
||||
new ApplicationProperties(),
|
||||
tempFileManager);
|
||||
}
|
||||
|
||||
private boolean containsDisallowed(String html) throws Exception {
|
||||
Method m =
|
||||
ConvertWebsiteToPDF.class.getDeclaredMethod(
|
||||
"containsDisallowedUriScheme", String.class);
|
||||
m.setAccessible(true);
|
||||
return (boolean) m.invoke(sut, html);
|
||||
}
|
||||
|
||||
private String percentDecode(String content) throws Exception {
|
||||
Method m = ConvertWebsiteToPDF.class.getDeclaredMethod("percentDecode", String.class);
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(sut, content);
|
||||
}
|
||||
|
||||
private String decodeEntities(String content) throws Exception {
|
||||
Method m =
|
||||
ConvertWebsiteToPDF.class.getDeclaredMethod(
|
||||
"decodeNumericHtmlEntities", String.class);
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(sut, content);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("containsDisallowedUriScheme")
|
||||
class DisallowedScheme {
|
||||
|
||||
@Test
|
||||
@DisplayName("null and empty content are allowed")
|
||||
void nullAndEmpty() throws Exception {
|
||||
assertThat(containsDisallowed(null)).isFalse();
|
||||
assertThat(containsDisallowed("")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plain safe html is allowed")
|
||||
void safeHtml() throws Exception {
|
||||
assertThat(
|
||||
containsDisallowed(
|
||||
"<html><body><a href=\"https://x.com\">ok</a></body></html>"))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a literal file:/// scheme is rejected")
|
||||
void literalFileScheme() throws Exception {
|
||||
assertThat(containsDisallowed("<a href=\"file:///etc/passwd\">x</a>")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an uppercase FILE: scheme is rejected after lower-casing")
|
||||
void uppercaseFileScheme() throws Exception {
|
||||
assertThat(containsDisallowed("<a href=\"FILE://server/share\">x</a>")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a percent-encoded file scheme separator is rejected")
|
||||
void percentEncodedSeparator() throws Exception {
|
||||
// file:%2f%2f decodes to file:// during normalization
|
||||
assertThat(containsDisallowed("<a href=\"file:%2f%2fetc/passwd\">x</a>")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an html-entity encoded slash sequence is rejected")
|
||||
void htmlEntitySlashes() throws Exception {
|
||||
// file:// -> file:// after numeric-entity decoding
|
||||
assertThat(containsDisallowed("<a href=\"file://etc\">x</a>")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a named-entity colon/slash sequence is rejected")
|
||||
void namedEntitySlashes() throws Exception {
|
||||
// file:// -> file:// after named-entity replacement
|
||||
assertThat(containsDisallowed("<a href=\"file://etc\">x</a>")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the word 'profile:' is not mistaken for a file scheme")
|
||||
void wordBoundaryGuard() throws Exception {
|
||||
// the (?<![a-z0-9_]) lookbehind prevents matching the 'file' inside 'profile'
|
||||
assertThat(containsDisallowed("<span>profile://something</span>")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("percentDecode")
|
||||
class PercentDecode {
|
||||
|
||||
@Test
|
||||
@DisplayName("decodes valid percent escapes")
|
||||
void decodesValid() throws Exception {
|
||||
assertThat(percentDecode("a%2fb")).isEqualTo("a/b");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves a trailing incomplete escape untouched")
|
||||
void trailingIncomplete() throws Exception {
|
||||
// not enough trailing chars for a full %XX -> appended literally
|
||||
assertThat(percentDecode("end%2")).isEqualTo("end%2");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves a non-hex escape untouched")
|
||||
void nonHexEscape() throws Exception {
|
||||
assertThat(percentDecode("a%zzb")).isEqualTo("a%zzb");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("content with no escapes is returned unchanged")
|
||||
void noEscapes() throws Exception {
|
||||
assertThat(percentDecode("plain text")).isEqualTo("plain text");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("decodeNumericHtmlEntities")
|
||||
class DecodeNumericHtmlEntities {
|
||||
|
||||
@Test
|
||||
@DisplayName("decodes a decimal numeric entity")
|
||||
void decimalEntity() throws Exception {
|
||||
// / is '/'
|
||||
assertThat(decodeEntities("a/b")).isEqualTo("a/b");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decodes a hexadecimal numeric entity")
|
||||
void hexEntity() throws Exception {
|
||||
// / is '/'
|
||||
assertThat(decodeEntities("a/b")).isEqualTo("a/b");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("content without entities is unchanged")
|
||||
void noEntities() throws Exception {
|
||||
assertThat(decodeEntities("nothing here")).isEqualTo("nothing here");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("controller is constructed with its collaborators")
|
||||
void constructed() {
|
||||
assertThat(sut).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reflection invocation surfaces are wired correctly")
|
||||
void reflectionWired() throws Exception {
|
||||
// guards against a refactor renaming the private helpers used above
|
||||
try {
|
||||
assertThat(containsDisallowed("safe")).isFalse();
|
||||
} catch (InvocationTargetException e) {
|
||||
throw new AssertionError("helper invocation failed", e.getCause());
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
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.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
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 org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
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.PDFWithPageNums;
|
||||
import stirling.software.SPDF.pdf.parser.PdfModels.Bounds;
|
||||
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
|
||||
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
/**
|
||||
* Additional coverage for {@link ExtractCSVController}. The Tabula parser is mocked so
|
||||
* deterministic table fragments drive the single-table, multi-table and no-table response branches;
|
||||
* documents are built in-memory.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExtractCSVControllerMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TabulaTableParser tabulaTableParser;
|
||||
|
||||
@InjectMocks private ExtractCSVController controller;
|
||||
|
||||
private static PDDocument docWithPages(int pages) {
|
||||
PDDocument doc = new PDDocument();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdf(String name) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, "pdf".getBytes());
|
||||
}
|
||||
|
||||
/** Build a TableFragment whose only meaningful payload for CSV output is rawRows. */
|
||||
private static TableFragment fragment(List<List<String>> rawRows) {
|
||||
return new TableFragment(
|
||||
"tbl",
|
||||
1,
|
||||
new Bounds(0f, 0f, 100f, 100f),
|
||||
List.of(),
|
||||
List.of(),
|
||||
rawRows,
|
||||
rawRows.isEmpty() ? 0 : rawRows.get(0).size(),
|
||||
1.0f,
|
||||
List.of(),
|
||||
null);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("response shape by table count")
|
||||
class ResponseShape {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns no content when no tables are found")
|
||||
void noTablesNoContent() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("data.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
|
||||
when(tabulaTableParser.parse(any(PDDocument.class), eq(1))).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<?> response = controller.pdfToCsv(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a single CSV body when exactly one table is found")
|
||||
void singleTableCsv() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("report.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
|
||||
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
fragment(
|
||||
List.of(
|
||||
List.of("Name", "Age"),
|
||||
List.of("Alice", "30")))));
|
||||
|
||||
ResponseEntity<?> response = controller.pdfToCsv(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType().toString()).startsWith("text/csv");
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("report_extracted.csv");
|
||||
assertThat(response.getBody().toString()).contains("Name").contains("Alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns a zip when multiple tables span multiple pages")
|
||||
void multiTableZip() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("multi.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(2));
|
||||
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
|
||||
.thenReturn(List.of(fragment(List.of(List.of("a", "b")))));
|
||||
when(tabulaTableParser.parse(any(PDDocument.class), eq(2)))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
fragment(List.of(List.of("c", "d"))),
|
||||
fragment(List.of(List.of("e", "f")))));
|
||||
|
||||
ResponseEntity<?> response = controller.pdfToCsv(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename())
|
||||
.isEqualTo("multi_extracted.zip");
|
||||
|
||||
byte[] body = (byte[]) response.getBody();
|
||||
assertThat(zipEntryNames(body))
|
||||
.containsExactlyInAnyOrder(
|
||||
"multi_p1_t1.csv", "multi_p2_t1.csv", "multi_p2_t2.csv");
|
||||
}
|
||||
|
||||
private List<String> zipEntryNames(byte[] zipBytes) throws Exception {
|
||||
java.util.List<String> names = new java.util.ArrayList<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
names.add(entry.getName());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("error propagation")
|
||||
class Errors {
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates a parser failure")
|
||||
void parserFailurePropagates() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("bad.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
|
||||
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
|
||||
.thenThrow(new java.io.IOException("parse boom"));
|
||||
|
||||
assertThatThrownBy(() -> controller.pdfToCsv(request))
|
||||
.isInstanceOf(java.io.IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates a document load failure")
|
||||
void loadFailurePropagates() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("corrupt.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom"));
|
||||
|
||||
assertThatThrownBy(() -> controller.pdfToCsv(request))
|
||||
.isInstanceOf(java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single table CSV body is quote-wrapped per the EXCEL/QuoteMode.ALL format")
|
||||
void csvBodyIsQuoted() throws Exception {
|
||||
PDFWithPageNums request = new PDFWithPageNums();
|
||||
request.setFileInput(pdf("q.pdf"));
|
||||
request.setPageNumbers("all");
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
|
||||
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
|
||||
.thenReturn(List.of(fragment(List.of(List.of("x", "y")))));
|
||||
|
||||
ResponseEntity<?> response = controller.pdfToCsv(request);
|
||||
|
||||
String body = response.getBody().toString();
|
||||
// QuoteMode.ALL wraps every field in double quotes.
|
||||
assertThat(body).contains("\"x\"").contains("\"y\"");
|
||||
assertThat(body.getBytes(StandardCharsets.UTF_8)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.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.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Gap coverage for {@link PdfVectorExportController#convertPdfToVector} and the Ghostscript
|
||||
* PDF-to-vector helper, complementing PdfVectorExportControllerTest (which only covers the
|
||||
* PostScript-to-PDF endpoint).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("PdfVectorExportController convertPdfToVector")
|
||||
class PdfVectorExportControllerMoreTest {
|
||||
|
||||
private final List<Path> tempPaths = new ArrayList<>();
|
||||
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private ProcessExecutor ghostscriptExecutor;
|
||||
@InjectMocks private PdfVectorExportController controller;
|
||||
|
||||
// Real manager used only to mint genuine TempFile instances for the mock to hand back.
|
||||
private final TempFileManager realTempFileManager =
|
||||
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
|
||||
private Map<ProcessExecutor.Processes, ProcessExecutor> originalExecutors;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
// Return a real TempFile so no Mockito when() runs re-entrantly inside the thenAnswer.
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(inv -> realTempFileManager.createManagedTempFile(inv.getArgument(0)));
|
||||
lenient()
|
||||
.when(tempFileManager.createTempFile(any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
String suffix = invocation.<String>getArgument(0);
|
||||
Path path =
|
||||
Files.createTempFile("vec_in", suffix == null ? "" : suffix);
|
||||
tempPaths.add(path);
|
||||
return path.toFile();
|
||||
});
|
||||
|
||||
Field instancesField = ProcessExecutor.class.getDeclaredField("instances");
|
||||
instancesField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<ProcessExecutor.Processes, ProcessExecutor> instances =
|
||||
(Map<ProcessExecutor.Processes, ProcessExecutor>) instancesField.get(null);
|
||||
originalExecutors = Map.copyOf(instances);
|
||||
instances.clear();
|
||||
instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, ghostscriptExecutor);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
Field instancesField = ProcessExecutor.class.getDeclaredField("instances");
|
||||
instancesField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<ProcessExecutor.Processes, ProcessExecutor> instances =
|
||||
(Map<ProcessExecutor.Processes, ProcessExecutor>) instancesField.get(null);
|
||||
instances.clear();
|
||||
if (originalExecutors != null) {
|
||||
instances.putAll(originalExecutors);
|
||||
}
|
||||
reset(ghostscriptExecutor, tempFileManager, endpointConfiguration);
|
||||
for (Path path : tempPaths) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
tempPaths.clear();
|
||||
}
|
||||
|
||||
private ProcessExecutorResult okResult() {
|
||||
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
|
||||
lenient().when(result.getRc()).thenReturn(0);
|
||||
lenient().when(result.getMessages()).thenReturn("");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PdfVectorExportRequest request(String outputFormat) {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "in.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[] {1});
|
||||
PdfVectorExportRequest request = new PdfVectorExportRequest();
|
||||
request.setFileInput(file);
|
||||
request.setOutputFormat(outputFormat);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("media types per output format")
|
||||
class MediaTypes {
|
||||
|
||||
@Test
|
||||
@DisplayName("eps output yields application/postscript")
|
||||
void epsContentType() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult ok = okResult();
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToVector(request("eps"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.parseMediaType("application/postscript"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ps output yields application/postscript")
|
||||
void psContentType() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult ok = okResult();
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToVector(request("ps"));
|
||||
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.parseMediaType("application/postscript"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pcl output yields the HP-PCL media type")
|
||||
void pclContentType() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult ok = okResult();
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToVector(request("pcl"));
|
||||
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.parseMediaType("application/vnd.hp-PCL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("xps output yields the MS XPS media type")
|
||||
void xpsContentType() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult ok = okResult();
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToVector(request("xps"));
|
||||
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.parseMediaType("application/vnd.ms-xpsdocument"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null output format defaults to eps")
|
||||
void nullOutputFormatDefaultsToEps() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult ok = okResult();
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToVector(request(null));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.parseMediaType("application/postscript"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uppercase output format is normalized to lowercase")
|
||||
void uppercaseFormatNormalized() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult ok = okResult();
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToVector(request("EPS"));
|
||||
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.parseMediaType("application/postscript"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("failure paths")
|
||||
class Failures {
|
||||
|
||||
@Test
|
||||
@DisplayName("disabled Ghostscript group throws a conversion exception")
|
||||
void ghostscriptDisabledThrows() {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-zero Ghostscript return code throws a conversion exception")
|
||||
void nonZeroReturnCodeThrows() {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult bad = mock(ProcessExecutorResult.class);
|
||||
lenient().when(bad.getRc()).thenReturn(1);
|
||||
lenient().when(bad.getMessages()).thenReturn("some non-critical failure");
|
||||
try {
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(bad);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unsupported output format is rejected before any device mapping")
|
||||
void unsupportedFormatThrows() {
|
||||
// "svg" is not in the validated set and falls through to the device-switch default.
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("svg")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ghostscript runs the configured command for a successful conversion")
|
||||
void runsGhostscriptCommand() throws Exception {
|
||||
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
|
||||
ProcessExecutorResult ok = okResult();
|
||||
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
|
||||
|
||||
ResponseEntity<Resource> response = controller.convertPdfToVector(request("eps"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
org.mockito.Mockito.verify(ghostscriptExecutor).runCommandWithOutputHandling(any());
|
||||
}
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.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.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
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.Mockito;
|
||||
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.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.controller.api.converters.ConvertPDFToPDFA;
|
||||
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
|
||||
import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest;
|
||||
import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest;
|
||||
import stirling.software.SPDF.model.api.misc.ListAttachmentsRequest;
|
||||
import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest;
|
||||
import stirling.software.SPDF.service.AttachmentServiceInterface;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Coverage for the AttachmentController endpoints not exercised by AttachmentControllerTest:
|
||||
* extract, list, rename, delete plus their validation paths and the add-attachment validation
|
||||
* branches.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("AttachmentController extract/list/rename/delete")
|
||||
class AttachmentControllerMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private AttachmentServiceInterface pdfAttachmentService;
|
||||
@Mock private ConvertPDFToPDFA convertPDFToPDFA;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private AttachmentController controller;
|
||||
|
||||
private PDDocument mockDocument;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
controller =
|
||||
new AttachmentController(
|
||||
pdfDocumentFactory,
|
||||
pdfAttachmentService,
|
||||
convertPDFToPDFA,
|
||||
tempFileManager);
|
||||
mockDocument = mock(PDDocument.class);
|
||||
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("att_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;
|
||||
});
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdf() {
|
||||
return new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, "pdf".getBytes());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractAttachments")
|
||||
class Extract {
|
||||
|
||||
@Test
|
||||
@DisplayName("writes a zip when attachments are present")
|
||||
void extractsToZip() throws Exception {
|
||||
ExtractAttachmentsRequest request = new ExtractAttachmentsRequest();
|
||||
request.setFileInput(pdf());
|
||||
|
||||
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.extractAttachments(mockDocument))
|
||||
.thenReturn(Optional.of("zip-bytes".getBytes()));
|
||||
|
||||
ResponseEntity<Resource> expected =
|
||||
ResponseEntity.ok(new ByteArrayResource("zip-bytes".getBytes()));
|
||||
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.zipFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractAttachments(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(pdfAttachmentService).extractAttachments(mockDocument);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws when no attachments are found")
|
||||
void noAttachmentsThrows() throws Exception {
|
||||
ExtractAttachmentsRequest request = new ExtractAttachmentsRequest();
|
||||
request.setFileInput(pdf());
|
||||
|
||||
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.extractAttachments(mockDocument))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> controller.extractAttachments(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses fileId for the output name when no upload filename is available")
|
||||
void usesFileIdForName() throws Exception {
|
||||
ExtractAttachmentsRequest request = new ExtractAttachmentsRequest();
|
||||
request.setFileId("server-file-id");
|
||||
|
||||
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.extractAttachments(mockDocument))
|
||||
.thenReturn(Optional.of("zip".getBytes()));
|
||||
|
||||
ResponseEntity<Resource> expected =
|
||||
ResponseEntity.ok(new ByteArrayResource("zip".getBytes()));
|
||||
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.zipFileToWebResponse(
|
||||
any(TempFile.class), anyString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.extractAttachments(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("listAttachments")
|
||||
class ListAttachments {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the attachment metadata list")
|
||||
void returnsList() throws Exception {
|
||||
ListAttachmentsRequest request = new ListAttachmentsRequest();
|
||||
request.setFileInput(pdf());
|
||||
|
||||
AttachmentInfo info = new AttachmentInfo();
|
||||
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.listAttachments(mockDocument)).thenReturn(List.of(info));
|
||||
|
||||
ResponseEntity<List<AttachmentInfo>> response = controller.listAttachments(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertEquals(1, response.getBody().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns an empty list when the PDF has no attachments")
|
||||
void returnsEmptyList() throws Exception {
|
||||
ListAttachmentsRequest request = new ListAttachmentsRequest();
|
||||
request.setFileInput(pdf());
|
||||
|
||||
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.listAttachments(mockDocument)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<List<AttachmentInfo>> response = controller.listAttachments(request);
|
||||
|
||||
assertEquals(0, response.getBody().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("renameAttachment")
|
||||
class Rename {
|
||||
|
||||
@Test
|
||||
@DisplayName("renames and returns the updated PDF")
|
||||
void renames() throws Exception {
|
||||
RenameAttachmentRequest request = new RenameAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachmentName("old.txt");
|
||||
request.setNewName("new.txt");
|
||||
|
||||
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.renameAttachment(mockDocument, "old.txt", "new.txt"))
|
||||
.thenReturn(mockDocument);
|
||||
|
||||
ResponseEntity<Resource> expected =
|
||||
ResponseEntity.ok(new ByteArrayResource("pdf".getBytes()));
|
||||
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfDocToWebResponse(
|
||||
any(PDDocument.class),
|
||||
anyString(),
|
||||
any(TempFileManager.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.renameAttachment(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(pdfAttachmentService).renameAttachment(mockDocument, "old.txt", "new.txt");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank attachment name")
|
||||
void blankAttachmentNameThrows() {
|
||||
RenameAttachmentRequest request = new RenameAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachmentName(" ");
|
||||
request.setNewName("new.txt");
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> controller.renameAttachment(request));
|
||||
verifyNoInteractions(pdfAttachmentService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a null new name")
|
||||
void nullNewNameThrows() {
|
||||
RenameAttachmentRequest request = new RenameAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachmentName("old.txt");
|
||||
request.setNewName(null);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> controller.renameAttachment(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteAttachment")
|
||||
class Delete {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes and returns the updated PDF")
|
||||
void deletes() throws Exception {
|
||||
DeleteAttachmentRequest request = new DeleteAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachmentName("file.txt");
|
||||
|
||||
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.deleteAttachment(mockDocument, "file.txt"))
|
||||
.thenReturn(mockDocument);
|
||||
|
||||
ResponseEntity<Resource> expected =
|
||||
ResponseEntity.ok(new ByteArrayResource("pdf".getBytes()));
|
||||
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfDocToWebResponse(
|
||||
any(PDDocument.class),
|
||||
anyString(),
|
||||
any(TempFileManager.class)))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<Resource> response = controller.deleteAttachment(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(pdfAttachmentService).deleteAttachment(mockDocument, "file.txt");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a null attachment name")
|
||||
void nullAttachmentNameThrows() {
|
||||
DeleteAttachmentRequest request = new DeleteAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachmentName(null);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> controller.deleteAttachment(request));
|
||||
verifyNoInteractions(pdfAttachmentService);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("addAttachments validation")
|
||||
class AddValidation {
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a null attachment list")
|
||||
void nullAttachmentsThrows() {
|
||||
stirling.software.SPDF.model.api.misc.AddAttachmentRequest request =
|
||||
new stirling.software.SPDF.model.api.misc.AddAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachments(null);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an empty attachment list")
|
||||
void emptyAttachmentsThrows() {
|
||||
stirling.software.SPDF.model.api.misc.AddAttachmentRequest request =
|
||||
new stirling.software.SPDF.model.api.misc.AddAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachments(List.of());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an empty attachment entry")
|
||||
void emptyAttachmentEntryThrows() {
|
||||
MultipartFile empty =
|
||||
new MockMultipartFile("attachment", "empty.txt", "text/plain", new byte[0]);
|
||||
stirling.software.SPDF.model.api.misc.AddAttachmentRequest request =
|
||||
new stirling.software.SPDF.model.api.misc.AddAttachmentRequest();
|
||||
request.setFileInput(pdf());
|
||||
request.setAttachments(List.of(empty));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request));
|
||||
verifyNoInteractions(pdfAttachmentService);
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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 org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Additional branch coverage for {@link AutoSplitPdfController}: the actual splitting that happens
|
||||
* when a recognised QR divider is embedded mid-document, plus duplex-mode skipping of the divider
|
||||
* back page. QR images are generated with zxing; no rendering of external resources is required.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("AutoSplitPdfController additional branch tests")
|
||||
class AutoSplitPdfControllerMoreTest {
|
||||
|
||||
private static final String VALID_QR = "https://stirlingpdf.com";
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AutoSplitPdfController controller;
|
||||
|
||||
@TempDir java.nio.file.Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getSystem().setMaxDPI(150);
|
||||
controller =
|
||||
new AutoSplitPdfController(
|
||||
pdfDocumentFactory, tempFileManager, applicationProperties);
|
||||
when(tempFileManager.createTempFile(".zip"))
|
||||
.thenAnswer(inv -> Files.createTempFile(tempDir, "split", ".zip").toFile());
|
||||
when(pdfDocumentFactory.load(any(InputStream.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(inv.<InputStream>getArgument(0).readAllBytes()));
|
||||
}
|
||||
|
||||
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 w = matrix.getWidth();
|
||||
int h = matrix.getHeight();
|
||||
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
for (int x = 0; x < w; x++) {
|
||||
for (int y = 0; y < h; y++) {
|
||||
image.setRGB(x, y, matrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB());
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private static void addPlainPage(PDDocument doc) {
|
||||
doc.addPage(new PDPage(new PDRectangle(300, 300)));
|
||||
}
|
||||
|
||||
private static void addQrDividerPage(PDDocument doc, BufferedImage qr) throws Exception {
|
||||
PDPage page = new PDPage(new PDRectangle(300, 300));
|
||||
doc.addPage(page);
|
||||
PDImageXObject xobj = LosslessFactory.createFromImage(doc, qr);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(xobj, 25, 25, 250, 250);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] docToBytes(PDDocument doc) throws Exception {
|
||||
try (doc) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static AutoSplitPdfRequest request(byte[] bytes, Boolean duplex) {
|
||||
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
|
||||
req.setFileInput(new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", bytes));
|
||||
req.setDuplexMode(duplex);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static List<String> zipEntries(Resource res) throws Exception {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (InputStream in = res.getInputStream();
|
||||
ZipInputStream zis = new ZipInputStream(in)) {
|
||||
ZipEntry e;
|
||||
while ((e = zis.getNextEntry()) != null) {
|
||||
names.add(e.getName());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Splitting on a QR divider")
|
||||
class QrDividerSplit {
|
||||
|
||||
@Test
|
||||
@DisplayName("a mid-document QR divider produces two output PDFs")
|
||||
void dividerProducesTwoDocs() throws Exception {
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
PDDocument doc = new PDDocument();
|
||||
addPlainPage(doc); // content section 1
|
||||
addQrDividerPage(doc, qr); // divider -> starts section 2
|
||||
addPlainPage(doc); // content section 2
|
||||
byte[] bytes = docToBytes(doc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(bytes, false));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<String> names = zipEntries(response.getBody());
|
||||
// First doc = page 1; second doc = divider page + page 3.
|
||||
assertThat(names).hasSize(2);
|
||||
assertThat(names).containsExactly("doc_1.pdf", "doc_2.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("duplex mode drops the page following the divider")
|
||||
void duplexDropsBackPage() throws Exception {
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
PDDocument doc = new PDDocument();
|
||||
addPlainPage(doc); // section 1
|
||||
addQrDividerPage(doc, qr); // divider
|
||||
addPlainPage(doc); // back of divider -> skipped in duplex
|
||||
addPlainPage(doc); // section 2 content
|
||||
byte[] bytes = docToBytes(doc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(bytes, true));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
List<String> names = zipEntries(response.getBody());
|
||||
assertThat(names).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("output PDFs from a divider split are individually loadable")
|
||||
void outputsAreLoadable() throws Exception {
|
||||
BufferedImage qr = qrImage(VALID_QR, 250);
|
||||
PDDocument doc = new PDDocument();
|
||||
addPlainPage(doc);
|
||||
addQrDividerPage(doc, qr);
|
||||
addPlainPage(doc);
|
||||
byte[] bytes = docToBytes(doc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.autoSplitPdf(request(bytes, false));
|
||||
|
||||
int total = 0;
|
||||
try (InputStream in = response.getBody().getInputStream();
|
||||
ZipInputStream zis = new ZipInputStream(in)) {
|
||||
ZipEntry e;
|
||||
while ((e = zis.getNextEntry()) != null) {
|
||||
byte[] entry = zis.readAllBytes();
|
||||
try (PDDocument loaded = Loader.loadPDF(entry)) {
|
||||
total += loaded.getNumberOfPages();
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
// 3 source pages: the QR divider page itself is consumed as a boundary, leaving
|
||||
// page 1 in the first doc and page 3 in the second (2 pages total).
|
||||
assertThat(total).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.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.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.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.RemoveBlankPagesRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Endpoint-level coverage for {@link BlankPageController#removeBlankPages} using real PDFs so the
|
||||
* text/image blank detection and zip-assembly logic actually run.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("BlankPageController removeBlankPages")
|
||||
class BlankPageControllerMoreTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private BlankPageController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
controller = new BlankPageController(pdfDocumentFactory, tempFileManager);
|
||||
|
||||
when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File f =
|
||||
Files.createTempFile("blank_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;
|
||||
});
|
||||
|
||||
// load(MultipartFile) hands back a freshly loaded document from the upload bytes.
|
||||
lenient()
|
||||
.when(
|
||||
pdfDocumentFactory.load(
|
||||
any(org.springframework.web.multipart.MultipartFile.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
org.springframework.web.multipart.MultipartFile mf = inv.getArgument(0);
|
||||
return Loader.loadPDF(mf.getBytes());
|
||||
});
|
||||
|
||||
// createNewDocument() returns a real, empty document the controller fills + saves.
|
||||
lenient().when(pdfDocumentFactory.createNewDocument()).thenAnswer(inv -> new PDDocument());
|
||||
}
|
||||
|
||||
private static byte[] textPage(String text) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
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), 14);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText(text);
|
||||
cs.endText();
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] emptyPages(int count) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
// Empty Resources so image detection has a dict to scan instead of NPE-ing.
|
||||
page.setResources(new PDResources());
|
||||
doc.addPage(page);
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** A document with one text page and one truly blank page. */
|
||||
private static byte[] mixedTextAndBlank() throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
PDPage textPage = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(textPage);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, textPage)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText("Has content");
|
||||
cs.endText();
|
||||
}
|
||||
PDPage blankPage = new PDPage(PDRectangle.A4);
|
||||
// Empty Resources so image detection has a dict to scan instead of NPE-ing.
|
||||
blankPage.setResources(new PDResources());
|
||||
doc.addPage(blankPage);
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** A page that carries a black image so the image-based blank detection branch executes. */
|
||||
private static byte[] imagePage(Color color) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
java.awt.image.BufferedImage img =
|
||||
new java.awt.image.BufferedImage(
|
||||
120, 120, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
java.awt.Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, 120, 120);
|
||||
g.dispose();
|
||||
PDImageXObject xobj = LosslessFactory.createFromImage(doc, img);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(xobj, 100, 100, 200, 200);
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static RemoveBlankPagesRequest request(byte[] pdf, int threshold, float whitePercent) {
|
||||
RemoveBlankPagesRequest req = new RemoveBlankPagesRequest();
|
||||
req.setFileInput(new MockMultipartFile("fileInput", "in.pdf", "application/pdf", pdf));
|
||||
req.setThreshold(threshold);
|
||||
req.setWhitePercent(whitePercent);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static List<String> zipNames(Resource resource) throws Exception {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(resource.getContentAsByteArray()))) {
|
||||
ZipEntry e;
|
||||
while ((e = zis.getNextEntry()) != null) {
|
||||
names.add(e.getName());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("happy paths")
|
||||
class HappyPaths {
|
||||
|
||||
@Test
|
||||
@DisplayName("all-text document keeps every page in the non-blank PDF only")
|
||||
void allTextPages() throws Exception {
|
||||
ResponseEntity<Resource> response =
|
||||
controller.removeBlankPages(request(textPage("Hello"), 10, 99.9f));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
List<String> names = zipNames(response.getBody());
|
||||
// Only non-blank pages exist, so only the non-blank entry is written.
|
||||
assertEquals(1, names.size());
|
||||
assertTrue(names.get(0).endsWith("_nonBlankPages.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("document with only empty pages produces the all-blank entry")
|
||||
void onlyBlankPages() throws Exception {
|
||||
ResponseEntity<Resource> response =
|
||||
controller.removeBlankPages(request(emptyPages(2), 10, 99.9f));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<String> names = zipNames(response.getBody());
|
||||
assertEquals(1, names.size());
|
||||
assertTrue(names.get(0).endsWith("_allBlankPages.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mixed text and blank pages yields both non-blank and blank entries")
|
||||
void mixedPages() throws Exception {
|
||||
ResponseEntity<Resource> response =
|
||||
controller.removeBlankPages(request(mixedTextAndBlank(), 10, 99.9f));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<String> names = zipNames(response.getBody());
|
||||
assertEquals(2, names.size());
|
||||
assertTrue(names.stream().anyMatch(n -> n.endsWith("_nonBlankPages.pdf")));
|
||||
assertTrue(names.stream().anyMatch(n -> n.endsWith("_blankPages.pdf")));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("image-based blank detection")
|
||||
class ImageDetection {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with a black image is treated as non-blank")
|
||||
void blackImageIsNonBlank() throws Exception {
|
||||
// Black image -> not enough white -> non-blank branch.
|
||||
ResponseEntity<Resource> response =
|
||||
controller.removeBlankPages(request(imagePage(Color.BLACK), 10, 99.9f));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<String> names = zipNames(response.getBody());
|
||||
assertTrue(names.get(0).endsWith("_nonBlankPages.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("page with a white image counts as blank")
|
||||
void whiteImageIsBlank() throws Exception {
|
||||
// White image with a high white-percent threshold -> blank branch.
|
||||
ResponseEntity<Resource> response =
|
||||
controller.removeBlankPages(request(imagePage(Color.WHITE), 10, 50.0f));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<String> names = zipNames(response.getBody());
|
||||
assertEquals(1, names.size());
|
||||
assertTrue(names.get(0).endsWith("_allBlankPages.pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses configured maxDPI when application properties bean is present")
|
||||
void usesConfiguredMaxDpi() throws Exception {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().setMaxDPI(40);
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
|
||||
mocked.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
|
||||
.thenReturn(props);
|
||||
|
||||
ResponseEntity<Resource> response =
|
||||
controller.removeBlankPages(request(imagePage(Color.BLACK), 10, 99.9f));
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("error handling")
|
||||
class Errors {
|
||||
|
||||
@Test
|
||||
@DisplayName("loader IOException is caught and returned as a 500 response")
|
||||
void corruptPdfReturnsServerError() throws Exception {
|
||||
RemoveBlankPagesRequest req = request("garbage".getBytes(), 10, 99.9f);
|
||||
when(pdfDocumentFactory.load(
|
||||
any(org.springframework.web.multipart.MultipartFile.class)))
|
||||
.thenThrow(new IOException("bad pdf"));
|
||||
|
||||
// The controller swallows IOException from the loader and maps it to HTTP 500.
|
||||
ResponseEntity<Resource> response = controller.removeBlankPages(req);
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createZipEntry writes a loadable PDF with the supplied pages")
|
||||
void createZipEntryWritesPages() throws Exception {
|
||||
try (PDDocument src = new PDDocument()) {
|
||||
src.addPage(new PDPage(PDRectangle.A4));
|
||||
src.addPage(new PDPage(PDRectangle.A4));
|
||||
List<PDPage> pages = new ArrayList<>();
|
||||
src.getPages().forEach(pages::add);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
|
||||
controller.createZipEntry(zos, pages, "entry.pdf");
|
||||
}
|
||||
|
||||
try (ZipInputStream zis =
|
||||
new ZipInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertEquals("entry.pdf", entry.getName());
|
||||
try (PDDocument loaded = Loader.loadPDF(zis.readAllBytes())) {
|
||||
assertEquals(2, loaded.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+758
@@ -0,0 +1,758 @@
|
||||
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.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
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.concurrent.atomic.AtomicInteger;
|
||||
|
||||
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.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.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.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
/**
|
||||
* Additional unit tests for {@link CompressController} covering the Ghostscript / qpdf
|
||||
* orchestration paths that the base test deliberately skips. Real external binaries are never
|
||||
* launched: the static {@link ProcessExecutor} instance map is patched via reflection with mocks
|
||||
* that fake a successful run and write a small valid PDF to the expected output file.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CompressControllerMoreTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@Mock private ProcessExecutor ghostscriptExecutor;
|
||||
@Mock private ProcessExecutor qpdfExecutor;
|
||||
|
||||
@InjectMocks private CompressController controller;
|
||||
|
||||
/** Real temp files created during a test; cleaned up after each test. */
|
||||
private final List<File> createdFiles = new ArrayList<>();
|
||||
|
||||
/** Previous occupants of the static instances map, restored in tearDown. */
|
||||
private ProcessExecutor previousGhostscript;
|
||||
|
||||
private ProcessExecutor previousQpdf;
|
||||
private boolean hadGhostscript;
|
||||
private boolean hadQpdf;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Both external tool groups enabled so the orchestration paths run.
|
||||
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(true);
|
||||
|
||||
// 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-more-test",
|
||||
inv.<String>getArgument(0))
|
||||
.toFile();
|
||||
createdFiles.add(f);
|
||||
return newRealBackedTempFile(f);
|
||||
});
|
||||
|
||||
// The final reload and any image-compression reload return real PDFBox documents.
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(File.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(Path.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
|
||||
|
||||
installExecutorMocks();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
restoreExecutorMocks();
|
||||
for (File f : createdFiles) {
|
||||
try {
|
||||
Files.deleteIfExists(f.toPath());
|
||||
} catch (Exception ignored) {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
createdFiles.clear();
|
||||
}
|
||||
|
||||
// ----- static ProcessExecutor instance-map patching ----------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<ProcessExecutor.Processes, ProcessExecutor> executorInstances() throws Exception {
|
||||
Field field = ProcessExecutor.class.getDeclaredField("instances");
|
||||
field.setAccessible(true);
|
||||
return (Map<ProcessExecutor.Processes, ProcessExecutor>) field.get(null);
|
||||
}
|
||||
|
||||
private void installExecutorMocks() throws Exception {
|
||||
Map<ProcessExecutor.Processes, ProcessExecutor> instances = executorInstances();
|
||||
|
||||
hadGhostscript = instances.containsKey(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
previousGhostscript = instances.get(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
hadQpdf = instances.containsKey(ProcessExecutor.Processes.QPDF);
|
||||
previousQpdf = instances.get(ProcessExecutor.Processes.QPDF);
|
||||
|
||||
instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, ghostscriptExecutor);
|
||||
instances.put(ProcessExecutor.Processes.QPDF, qpdfExecutor);
|
||||
}
|
||||
|
||||
private void restoreExecutorMocks() throws Exception {
|
||||
Map<ProcessExecutor.Processes, ProcessExecutor> instances = executorInstances();
|
||||
if (hadGhostscript) {
|
||||
instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, previousGhostscript);
|
||||
} else {
|
||||
instances.remove(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
}
|
||||
if (hadQpdf) {
|
||||
instances.put(ProcessExecutor.Processes.QPDF, previousQpdf);
|
||||
} else {
|
||||
instances.remove(ProcessExecutor.Processes.QPDF);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ----- executor stubbing helpers ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a result mock with the given return code; assigned to a local before any thenReturn.
|
||||
*/
|
||||
private ProcessExecutorResult resultWithRc(int rc) {
|
||||
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
|
||||
lenient().when(result.getRc()).thenReturn(rc);
|
||||
lenient().when(result.getMessages()).thenReturn("");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Locate the -sOutputFile= path in a gs command.
|
||||
private static Path ghostscriptOutputPath(List<String> command) {
|
||||
for (String arg : command) {
|
||||
if (arg.startsWith("-sOutputFile=")) {
|
||||
return Path.of(arg.substring("-sOutputFile=".length()));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The qpdf output path is the last argument of the command.
|
||||
private static Path qpdfOutputPath(List<String> command) {
|
||||
return Path.of(command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
/** Stub gs to write a valid PDF to its output file and report success. */
|
||||
private void stubGhostscriptSuccess(byte[] pdfToWrite) throws Exception {
|
||||
ProcessExecutorResult okResult = resultWithRc(0);
|
||||
lenient()
|
||||
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path out = ghostscriptOutputPath(command);
|
||||
if (out != null) {
|
||||
Files.write(out, pdfToWrite);
|
||||
}
|
||||
return okResult;
|
||||
});
|
||||
}
|
||||
|
||||
/** Stub gs to report a non-zero, non-critical return code (output stays untouched). */
|
||||
private void stubGhostscriptNonZero() throws Exception {
|
||||
ProcessExecutorResult badResult = resultWithRc(1);
|
||||
lenient()
|
||||
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
|
||||
.thenReturn(badResult);
|
||||
}
|
||||
|
||||
/** Stub qpdf to write a valid PDF to its output file and report success. */
|
||||
private void stubQpdfSuccess(byte[] pdfToWrite) throws Exception {
|
||||
ProcessExecutorResult okResult = resultWithRc(0);
|
||||
lenient()
|
||||
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path out = qpdfOutputPath(command);
|
||||
if (out != null) {
|
||||
Files.write(out, pdfToWrite);
|
||||
}
|
||||
return okResult;
|
||||
});
|
||||
}
|
||||
|
||||
// ----- tiny in-memory PDF builders ---------------------------------------------------------
|
||||
|
||||
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 more");
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF with one large (>400px) image so the image-compression branch actually resizes it. */
|
||||
private byte[] largeImagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
// >400px so the resize branch fires; filled via Graphics2D (instant vs per-pixel).
|
||||
java.awt.image.BufferedImage img =
|
||||
new java.awt.image.BufferedImage(
|
||||
500, 500, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
java.awt.Graphics2D g = img.createGraphics();
|
||||
g.setColor(java.awt.Color.LIGHT_GRAY);
|
||||
g.fillRect(0, 0, 500, 500);
|
||||
g.setColor(java.awt.Color.DARK_GRAY);
|
||||
g.fillRect(60, 60, 380, 380);
|
||||
g.dispose();
|
||||
PDImageXObject image = LosslessFactory.createFromImage(doc, img);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.drawImage(image, 50, 50, 400, 400);
|
||||
}
|
||||
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 (java.io.InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Ghostscript orchestration
|
||||
// ===========================================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Ghostscript orchestration")
|
||||
class Ghostscript {
|
||||
|
||||
@Test
|
||||
@DisplayName("level 6 runs Ghostscript successfully and returns OK")
|
||||
void level6_ghostscriptSuccess_returnsOk() throws Exception {
|
||||
byte[] gsOut = textOnlyPdfBytes();
|
||||
stubGhostscriptSuccess(gsOut);
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(6);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each optimize level 6-9 with Ghostscript enabled returns OK")
|
||||
void levels6to9_ghostscriptSuccess_returnsOk() throws Exception {
|
||||
// Stub once: re-stubbing a mock inside the loop re-invokes the previous answer
|
||||
// with empty matcher args, tripping qpdfOutputPath's last-element lookup.
|
||||
stubGhostscriptSuccess(textOnlyPdfBytes());
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
for (int level = 6; level <= 9; level++) {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(level);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("levels 1-5 skip Ghostscript (never invoked) but still return OK")
|
||||
void lowLevels_ghostscriptNotInvoked_returnsOk() throws Exception {
|
||||
stubGhostscriptSuccess(textOnlyPdfBytes());
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
for (int level = 1; level <= 5; level++) {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(level);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
// gs only runs for levels >= 6.
|
||||
org.mockito.Mockito.verify(ghostscriptExecutor, org.mockito.Mockito.never())
|
||||
.runCommandWithOutputHandling(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grayscale flag at level 6 still drives Ghostscript and returns OK")
|
||||
void grayscale_level6_returnsOk() throws Exception {
|
||||
stubGhostscriptSuccess(textOnlyPdfBytes());
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(7);
|
||||
request.setGrayscale(true);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-zero Ghostscript exit is propagated as GhostscriptException")
|
||||
void ghostscriptNonZeroExit_propagates() throws Exception {
|
||||
// A non-zero gs return code is wrapped and rethrown (see optimizePdf's
|
||||
// catch (GhostscriptException) -> throw e), not swallowed.
|
||||
stubGhostscriptNonZero();
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(6);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("critical Ghostscript error is propagated as GhostscriptException")
|
||||
void ghostscriptCriticalError_propagates() throws Exception {
|
||||
// Output containing a recognized critical marker triggers
|
||||
// detectGhostscriptCriticalError.
|
||||
ProcessExecutorResult criticalResult = mock(ProcessExecutorResult.class);
|
||||
lenient().when(criticalResult.getRc()).thenReturn(0);
|
||||
lenient()
|
||||
.when(criticalResult.getMessages())
|
||||
.thenReturn("Page 1\nERROR: Could not draw this page");
|
||||
lenient()
|
||||
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
|
||||
.thenReturn(criticalResult);
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(8);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ghostscript IOException is wrapped and propagated as GhostscriptException")
|
||||
void ghostscriptIOException_propagates() throws Exception {
|
||||
// An IOException from the gs executor is wrapped via
|
||||
// createGhostscriptCompressionException and rethrown, not swallowed.
|
||||
lenient()
|
||||
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
|
||||
.thenThrow(new IOException("gs boom"));
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(6);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// QPDF orchestration
|
||||
// ===========================================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("QPDF orchestration")
|
||||
class Qpdf {
|
||||
|
||||
@Test
|
||||
@DisplayName("low level with only qpdf enabled recompresses and returns OK")
|
||||
void lowLevel_qpdfOnly_returnsOk() throws Exception {
|
||||
// Disable Ghostscript so qpdf is the only external tool that runs.
|
||||
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(2);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("linearize option drives qpdf --linearize and returns OK")
|
||||
void linearize_qpdf_returnsOk() throws Exception {
|
||||
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
|
||||
// Capture the qpdf command so we can assert --linearize is present.
|
||||
List<List<String>> captured = new ArrayList<>();
|
||||
ProcessExecutorResult okResult = resultWithRc(0);
|
||||
byte[] pdfToWrite = textOnlyPdfBytes();
|
||||
lenient()
|
||||
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
captured.add(command);
|
||||
Path out = qpdfOutputPath(command);
|
||||
Files.write(out, pdfToWrite);
|
||||
return okResult;
|
||||
});
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(3);
|
||||
request.setLinearize(true);
|
||||
request.setNormalize(true);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(captured).isNotEmpty();
|
||||
assertThat(captured.get(0)).contains("--linearize");
|
||||
assertThat(captured.get(0)).contains("--normalize-content=y");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("higher level enables qpdf --optimize-images and jpeg quality")
|
||||
void highLevel_qpdfOptimizeImages_returnsOk() throws Exception {
|
||||
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
|
||||
List<List<String>> captured = new ArrayList<>();
|
||||
ProcessExecutorResult okResult = resultWithRc(0);
|
||||
byte[] pdfToWrite = textOnlyPdfBytes();
|
||||
lenient()
|
||||
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
captured.add(command);
|
||||
Files.write(qpdfOutputPath(command), pdfToWrite);
|
||||
return okResult;
|
||||
});
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(5);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(captured.get(0)).contains("--optimize-images");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("qpdf IOException is swallowed; processing still returns OK")
|
||||
void qpdfIOException_swallowed_returnsOk() throws Exception {
|
||||
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
lenient()
|
||||
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
|
||||
.thenThrow(new IOException("qpdf boom"));
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(2);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ghostscript + qpdf both enabled at high level returns OK")
|
||||
void ghostscriptAndQpdf_highLevel_returnsOk() throws Exception {
|
||||
stubGhostscriptSuccess(textOnlyPdfBytes());
|
||||
stubQpdfSuccess(textOnlyPdfBytes());
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(9);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Target-size (auto) mode iterative loop
|
||||
// ===========================================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("target expected-size (auto) mode")
|
||||
class AutoMode {
|
||||
|
||||
@Test
|
||||
@DisplayName("auto mode reaches target on first pass when gs shrinks enough")
|
||||
void autoMode_targetMetFirstPass_returnsOk() throws Exception {
|
||||
// gs writes a tiny PDF so the very first size check meets a generous target.
|
||||
byte[] tiny = textOnlyPdfBytes();
|
||||
stubGhostscriptSuccess(tiny);
|
||||
stubQpdfSuccess(tiny);
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize("10MB"); // easily met
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto mode escalates the level when target is not met, then terminates")
|
||||
void autoMode_escalatesLevel_returnsOk() throws Exception {
|
||||
// gs always writes the same moderate PDF whose size stays just above the target,
|
||||
// forcing the loop to escalate optimizeLevel from a low start until it caps at 9.
|
||||
byte[] moderate = largeImagePdfBytes();
|
||||
// Target ~25% of input => start level 6 (gs eligible); gs success skips image
|
||||
// compression so size stays constant and the loop escalates 6 -> 9 (>=2 gs calls).
|
||||
long target = moderate.length / 4;
|
||||
final AtomicInteger gsCalls = new AtomicInteger();
|
||||
ProcessExecutorResult okResult = resultWithRc(0);
|
||||
lenient()
|
||||
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
gsCalls.incrementAndGet();
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path out = ghostscriptOutputPath(command);
|
||||
if (out != null) {
|
||||
Files.write(out, moderate);
|
||||
}
|
||||
return okResult;
|
||||
});
|
||||
stubQpdfSuccess(moderate);
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(moderate));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize(target + "B"); // never met => escalates through levels
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// Escalation reaches gs-eligible levels (>=6) more than once before bailing at max.
|
||||
assertThat(gsCalls.get()).isGreaterThan(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auto mode with qpdf only (low starting level) escalates and returns OK")
|
||||
void autoMode_qpdfOnly_returnsOk() throws Exception {
|
||||
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
|
||||
byte[] moderate = largeImagePdfBytes();
|
||||
stubQpdfSuccess(moderate);
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(moderate));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize("1KB");
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Image compression branches reached via the controller
|
||||
// ===========================================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("image compression branches")
|
||||
class ImageCompression {
|
||||
|
||||
@Test
|
||||
@DisplayName("level 4 with a large image and no gs (level<6) compresses the image")
|
||||
void level4_largeImage_compresses_returnsOk() throws Exception {
|
||||
// Disable both external tools so the Java image path is exercised end-to-end.
|
||||
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false);
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(4);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grayscale at low level compresses image via Java path when tools disabled")
|
||||
void grayscale_lowLevel_javaPath_returnsOk() throws Exception {
|
||||
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false);
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(largeImagePdfBytes()));
|
||||
request.setOptimizeLevel(2);
|
||||
request.setGrayscale(true);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("compressImagesInPDF scales a large image and grayscale converts it")
|
||||
void compressImagesInPDF_largeImage_grayscale() throws Exception {
|
||||
Path src = tempDir.resolve("large.pdf");
|
||||
Files.write(src, largeImagePdfBytes());
|
||||
|
||||
TempFile result = controller.compressImagesInPDF(src, 0.5, 0.6f, true);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
byte[] out = Files.readAllBytes(result.getPath());
|
||||
try (PDDocument doc = Loader.loadPDF(out)) {
|
||||
assertThat(doc.getNumberOfPages()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("compressImagesInPDF at high quality on a large image still produces a PDF")
|
||||
void compressImagesInPDF_largeImage_highQuality() throws Exception {
|
||||
Path src = tempDir.resolve("large2.pdf");
|
||||
Files.write(src, largeImagePdfBytes());
|
||||
|
||||
TempFile result = controller.compressImagesInPDF(src, 0.9, 0.95f, false);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(Files.readAllBytes(result.getPath())).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Result-size guard: optimized output never larger than original
|
||||
// ===========================================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("output size guard")
|
||||
class OutputSizeGuard {
|
||||
|
||||
@Test
|
||||
@DisplayName("when gs output is larger than original, the original is returned")
|
||||
void gsLargerThanOriginal_usesOriginal_returnsOk() throws Exception {
|
||||
byte[] original = textOnlyPdfBytes();
|
||||
// gs writes a deliberately bloated file so the >= inputFileSize guard trips.
|
||||
byte[] bloated = largeImagePdfBytes();
|
||||
stubGhostscriptSuccess(bloated);
|
||||
stubQpdfSuccess(bloated);
|
||||
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(original));
|
||||
request.setOptimizeLevel(6);
|
||||
|
||||
ResponseEntity<Resource> response = controller.optimizePdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(drain(response)).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Validation still holds with tools enabled
|
||||
// ===========================================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("validation with tools enabled")
|
||||
class Validation {
|
||||
|
||||
@Test
|
||||
@DisplayName("null file still throws IllegalArgumentException even with tools enabled")
|
||||
void nullFile_throws() {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no optimize options provided throws IllegalArgumentException")
|
||||
void noOptions_throws() throws Exception {
|
||||
OptimizePdfRequest request = new OptimizePdfRequest();
|
||||
request.setFileInput(multipart(textOnlyPdfBytes()));
|
||||
request.setOptimizeLevel(null);
|
||||
request.setExpectedOutputSize(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.optimizePdf(request))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
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.context.ApplicationContext;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.ExternalAppDepConfig;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.LicenseServiceInterface;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
/**
|
||||
* Exercises getAppConfig and the dynamic license/EE helpers, which the original
|
||||
* ConfigControllerTest does not cover. Uses a real ApplicationProperties so the full method body
|
||||
* runs.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
@DisplayName("ConfigController extra coverage")
|
||||
class ConfigControllerMoreTest {
|
||||
|
||||
@Mock private ApplicationContext applicationContext;
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private ServerCertificateServiceInterface serverCertificateService;
|
||||
@Mock private UserServiceInterface userService;
|
||||
@Mock private LicenseServiceInterface licenseService;
|
||||
@Mock private ExternalAppDepConfig externalAppDepConfig;
|
||||
@Mock private AppConfig appConfig;
|
||||
@Mock private Environment environment;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080");
|
||||
when(appConfig.getContextPath()).thenReturn("/");
|
||||
when(appConfig.getServerPort()).thenReturn("8080");
|
||||
when(applicationContext.getBean(AppConfig.class)).thenReturn(appConfig);
|
||||
lenient().when(applicationContext.getEnvironment()).thenReturn(environment);
|
||||
lenient().when(externalAppDepConfig.isDependenciesChecked()).thenReturn(true);
|
||||
}
|
||||
|
||||
private ConfigController newController() {
|
||||
return new ConfigController(
|
||||
applicationProperties,
|
||||
applicationContext,
|
||||
endpointConfiguration,
|
||||
serverCertificateService,
|
||||
userService,
|
||||
licenseService,
|
||||
externalAppDepConfig);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> bodyOf(ResponseEntity<Map<String, Object>> resp) {
|
||||
return resp.getBody();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getAppConfig")
|
||||
class GetAppConfig {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns wired config values with all services present")
|
||||
void returnsConfigWithServices() {
|
||||
when(licenseService.isRunningProOrHigher()).thenReturn(true);
|
||||
when(licenseService.isRunningEE()).thenReturn(false);
|
||||
when(licenseService.getLicenseTypeName()).thenReturn("ENTERPRISE");
|
||||
when(userService.isCurrentUserAdmin()).thenReturn(true);
|
||||
when(userService.isCurrentUserFirstLogin()).thenReturn(false);
|
||||
when(serverCertificateService.isEnabled()).thenReturn(true);
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
Map<String, Object> body = bodyOf(resp);
|
||||
assertThat(body).containsEntry("dependenciesReady", true);
|
||||
assertThat(body).containsEntry("baseUrl", "http://localhost:8080");
|
||||
assertThat(body).containsEntry("serverPort", "8080");
|
||||
assertThat(body).containsEntry("enableLogin", true);
|
||||
assertThat(body).containsEntry("isAdmin", true);
|
||||
assertThat(body).containsEntry("runningProOrHigher", true);
|
||||
assertThat(body).containsEntry("license", "ENTERPRISE");
|
||||
assertThat(body).containsEntry("serverCertificateEnabled", true);
|
||||
assertThat(body).containsKey("timestampTsaPresets");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("login disabled when userService is null (proprietary not loaded)")
|
||||
void loginDisabledWhenNoUserService() {
|
||||
userService = null;
|
||||
licenseService = null;
|
||||
serverCertificateService = null;
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
|
||||
|
||||
Map<String, Object> body = bodyOf(resp);
|
||||
assertThat(body).containsEntry("enableLogin", false);
|
||||
assertThat(body).containsEntry("isAdmin", false);
|
||||
assertThat(body).containsEntry("isNewUser", false);
|
||||
assertThat(body).containsEntry("serverCertificateEnabled", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to context beans when license service is null")
|
||||
void licenseFallsBackToContextBeans() {
|
||||
licenseService = null;
|
||||
when(applicationContext.containsBean("runningProOrHigher")).thenReturn(true);
|
||||
when(applicationContext.getBean("runningProOrHigher", Boolean.class)).thenReturn(true);
|
||||
when(applicationContext.containsBean("runningEE")).thenReturn(true);
|
||||
when(applicationContext.getBean("runningEE", Boolean.class)).thenReturn(true);
|
||||
when(applicationContext.containsBean("license")).thenReturn(true);
|
||||
when(applicationContext.getBean("license", String.class)).thenReturn("SERVER");
|
||||
when(applicationContext.containsBean("SSOAutoLogin")).thenReturn(true);
|
||||
when(applicationContext.getBean("SSOAutoLogin", Boolean.class)).thenReturn(true);
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
|
||||
|
||||
Map<String, Object> body = bodyOf(resp);
|
||||
assertThat(body).containsEntry("runningProOrHigher", true);
|
||||
assertThat(body).containsEntry("runningEE", true);
|
||||
assertThat(body).containsEntry("license", "SERVER");
|
||||
assertThat(body).containsEntry("SSOAutoLogin", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes Google Drive backend settings when enabled")
|
||||
void googleDriveEnabled() {
|
||||
ApplicationProperties.Premium.ProFeatures.GoogleDrive gd =
|
||||
applicationProperties.getPremium().getProFeatures().getGoogleDrive();
|
||||
gd.setEnabled(true);
|
||||
gd.setClientId("cid");
|
||||
gd.setApiKey("key");
|
||||
gd.setAppId("aid");
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
|
||||
|
||||
Map<String, Object> body = bodyOf(resp);
|
||||
assertThat(body).containsEntry("googleDriveEnabled", true);
|
||||
assertThat(body).containsEntry("googleDriveClientId", "cid");
|
||||
assertThat(body).containsEntry("googleDriveApiKey", "key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includes version/machine info beans when available")
|
||||
void versionAndMachineBeans() {
|
||||
when(applicationContext.containsBean("appVersion")).thenReturn(true);
|
||||
when(applicationContext.getBean("appVersion", String.class)).thenReturn("9.9.9");
|
||||
when(applicationContext.containsBean("machineType")).thenReturn(true);
|
||||
when(applicationContext.getBean("machineType", String.class)).thenReturn("Docker");
|
||||
when(applicationContext.containsBean("activeSecurity")).thenReturn(true);
|
||||
when(applicationContext.getBean("activeSecurity", Boolean.class)).thenReturn(true);
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
|
||||
|
||||
Map<String, Object> body = bodyOf(resp);
|
||||
assertThat(body).containsEntry("appVersion", "9.9.9");
|
||||
assertThat(body).containsEntry("machineType", "Docker");
|
||||
assertThat(body).containsEntry("activeSecurity", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isCurrentUserAdmin exception leaves isAdmin false")
|
||||
void adminCheckExceptionSwallowed() {
|
||||
when(userService.isCurrentUserAdmin()).thenThrow(new RuntimeException("boom"));
|
||||
when(userService.isCurrentUserFirstLogin()).thenThrow(new RuntimeException("boom"));
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
|
||||
|
||||
Map<String, Object> body = bodyOf(resp);
|
||||
assertThat(body).containsEntry("isAdmin", false);
|
||||
assertThat(body).containsEntry("isNewUser", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns basic config with error key when AppConfig bean lookup fails")
|
||||
void returnsErrorConfigOnException() {
|
||||
when(applicationContext.getBean(AppConfig.class))
|
||||
.thenThrow(new RuntimeException("no bean"));
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
Map<String, Object> body = bodyOf(resp);
|
||||
assertThat(body).containsEntry("error", "Unable to retrieve full configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user