terrology / tests /test_web.py
Tom
v1.7.0: web UI feature parity — route maps, two-point mode, size/scale, grouped panel
727983f
Raw
History Blame Contribute Delete
22.1 kB
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from web.app import JobParams, _rate_limit_key, app
from web.jobs import JobStatus, store
@pytest.fixture(autouse=True)
def clear_store():
"""Clear the job store before each test."""
store._jobs.clear()
yield
store._jobs.clear()
class TestConcurrencyCap:
"""Test that concurrent job creation respects MAX_CONCURRENT_JOBS."""
def test_single_job_succeeds(self):
"""A single job creation request should succeed."""
client = TestClient(app)
response = client.post(
"/api/jobs",
json={
"lat": 50.0,
"lon": 0.0,
"radius": 500,
"shape": "square",
"terrain_exag": 2.0,
"colors": 4,
},
)
assert response.status_code == 200
assert "job_id" in response.json()
def test_max_jobs_with_queued(self, monkeypatch):
"""When MAX_CONCURRENT_JOBS=1, second request should 503 if first is QUEUED."""
# Force MAX_CONCURRENT_JOBS=1
monkeypatch.setenv("MAX_CONCURRENT_JOBS", "1")
# Reimport to pick up env var
import importlib
import web.app
importlib.reload(web.app)
from web.app import app as reloaded_app
client = TestClient(reloaded_app)
# Mock run_job to keep the job QUEUED/RUNNING
def mock_run_job(job_id, params):
pass
with patch("web.app.run_job", side_effect=mock_run_job):
# First request should succeed
response1 = client.post(
"/api/jobs",
json={
"lat": 50.0,
"lon": 0.0,
"radius": 500,
"shape": "square",
"terrain_exag": 2.0,
"colors": 4,
},
)
assert response1.status_code == 200
job_id_1 = response1.json()["job_id"]
# Verify job is in store as QUEUED
job1 = store.get(job_id_1)
assert job1 is not None
assert job1.status == JobStatus.QUEUED
# Second request should fail with 503
response2 = client.post(
"/api/jobs",
json={
"lat": 50.0,
"lon": 0.0,
"radius": 500,
"shape": "square",
"terrain_exag": 2.0,
"colors": 4,
},
)
assert response2.status_code == 503
assert "busy" in response2.json()["detail"].lower()
def test_try_create_counts_queued_and_running(self):
"""try_create should count both QUEUED and RUNNING jobs as active."""
# Create a QUEUED job directly
job1 = store.create("job1")
assert job1.status == JobStatus.QUEUED
# Try to create with max_active=1
result = store.try_create("job2", max_active=1)
assert result is None, "Should reject when QUEUED count is at max"
# Update job1 to RUNNING
store.update("job1", status=JobStatus.RUNNING)
# Still should reject
result = store.try_create("job3", max_active=1)
assert result is None, "Should reject when RUNNING count is at max"
# Delete job1, now we should succeed
store.delete("job1")
result = store.try_create("job4", max_active=1)
assert result is not None, "Should succeed when below max"
assert result.id == "job4"
def test_try_create_is_atomic(self):
"""try_create should hold the lock during check-and-create."""
# This test verifies atomicity by checking that try_create
# counts active jobs consistently
store.create("job1")
store.update("job1", status=JobStatus.RUNNING)
# With max_active=1, should get None
result = store.try_create("job2", max_active=1)
assert result is None
# Verify job2 was NOT created
assert store.get("job2") is None
class TestCleanupLoopErrorHandling:
"""Test that _cleanup_loop handles errors gracefully."""
@pytest.mark.asyncio
async def test_cleanup_loop_continues_on_error(self, monkeypatch):
"""Cleanup loop should not die if cleanup_expired raises."""
import asyncio
call_count = 0
def mock_cleanup():
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("Test error")
# Second call succeeds
monkeypatch.setattr(store, "cleanup_expired", mock_cleanup)
# Mock asyncio.sleep in the web.app module to speed up the test
sleep_count = [0]
async def mock_sleep(duration):
sleep_count[0] += 1
if sleep_count[0] >= 3:
# After 3 sleeps, raise CancelledError to stop the loop
raise asyncio.CancelledError()
# Otherwise return immediately
monkeypatch.setattr("web.app.asyncio.sleep", mock_sleep)
# Import after patching
from web.app import _cleanup_loop
# Create a task but let it run with the mocked sleep
task = asyncio.create_task(_cleanup_loop())
try:
await task
except asyncio.CancelledError:
pass
# Verify that cleanup_expired was called multiple times
# (first call raises, second succeeds before loop exits)
assert call_count >= 2, f"Expected at least 2 calls, got {call_count}"
class TestRateLimitKeyFunction:
"""Test the _rate_limit_key function with X-Forwarded-For support."""
def test_default_uses_peer_ip(self):
"""Without TERROLOGY_TRUST_PROXY, should use direct peer IP."""
with patch("web.app.get_remote_address", return_value="192.168.1.1"):
from fastapi import Request
scope = {
"type": "http",
"method": "GET",
"headers": [(b"x-forwarded-for", b"10.0.0.1, 10.0.0.2")],
}
request = Request(scope)
key = _rate_limit_key(request)
assert key == "192.168.1.1"
def test_with_trust_proxy_uses_xff(self, monkeypatch):
"""With TERROLOGY_TRUST_PROXY=1, should use X-Forwarded-For."""
monkeypatch.setenv("TERROLOGY_TRUST_PROXY", "1")
from fastapi import Request
scope = {
"type": "http",
"method": "GET",
"headers": [(b"x-forwarded-for", b"10.0.0.1, 10.0.0.2, 10.0.0.3")],
}
request = Request(scope)
key = _rate_limit_key(request)
assert key == "10.0.0.1", "Should use first IP from X-Forwarded-For"
def test_trust_proxy_strips_whitespace(self, monkeypatch):
"""X-Forwarded-For parsing should strip whitespace."""
monkeypatch.setenv("TERROLOGY_TRUST_PROXY", "1")
from fastapi import Request
scope = {
"type": "http",
"method": "GET",
"headers": [(b"x-forwarded-for", b" 10.0.0.1 , 10.0.0.2 ")],
}
request = Request(scope)
key = _rate_limit_key(request)
assert key == "10.0.0.1"
def test_trust_proxy_fallback_no_xff(self, monkeypatch):
"""With TERROLOGY_TRUST_PROXY=1 but no XFF header, fall back to peer IP."""
monkeypatch.setenv("TERROLOGY_TRUST_PROXY", "1")
with patch("web.app.get_remote_address", return_value="192.168.1.1"):
from fastapi import Request
scope = {
"type": "http",
"method": "GET",
"headers": [],
}
request = Request(scope)
key = _rate_limit_key(request)
assert key == "192.168.1.1"
class TestJobStore:
"""Test JobStore functionality."""
def test_create_returns_job(self):
"""create() should return a Job with QUEUED status."""
job = store.create("test_job")
assert job.id == "test_job"
assert job.status == JobStatus.QUEUED
def test_get_returns_none_for_missing(self):
"""get() should return None for non-existent job."""
result = store.get("nonexistent")
assert result is None
def test_running_count(self):
"""running_count() should count only RUNNING jobs."""
store.create("job1") # QUEUED
store.create("job2")
store.update("job2", status=JobStatus.RUNNING) # RUNNING
store.create("job3")
store.update("job3", status=JobStatus.READY) # READY
assert store.running_count() == 1
def test_cleanup_expired(self):
"""cleanup_expired() should remove jobs older than JOB_TTL."""
from datetime import UTC, datetime, timedelta
from web.jobs import JOB_TTL
job1 = store.create("job1")
store.create("job2")
# Manually set job1 to be old (expired)
old_time = datetime.now(tz=UTC) - JOB_TTL - timedelta(seconds=1)
with store._lock:
job1.created_at = old_time
store.cleanup_expired()
assert store.get("job1") is None, "Expired job should be deleted"
assert store.get("job2") is not None, "Recent job should remain"
class TestJobParamsValidation:
"""Test JobParams field validation."""
# --- Route validation ---
def test_route_too_few_points(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, route=[[0.0, 51.5]])
def test_route_too_many_points(self):
route = [[float(i % 360 - 180), float(i % 180 - 90)] for i in range(5001)]
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, route=route)
def test_route_out_of_bounds_lon(self):
with pytest.raises(Exception):
JobParams(route=[[200.0, 51.5], [-0.1, 51.5]])
def test_route_out_of_bounds_lat(self):
with pytest.raises(Exception):
JobParams(route=[[-0.1, 95.0], [-0.1, 51.5]])
def test_route_three_element_point(self):
with pytest.raises(Exception):
JobParams(route=[[-0.1, 51.5, 100.0], [-0.1, 51.6]])
def test_valid_route_without_lat_lon(self):
params = JobParams(route=[[-0.1, 51.5], [-0.2, 51.6]])
assert params.route is not None
assert len(params.route) == 2
def test_route_and_to_lat_mutually_exclusive(self):
with pytest.raises(Exception):
JobParams(
lat=51.5,
lon=-0.1,
route=[[-0.1, 51.5], [-0.2, 51.6]],
to_lat=51.7,
to_lon=-0.3,
)
# --- Two-point mode ---
def test_to_lat_without_to_lon(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, to_lat=51.7)
def test_to_lon_without_to_lat(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, to_lon=-0.3)
def test_two_point_without_lat_lon(self):
with pytest.raises(Exception):
JobParams(to_lat=51.7, to_lon=-0.3)
def test_valid_two_point(self):
params = JobParams(lat=51.5, lon=-0.1, to_lat=51.7, to_lon=-0.3)
assert params.to_lat == 51.7
assert params.to_lon == -0.3
def test_to_lat_to_lon_and_polygon_mutually_exclusive(self):
with pytest.raises(Exception):
JobParams(
lat=51.5,
lon=-0.1,
to_lat=51.7,
to_lon=-0.3,
polygon=[[-0.2, 51.4], [-0.1, 51.4], [-0.1, 51.6], [-0.2, 51.6]],
)
# --- Bounds validation ---
def test_size_too_small(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, size=99.0)
def test_size_too_large(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, size=257.0)
def test_scale_too_small(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, scale=999.0)
def test_route_width_too_small(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, route_width=0.4)
def test_smooth_boundary_too_large(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, smooth_boundary=7)
def test_color_depth_mm_too_small(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, color_depth_mm=0.4)
def test_min_building_area_negative(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, min_building_area=-1.0)
# --- no_terrain conflicts ---
def test_no_terrain_and_no_buildings(self):
with pytest.raises(Exception):
JobParams(lat=51.5, lon=-0.1, no_terrain=True, no_buildings=True)
def test_no_terrain_and_route(self):
with pytest.raises(Exception):
JobParams(no_terrain=True, route=[[-0.1, 51.5], [-0.2, 51.6]])
# --- Span abuse guard ---
def test_route_span_too_large(self):
# Points ~400 km apart
with pytest.raises(Exception, match="300 km"):
JobParams(route=[[-5.0, 50.0], [5.0, 53.0]])
def test_two_point_span_too_large(self):
with pytest.raises(Exception, match="300 km"):
JobParams(lat=50.0, lon=-5.0, to_lat=54.0, to_lon=5.0)
# --- Backward-compat ---
def test_existing_pin_payload_still_valid(self):
params = JobParams(lat=51.5, lon=-0.1, radius=500)
assert params.lat == 51.5
assert params.lon == -0.1
assert params.radius == 500
# --- TestClient 422 checks ---
def test_client_route_too_few_points_returns_422(self):
client = TestClient(app)
with patch("web.app.run_job"):
resp = client.post(
"/api/jobs",
json={"route": [[0.0, 51.5]]},
)
assert resp.status_code == 422
def test_client_no_terrain_no_buildings_returns_422(self):
client = TestClient(app)
with patch("web.app.run_job"):
resp = client.post(
"/api/jobs",
json={
"lat": 51.5,
"lon": -0.1,
"no_terrain": True,
"no_buildings": True,
},
)
assert resp.status_code == 422
class TestWorkerParamMapping:
"""Test _worker correctly maps JobParams to run_pipeline kwargs."""
def _run_worker(self, params: dict, tmp_path):
"""Call _worker in-process with run_pipeline patched, return captured kwargs."""
from web.generate import _worker
captured = {}
def fake_run_pipeline(**kwargs):
captured.update(kwargs)
with patch("terrology.cli.run_pipeline", side_effect=fake_run_pipeline):
_worker(json.dumps(params), str(tmp_path))
return captured
def test_route_job_lat_lon_midpoint(self, tmp_path):
"""Route mode: lat/lon must equal the track midpoints."""
route = [[-0.1, 51.4], [-0.2, 51.6]]
params = {
"route": route,
"terrain_exag": 2.0,
"colors": 4,
"span_buffer": 0.05,
"route_width": 1.5,
}
kwargs = self._run_worker(params, tmp_path)
expected_lat = (51.4 + 51.6) / 2
expected_lon = (-0.1 + -0.2) / 2
assert abs(kwargs["lat"] - expected_lat) < 1e-9
assert abs(kwargs["lon"] - expected_lon) < 1e-9
def test_route_job_bbox_utm(self, tmp_path):
"""Route mode: bbox_utm must match independently computed value."""
from terrology.cli import _bbox_with_buffer, _setup_utm
route = [[-0.1, 51.4], [-0.2, 51.6]]
span_buffer = 0.05
params = {
"route": route,
"terrain_exag": 2.0,
"colors": 4,
"span_buffer": span_buffer,
}
kwargs = self._run_worker(params, tmp_path)
lat = (51.4 + 51.6) / 2
lon = (-0.1 + -0.2) / 2
_, to_utm, _ = _setup_utm(lat, lon)
pts_utm = [to_utm.transform(rlon, rlat) for rlon, rlat in route]
xs = [p[0] for p in pts_utm]
ys = [p[1] for p in pts_utm]
expected_bbox = _bbox_with_buffer(xs, ys, span_buffer)
assert kwargs["bbox_utm"] == pytest.approx(expected_bbox, rel=1e-6)
def test_route_job_route_points_utm_length_and_endpoints(self, tmp_path):
"""Route mode: route_points_utm has the right length and endpoints."""
from terrology.cli import _setup_utm
route = [[-0.1, 51.4], [-0.15, 51.5], [-0.2, 51.6]]
params = {
"route": route,
"terrain_exag": 2.0,
"colors": 4,
"span_buffer": 0.05,
}
kwargs = self._run_worker(params, tmp_path)
assert len(kwargs["route_points_utm"]) == 3
lat = (51.4 + 51.6) / 2
lon = (-0.1 + -0.2) / 2
_, to_utm, _ = _setup_utm(lat, lon)
first_expected = to_utm.transform(-0.1, 51.4)
last_expected = to_utm.transform(-0.2, 51.6)
assert kwargs["route_points_utm"][0] == pytest.approx(first_expected, rel=1e-6)
assert kwargs["route_points_utm"][-1] == pytest.approx(last_expected, rel=1e-6)
def test_route_width_forwarded(self, tmp_path):
"""route_width must be forwarded to run_pipeline."""
params = {
"route": [[-0.1, 51.4], [-0.2, 51.6]],
"terrain_exag": 2.0,
"colors": 4,
"route_width": 2.5,
}
kwargs = self._run_worker(params, tmp_path)
assert kwargs["route_width"] == 2.5
def test_two_point_bbox_utm_brackets_both_points(self, tmp_path):
"""Two-point mode: bbox_utm must contain both projected points."""
from terrology.cli import _bbox_with_buffer, _setup_utm
lat1, lon1 = 51.4, -0.1
lat2, lon2 = 51.6, -0.3
span_buffer = 0.05
params = {
"lat": lat1,
"lon": lon1,
"to_lat": lat2,
"to_lon": lon2,
"terrain_exag": 2.0,
"colors": 4,
"span_buffer": span_buffer,
}
kwargs = self._run_worker(params, tmp_path)
lat = (lat1 + lat2) / 2
lon = (lon1 + lon2) / 2
_, to_utm, _ = _setup_utm(lat, lon)
x1, y1 = to_utm.transform(lon1, lat1)
x2, y2 = to_utm.transform(lon2, lat2)
expected_bbox = _bbox_with_buffer([x1, x2], [y1, y2], span_buffer)
assert kwargs["bbox_utm"] == pytest.approx(expected_bbox, rel=1e-6)
def test_polygon_smooth_boundary(self, tmp_path):
"""Polygon + smooth_boundary=2: exterior coord count roughly quadruples."""
# Simple square polygon (4 corners)
polygon = [
[-0.2, 51.4],
[-0.1, 51.4],
[-0.1, 51.5],
[-0.2, 51.5],
[-0.2, 51.4], # closing ring
]
params = {
"polygon": polygon,
"smooth_boundary": 2,
"terrain_exag": 2.0,
"colors": 4,
}
captured = {}
def fake_run_pipeline(**kwargs):
captured.update(kwargs)
from web.generate import _worker
with patch("terrology.cli.run_pipeline", side_effect=fake_run_pipeline):
_worker(json.dumps(params), str(tmp_path))
# After 2 Chaikin iterations: 4 corners → 8 → 16 (exterior is closed so +1 for coords)
poly = captured.get("clip_polygon_wgs84")
assert poly is not None
# Original had 4 unique corners; after 2 iterations should have ~16
n_coords = len(poly.exterior.coords) - 1 # exclude closing duplicate
assert n_coords >= 12, (
f"Expected at least 12 coords after 2 Chaikin iterations, got {n_coords}"
)
def test_passthrough_kwargs(self, tmp_path):
"""All passthrough kwargs must be forwarded to run_pipeline."""
params = {
"lat": 51.5,
"lon": -0.1,
"radius": 500,
"terrain_exag": 2.0,
"colors": 4,
"scale": 5000.0,
"size": 200.0,
"min_building_area": 25.0,
"color_depth_mm": 2.0,
"waterway_width": 1.5,
"raceway_width": 2.0,
"no_terrain": False,
}
kwargs = self._run_worker(params, tmp_path)
assert kwargs["scale"] == 5000.0
assert kwargs["size"] == 200.0
assert kwargs["min_building_area"] == 25.0
assert kwargs["color_depth_mm"] == 2.0
assert kwargs["waterway_width"] == 1.5
assert kwargs["raceway_width"] == 2.0
assert kwargs["no_terrain"] is False
def test_status_json_ready_on_success(self, tmp_path):
"""_status.json must be written as ready on success."""
params = {
"lat": 51.5,
"lon": -0.1,
"radius": 500,
"terrain_exag": 2.0,
"colors": 4,
}
from web.generate import _worker
with patch("terrology.cli.run_pipeline"):
_worker(json.dumps(params), str(tmp_path))
status = json.loads((tmp_path / "_status.json").read_text())
assert status["status"] == "ready"
def test_status_json_error_on_exception(self, tmp_path):
"""_status.json must be written as error when run_pipeline raises."""
params = {
"lat": 51.5,
"lon": -0.1,
"radius": 500,
"terrain_exag": 2.0,
"colors": 4,
}
from web.generate import _worker
with patch("terrology.cli.run_pipeline", side_effect=RuntimeError("boom")):
_worker(json.dumps(params), str(tmp_path))
status = json.loads((tmp_path / "_status.json").read_text())
assert status["status"] == "error"
assert "boom" in status["error"]