| 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.""" |
| |
| monkeypatch.setenv("MAX_CONCURRENT_JOBS", "1") |
| |
| import importlib |
|
|
| import web.app |
|
|
| importlib.reload(web.app) |
| from web.app import app as reloaded_app |
|
|
| client = TestClient(reloaded_app) |
|
|
| |
| def mock_run_job(job_id, params): |
| pass |
|
|
| with patch("web.app.run_job", side_effect=mock_run_job): |
| |
| 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"] |
|
|
| |
| job1 = store.get(job_id_1) |
| assert job1 is not None |
| assert job1.status == JobStatus.QUEUED |
|
|
| |
| 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.""" |
| |
| job1 = store.create("job1") |
| assert job1.status == JobStatus.QUEUED |
|
|
| |
| result = store.try_create("job2", max_active=1) |
| assert result is None, "Should reject when QUEUED count is at max" |
|
|
| |
| store.update("job1", status=JobStatus.RUNNING) |
|
|
| |
| result = store.try_create("job3", max_active=1) |
| assert result is None, "Should reject when RUNNING count is at max" |
|
|
| |
| 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.""" |
| |
| |
| store.create("job1") |
| store.update("job1", status=JobStatus.RUNNING) |
|
|
| |
| result = store.try_create("job2", max_active=1) |
| assert result is None |
|
|
| |
| 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") |
| |
|
|
| monkeypatch.setattr(store, "cleanup_expired", mock_cleanup) |
|
|
| |
| sleep_count = [0] |
|
|
| async def mock_sleep(duration): |
| sleep_count[0] += 1 |
| if sleep_count[0] >= 3: |
| |
| raise asyncio.CancelledError() |
| |
|
|
| monkeypatch.setattr("web.app.asyncio.sleep", mock_sleep) |
|
|
| |
| from web.app import _cleanup_loop |
|
|
| |
| task = asyncio.create_task(_cleanup_loop()) |
| try: |
| await task |
| except asyncio.CancelledError: |
| pass |
|
|
| |
| |
| 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") |
| store.create("job2") |
| store.update("job2", status=JobStatus.RUNNING) |
| store.create("job3") |
| store.update("job3", status=JobStatus.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") |
|
|
| |
| 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.""" |
|
|
| |
|
|
| 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, |
| ) |
|
|
| |
|
|
| 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]], |
| ) |
|
|
| |
|
|
| 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) |
|
|
| |
|
|
| 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]]) |
|
|
| |
|
|
| def test_route_span_too_large(self): |
| |
| 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) |
|
|
| |
|
|
| 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 |
|
|
| |
|
|
| 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.""" |
| |
| polygon = [ |
| [-0.2, 51.4], |
| [-0.1, 51.4], |
| [-0.1, 51.5], |
| [-0.2, 51.5], |
| [-0.2, 51.4], |
| ] |
| 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)) |
|
|
| |
| poly = captured.get("clip_polygon_wgs84") |
| assert poly is not None |
| |
| n_coords = len(poly.exterior.coords) - 1 |
| 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"] |
|
|