CI: add engine coverage & linting/style fixes

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.
This commit is contained in:
Ludy87
2026-08-12 10:41:42 +02:00
parent 34819ae502
commit 06c6bec7ce
24 changed files with 498 additions and 412 deletions
+7 -7
View File
@@ -10,10 +10,10 @@ adjusting the format.
Usage:
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_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:")
+26
View File
@@ -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: |
+14
View File
@@ -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:
+6 -5
View File
@@ -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]
@@ -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)
@@ -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,
)
+4
View File
@@ -46,3 +46,7 @@ logs/
# OS
.DS_Store
Thumbs.db
# Coverage
.coverage
coverage/
+9 -5
View File
@@ -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"]
+35
View File
@@ -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())
+43 -3
View File
@@ -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"
@@ -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",
+4 -6
View File
@@ -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)
+1 -1
View File
@@ -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."""
+2 -5
View File
@@ -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
)
+1 -3
View File
@@ -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
@@ -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
+26 -11
View File
@@ -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:
@@ -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
@@ -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}"
@@ -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: "
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
b"trailer<</Root 1 0 R>>\n%%EOF")
return (
b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
b"trailer<</Root 1 0 R>>\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}"
@@ -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}'"
@@ -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"}
+44 -26
View File
@@ -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()