mirror of
https://github.com/azaion/detections.git
synced 2026-04-22 08:56:32 +00:00
[AZ-137] [AZ-138] Decompose test tasks and scaffold E2E test infrastructure
Made-with: Cursor
This commit is contained in:
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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)}
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
pytest
|
||||
pytest-csv
|
||||
requests==2.32.4
|
||||
sseclient-py
|
||||
pytest-timeout
|
||||
@@ -0,0 +1 @@
|
||||
"""POST /detect/{media_id} async flow, SSE /detect/stream events, annotations callback."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Health & engine lifecycle tests (FT-P-01, FT-P-02, FT-P-14, FT-P-15)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Invalid inputs, empty uploads, corrupt media, and expected HTTP error responses."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Latency and throughput baselines for sync detect and async pipelines."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Loader and annotations outage modes, retries, and degraded behavior."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Memory, concurrency, and payload size boundaries under load."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Auth headers, token refresh, and abuse-resistant API usage."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Synchronous POST /detect single-image scenarios (bounding boxes, config, class mapping)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Large-image tiling and overlap behavior for POST /detect."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Video ingestion, frame sampling, and end-to-end media processing."""
|
||||
Reference in New Issue
Block a user