[AZ-137] [AZ-138] Decompose test tasks and scaffold E2E test infrastructure

Made-with: Cursor
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-03-23 14:07:54 +02:00
parent 091d9a8fb0
commit 86d8e7e22d
47 changed files with 1883 additions and 88 deletions
+1
View File
@@ -0,0 +1 @@
COMPOSE_PROFILES=cpu
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["pytest", "--csv=/results/report.csv", "-v"]
+190
View File
@@ -0,0 +1,190 @@
import base64
import json
import random
import time
from contextlib import contextmanager
from pathlib import Path
import pytest
import requests
import sseclient
from pytest import ExitCode
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session, exitstatus):
if exitstatus in (ExitCode.NO_TESTS_COLLECTED, 5):
session.exitstatus = ExitCode.OK
class _SessionWithBase(requests.Session):
def __init__(self, base: str, default_timeout: float = 30):
super().__init__()
self._base = base.rstrip("/")
self._default_timeout = default_timeout
def request(self, method, url, **kwargs):
if url.startswith("http://") or url.startswith("https://"):
full = url
else:
path = url if url.startswith("/") else f"/{url}"
full = f"{self._base}{path}"
kwargs.setdefault("timeout", self._default_timeout)
return super().request(method, full, **kwargs)
@pytest.fixture(scope="session")
def base_url():
return "http://detections:8080"
@pytest.fixture(scope="session")
def http_client(base_url):
return _SessionWithBase(base_url, 30)
@pytest.fixture
def sse_client_factory(http_client):
@contextmanager
def _open():
with http_client.get("/detect/stream", stream=True, timeout=600) as resp:
resp.raise_for_status()
yield sseclient.SSEClient(resp)
return _open
@pytest.fixture(scope="session")
def mock_loader_url():
return "http://mock-loader:8080"
@pytest.fixture(scope="session")
def mock_annotations_url():
return "http://mock-annotations:8081"
@pytest.fixture(scope="session", autouse=True)
def wait_for_services(base_url, mock_loader_url, mock_annotations_url):
urls = [
f"{base_url}/health",
f"{mock_loader_url}/mock/status",
f"{mock_annotations_url}/mock/status",
]
deadline = time.time() + 120
while time.time() < deadline:
ok = True
for u in urls:
try:
r = requests.get(u, timeout=5)
if r.status_code != 200:
ok = False
break
except OSError:
ok = False
break
if ok:
return
time.sleep(2)
pytest.fail("services not ready within 120s")
@pytest.fixture(autouse=True)
def reset_mocks(mock_loader_url, mock_annotations_url):
requests.post(f"{mock_loader_url}/mock/reset", timeout=10)
requests.post(f"{mock_annotations_url}/mock/reset", timeout=10)
yield
def _read_media(name: str) -> bytes:
p = Path("/media") / name
if not p.is_file():
pytest.skip(f"missing {p}")
return p.read_bytes()
@pytest.fixture(scope="session")
def image_small():
return _read_media("image_small.jpg")
@pytest.fixture(scope="session")
def image_large():
return _read_media("image_large.JPG")
@pytest.fixture(scope="session")
def image_dense():
return _read_media("image_dense01.jpg")
@pytest.fixture(scope="session")
def image_dense_02():
return _read_media("image_dense02.jpg")
@pytest.fixture(scope="session")
def image_different_types():
return _read_media("image_different_types.jpg")
@pytest.fixture(scope="session")
def image_empty_scene():
return _read_media("image_empty_scene.jpg")
@pytest.fixture(scope="session")
def video_short_path():
return "/media/video_short01.mp4"
@pytest.fixture(scope="session")
def video_short_02_path():
return "/media/video_short02.mp4"
@pytest.fixture(scope="session")
def video_long_path():
return "/media/video_long03.mp4"
@pytest.fixture(scope="session")
def empty_image():
return b""
@pytest.fixture(scope="session")
def corrupt_image():
random.seed(42)
return random.randbytes(1024)
def _b64url_obj(obj: dict) -> str:
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
@pytest.fixture
def jwt_token():
header = (
base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode())
.decode()
.rstrip("=")
)
payload = _b64url_obj({"exp": int(time.time()) + 3600, "sub": "test"})
return f"{header}.{payload}.signature"
@pytest.fixture(scope="module")
def warm_engine(http_client, image_small):
deadline = time.time() + 120
files = {"file": ("warm.jpg", image_small, "image/jpeg")}
while time.time() < deadline:
try:
r = http_client.post("/detect", files=files)
if r.status_code == 200:
return
except OSError:
pass
time.sleep(2)
pytest.fail("engine warm-up failed after 120s")
+72
View File
@@ -0,0 +1,72 @@
name: detections-e2e
services:
mock-loader:
build: ./mocks/loader
volumes:
- ./fixtures:/models
networks:
- e2e-net
mock-annotations:
build: ./mocks/annotations
networks:
- e2e-net
detections:
profiles:
- cpu
build:
context: ..
dockerfile: Dockerfile
depends_on:
- mock-loader
- mock-annotations
environment:
LOADER_URL: http://mock-loader:8080
ANNOTATIONS_URL: http://mock-annotations:8081
volumes:
- ./fixtures/classes.json:/app/classes.json
- ./logs:/app/Logs
networks:
- e2e-net
detections-gpu:
profiles:
- gpu
build:
context: ..
dockerfile: Dockerfile.gpu
runtime: nvidia
depends_on:
- mock-loader
- mock-annotations
environment:
LOADER_URL: http://mock-loader:8080
ANNOTATIONS_URL: http://mock-annotations:8081
volumes:
- ./fixtures/classes.json:/app/classes.json
- ./logs:/app/Logs
networks:
e2e-net:
aliases:
- detections
e2e-runner:
profiles:
- cpu
- gpu
build: .
depends_on:
- mock-loader
- mock-annotations
volumes:
- ./fixtures:/media
- ./results:/results
networks:
- e2e-net
command: ["pytest", "--csv=/results/report.csv", "-v"]
networks:
e2e-net:
driver: bridge
View File
+21
View File
@@ -0,0 +1,21 @@
[
{ "Id": 0, "Name": "ArmorVehicle", "ShortName": "Броня", "Color": "#ff0000", "MaxSizeM": 8 },
{ "Id": 1, "Name": "Truck", "ShortName": "Вантаж.", "Color": "#00ff00", "MaxSizeM": 8 },
{ "Id": 2, "Name": "Vehicle", "ShortName": "Машина", "Color": "#0000ff", "MaxSizeM": 7 },
{ "Id": 3, "Name": "Atillery", "ShortName": "Арта", "Color": "#ffff00", "MaxSizeM": 14 },
{ "Id": 4, "Name": "Shadow", "ShortName": "Тінь", "Color": "#ff00ff", "MaxSizeM": 9 },
{ "Id": 5, "Name": "Trenches", "ShortName": "Окопи", "Color": "#00ffff", "MaxSizeM": 10 },
{ "Id": 6, "Name": "MilitaryMan", "ShortName": "Військов", "Color": "#188021", "MaxSizeM": 2 },
{ "Id": 7, "Name": "TyreTracks", "ShortName": "Накати", "Color": "#800000", "MaxSizeM": 5 },
{ "Id": 8, "Name": "AdditArmoredTank", "ShortName": "Танк.захист", "Color": "#008000", "MaxSizeM": 7 },
{ "Id": 9, "Name": "Smoke", "ShortName": "Дим", "Color": "#000080", "MaxSizeM": 8 },
{ "Id": 10, "Name": "Plane", "ShortName": "Літак", "Color": "#a52a2a", "MaxSizeM": 12 },
{ "Id": 11, "Name": "Moto", "ShortName": "Мото", "Color": "#808000", "MaxSizeM": 3 },
{ "Id": 12, "Name": "CamouflageNet", "ShortName": "Сітка", "Color": "#87ceeb", "MaxSizeM": 14 },
{ "Id": 13, "Name": "CamouflageBranches", "ShortName": "Гілки", "Color": "#2f4f4f", "MaxSizeM": 8 },
{ "Id": 14, "Name": "Roof", "ShortName": "Дах", "Color": "#1e90ff", "MaxSizeM": 15 },
{ "Id": 15, "Name": "Building", "ShortName": "Будівля", "Color": "#ffb6c1", "MaxSizeM": 20 },
{ "Id": 16, "Name": "Caponier", "ShortName": "Капонір", "Color": "#ffa500", "MaxSizeM": 10 },
{ "Id": 17, "Name": "Ammo", "ShortName": "БК", "Color": "#33658a", "MaxSizeM": 2 },
{ "Id": 18, "Name": "Protect.Struct", "ShortName": "Зуби.драк", "Color": "#969647", "MaxSizeM": 2 }
]
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask gunicorn
COPY app.py .
EXPOSE 8081
CMD ["gunicorn", "-b", "0.0.0.0:8081", "-w", "1", "--timeout", "120", "app:app"]
+58
View File
@@ -0,0 +1,58 @@
from flask import Flask, request
app = Flask(__name__)
_mode = "normal"
_annotations: list = []
def _fail():
return _mode == "error"
@app.route("/annotations", methods=["POST"])
def annotations():
if _fail():
return "", 503
_annotations.append(request.get_json(silent=True))
return "", 200
@app.route("/auth/refresh", methods=["POST"])
def auth_refresh():
if _fail():
return "", 503
return {"token": "refreshed-test-token"}
@app.route("/mock/config", methods=["POST"])
def mock_config():
global _mode
body = request.get_json(silent=True) or {}
mode = body.get("mode", "normal")
if mode not in ("normal", "error"):
return "", 400
_mode = mode
return "", 200
@app.route("/mock/reset", methods=["POST"])
def mock_reset():
global _mode, _annotations
_mode = "normal"
_annotations.clear()
return "", 200
@app.route("/mock/status", methods=["GET"])
def mock_status():
return {
"mode": _mode,
"annotation_count": len(_annotations),
"annotations": list(_annotations),
}
@app.route("/mock/annotations", methods=["GET"])
def mock_annotations_list():
return {"annotations": list(_annotations)}
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask gunicorn
COPY app.py .
EXPOSE 8080
CMD ["gunicorn", "-b", "0.0.0.0:8080", "-w", "1", "--timeout", "120", "app:app"]
+110
View File
@@ -0,0 +1,110 @@
import os
from pathlib import Path
from flask import Flask, request
app = Flask(__name__)
_mode = "normal"
_first_fail_remaining = False
_uploads: dict[tuple[str, str], bytes] = {}
_load_count = 0
_upload_count = 0
def _models_root() -> Path:
return Path(os.environ.get("MODELS_ROOT", "/models"))
def _resolve_disk_path(filename: str, folder: str | None) -> Path | None:
root = _models_root()
if folder:
p = root / folder / filename
else:
p = root / filename
if p.is_file():
return p
if folder is None:
alt = root / "models" / filename
if alt.is_file():
return alt
return None
def _should_fail_load() -> bool:
global _first_fail_remaining
if _mode == "error":
return True
if _mode == "first_fail":
if _first_fail_remaining:
_first_fail_remaining = False
return True
return False
return False
@app.route("/load/<path:filename>", methods=["GET", "POST"])
def load(filename):
global _load_count
folder = None
if request.method == "POST" and request.is_json:
body = request.get_json(silent=True) or {}
folder = body.get("folder")
if _should_fail_load():
return "", 503
path = _resolve_disk_path(filename, folder)
if path is None:
key = (folder or "", filename)
data = _uploads.get(key)
if data is None and folder:
data = _uploads.get(("", filename))
if data is None:
return "", 404
_load_count += 1
return data, 200
_load_count += 1
return path.read_bytes(), 200
@app.route("/upload/<path:filename>", methods=["POST"])
def upload(filename):
global _upload_count
folder = request.form.get("folder") or ""
f = request.files.get("data")
if not f:
return "", 400
_uploads[(folder, filename)] = f.read()
_upload_count += 1
return "", 200
@app.route("/mock/config", methods=["POST"])
def mock_config():
global _mode, _first_fail_remaining
body = request.get_json(silent=True) or {}
mode = body.get("mode", "normal")
if mode not in ("normal", "error", "first_fail"):
return "", 400
_mode = mode
_first_fail_remaining = mode == "first_fail"
return "", 200
@app.route("/mock/reset", methods=["POST"])
def mock_reset():
global _mode, _first_fail_remaining, _uploads, _load_count, _upload_count
_mode = "normal"
_first_fail_remaining = False
_uploads.clear()
_load_count = 0
_upload_count = 0
return "", 200
@app.route("/mock/status", methods=["GET"])
def mock_status():
return {
"mode": _mode,
"upload_count": _upload_count,
"load_count": _load_count,
}
+6
View File
@@ -0,0 +1,6 @@
[pytest]
markers =
gpu: marks tests requiring GPU runtime
cpu: marks tests for CPU-only runtime
slow: marks tests that take >30s
timeout = 120
+5
View File
@@ -0,0 +1,5 @@
pytest
pytest-csv
requests==2.32.4
sseclient-py
pytest-timeout
View File
+1
View File
@@ -0,0 +1 @@
"""POST /detect/{media_id} async flow, SSE /detect/stream events, annotations callback."""
+1
View File
@@ -0,0 +1 @@
"""Health & engine lifecycle tests (FT-P-01, FT-P-02, FT-P-14, FT-P-15)."""
+1
View File
@@ -0,0 +1 @@
"""Invalid inputs, empty uploads, corrupt media, and expected HTTP error responses."""
+1
View File
@@ -0,0 +1 @@
"""Latency and throughput baselines for sync detect and async pipelines."""
+1
View File
@@ -0,0 +1 @@
"""Loader and annotations outage modes, retries, and degraded behavior."""
+1
View File
@@ -0,0 +1 @@
"""Memory, concurrency, and payload size boundaries under load."""
+1
View File
@@ -0,0 +1 @@
"""Auth headers, token refresh, and abuse-resistant API usage."""
+1
View File
@@ -0,0 +1 @@
"""Synchronous POST /detect single-image scenarios (bounding boxes, config, class mapping)."""
+1
View File
@@ -0,0 +1 @@
"""Large-image tiling and overlap behavior for POST /detect."""
+1
View File
@@ -0,0 +1 @@
"""Video ingestion, frame sampling, and end-to-end media processing."""