From 06c6bec7ce590dbfc54ec7bc2a6a3e23b6d97e6b Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Wed, 12 Aug 2026 10:41:42 +0200 Subject: [PATCH] CI: add engine coverage & linting/style fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AI engine coverage reporting to CI (run tests with coverage, show in step summary, and upload artifact). Relax and align engine pre-commit package specifiers and update uv.lock. Increase ruff line-length and add per-file ignores; update Taskfile and pre-commit Taskfile to use engine ruff config. Add coverage entries to engine/.gitignore. Apply numerous non-functional Python style and formatting fixes across scripts and cucumber test steps (imports, f-strings, line breaks, noqa markers, single-line asserts) to satisfy linters—no behavioral changes intended. --- .github/scripts/check_language_toml.py | 14 +- .github/workflows/ai-engine.yml | 26 +++ .taskfiles/engine.yml | 14 ++ .taskfiles/pre-commit.yml | 11 +- .../resources/static/python/png_to_webp.py | 6 +- .../resources/static/python/split_photos.py | 68 ++++-- engine/.gitignore | 4 + engine/pyproject.toml | 14 +- engine/scripts/check_coverage.py | 35 +++ engine/uv.lock | 46 +++- scripts/translations/ai_translation_helper.py | 2 +- scripts/translations/auto_translate.py | 10 +- scripts/translations/batch_translator.py | 2 +- scripts/translations/translation_analyzer.py | 7 +- scripts/translations/translation_merger.py | 4 +- .../features/enterprise/steps/steps.py | 6 +- testing/cucumber/features/environment.py | 37 +++- .../features/steps/auth_step_definitions.py | 72 ++----- .../steps/enterprise_step_definitions.py | 37 +--- .../steps/folders_step_definitions.py | 74 ++----- .../steps/multinode_step_definitions.py | 203 +++++++++++------- .../features/steps/step_definitions.py | 146 +++++-------- .../steps/webhook_step_definitions.py | 2 +- testing/ledger/generate_test_pdfs.py | 70 +++--- 24 files changed, 498 insertions(+), 412 deletions(-) create mode 100644 engine/scripts/check_coverage.py diff --git a/.github/scripts/check_language_toml.py b/.github/scripts/check_language_toml.py index ecd1c648cd..14dced98a5 100644 --- a/.github/scripts/check_language_toml.py +++ b/.github/scripts/check_language_toml.py @@ -10,10 +10,10 @@ adjusting the format. Usage: python check_language_toml.py --reference-file --branch [--actor ] [--files ] -""" -# Sample for Windows: -# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml +Sample for Windows: + python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml +""" # noqa: E501 import argparse import glob @@ -223,11 +223,11 @@ def check_for_differences(reference_file, file_list, branch, actor): has_differences = True if reference_key_count > current_key_count: report.append( - f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing." + f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing." # noqa: E501 ) elif reference_key_count < current_key_count: report.append( - f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed." + f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed." # noqa: E501 ) else: report.append("1. **Test Status:** ✅ **_Passed_**") @@ -248,7 +248,7 @@ def check_for_differences(reference_file, file_list, branch, actor): report.append(" - **Issue:**") if missing_keys_list: report.append( - f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**." + f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501 ) report.append("") report.append(" Use the following command to remove them:") @@ -256,7 +256,7 @@ def check_for_differences(reference_file, file_list, branch, actor): report.append("") if extra_keys_list: report.append( - f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**." + f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501 ) report.append("") report.append(" Use the following command to add them:") diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 056dc35ca3..9e557abb3c 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -83,6 +83,32 @@ jobs: }); } + - name: Run engine tests with coverage + id: engine-coverage + if: always() + run: task engine:test:coverage + + - name: Add engine coverage to step summary + if: always() && steps.engine-coverage.outcome == 'success' + working-directory: engine + run: | + { + echo '## AI Engine coverage' + echo + echo '```text' + uv run coverage report --show-missing + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload engine coverage report + if: always() && steps.engine-coverage.outcome == 'success' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ai-engine-coverage + path: engine/coverage/ + retention-days: 7 + if-no-files-found: warn + - name: Fail if engine check failed if: steps.engine-check.outcome == 'failure' run: | diff --git a/.taskfiles/engine.yml b/.taskfiles/engine.yml index 12d76b08a7..24aade0460 100644 --- a/.taskfiles/engine.yml +++ b/.taskfiles/engine.yml @@ -110,6 +110,20 @@ tasks: cmds: - uv run --locked --group engine --group engine-dev pytest tests + test:coverage: + desc: "Run tests with coverage reporting" + deps: [prepare] + cmds: + - >- + uv run --locked --group engine --group engine-dev pytest tests + --cov=src/stirling + --cov-fail-under=80 + --cov-report=term-missing + --cov-report=xml:coverage/coverage.xml + --cov-report=html:coverage/html + --cov-report=json:coverage/coverage.json + - uv run python scripts/check_coverage.py coverage/coverage.json --minimum 80 + fix: desc: "Auto-fix lint + format" cmds: diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 444115010f..a0f4281aef 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -7,10 +7,10 @@ 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' + 'scripts/**/*.py' '.github/scripts/*.py' 'app/core/src/main/resources/static/python/*.py' - ':(exclude)*split_photos.py' + 'testing/**/*.py' SPELL_FILES: >- '*.html' '*.css' @@ -80,7 +80,7 @@ tasks: desc: "Install the pinned pre-commit Python tools" run: once cmds: - - uv sync --project engine --locked --group pre-commit + - uv sync --locked --project engine --group pre-commit sources: - engine/uv.lock - engine/pyproject.toml @@ -101,12 +101,13 @@ tasks: ruff: deps: [install] cmds: - - uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=120 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}}) + # - uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}}) + - uv run --project engine --locked --group pre-commit ruff check --config engine/pyproject.toml {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}}) ruff-format: deps: [install] cmds: - - uv run --project engine --locked --group pre-commit ruff format --isolated --line-length=120 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}}) + - uv run --project engine --locked --group pre-commit ruff format --config engine/pyproject.toml {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}}) codespell: deps: [install] diff --git a/app/core/src/main/resources/static/python/png_to_webp.py b/app/core/src/main/resources/static/python/png_to_webp.py index 094b1b5033..558f2770ea 100644 --- a/app/core/src/main/resources/static/python/png_to_webp.py +++ b/app/core/src/main/resources/static/python/png_to_webp.py @@ -12,7 +12,7 @@ To convert a PDF file to a single WebP image: To adjust the DPI resolution for rendering PDF pages: python script.py input.pdf output_directory --dpi 150 -""" +""" # noqa: E501 import argparse import os @@ -54,9 +54,7 @@ def resize_image(input_image_path, output_image_path, max_size=(16383, 16383)): # Resize the image resized_image = image.resize((new_width, new_height), Image.LANCZOS) resized_image.save(output_image_path, format="WEBP", quality=100) - print( - f"The image was successfully resized to ({new_width}, {new_height}) and saved as WebP: {output_image_path}" - ) + print(f"The image was successfully resized to ({new_width}, {new_height}) and saved as WebP: {output_image_path}") else: # If dimensions are within the allowed limits, save the image directly image.save(output_image_path, format="WEBP", quality=100) diff --git a/app/core/src/main/resources/static/python/split_photos.py b/app/core/src/main/resources/static/python/split_photos.py index 510ca84092..83f87c8b09 100644 --- a/app/core/src/main/resources/static/python/split_photos.py +++ b/app/core/src/main/resources/static/python/split_photos.py @@ -1,13 +1,14 @@ import argparse -import sys +import os + import cv2 import numpy as np -import os + def find_photo_boundaries(image, background_color, tolerance=30, min_area=10000, min_contour_area=500): mask = cv2.inRange(image, background_color - tolerance, background_color + tolerance) mask = cv2.bitwise_not(mask) - kernel = np.ones((5,5),np.uint8) + kernel = np.ones((5, 5), np.uint8) mask = cv2.dilate(mask, kernel, iterations=2) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) @@ -21,6 +22,7 @@ def find_photo_boundaries(image, background_color, tolerance=30, min_area=10000, return photo_boundaries + def estimate_background_color(image, sample_points=5): h, w, _ = image.shape points = [ @@ -37,6 +39,7 @@ def estimate_background_color(image, sample_points=5): return np.median(colors, axis=0) + def auto_rotate(image, angle_threshold=1): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150, apertureSize=3) @@ -61,8 +64,6 @@ def auto_rotate(image, angle_threshold=1): return cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) - - def crop_borders(image, border_color, tolerance=30): mask = cv2.inRange(image, border_color - tolerance, border_color + tolerance) @@ -73,14 +74,19 @@ def crop_borders(image, border_color, tolerance=30): largest_contour = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(largest_contour) - return image[y:y+h, x:x+w] + return image[y : y + h, x : x + w] -def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min_contour_area=500, angle_threshold=10, border_size=0): + +def split_photos( + input_file, output_directory, tolerance=30, min_area=10000, min_contour_area=500, angle_threshold=10, border_size=0 +): image = cv2.imread(input_file) background_color = estimate_background_color(image) # Add a constant border around the image - image = cv2.copyMakeBorder(image, border_size, border_size, border_size, border_size, cv2.BORDER_CONSTANT, value=background_color) + image = cv2.copyMakeBorder( + image, border_size, border_size, border_size, border_size, cv2.BORDER_CONSTANT, value=background_color + ) photo_boundaries = find_photo_boundaries(image, background_color, tolerance) @@ -91,7 +97,7 @@ def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min input_file_basename = os.path.splitext(os.path.basename(input_file))[0] for idx, (x, y, w, h) in enumerate(photo_boundaries): - cropped_image = image[y:y+h, x:x+w] + cropped_image = image[y : y + h, x : x + w] cropped_image = auto_rotate(cropped_image, angle_threshold) # Remove the added border, but ensure we don't create an empty image @@ -100,23 +106,51 @@ def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min # Check if the cropped image is valid before saving if cropped_image.size == 0 or cropped_image.shape[0] == 0 or cropped_image.shape[1] == 0: - print(f"Warning: Skipping empty image for region {idx+1}") + print(f"Warning: Skipping empty image for region {idx + 1}") continue - output_path = os.path.join(output_directory, f"{input_file_basename}_{idx+1}.png") + output_path = os.path.join(output_directory, f"{input_file_basename}_{idx + 1}.png") cv2.imwrite(output_path, cropped_image) print(f"Saved {output_path}") + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Split photos in an image") parser.add_argument("input_file", help="The input scanned image containing multiple photos.") parser.add_argument("output_directory", help="The directory where the result images should be placed.") - parser.add_argument("--tolerance", type=int, default=30, help="Determines the range of color variation around the estimated background color (default: 30).") - parser.add_argument("--min_area", type=int, default=10000, help="Sets the minimum area threshold for a photo (default: 10000).") - parser.add_argument("--min_contour_area", type=int, default=500, help="Sets the minimum contour area threshold for a photo (default: 500).") - parser.add_argument("--angle_threshold", type=int, default=10, help="Sets the minimum absolute angle required for the image to be rotated (default: 10).") - parser.add_argument("--border_size", type=int, default=0, help="Sets the size of the border added and removed to prevent white borders in the output (default: 0).") + parser.add_argument( + "--tolerance", + type=int, + default=30, + help="Determines the range of color variation around the estimated background color (default: 30).", + ) + parser.add_argument( + "--min_area", type=int, default=10000, help="Sets the minimum area threshold for a photo (default: 10000)." + ) + parser.add_argument( + "--min_contour_area", type=int, default=500, help="Sets the minimum contour area threshold for a photo (default: 500)." + ) + parser.add_argument( + "--angle_threshold", + type=int, + default=10, + help="Sets the minimum absolute angle required for the image to be rotated (default: 10).", + ) + parser.add_argument( + "--border_size", + type=int, + default=0, + help="Sets the size of the border added and removed to prevent white borders in the output (default: 0).", + ) args = parser.parse_args() - split_photos(args.input_file, args.output_directory, tolerance=args.tolerance, min_area=args.min_area, min_contour_area=args.min_contour_area, angle_threshold=args.angle_threshold, border_size=args.border_size) + split_photos( + args.input_file, + args.output_directory, + tolerance=args.tolerance, + min_area=args.min_area, + min_contour_area=args.min_contour_area, + angle_threshold=args.angle_threshold, + border_size=args.border_size, + ) diff --git a/engine/.gitignore b/engine/.gitignore index 0602cd44db..76a0178f76 100644 --- a/engine/.gitignore +++ b/engine/.gitignore @@ -46,3 +46,7 @@ logs/ # OS .DS_Store Thumbs.db + +# Coverage +.coverage +coverage/ diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 3f2c277fbb..ec2ad4d8a6 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -29,6 +29,7 @@ engine-dev = [ "datamodel-code-generator[ruff]==0.64.0", "pyright>=1.1.411", "pytest>=9.1.1", + "pytest-cov>=7.0.0", "referencing>=0.37.0", "ruff==0.15.5", ] @@ -64,9 +65,9 @@ updater-signatures = [ ] # Pinned repository-wide pre-commit tooling. pre-commit = [ - "codespell==2.4.2", - "ruff==0.15.5", - "tomli-w==1.2.0", + "codespell>=2.4.2", + "ruff>=0.15.5", + "tomli-w>=1.2.0", ] [build-system] @@ -81,7 +82,7 @@ exclude = ["tests"] default-groups = [] [tool.ruff] -line-length = 120 +line-length = 127 target-version = "py313" [tool.ruff.lint] @@ -95,9 +96,12 @@ select = [ "UP", "PYI", # flake8-pyi: flags deprecated typing constructs "FA", # flake8-future-annotations: flags missing future annotations imports - "BLE", # flake8-blind-except: flags bare `except Exception` ] +[tool.ruff.lint.per-file-ignores] +"testing/**/*.py" = ["N803", "BLE001", "E501"] +"*split_photos.py" = ["E501", "N806"] + [tool.ruff.lint.isort] known-first-party = ["stirling", "tests"] diff --git a/engine/scripts/check_coverage.py b/engine/scripts/check_coverage.py new file mode 100644 index 0000000000..e7d3273748 --- /dev/null +++ b/engine/scripts/check_coverage.py @@ -0,0 +1,35 @@ +"""Fail when any measured source file falls below the required coverage.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("report", type=Path) + parser.add_argument("--minimum", type=float, default=90.0) + args = parser.parse_args() + + data = json.loads(args.report.read_text(encoding="utf-8")) + failures: list[tuple[str, float]] = [] + for filename, details in data["files"].items(): + coverage = float(details["summary"]["percent_covered"]) + if coverage < args.minimum: + failures.append((filename, coverage)) + + if failures: + print(f"Per-file coverage below {args.minimum:.1f}%:", file=sys.stderr) + for filename, coverage in sorted(failures): + print(f" {coverage:.1f}% {filename}", file=sys.stderr) + return 1 + + print(f"Per-file coverage: every file is at least {args.minimum:.1f}%.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/engine/uv.lock b/engine/uv.lock index 0b25716add..870f199151 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -449,6 +449,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + [[package]] name = "cryptography" version = "50.0.0" @@ -655,6 +679,7 @@ engine-dev = [ { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "referencing" }, { name = "ruff" }, ] @@ -714,13 +739,14 @@ engine-dev = [ { name = "datamodel-code-generator", extras = ["ruff"], specifier = "==0.64.0" }, { name = "pyright", specifier = ">=1.1.411" }, { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "referencing", specifier = ">=0.37.0" }, { name = "ruff", specifier = "==0.15.5" }, ] pre-commit = [ - { name = "codespell", specifier = "==2.4.2" }, - { name = "ruff", specifier = "==0.15.5" }, - { name = "tomli-w", specifier = "==1.2.0" }, + { name = "codespell", specifier = ">=2.4.2" }, + { name = "ruff", specifier = ">=0.15.5" }, + { name = "tomli-w", specifier = ">=1.2.0" }, ] tools = [ { name = "deep-translator", specifier = ">=1.11.4" }, @@ -2549,6 +2575,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" diff --git a/scripts/translations/ai_translation_helper.py b/scripts/translations/ai_translation_helper.py index 03b606f1d9..6e518c9c08 100644 --- a/scripts/translations/ai_translation_helper.py +++ b/scripts/translations/ai_translation_helper.py @@ -52,7 +52,7 @@ class AITranslationHelper: "target_languages": languages, "max_entries_per_language": max_entries_per_language, "instructions": { - "format": "Translate each entry maintaining JSON structure and placeholder variables like {n}, {total}, {filename}", + "format": "Translate each entry maintaining JSON structure and placeholder variables like {n}, {total}, {filename}", # noqa: E501 "context": "This is for a PDF manipulation tool. Keep technical terms consistent.", "placeholders": "Preserve all placeholders: {n}, {total}, {filename}, etc.", "style": "Keep translations concise and user-friendly", diff --git a/scripts/translations/auto_translate.py b/scripts/translations/auto_translate.py index cabfb9269e..fe770ce4c1 100644 --- a/scripts/translations/auto_translate.py +++ b/scripts/translations/auto_translate.py @@ -10,9 +10,9 @@ import json import os import subprocess import sys -from concurrent.futures import ThreadPoolExecutor import time import tomllib +from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -152,7 +152,7 @@ def translate_batches(batch_files, language_code, api_key, timeout=600, model="g print(f"\n[{i}/{total}] Translating {batch_file}...") # Always pass API key since it's required - cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}' + cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}' # noqa: E501 try: result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) @@ -223,7 +223,7 @@ def apply_translations(merged_file, language_code): """Apply merged translations to the language file.""" print(f"\n📝 Applying translations to {language_code}...") - cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}" + cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}" # noqa: E501 if not run_command(cmd): print("✗ Failed to apply translations") @@ -352,9 +352,7 @@ Examples: sys.exit(0) # Step 2: Translate all batches - translated_files = translate_batches( - batch_files, args.language, api_key, args.timeout, args.model, args.parallel - ) + translated_files = translate_batches(batch_files, args.language, api_key, args.timeout, args.model, args.parallel) if translated_files is None: sys.exit(1) diff --git a/scripts/translations/batch_translator.py b/scripts/translations/batch_translator.py index 864e580fe0..4698647283 100644 --- a/scripts/translations/batch_translator.py +++ b/scripts/translations/batch_translator.py @@ -98,7 +98,7 @@ CRITICAL RULES - MUST FOLLOW EXACTLY: - Do not remove any part of the original meaning - Keep the same level of detail -Return ONLY the translated JSON. No markdown, no explanations, just the JSON object.""" +Return ONLY the translated JSON. No markdown, no explanations, just the JSON object.""" # noqa: E501 def _record_usage(self, response) -> None: """Accumulate token usage/cost and print a per-batch line.""" diff --git a/scripts/translations/translation_analyzer.py b/scripts/translations/translation_analyzer.py index 9e38c2ad1f..ba97b0bb41 100644 --- a/scripts/translations/translation_analyzer.py +++ b/scripts/translations/translation_analyzer.py @@ -46,10 +46,7 @@ class TranslationAnalyzer: # Convert lists to sets for faster lookup return { - lang: set(patterns) - for lang, data in ignore_data.items() - for patterns in [data.get("ignore", [])] - if patterns + lang: set(patterns) for lang, data in ignore_data.items() for patterns in [data.get("ignore", [])] if patterns } except Exception as e: print(f"Warning: Could not load ignore file {self.ignore_file}: {e}") @@ -282,7 +279,7 @@ def main(): print("\nBottom 5 Languages Needing Attention:") for result in sorted_by_completion[-5:]: print( - f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)" + f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)" # noqa: E501 ) diff --git a/scripts/translations/translation_merger.py b/scripts/translations/translation_merger.py index 10b4a99d14..46ca0bd221 100644 --- a/scripts/translations/translation_merger.py +++ b/scripts/translations/translation_merger.py @@ -467,9 +467,7 @@ def main(): # Extract translations from template format or simple dict if "translations" in translations_data: - translations = { - k: v["translated"] for k, v in translations_data["translations"].items() if v.get("translated") - } + translations = {k: v["translated"] for k, v in translations_data["translations"].items() if v.get("translated")} else: translations = translations_data diff --git a/testing/cucumber/features/enterprise/steps/steps.py b/testing/cucumber/features/enterprise/steps/steps.py index fb347015ab..77d747e015 100644 --- a/testing/cucumber/features/enterprise/steps/steps.py +++ b/testing/cucumber/features/enterprise/steps/steps.py @@ -16,6 +16,6 @@ _parent_steps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../s if _parent_steps not in sys.path: sys.path.insert(0, _parent_steps) -from step_definitions import * # noqa: F401, F403 -from auth_step_definitions import * # noqa: F401, F403 -from enterprise_step_definitions import * # noqa: F401, F403 +from auth_step_definitions import * # noqa: E402, F403 +from enterprise_step_definitions import * # noqa: E402, F403 +from step_definitions import * # noqa: E402, F403 diff --git a/testing/cucumber/features/environment.py b/testing/cucumber/features/environment.py index 208e45b23b..7b74b06b75 100644 --- a/testing/cucumber/features/environment.py +++ b/testing/cucumber/features/environment.py @@ -12,12 +12,25 @@ _REPORT_DIR = os.environ.get("TEST_REPORT_DIR", "") # @login and @register scenarios work in both modes. # The "jwt" tag itself is included so that feature-level @jwt tagging is sufficient # to mark an entire feature as JWT-dependent. -_JWT_DEPENDENT_TAGS = frozenset({ - # jwt_auth.feature scenario tags - "me", "refresh", "logout", "role", "token", "mfa", "apikey", - # proprietary/enterprise feature tags (all scenarios in these features need JWT) - "jwt", "user_mgmt", "admin_settings", "audit", "signature", "team", -}) +_JWT_DEPENDENT_TAGS = frozenset( + { + # jwt_auth.feature scenario tags + "me", + "refresh", + "logout", + "role", + "token", + "mfa", + "apikey", + # proprietary/enterprise feature tags (all scenarios in these features need JWT) + "jwt", + "user_mgmt", + "admin_settings", + "audit", + "signature", + "team", + } +) # Tags for scenarios that require the policies feature (policies.enabled=true). _POLICIES_DEPENDENT_TAGS = frozenset({"policies", "webhook"}) @@ -56,7 +69,9 @@ def _get_docker_log_line_count(): try: result = subprocess.run( ["docker", "logs", _CONTAINER_NAME], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) return len(result.stdout.splitlines()) + len(result.stderr.splitlines()) except Exception: @@ -69,7 +84,9 @@ def _capture_docker_logs_window(start_line, scenario_name): try: result = subprocess.run( ["docker", "logs", _CONTAINER_NAME], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) all_lines = (result.stdout + result.stderr).splitlines() window = all_lines[start_line:] @@ -200,9 +217,7 @@ def after_scenario(context, scenario): # Remove any temporary files generated during the scenario for temp_file in os.listdir("."): - if temp_file.startswith("genericNonCustomisableName") or temp_file.startswith( - "temp_image_" - ): + if temp_file.startswith("genericNonCustomisableName") or temp_file.startswith("temp_image_"): try: os.remove(temp_file) except Exception: diff --git a/testing/cucumber/features/steps/auth_step_definitions.py b/testing/cucumber/features/steps/auth_step_definitions.py index 5206c09b8c..d7cc407067 100644 --- a/testing/cucumber/features/steps/auth_step_definitions.py +++ b/testing/cucumber/features/steps/auth_step_definitions.py @@ -11,8 +11,6 @@ Covers: - User registration """ -import json as json_module - import requests from behave import given, then, when @@ -60,9 +58,7 @@ def _do_login(username, password): def step_logged_in_as_admin(context): """Login as the default admin user and store the JWT token in context.""" response = _do_login(ADMIN_USERNAME, ADMIN_PASSWORD) - assert response.status_code == 200, ( - f"Admin login failed (status {response.status_code}): {response.text}" - ) + assert response.status_code == 200, f"Admin login failed (status {response.status_code}): {response.text}" data = response.json() context.jwt_token = data["session"]["access_token"] @@ -70,9 +66,7 @@ def step_logged_in_as_admin(context): @given("I store the JWT token") def step_store_current_jwt(context): """Store the currently held jwt_token into context for later comparison.""" - assert hasattr(context, "jwt_token") and context.jwt_token, ( - "No JWT token available – did you log in first?" - ) + assert hasattr(context, "jwt_token") and context.jwt_token, "No JWT token available – did you log in first?" context.original_jwt_token = context.jwt_token @@ -206,8 +200,6 @@ def step_get_with_empty_auth_header(context, endpoint): ) - - @when('I send a GET request to "{endpoint}" with the stored JWT token') def step_get_with_stored_jwt(context, endpoint): """Send GET request using the JWT token currently stored in context.""" @@ -252,9 +244,7 @@ def step_post_with_invalid_jwt(context, endpoint, token_value): ) -@when( - 'I send a JSON POST request to "{endpoint}" with JWT authentication and body \'{json_body}\'' -) +@when("I send a JSON POST request to \"{endpoint}\" with JWT authentication and body '{json_body}'") def step_json_post_with_jwt(context, endpoint, json_body): """Send JSON POST request using the stored JWT token and a JSON body.""" headers = { @@ -269,9 +259,7 @@ def step_json_post_with_jwt(context, endpoint, json_body): ) -@when( - 'I send a JSON POST request to "{endpoint}" with API key "{api_key}" and body \'{json_body}\'' -) +@when('I send a JSON POST request to "{endpoint}" with API key "{api_key}" and body \'{json_body}\'') def step_json_post_with_api_key(context, endpoint, api_key, json_body): """Send JSON POST request using X-API-KEY header and a JSON body.""" headers = { @@ -333,8 +321,7 @@ def step_status_code_one_of(context, codes): allowed = [int(c.strip()) for c in codes.split(",")] actual = context.response.status_code assert actual in allowed, ( - f"Expected status code to be one of {allowed} but got {actual}. " - f"Body: {context.response.text[:500]}" + f"Expected status code to be one of {allowed} but got {actual}. Body: {context.response.text[:500]}" ) @@ -348,15 +335,11 @@ def step_response_contains_jwt(context): """Assert the response has a session.access_token that looks like a JWT.""" data = context.response.json() assert "session" in data, f"No 'session' key in response: {data}" - assert "access_token" in data["session"], ( - f"No 'access_token' in session: {data['session']}" - ) + assert "access_token" in data["session"], f"No 'access_token' in session: {data['session']}" token = data["session"]["access_token"] assert token, "access_token is empty" parts = token.split(".") - assert len(parts) == 3, ( - f"JWT should have 3 dot-separated parts but got {len(parts)}: {token[:60]}..." - ) + assert len(parts) == 3, f"JWT should have 3 dot-separated parts but got {len(parts)}: {token[:60]}..." @then("the JWT access token should have three dot-separated parts") @@ -366,9 +349,7 @@ def step_jwt_three_parts(context): token = data.get("session", {}).get("access_token", "") assert token, "No access_token found in response" parts = token.split(".") - assert len(parts) == 3, ( - f"JWT must have 3 parts (header.payload.signature) but got {len(parts)}: {token[:60]}" - ) + assert len(parts) == 3, f"JWT must have 3 parts (header.payload.signature) but got {len(parts)}: {token[:60]}" # --------------------------------------------------------------------------- @@ -376,13 +357,11 @@ def step_jwt_three_parts(context): # --------------------------------------------------------------------------- -@then("the response JSON should have field \"{field}\"") +@then('the response JSON should have field "{field}"') def step_json_has_field(context, field): """Assert the top-level response JSON contains the specified field.""" data = context.response.json() - assert field in data, ( - f"Expected field '{field}' in response JSON but only found: {list(data.keys())}" - ) + assert field in data, f"Expected field '{field}' in response JSON but only found: {list(data.keys())}" @then('the response JSON should have a user with username "{username}"') @@ -409,9 +388,7 @@ def step_json_user_field_not_empty(context, field): data = context.response.json() assert "user" in data, f"No 'user' in response: {list(data.keys())}" value = data["user"].get(field) - assert value is not None and str(value) != "", ( - f"Expected user field '{field}' to be non-empty, got: {value!r}" - ) + assert value is not None and str(value) != "", f"Expected user field '{field}' to be non-empty, got: {value!r}" @then('the response JSON user field "{field}" should equal "{expected}"') @@ -424,9 +401,7 @@ def step_json_user_field_equals(context, field, expected): assert "user" in data, f"No 'user' in response: {list(data.keys())}" value = data["user"].get(field, "") actual = str(value).lower() if isinstance(value, bool) else str(value) - assert actual == expected, ( - f"Expected user field '{field}' == '{expected}' but got '{actual}'" - ) + assert actual == expected, f"Expected user field '{field}' == '{expected}' but got '{actual}'" @then('the response JSON field "{field}" should equal "{expected}"') @@ -439,10 +414,7 @@ def step_json_top_field_equals(context, field, expected): data = context.response.json() value = data.get(field, "") actual = str(value).lower() if isinstance(value, bool) else str(value) - assert actual == expected, ( - f"Expected JSON field '{field}' == '{expected}' but got '{actual}'. " - f"Full response: {data}" - ) + assert actual == expected, f"Expected JSON field '{field}' == '{expected}' but got '{actual}'. Full response: {data}" @then('the response JSON session field "{field}" should be positive') @@ -459,15 +431,9 @@ def step_json_session_field_positive(context, field): def step_json_error_contains(context, error_text): """Assert the error/message/detail field contains the expected substring (case-insensitive).""" data = context.response.json() - error = ( - data.get("error") - or data.get("message") - or data.get("detail") - or "" - ) + error = data.get("error") or data.get("message") or data.get("detail") or "" assert error_text.lower() in str(error).lower(), ( - f"Expected '{error_text}' (case-insensitive) in error response but got: '{error}'. " - f"Full response: {data}" + f"Expected '{error_text}' (case-insensitive) in error response but got: '{error}'. Full response: {data}" ) @@ -480,9 +446,7 @@ def step_json_error_contains(context, error_text): def step_store_jwt_from_login(context): """Extract and store access_token from the login response.""" data = context.response.json() - assert "session" in data and "access_token" in data["session"], ( - f"No access_token in login response: {data}" - ) + assert "session" in data and "access_token" in data["session"], f"No access_token in login response: {data}" context.jwt_token = data["session"]["access_token"] assert context.jwt_token, "Stored JWT token is empty" @@ -491,9 +455,7 @@ def step_store_jwt_from_login(context): def step_update_stored_jwt(context): """Replace the stored JWT token with the new one from the current response.""" data = context.response.json() - assert "session" in data and "access_token" in data["session"], ( - f"No access_token in response: {data}" - ) + assert "session" in data and "access_token" in data["session"], f"No access_token in response: {data}" new_token = data["session"]["access_token"] assert new_token, "New JWT token from response is empty" context.jwt_token = new_token diff --git a/testing/cucumber/features/steps/enterprise_step_definitions.py b/testing/cucumber/features/steps/enterprise_step_definitions.py index ae3c7a57d4..7b8bc6bb37 100644 --- a/testing/cucumber/features/steps/enterprise_step_definitions.py +++ b/testing/cucumber/features/steps/enterprise_step_definitions.py @@ -12,7 +12,7 @@ Covers: """ import requests -from behave import given, then, when +from behave import then, when BASE_URL = "http://localhost:8080" @@ -131,9 +131,7 @@ def step_delete_no_auth_and_params(context, endpoint, params): # --------------------------------------------------------------------------- -@when( - 'I use the stored value to send a GET request to "{endpoint_template}" with JWT authentication' -) +@when('I use the stored value to send a GET request to "{endpoint_template}" with JWT authentication') def step_get_stored_jwt(context, endpoint_template): """Send GET request substituting {stored} in the path with context.stored_value.""" endpoint = _expand_stored(endpoint_template, context) @@ -144,9 +142,7 @@ def step_get_stored_jwt(context, endpoint_template): ) -@when( - 'I use the stored value to send a GET request to "{endpoint_template}" with no authentication' -) +@when('I use the stored value to send a GET request to "{endpoint_template}" with no authentication') def step_get_stored_no_auth(context, endpoint_template): """Send GET request substituting {stored} in the path with no authentication.""" endpoint = _expand_stored(endpoint_template, context) @@ -156,9 +152,7 @@ def step_get_stored_no_auth(context, endpoint_template): ) -@when( - 'I use the stored value to send a DELETE request to "{endpoint_template}" with JWT authentication' -) +@when('I use the stored value to send a DELETE request to "{endpoint_template}" with JWT authentication') def step_delete_stored_jwt(context, endpoint_template): """Send DELETE request substituting {stored} in the path with context.stored_value.""" endpoint = _expand_stored(endpoint_template, context) @@ -169,9 +163,7 @@ def step_delete_stored_jwt(context, endpoint_template): ) -@when( - 'I use the stored value to send a POST request to "{endpoint_template}" with JWT authentication' -) +@when('I use the stored value to send a POST request to "{endpoint_template}" with JWT authentication') def step_post_stored_jwt(context, endpoint_template): """Send POST request substituting {stored} in the path with context.stored_value.""" endpoint = _expand_stored(endpoint_template, context) @@ -207,8 +199,7 @@ def step_json_top_field_not_empty(context, field): data = context.response.json() value = data.get(field) assert value is not None and str(value) != "", ( - f"Expected field '{field}' to be non-empty, got: {value!r}. " - f"Full response: {data}" + f"Expected field '{field}' to be non-empty, got: {value!r}. Full response: {data}" ) @@ -223,8 +214,7 @@ def step_response_is_list(context): """Assert that the top-level response JSON value is a list.""" data = context.response.json() assert isinstance(data, list), ( - f"Expected response to be a JSON list but got: {type(data).__name__}. " - f"Content: {str(data)[:200]}" + f"Expected response to be a JSON list but got: {type(data).__name__}. Content: {str(data)[:200]}" ) @@ -234,8 +224,7 @@ def step_json_field_is_list(context, field): data = context.response.json() value = data.get(field) assert isinstance(value, list), ( - f"Expected field '{field}' to be a list but got: {type(value).__name__}. " - f"Full response: {data}" + f"Expected field '{field}' to be a list but got: {type(value).__name__}. Full response: {data}" ) @@ -245,10 +234,7 @@ def step_json_field_is_true(context, field): data = context.response.json() value = data.get(field) actual = str(value).lower() if isinstance(value, bool) else str(value).lower() - assert actual == "true", ( - f"Expected field '{field}' to be true but got: {value!r}. " - f"Full response: {data}" - ) + assert actual == "true", f"Expected field '{field}' to be true but got: {value!r}. Full response: {data}" @then('the response JSON field "{field}" should be false') @@ -257,7 +243,4 @@ def step_json_field_is_false(context, field): data = context.response.json() value = data.get(field) actual = str(value).lower() if isinstance(value, bool) else str(value).lower() - assert actual == "false", ( - f"Expected field '{field}' to be false but got: {value!r}. " - f"Full response: {data}" - ) + assert actual == "false", f"Expected field '{field}' to be false but got: {value!r}. Full response: {data}" diff --git a/testing/cucumber/features/steps/folders_step_definitions.py b/testing/cucumber/features/steps/folders_step_definitions.py index 40eb122f11..8242997472 100644 --- a/testing/cucumber/features/steps/folders_step_definitions.py +++ b/testing/cucumber/features/steps/folders_step_definitions.py @@ -40,9 +40,7 @@ HTTP_TIMEOUT = 30 def _jwt_headers(context): token = getattr(context, "jwt_token", None) - assert token, ( - "No JWT token in context. Use 'Given I am logged in as admin' first." - ) + assert token, "No JWT token in context. Use 'Given I am logged in as admin' first." return {"Authorization": f"Bearer {token}"} @@ -74,10 +72,7 @@ def _resolve_folder_id(context, ref): if ref.endswith(".id"): name = ref[:-3] _ensure_folders_dict(context) - assert name in context.folders_by_name, ( - f"No folder named {name!r} stashed; available: " - f"{list(context.folders_by_name)}" - ) + assert name in context.folders_by_name, f"No folder named {name!r} stashed; available: {list(context.folders_by_name)}" return context.folders_by_name[name] return ref @@ -120,9 +115,7 @@ def step_clear_all_folders(context): early as a clear assertion rather than letting individual steps fail with cryptic errors. """ - response = requests.get( - FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT - ) + response = requests.get(FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT) assert response.status_code in (200, 204), ( f"Folder list returned {response.status_code} during teardown - " f"is the proprietary storage-folders module deployed? Body: " @@ -145,8 +138,7 @@ def step_clear_all_folders(context): def step_folder_exists(context, name): response = _create_folder(context, name) assert response.status_code == 201, ( - f"Could not create folder {name!r} during Given step: " - f"{response.status_code} {response.text}" + f"Could not create folder {name!r} during Given step: {response.status_code} {response.text}" ) _ensure_folders_dict(context) context.folders_by_name[name] = response.json()["id"] @@ -157,15 +149,9 @@ def step_folder_exists(context, name): def step_folder_exists_under(context, name, parent): _ensure_folders_dict(context) parent_id = context.folders_by_name.get(parent) - assert parent_id, ( - f"Parent folder {parent!r} not created yet; available: " - f"{list(context.folders_by_name)}" - ) + assert parent_id, f"Parent folder {parent!r} not created yet; available: {list(context.folders_by_name)}" response = _create_folder(context, name, parent_id=parent_id) - assert response.status_code == 201, ( - f"Could not create child folder {name!r}: " - f"{response.status_code} {response.text}" - ) + assert response.status_code == 201, f"Could not create child folder {name!r}: {response.status_code} {response.text}" context.folders_by_name[name] = response.json()["id"] context.response = response @@ -254,9 +240,7 @@ def step_delete_folder(context, name): @when("I list folders") def step_list_folders(context): - response = requests.get( - FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT - ) + response = requests.get(FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT) context.response = response @@ -270,10 +254,7 @@ def step_list_folders(context): def step_patch_file_folder(context, filename, folder_ref): _ensure_files_dict(context) file_id = context.uploaded_files.get(filename) - assert file_id, ( - f"File {filename!r} not uploaded yet; available: " - f"{list(context.uploaded_files)}" - ) + assert file_id, f"File {filename!r} not uploaded yet; available: {list(context.uploaded_files)}" folder_id = _resolve_folder_id(context, folder_ref) response = requests.patch( f"{FILES_URL}/{file_id}/folder", @@ -286,9 +267,7 @@ def step_patch_file_folder(context, filename, folder_ref): @when("I list files") def step_list_files(context): - response = requests.get( - FILES_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT - ) + response = requests.get(FILES_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT) context.response = response @@ -322,52 +301,39 @@ def step_post_folder_no_auth(context, name): @then('the response JSON folder should have name "{name}"') def step_response_folder_name(context, name): data = context.response.json() - assert data.get("name") == name, ( - f"Expected name={name!r}, got {data.get('name')!r}. Body: {data}" - ) + assert data.get("name") == name, f"Expected name={name!r}, got {data.get('name')!r}. Body: {data}" @then("the response JSON folder.parentFolderId should be null") def step_response_folder_parent_null(context): data = context.response.json() - assert data.get("parentFolderId") is None, ( - f"Expected parentFolderId=null, got {data.get('parentFolderId')!r}" - ) + assert data.get("parentFolderId") is None, f"Expected parentFolderId=null, got {data.get('parentFolderId')!r}" @then('the response JSON folder.parentFolderId should equal "{ref}"') def step_response_folder_parent_equal(context, ref): data = context.response.json() expected = _resolve_folder_id(context, ref) - assert data.get("parentFolderId") == expected, ( - f"Expected parentFolderId={expected!r}, " - f"got {data.get('parentFolderId')!r}" - ) + assert data.get("parentFolderId") == expected, f"Expected parentFolderId={expected!r}, got {data.get('parentFolderId')!r}" @then("the response JSON folder.createdAt should not be empty") def step_response_folder_createdat_not_empty(context): data = context.response.json() - assert data.get("createdAt"), ( - f"Expected non-empty createdAt; body: {data}" - ) + assert data.get("createdAt"), f"Expected non-empty createdAt; body: {data}" @then("the response JSON file.folderId should be null") def step_response_file_folderid_null(context): data = context.response.json() - assert data.get("folderId") is None, ( - f"Expected folderId=null, got {data.get('folderId')!r}" - ) + assert data.get("folderId") is None, f"Expected folderId=null, got {data.get('folderId')!r}" @then('the response JSON file.folderId should equal "{ref}"') def step_response_file_folderid_equal(context, ref): data = context.response.json() expected = _resolve_folder_id(context, ref) - assert data.get("folderId") == expected, ( - f"Expected folderId={expected!r}, got {data.get('folderId')!r}" - ) + assert data.get("folderId") == expected, f"Expected folderId={expected!r}, got {data.get('folderId')!r}" @then('the folder list should contain "{name}"') @@ -381,19 +347,13 @@ def step_folder_list_contains(context, name): def step_folder_list_not_contains(context, name): folders = context.response.json() names = [f.get("name") for f in folders] - assert name not in names, ( - f"Folder {name!r} should not be in list: {names}" - ) + assert name not in names, f"Folder {name!r} should not be in list: {names}" @then('the file list should contain a file named "{filename}" with folderId null') def step_file_list_contains_root_file(context, filename): files = context.response.json() - matches = [ - f - for f in files - if f.get("fileName") == filename and f.get("folderId") is None - ] + matches = [f for f in files if f.get("fileName") == filename and f.get("folderId") is None] assert matches, ( f"No file named {filename!r} with folderId=null in list. " f"Files seen: " diff --git a/testing/cucumber/features/steps/multinode_step_definitions.py b/testing/cucumber/features/steps/multinode_step_definitions.py index e43673dc86..9f91b7fbfc 100644 --- a/testing/cucumber/features/steps/multinode_step_definitions.py +++ b/testing/cucumber/features/steps/multinode_step_definitions.py @@ -24,7 +24,10 @@ ADMIN_PASS = "stirling" def _sh(args, stdin=None, timeout=60): """Run a command, return (returncode, stdout, stderr).""" r = subprocess.run( - args, input=stdin, capture_output=True, timeout=timeout, + args, + input=stdin, + capture_output=True, + timeout=timeout, text=(stdin is None or isinstance(stdin, str)), ) out = r.stdout if isinstance(r.stdout, str) else r.stdout.decode("utf-8", "replace") @@ -34,9 +37,7 @@ def _sh(args, stdin=None, timeout=60): def _psql(query): """Run a query against the shared Postgres, return the raw tab/newline output (trimmed).""" - rc, out, err = _sh( - ["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", query] - ) + rc, out, err = _sh(["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", query]) assert rc == 0, f"psql failed: {err.strip() or out.strip()}" return out.strip() @@ -47,10 +48,7 @@ def _psql_int(query): def _network(): - rc, out, _ = _sh( - ["docker", "inspect", "-f", - "{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}", NODES[0]] - ) + rc, out, _ = _sh(["docker", "inspect", "-f", "{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}", NODES[0]]) return out.strip() or "compose_stirling-multinode" @@ -89,18 +87,25 @@ def _names_on_node(node, path, token): data = json.loads(body) except ValueError: return set() - items = data if isinstance(data, list) else data.get(path.rsplit("/", 1)[-1], []) \ - or data.get("sources", []) or data.get("policies", []) + items = ( + data + if isinstance(data, list) + else data.get(path.rsplit("/", 1)[-1], []) or data.get("sources", []) or data.get("policies", []) + ) return {i.get("name") for i in items if isinstance(i, dict)} def _policy_body(name, source_ids=None, enabled=True): - return json.dumps({ - "name": name, "enabled": enabled, "trigger": None, - "sourceIds": source_ids or [], - "steps": [{"operation": "/api/v1/misc/compress-pdf", "parameters": {}}], - "output": {"type": "inline", "options": {}}, - }) + return json.dumps( + { + "name": name, + "enabled": enabled, + "trigger": None, + "sourceIds": source_ids or [], + "steps": [{"operation": "/api/v1/misc/compress-pdf", "parameters": {}}], + "output": {"type": "inline", "options": {}}, + } + ) def _any_connection_id(context): @@ -108,8 +113,7 @@ def _any_connection_id(context): cid = getattr(context, "_seed_conn_id", None) if cid: return cid - r = requests.get(f"{LB_URL}/api/v1/integrations", - headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15) + r = requests.get(f"{LB_URL}/api/v1/integrations", headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15) assert r.status_code == 200, f"list integrations failed: HTTP {r.status_code}" s3 = next((c for c in r.json() if c.get("integrationType") == "S3"), None) assert s3, "no S3 connection available (did the seed run?)" @@ -119,33 +123,45 @@ def _any_connection_id(context): def _s3_source_body(name, connection_id): # Folder sources are config-gated; S3 sources against the seeded connection always work. - return json.dumps({ - "name": name, "type": "s3", - "options": {"connectionId": connection_id, "prefix": "regr/", "mode": "snapshot"}, - "enabled": True, - }) + return json.dumps( + { + "name": name, + "type": "s3", + "options": {"connectionId": connection_id, "prefix": "regr/", "mode": "snapshot"}, + "enabled": True, + } + ) def _source_id_by_name(context, name): - r = requests.get(f"{LB_URL}/api/v1/sources", - headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15) + r = requests.get(f"{LB_URL}/api/v1/sources", headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15) assert r.status_code == 200, f"list sources failed: HTTP {r.status_code}" return next((s["id"] for s in r.json().get("sources", []) if s.get("name") == name), None) def _s3_connection_body(name): - return json.dumps({ - "integrationType": "S3", "name": name, "scope": "SERVER", "enabled": True, - "locked": False, "defaultAccess": "ORG_ALL", - "config": {"bucket": BUCKET, "region": "us-east-1", "endpoint": "http://minio:9000", - "accessKeyId": "minioadmin", "secretAccessKey": "minioadmin", - "pathStyleAccess": True}, - }) + return json.dumps( + { + "integrationType": "S3", + "name": name, + "scope": "SERVER", + "enabled": True, + "locked": False, + "defaultAccess": "ORG_ALL", + "config": { + "bucket": BUCKET, + "region": "us-east-1", + "endpoint": "http://minio:9000", + "accessKeyId": "minioadmin", + "secretAccessKey": "minioadmin", + "pathStyleAccess": True, + }, + } + ) def _lb_login(context): - r = requests.post(f"{LB_URL}/api/v1/auth/login", - json={"username": ADMIN_USER, "password": ADMIN_PASS}, timeout=15) + r = requests.post(f"{LB_URL}/api/v1/auth/login", json={"username": ADMIN_USER, "password": ADMIN_PASS}, timeout=15) assert r.status_code == 200, f"admin login via LB failed: HTTP {r.status_code}" context.jwt_token = r.json()["session"]["access_token"] @@ -154,6 +170,7 @@ def _pdf_bytes(marker): """A minimal valid single-page PDF carrying a unique marker (so outputs are identifiable).""" try: from reportlab.pdfgen import canvas + buf = io.BytesIO() c = canvas.Canvas(buf) c.drawString(100, 750, f"multinode-regression {marker}") @@ -162,10 +179,12 @@ def _pdf_bytes(marker): return buf.getvalue() except Exception: # Fallback: a hand-rolled minimal PDF if reportlab is unavailable. - return (b"%PDF-1.4\n1 0 obj<>endobj\n" - b"2 0 obj<>endobj\n" - b"3 0 obj<>endobj\n" - b"trailer<>\n%%EOF") + return ( + b"%PDF-1.4\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"trailer<>\n%%EOF" + ) def _mc(context, script, stdin=None): @@ -173,14 +192,12 @@ def _mc(context, script, stdin=None): net = getattr(context, "_net", None) or _network() context._net = net full = f"mc alias set local http://minio:9000 minioadmin minioadmin >/dev/null 2>&1 && {script}" - args = ["docker", "run", "-i", "--rm", "--network", net, "--entrypoint", "/bin/sh", - "minio/mc", "-c", full] + args = ["docker", "run", "-i", "--rm", "--network", net, "--entrypoint", "/bin/sh", "minio/mc", "-c", full] return _sh(args, stdin=stdin, timeout=90) def _policy_id_by_name(context, name): - r = requests.get(f"{LB_URL}/api/v1/policies", - headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15) + r = requests.get(f"{LB_URL}/api/v1/policies", headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15) assert r.status_code == 200, f"list policies failed: HTTP {r.status_code}" data = r.json() items = data if isinstance(data, list) else data.get("policies", []) @@ -227,8 +244,8 @@ def step_lb_requests(context, endpoint, count): def step_distinct_nodes(context, n): distinct = set(context._served_by) assert len(distinct) >= n, ( - f"expected >= {n} distinct upstreams, saw {sorted(distinct)} " - f"(is the X-Served-By header configured on the LB?)") + f"expected >= {n} distinct upstreams, saw {sorted(distinct)} (is the X-Served-By header configured on the LB?)" + ) @then("every load-balanced response should be {code:d}") @@ -248,8 +265,9 @@ def step_token_every_node(context): @then("the signing keys should be stored in the shared database") def step_keys_in_db(context): - assert _psql_int("select count(*) from jwt_signing_keys") >= 1, \ + assert _psql_int("select count(*) from jwt_signing_keys") >= 1, ( "no rows in jwt_signing_keys - keys are not persisted in the shared DB" + ) @then("every stored private key should be encrypted at rest") @@ -263,9 +281,9 @@ def step_keys_encrypted(context): @when('I create a team named "{name}" through the load balancer') def step_create_team(context, name): context._team_name = name - r = requests.post(f"{LB_URL}/api/v1/team/create", - headers={"Authorization": f"Bearer {_token(context)}"}, - data={"name": name}, timeout=15) + r = requests.post( + f"{LB_URL}/api/v1/team/create", headers={"Authorization": f"Bearer {_token(context)}"}, data={"name": name}, timeout=15 + ) assert r.status_code in (200, 201, 409), f"create team failed: HTTP {r.status_code}" @@ -335,9 +353,7 @@ def step_files_processed(context, seconds): if remaining == 0: break time.sleep(3) - assert remaining == 0, ( - f"{remaining} of {len(context._dropped)} dropped files were still unprocessed after " - f"{seconds}s") + assert remaining == 0, f"{remaining} of {len(context._dropped)} dropped files were still unprocessed after {seconds}s" for node in NODES: rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Status}}", node]) assert out.strip() == "running", f"{node} crashed during concurrent processing" @@ -348,15 +364,19 @@ def step_ledger_claim_atomic(context): # Exactly-once relies on the (identity_hash, policy_id) primary key: two nodes claiming the same file both insert it, but only one wins; this proves the constraint rejects the second claim. ihash = "regr-" + uuid.uuid4().hex pol = "regr-policy-" + uuid.uuid4().hex[:8] - insert = (f"insert into policy_processed_files (identity_hash, policy_id, status, attempts) " - f"values ('{ihash}', '{pol}', 'PROCESSING', 1)") + insert = ( + f"insert into policy_processed_files (identity_hash, policy_id, status, attempts) " + f"values ('{ihash}', '{pol}', 'PROCESSING', 1)" + ) _psql(insert) # first claim wins - rc, out, err = _sh(["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", - "-tAc", insert]) # second claim must be rejected + rc, out, err = _sh( + ["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", insert] + ) # second claim must be rejected _psql(f"delete from policy_processed_files where identity_hash = '{ihash}'") assert rc != 0 and "duplicate key" in (out + err).lower(), ( "a second claim for the same file and policy was NOT rejected - the ledger's exactly-once " - "guarantee is not enforced by the primary key") + "guarantee is not enforced by the primary key" + ) # --------------------------------------------------------------------------- policy run coordination (gap) @@ -368,8 +388,7 @@ def step_run_policy_on_node(context, name, idx): assert pid, f"policy '{name}' not found" # Drop an input so the trigger actually produces a run (the source is otherwise empty). marker = uuid.uuid4().hex[:12] - _mc(context, f"mc pipe local/{BUCKET}/{SOURCE_PREFIX}runvis-{marker}.pdf", - stdin=_pdf_bytes(marker)) + _mc(context, f"mc pipe local/{BUCKET}/{SOURCE_PREFIX}runvis-{marker}.pdf", stdin=_pdf_bytes(marker)) status, _ = _curl_on_node(node, "POST", f"/api/v1/policies/{pid}/trigger", token=_token(context)) assert status in (200, 202), f"triggering the policy on {node} failed: HTTP {status}" # Grab the runId that node recorded for the run it just executed. @@ -393,7 +412,8 @@ def step_run_visible_every(context): assert context._run_id in run_ids, ( f"run {context._run_id} (executed on {context._run_node}) is not visible from {node} - " f"PolicyRunRegistry is a per-node in-JVM map, so run status and cancellation do not " - f"cross nodes") + f"cross nodes" + ) # --------------------------------------------------------------------------- rate limiting @@ -401,9 +421,21 @@ def step_run_visible_every(context): def step_ratelimit_shared(context): # In cluster mode the ValkeyRateLimitStore holds counters in Valkey; probe that a key exists. net = context._net or _network() - rc, out, err = _sh(["docker", "run", "--rm", "--network", net, "--entrypoint", "/bin/sh", - "valkey/valkey:8-alpine", "-c", - "valkey-cli -h valkey keys '*'"], timeout=30) + rc, out, err = _sh( + [ + "docker", + "run", + "--rm", + "--network", + net, + "--entrypoint", + "/bin/sh", + "valkey/valkey:8-alpine", + "-c", + "valkey-cli -h valkey keys '*'", + ], + timeout=30, + ) assert rc == 0, f"valkey probe failed: {err.strip()}" assert out.strip(), "no keys in Valkey - rate-limit/backplane state is not shared" @@ -458,8 +490,9 @@ def _auth(context): # --- policies --- @when('I create a policy named "{name}" on node "{idx}"') def step_create_policy_on_node(context, name, idx): - status, body = _curl_on_node(_node(idx), "POST", "/api/v1/policies", token=_token(context), - data=_policy_body(name), content_type="application/json") + status, body = _curl_on_node( + _node(idx), "POST", "/api/v1/policies", token=_token(context), data=_policy_body(name), content_type="application/json" + ) assert status in (200, 201), f"create policy on {_node(idx)} failed: HTTP {status}: {body[:200]}" @@ -467,9 +500,12 @@ def step_create_policy_on_node(context, name, idx): def step_create_policy_ref(context, name, src): sid = _source_id_by_name(context, src) assert sid, f"source '{src}' not found" - r = requests.post(f"{LB_URL}/api/v1/policies", - headers={**_auth(context), "Content-Type": "application/json"}, - data=_policy_body(name, [sid]), timeout=15) + r = requests.post( + f"{LB_URL}/api/v1/policies", + headers={**_auth(context), "Content-Type": "application/json"}, + data=_policy_body(name, [sid]), + timeout=15, + ) assert r.status_code in (200, 201), f"create referencing policy failed: HTTP {r.status_code}" @@ -479,9 +515,9 @@ def step_rename_policy(context, old, new): assert pid, f"policy '{old}' not found" pol = requests.get(f"{LB_URL}/api/v1/policies/{pid}", headers=_auth(context), timeout=15).json() pol["name"] = new - r = requests.post(f"{LB_URL}/api/v1/policies", - headers={**_auth(context), "Content-Type": "application/json"}, - json=pol, timeout=15) + r = requests.post( + f"{LB_URL}/api/v1/policies", headers={**_auth(context), "Content-Type": "application/json"}, json=pol, timeout=15 + ) assert r.status_code in (200, 201), f"rename policy failed: HTTP {r.status_code}" @@ -524,8 +560,14 @@ def step_triggers_identical(context): @when('I create an S3 source named "{name}" on node "{idx}"') def step_create_source_on_node(context, name, idx): conn = _any_connection_id(context) - status, body = _curl_on_node(_node(idx), "POST", "/api/v1/sources", token=_token(context), - data=_s3_source_body(name, conn), content_type="application/json") + status, body = _curl_on_node( + _node(idx), + "POST", + "/api/v1/sources", + token=_token(context), + data=_s3_source_body(name, conn), + content_type="application/json", + ) assert status in (200, 201), f"create source on {_node(idx)} failed: HTTP {status}: {body[:200]}" @@ -556,16 +598,18 @@ def step_source_delete_guarded(context, name, idx): sid = _source_id_by_name(context, name) assert sid, f"source '{name}' not found" status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/sources/{sid}", token=_token(context)) - assert status == 409, ( - f"expected 409 (source referenced by a policy created on another node), got HTTP {status}") + assert status == 409, f"expected 409 (source referenced by a policy created on another node), got HTTP {status}" # --- connections (integration configs) --- @when('I create an S3 connection named "{name}" via the load balancer') def step_create_conn(context, name): - r = requests.post(f"{LB_URL}/api/v1/integrations", - headers={**_auth(context), "Content-Type": "application/json"}, - data=_s3_connection_body(name), timeout=15) + r = requests.post( + f"{LB_URL}/api/v1/integrations", + headers={**_auth(context), "Content-Type": "application/json"}, + data=_s3_connection_body(name), + timeout=15, + ) assert r.status_code in (200, 201), f"create connection failed: HTTP {r.status_code}: {r.text[:200]}" context._conn_id = r.json()["id"] @@ -580,10 +624,9 @@ def step_conn_resolves(context, name): assert secret in (None, "", "********"), f"{node} leaked the connection secret on read" -@when('I delete the connection via the load balancer') +@when("I delete the connection via the load balancer") def step_delete_conn(context): - r = requests.delete(f"{LB_URL}/api/v1/integrations/{context._conn_id}", - headers=_auth(context), timeout=15) + r = requests.delete(f"{LB_URL}/api/v1/integrations/{context._conn_id}", headers=_auth(context), timeout=15) assert r.status_code in (200, 204), f"delete connection failed: HTTP {r.status_code}" diff --git a/testing/cucumber/features/steps/step_definitions.py b/testing/cucumber/features/steps/step_definitions.py index aa1d45cac0..73cc032e42 100644 --- a/testing/cucumber/features/steps/step_definitions.py +++ b/testing/cucumber/features/steps/step_definitions.py @@ -1,19 +1,20 @@ -import json as json_module -import os -import requests -from behave import given, when, then -from pypdf import PdfWriter, PdfReader -from pypdf.errors import PdfReadError import io +import json as json_module +import mimetypes +import os import random +import re import string +import zipfile + +import requests +from behave import given, then, when +from PIL import Image, ImageDraw +from pypdf import PdfReader, PdfWriter +from pypdf.errors import PdfReadError from reportlab.lib.pagesizes import letter from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas -import mimetypes -import zipfile -import re -from PIL import Image, ImageDraw API_HEADERS = {"X-API-KEY": "123456789"} @@ -99,15 +100,11 @@ def create_black_box_image(file_name, size): can.save() -@given( - "the pdf contains {image_count:d} images of size {width:d}x{height:d} on {page_count:d} pages" -) +@given("the pdf contains {image_count:d} images of size {width:d}x{height:d} on {page_count:d} pages") def step_impl(context, image_count, width, height, page_count): context.param_name = "fileInput" context.file_name = "genericNonCustomisableName.pdf" - create_pdf_with_images_and_boxes( - context.file_name, image_count, page_count, width, height - ) + create_pdf_with_images_and_boxes(context.file_name, image_count, page_count, width, height) if not hasattr(context, "files"): context.files = {} context.files[context.param_name] = open(context.file_name, "rb") @@ -122,13 +119,9 @@ def add_black_boxes_to_image(image): return image -def create_pdf_with_images_and_boxes( - file_name, image_count, page_count, image_width, image_height -): +def create_pdf_with_images_and_boxes(file_name, image_count, page_count, image_width, image_height): page_width, page_height = max(letter[0], image_width), max(letter[1], image_height) - boxes_per_page = image_count // page_count + ( - 1 if image_count % page_count != 0 else 0 - ) + boxes_per_page = image_count // page_count + (1 if image_count % page_count != 0 else 0) writer = PdfWriter() box_counter = 0 @@ -143,9 +136,7 @@ def create_pdf_with_images_and_boxes( # Simulating a dynamic image creation (replace this with your actual image creation logic) # For demonstration, we'll create a simple black image - dummy_image = Image.new( - "RGB", (image_width, image_height), color="white" - ) # Create a white image + dummy_image = Image.new("RGB", (image_width, image_height), color="white") # Create a white image dummy_image = add_black_boxes_to_image(dummy_image) # Add black boxes # Convert the PIL Image to bytes to pass to drawImage @@ -161,9 +152,7 @@ def create_pdf_with_images_and_boxes( break # Add the image to the PDF - can.drawImage( - ImageReader(image_bytes), x, y, width=image_width, height=image_height - ) + can.drawImage(ImageReader(image_bytes), x, y, width=image_width, height=image_height) box_counter += 1 can.showPage() @@ -206,9 +195,7 @@ def create_pdf_with_black_boxes(file_name, image_count, page_count): packet = io.BytesIO() can = canvas.Canvas(packet, pagesize=(page_width, page_height)) - boxes_per_page = image_count // page_count + ( - 1 if image_count % page_count != 0 else 0 - ) + boxes_per_page = image_count // page_count + (1 if image_count % page_count != 0 else 0) for i in range(boxes_per_page): if box_counter >= image_count: break @@ -496,9 +483,7 @@ def step_pdf_has_attachment(context, attachment_name): writer = PdfWriter() for page in reader.pages: writer.add_page(page) - attachment_bytes = ( - f"Attachment: {attachment_name}\nThis is test attachment content.".encode("utf-8") - ) + attachment_bytes = f"Attachment: {attachment_name}\nThis is test attachment content.".encode() writer.add_attachment(attachment_name, attachment_bytes) with open(context.file_name, "wb") as f: writer.write(f) @@ -527,10 +512,7 @@ def step_pdf_has_qr_split_marker(context, page_num): try: import qrcode as _qrcode except ImportError: - raise ImportError( - "qrcode package is required for this step. " - "Install with: pip install 'qrcode[pil]'" - ) + raise ImportError("qrcode package is required for this step. Install with: pip install 'qrcode[pil]'") reader = PdfReader(context.file_name) qr = _qrcode.QRCode(box_size=4, border=2) qr.add_data("https://github.com/Stirling-Tools/Stirling-PDF") @@ -545,9 +527,7 @@ def step_pdf_has_qr_split_marker(context, page_num): packet = io.BytesIO() can = canvas.Canvas(packet, pagesize=letter) w, h = letter - can.drawImage( - ImageReader(qr_bytes), (w - 100) / 2, (h - 100) / 2, width=100, height=100 - ) + can.drawImage(ImageReader(qr_bytes), (w - 100) / 2, (h - 100) / 2, width=100, height=100) can.showPage() can.save() packet.seek(0) @@ -647,9 +627,9 @@ def step_send_api_request(context, endpoint): @then('the response content type should be "{content_type}"') def step_check_response_content_type(context, content_type): actual_content_type = context.response.headers.get("Content-Type", "") - assert actual_content_type.startswith( - content_type - ), f"Expected {content_type} but got {actual_content_type}. Response content: {context.response.content}" + assert actual_content_type.startswith(content_type), ( + f"Expected {content_type} but got {actual_content_type}. Response content: {context.response.content}" + ) @then("the response file should have size greater than {size:d}") @@ -672,20 +652,16 @@ def step_check_response_pdf_passworded(context): reader = PdfReader(response_file) assert reader.is_encrypted except PdfReadError as e: - raise AssertionError( - f"Failed to read PDF: {str(e)}. Response content: {context.response.content}" - ) + raise AssertionError(f"Failed to read PDF: {str(e)}. Response content: {context.response.content}") except Exception as e: - raise AssertionError( - f"An error occurred: {str(e)}. Response content: {context.response.content}" - ) + raise AssertionError(f"An error occurred: {str(e)}. Response content: {context.response.content}") @then("the response status code should be {status_code:d}") def step_check_response_status_code(context, status_code): - assert ( - context.response.status_code == status_code - ), f"Expected status code {status_code} but got {context.response.status_code}" + assert context.response.status_code == status_code, ( + f"Expected status code {status_code} but got {context.response.status_code}" + ) @then('the response should contain error message "{message}"') @@ -693,9 +669,7 @@ def step_check_response_error_message(context, message): response_json = context.response.json() # Check for error message in both "error" (old format) and "detail" (RFC 7807 ProblemDetail) error_message = response_json.get("error") or response_json.get("detail") - assert ( - error_message == message - ), f"Expected error message '{message}' but got '{error_message}'" + assert error_message == message, f"Expected error message '{message}' but got '{error_message}'" @then('the response PDF metadata should include "{metadata_key}" as "{metadata_value}"') @@ -703,9 +677,9 @@ def step_check_response_pdf_metadata(context, metadata_key, metadata_value): response_file = io.BytesIO(context.response.content) reader = PdfReader(response_file) metadata = reader.metadata - assert ( - metadata.get("/" + metadata_key) == metadata_value - ), f"Expected {metadata_key} to be '{metadata_value}' but got '{metadata.get(metadata_key)}'" + assert metadata.get("/" + metadata_key) == metadata_value, ( + f"Expected {metadata_key} to be '{metadata_value}' but got '{metadata.get(metadata_key)}'" + ) @then('the response file should have extension "{extension}"') @@ -718,9 +692,9 @@ def step_check_response_file_extension(context, extension): if part.strip().startswith("filename"): filename = part.split("=")[1].strip().strip('"') break - assert filename.endswith( - extension - ), f"Expected file extension {extension} but got {filename}. Response content: {context.response.content}" + assert filename.endswith(extension), ( + f"Expected file extension {extension} but got {filename}. Response content: {context.response.content}" + ) @then('save the response file as "{filename}" for debugging') @@ -735,9 +709,7 @@ def step_check_response_pdf_page_count(context, page_count): response_file = io.BytesIO(context.response.content) reader = PdfReader(io.BytesIO(response_file.getvalue())) actual_page_count = len(reader.pages) - assert ( - actual_page_count == page_count - ), f"Expected {page_count} pages but got {actual_page_count} pages" + assert actual_page_count == page_count, f"Expected {page_count} pages but got {actual_page_count} pages" @then("the response ZIP should contain {file_count:d} files") @@ -745,61 +717,45 @@ def step_check_response_zip_file_count(context, file_count): response_file = io.BytesIO(context.response.content) with zipfile.ZipFile(io.BytesIO(response_file.getvalue())) as zip_file: actual_file_count = len(zip_file.namelist()) - assert ( - actual_file_count == file_count - ), f"Expected {file_count} files but got {actual_file_count} files" + assert actual_file_count == file_count, f"Expected {file_count} files but got {actual_file_count} files" -@then( - "the response ZIP file should contain {doc_count:d} documents each having {pages_per_doc:d} pages" -) +@then("the response ZIP file should contain {doc_count:d} documents each having {pages_per_doc:d} pages") def step_check_response_zip_doc_page_count(context, doc_count, pages_per_doc): response_file = io.BytesIO(context.response.content) with zipfile.ZipFile(io.BytesIO(response_file.getvalue())) as zip_file: actual_doc_count = len(zip_file.namelist()) - assert ( - actual_doc_count == doc_count - ), f"Expected {doc_count} documents but got {actual_doc_count} documents" + assert actual_doc_count == doc_count, f"Expected {doc_count} documents but got {actual_doc_count} documents" for file_name in zip_file.namelist(): with zip_file.open(file_name) as pdf_file: reader = PdfReader(pdf_file) actual_pages_per_doc = len(reader.pages) - assert ( - actual_pages_per_doc == pages_per_doc - ), f"Expected {pages_per_doc} pages per document but got {actual_pages_per_doc} pages in document {file_name}" + assert actual_pages_per_doc == pages_per_doc, ( + f"Expected {pages_per_doc} pages per document but got {actual_pages_per_doc} pages in document {file_name}" + ) @then('the JSON value of "{key}" should be "{expected_value}"') def step_check_json_value(context, key, expected_value): actual_value = context.response.json().get(key) - assert ( - actual_value == expected_value - ), f"Expected JSON value for '{key}' to be '{expected_value}' but got '{actual_value}'" + assert actual_value == expected_value, f"Expected JSON value for '{key}' to be '{expected_value}' but got '{actual_value}'" -@then( - 'JSON list entry containing "{identifier_key}" as "{identifier_value}" should have "{target_key}" as "{target_value}"' -) -def step_check_json_list_entry( - context, identifier_key, identifier_self, target_key, target_value -): +@then('JSON list entry containing "{identifier_key}" as "{identifier_value}" should have "{target_key}" as "{target_value}"') +def step_check_json_list_entry(context, identifier_key, identifier_value, target_key, target_value): json_response = context.response.json() for entry in json_response: if entry.get(identifier_key) == identifier_value: - assert ( - entry.get(target_key) == target_value - ), f"Expected {target_key} to be {target_value} in entry where {identifier_key} is {identifier_value}, but found {entry.get(target_key)}" + assert entry.get(target_key) == target_value, ( + f"Expected {target_key} to be {target_value} in entry where {identifier_key} is {identifier_value}, but found {entry.get(target_key)}" + ) break else: - raise AssertionError( - f"No entry with {identifier_key} as {identifier_value} found" - ) + raise AssertionError(f"No entry with {identifier_key} as {identifier_value} found") @then('the response should match the regex "{pattern}"') def step_response_matches_regex(context, pattern): response_text = context.response.text - assert re.match( - pattern, response_text - ), f"Response '{response_text}' does not match the expected pattern '{pattern}'" + assert re.match(pattern, response_text), f"Response '{response_text}' does not match the expected pattern '{pattern}'" diff --git a/testing/cucumber/features/steps/webhook_step_definitions.py b/testing/cucumber/features/steps/webhook_step_definitions.py index bcf88bd715..df558c6ce5 100644 --- a/testing/cucumber/features/steps/webhook_step_definitions.py +++ b/testing/cucumber/features/steps/webhook_step_definitions.py @@ -2,7 +2,7 @@ import hashlib import hmac import requests -from behave import given, when, then +from behave import given, then, when BASE_URL = "http://localhost:8080" API_HEADERS = {"X-API-KEY": "123456789"} diff --git a/testing/ledger/generate_test_pdfs.py b/testing/ledger/generate_test_pdfs.py index 8712f5d4dd..a98cb77af3 100644 --- a/testing/ledger/generate_test_pdfs.py +++ b/testing/ledger/generate_test_pdfs.py @@ -57,9 +57,11 @@ def create_clean_invoice(): pdf.cell(0, 8, "Grand Total: $8,195.00", new_x="LMARGIN", new_y="NEXT") pdf.ln(3) pdf.cell( - 0, 8, + 0, + 8, "Breakdown: $6,000.00 + $1,000.00 + $450.00 = $7,450.00", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) pdf.output("testing/ledger/clean_invoice.pdf") @@ -87,9 +89,11 @@ def create_tally_error(): pdf.ln(5) pdf.cell( - 0, 8, + 0, + 8, "Total Q1 spend: $68,000 + $66,000 + $71,200 = $205,200", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) pdf.output("testing/ledger/tally_error.pdf") @@ -156,12 +160,9 @@ def create_consistency_error(): _body(pdf) _table_row(pdf, ["Metric", "Q1", "Q2", "Q3", "Q4", "FY2025"], bold=True) - _table_row(pdf, ["Revenue", "$5,100,000", "$5,800,000", "$6,200,000", - "$7,200,000", "$24,300,000"]) - _table_row(pdf, ["Expenses", "$4,300,000", "$4,400,000", "$4,600,000", - "$4,900,000", "$18,200,000"]) - _table_row(pdf, ["Profit", "$800,000", "$1,400,000", "$1,600,000", - "$2,300,000", "$6,100,000"]) + _table_row(pdf, ["Revenue", "$5,100,000", "$5,800,000", "$6,200,000", "$7,200,000", "$24,300,000"]) + _table_row(pdf, ["Expenses", "$4,300,000", "$4,400,000", "$4,600,000", "$4,900,000", "$18,200,000"]) + _table_row(pdf, ["Profit", "$800,000", "$1,400,000", "$1,600,000", "$2,300,000", "$6,100,000"]) pdf.ln(5) # BUG: Page 1 says Total Revenue = $24,500,000 @@ -169,9 +170,11 @@ def create_consistency_error(): # Page 1 says Net Profit = $6,300,000 # Page 2 table says Profit FY2025 = $6,100,000 pdf.cell( - 0, 8, + 0, + 8, "Full-year revenue of $24,300,000 exceeded targets by 8%.", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) pdf.output("testing/ledger/consistency_error.pdf") @@ -199,15 +202,19 @@ def create_mixed_errors(): pdf.ln(5) # BUG: 51000 + 42000 + 29250 + 61500 = 183,750, NOT 182,750 pdf.cell( - 0, 8, + 0, + 8, "Total revenue: $51,000 + $42,000 + $29,250 + $61,500 = $182,750", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) pdf.ln(3) pdf.cell( - 0, 8, + 0, + 8, "Commission rate: 10% across all regions.", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) pdf.output("testing/ledger/mixed_errors.pdf") @@ -235,37 +242,47 @@ def create_statement_errors(): # Correct claim: profit grew from 2.5M to 3.1M = 24% growth pdf.cell( - 0, 8, + 0, + 8, "Profit grew 24% year-over-year, from $2,500,000 to $3,100,000.", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) # BUG: Revenue grew from 10M to 11.2M = 12% growth, NOT 15% pdf.cell( - 0, 8, + 0, + 8, "Revenue increased 15% compared to the prior year.", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) # BUG: Expenses went UP from 7.5M to 8.1M, NOT decreased pdf.cell( - 0, 8, + 0, + 8, "Operating expenses decreased year-over-year.", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) # BUG: Headcount grew from 142 to 187 = 31.7%, NOT 25% pdf.cell( - 0, 8, + 0, + 8, "The team expanded by 25%, growing from 142 to 187 employees.", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) # Correct claim: profit margin = 3.1M / 11.2M = 27.68% pdf.cell( - 0, 8, + 0, + 8, "Net profit margin reached approximately 28%.", - new_x="LMARGIN", new_y="NEXT", + new_x="LMARGIN", + new_y="NEXT", ) pdf.output("testing/ledger/statement_errors.pdf") @@ -274,6 +291,7 @@ def create_statement_errors(): if __name__ == "__main__": import os + os.makedirs("testing/ledger", exist_ok=True) print("Generating test PDFs:") create_clean_invoice()