mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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:")
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -46,3 +46,7 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Coverage
|
||||
.coverage
|
||||
coverage/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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())
|
||||
Generated
+43
-3
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user