from decimal import Decimal from io import StringIO import os from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch from django.core.management import call_command from django.test import SimpleTestCase import requests from config.proxy import resolve_requests_proxy_url from location_data.openeo_service import ( OpenEOConnectionSettings, OpenEOServiceError, OpenEOExecutionError, _log_raw_payload_summary, _load_first_json_payload, _load_job_result_payload, _resolve_openeo_proxy_url_from_env, _run_aggregate_spatial_job, log_openeo_request_summary, build_openeo_requests_session, build_empty_metric_payload, connect_openeo, is_openeo_auth_configured, linear_to_db, merge_metric_results, parse_aggregate_spatial_response, ) class OpenEOServiceParsingTests(SimpleTestCase): def test_parse_feature_collection_results(self): payload = { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": "cell-1", "properties": {"mean": 0.61}, }, { "type": "Feature", "id": "cell-2", "properties": {"mean": 0.47}, }, ], } result = parse_aggregate_spatial_response(payload, "ndvi") self.assertEqual(result["cell-1"]["ndvi"], 0.61) self.assertEqual(result["cell-2"]["ndvi"], 0.47) def test_parse_mapping_results(self): payload = { "cell-1": {"mean": 12.4}, "cell-2": {"mean": 15.1}, } result = parse_aggregate_spatial_response(payload, "ndwi") self.assertEqual(result["cell-1"]["ndwi"], 12.4) self.assertEqual(result["cell-2"]["ndwi"], 15.1) def test_parse_mapping_results_maps_numeric_keys_to_expected_feature_ids(self): payload = { "0": {"mean": 12.4}, "1": {"mean": 15.1}, } result = parse_aggregate_spatial_response( payload, "ndwi", expected_feature_ids=["cell-1", "cell-2"], ) self.assertEqual(result["cell-1"]["ndwi"], 12.4) self.assertEqual(result["cell-2"]["ndwi"], 15.1) def test_parse_list_results_maps_positional_payload_to_expected_feature_ids(self): payload = [{"mean": 0.61}, {"mean": 0.47}] result = parse_aggregate_spatial_response( payload, "ndvi", expected_feature_ids=["cell-1", "cell-2"], ) self.assertEqual(result["cell-1"]["ndvi"], 0.61) self.assertEqual(result["cell-2"]["ndvi"], 0.47) def test_parse_list_results_extracts_scalar_from_nested_list_payloads(self): payload = [[0.61], [0.47]] result = parse_aggregate_spatial_response( payload, "ndvi", expected_feature_ids=["cell-1", "cell-2"], ) self.assertEqual(result["cell-1"]["ndvi"], 0.61) self.assertEqual(result["cell-2"]["ndvi"], 0.47) def test_log_raw_payload_summary_warns_for_empty_payload(self): with self.assertLogs("location_data.openeo_service", level="WARNING") as captured: summary = _log_raw_payload_summary({}, metric_name="ndvi", job_ref="job-1") self.assertEqual(summary["returned_cell_count"], 0) self.assertIn("openEO payload is empty for job_ref=job-1", "\n".join(captured.output)) def test_parse_logs_feature_mismatch_for_unexpected_keys(self): payload = {"cell-1": {"foo": 12.4}} with self.assertLogs("location_data.openeo_service", level="WARNING") as captured: result = parse_aggregate_spatial_response(payload, "ndwi", job_ref="job-2") self.assertEqual(result["cell-1"]["ndwi"], 12.4) self.assertIn("Feature mismatch for cell=cell-1, available_keys=['foo']", "\n".join(captured.output)) def test_linear_to_db(self): self.assertEqual(linear_to_db(10.0), 10.0) self.assertEqual(linear_to_db(Decimal("1.0")), 0.0) self.assertIsNone(linear_to_db(0)) self.assertIsNone(linear_to_db(-1)) def test_merge_metric_results(self): target = {"cell-1": build_empty_metric_payload()} merge_metric_results( target, { "cell-1": {"ndvi": 0.5}, "cell-2": {"ndwi": 0.2}, }, ) self.assertEqual(target["cell-1"]["ndvi"], 0.5) self.assertEqual(target["cell-2"]["ndwi"], 0.2) self.assertIn("soil_vv_db", target["cell-2"]) def test_log_openeo_request_summary_logs_expected_fields(self): cell = Mock() cell.cell_code = "cell-1" with self.assertLogs("location_data.openeo_service", level="INFO") as captured: log_openeo_request_summary( cells=[cell], temporal_start="2026-04-08", temporal_end="2026-05-08", spatial_extent={"west": 49.9995, "south": 49.9995, "east": 50.0005, "north": 50.0005}, selected_features=["ndvi", "ndwi"], ) joined = "\n".join(captured.output) self.assertIn("openEO request summary", joined) self.assertIn('"cell_count": 1', joined) self.assertIn('"date_range_days": 31', joined) self.assertIn('"metrics": ["ndvi", "ndwi"]', joined) class OpenEOConnectionTests(SimpleTestCase): def test_default_openeo_timeout_is_ten_minutes(self): with patch.dict(os.environ, {}, clear=True): settings = OpenEOConnectionSettings.from_env() self.assertEqual(settings.timeout_seconds, 600.0) def test_default_openeo_proxy_url_uses_proxychains_endpoint_without_wrapping_process(self): with patch.dict( os.environ, { "ENABLE_PROXYCHAINS": "0", "PROXYCHAINS_PROXY_TYPE": "socks4", "PROXYCHAINS_PROXY_HOST": "host.docker.internal", "PROXYCHAINS_PROXY_PORT": "10808", "OPENEO_PROXY_URL": "socks5h://host.docker.internal:10808", }, clear=False, ): self.assertEqual( _resolve_openeo_proxy_url_from_env(), "socks4a://host.docker.internal:10808", ) def test_requests_proxy_is_disabled_when_proxychains_targets_same_endpoint(self): with patch.dict( os.environ, { "ENABLE_PROXYCHAINS": "1", "PROXYCHAINS_PROXY_TYPE": "socks4", "PROXYCHAINS_PROXY_HOST": "host.docker.internal", "PROXYCHAINS_PROXY_PORT": "10808", }, clear=False, ): self.assertEqual( resolve_requests_proxy_url("socks5h://host.docker.internal:10808"), "", ) def test_is_openeo_auth_configured_for_client_credentials(self): self.assertTrue( is_openeo_auth_configured( OpenEOConnectionSettings( auth_method="client_credentials", client_id="client-id", client_secret="client-secret", ) ) ) def test_is_openeo_auth_configured_for_password(self): self.assertTrue( is_openeo_auth_configured( OpenEOConnectionSettings( auth_method="password", username="user@example.com", password="secret", ) ) ) def test_verify_openeo_auth_command_skips_when_unconfigured(self): stdout = StringIO() call_command("verify_openeo_auth", "--skip-if-unconfigured", stdout=stdout) self.assertIn("openEO auth check skipped", stdout.getvalue()) def test_connect_openeo_applies_proxy_to_session(self): connection = Mock() connection._get_oidc_provider.return_value = ("provider-1", None) connection.get.return_value.json.return_value = { "providers": [ { "id": "provider-1", "title": "Provider 1", "issuer": "https://issuer.example.com", "scopes": ["openid"], "default_clients": [], } ] } openeo_module = Mock() openeo_module.connect.return_value = connection oidc_module = Mock() oidc_module.OidcClientCredentialsAuthenticator.return_value = Mock() oidc_module.OidcClientInfo.side_effect = lambda **kwargs: kwargs oidc_module.OidcProviderInfo.side_effect = lambda **kwargs: kwargs oidc_module.OidcResourceOwnerPasswordAuthenticator.return_value = Mock() connection._authenticate_oidc.return_value = connection settings = OpenEOConnectionSettings( backend_url="https://openeofed.dataspace.copernicus.eu", auth_method="password", timeout_seconds=123, client_id="client-id", username="user@example.com", password="secret", proxy_url="socks5h://127.0.0.1:10808", ) with patch.dict( "sys.modules", { "openeo": openeo_module, "openeo.rest": Mock(), "openeo.rest.auth": Mock(), "openeo.rest.auth.oidc": oidc_module, }, ): connect_openeo(settings) self.assertEqual(openeo_module.connect.call_args.kwargs["default_timeout"], 123) session = openeo_module.connect.call_args.kwargs["session"] self.assertEqual(session.proxies["https"], "socks5h://127.0.0.1:10808") self.assertFalse(session.trust_env) def test_timeout_override_session_logs_request_payload_before_dispatch(self): response = Mock(status_code=200, headers={"Content-Type": "application/json"}) response.text = "{}" response.url = "https://openeofed.dataspace.copernicus.eu/result" with patch.object(requests.Session, "request", return_value=response) as request_mock: with self.assertLogs("location_data.openeo_service", level="INFO") as captured: session = build_openeo_requests_session(OpenEOConnectionSettings(proxy_url="socks5h://127.0.0.1:10808")) session.request( "post", "https://openeofed.dataspace.copernicus.eu/result", json={"process": {"foo": "bar"}}, headers={"Authorization": "Bearer secret"}, ) request_mock.assert_called_once() self.assertTrue(any("openEO request payload" in line for line in captured.output)) self.assertTrue(any("***redacted***" in line for line in captured.output)) def test_connect_openeo_raises_clear_error_for_html_capabilities_response(self): settings = OpenEOConnectionSettings( backend_url="https://openeofed.dataspace.copernicus.eu", timeout_seconds=600, ) bad_response = Mock() bad_response.url = "https://openeofed.dataspace.copernicus.eu/" bad_response.headers = {"Content-Type": "text/html"} bad_response.text = "proxy page" session = build_openeo_requests_session(settings) session.last_response_url = bad_response.url session.last_response_content_type = "text/html" session.last_response_preview = bad_response.text openeo_module = Mock() openeo_module.connect.side_effect = requests.exceptions.JSONDecodeError("Expecting value", "", 0) oidc_module = Mock() with patch("location_data.openeo_service.build_openeo_requests_session", return_value=session): with patch.dict( "sys.modules", { "openeo": openeo_module, "openeo.rest": Mock(), "openeo.rest.auth": Mock(), "openeo.rest.auth.oidc": oidc_module, }, ): with self.assertRaisesRegex(OpenEOServiceError, "non-JSON response"): connect_openeo(settings) def test_build_openeo_requests_session_mounts_retrying_adapters(self): session = build_openeo_requests_session( OpenEOConnectionSettings( timeout_seconds=120, http_retry_total=5, http_retry_backoff_factor=2.0, ) ) https_adapter = session.get_adapter("https://openeofed.dataspace.copernicus.eu") self.assertEqual(https_adapter.max_retries.total, 5) self.assertEqual(https_adapter.max_retries.connect, 5) self.assertEqual(https_adapter.max_retries.backoff_factor, 2.0) self.assertIsNone(https_adapter.max_retries.allowed_methods) def test_run_aggregate_spatial_job_prefers_batch_job_results(self): process = Mock() job = Mock(job_id="job-123") process.create_job.return_value = job job.start_and_wait.return_value = job results = Mock() job.get_results.return_value = results def write_json(target_dir): Path(target_dir, "result.json").write_text('{"cell-1": {"mean": 0.5}}', encoding="utf-8") results.download_files.side_effect = write_json payload, job_ref = _run_aggregate_spatial_job(process, metric_name="ndvi") self.assertEqual(payload, {"cell-1": {"mean": 0.5}}) self.assertEqual(job_ref, "job-123") process.execute.assert_not_called() def test_load_job_result_payload_archives_exact_raw_json_file(self): job = Mock(job_id="job-123") results = Mock() job.get_results.return_value = results raw_json = '{\n "cell-1": {"mean": 0.5}\n}\n' def write_json(target_dir): Path(target_dir, "timeseries.json").write_text(raw_json, encoding="utf-8") results.download_files.side_effect = write_json with TemporaryDirectory() as archive_dir: with patch.dict(os.environ, {"OPENEO_PAYLOAD_ARCHIVE_DIR": archive_dir}, clear=False): payload = _load_job_result_payload(job, metric_name="ndvi") archive_path = Path(archive_dir) / "job-123__ndvi__timeseries.json" self.assertTrue(archive_path.exists()) self.assertEqual(archive_path.read_text(encoding="utf-8"), raw_json) self.assertEqual(payload, {"cell-1": {"mean": 0.5}}) def test_load_first_json_payload_prefers_stac_asset_data_over_metadata(self): with TemporaryDirectory() as temp_dir: Path(temp_dir, "item.json").write_text( ( '{"stac_version":"1.0.0","assets":{"timeseries.json":{"href":"timeseries.json"}},' '"extent":{"spatial":{},"temporal":{}}}' ), encoding="utf-8", ) Path(temp_dir, "timeseries.json").write_text('{"cell-1": {"mean": 0.5}}', encoding="utf-8") payload = _load_first_json_payload(Path(temp_dir), job_ref="job-asset") self.assertEqual(payload, {"cell-1": {"mean": 0.5}}) def test_load_first_json_payload_raises_clear_error_for_invalid_json(self): with TemporaryDirectory() as temp_dir: Path(temp_dir, "result.json").write_text("not-json", encoding="utf-8") with self.assertRaises(OpenEOExecutionError): with self.assertLogs("location_data.openeo_service", level="ERROR"): _load_first_json_payload(Path(temp_dir), job_ref="job-123")