from __future__ import annotations from datetime import datetime, timezone from email.message import Message from types import SimpleNamespace from urllib.error import URLError import uuid import pytest from app.core.config import Settings from app.models import Dataset from app.services.source_catalog_probe_service import SourceCatalogProbeService from app.services.statbel_catalog_probe import StatbelCatalogError, parse_statbel_population_catalog NOW = datetime(2026, 7, 16, 18, 0, tzinfo=timezone.utc) STATBEL_DCAT_URL = "https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl" ALZ_RELEASE_URL = "https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen" def _dataset_block( year: int, *, node_id: int, host: str = "statbel.fgov.be", include_new_zip: bool = True, include_standard_zip: bool = False, ) -> str: distributions = [ f"https://{host}/sites/default/files/files/opendata/bevolking/sectoren/OPENDATA_SECTOREN_{year}_NEW.xlsx#distribution{node_id}", f"https://{host}/sites/default/files/files/opendata/bevolking/sectoren/OPENDATA_SECTOREN_{year}_OLD.zip#distribution{node_id}", ] if include_new_zip: distributions.append( f"https://{host}/sites/default/files/files/opendata/bevolking/sectoren/OPENDATA_SECTOREN_{year}_NEW.zip#distribution{node_id}" ) if include_standard_zip: distributions.append( f"https://{host}/sites/default/files/files/opendata/bevolking/sectoren/OPENDATA_SECTOREN_{year}.zip#distribution{node_id}" ) distribution_values = ",\n ".join(f"<{value}>" for value in distributions) return f""" a dcat:Dataset ; dct:title "Bevolking per statistische sector"@nl ; dct:alternative "Bevolking per statistische sector [Periode: {year}]"@nl ; dct:identifier "NodeID{node_id}" ; dct:license ; dct:temporal [ dcat:startDate "{year}-01-01"^^xsd:date ] ; dcat:landingPage ; dcat:distribution {distribution_values} . """ def _catalog(*blocks: str) -> bytes: return f""" @prefix dcat: . @prefix dct: . @prefix xsd: . a dcat:Catalog ; dct:modified "2026-07-07"^^xsd:date . {''.join(blocks)} """.encode() class _Response: def __init__( self, content: bytes, *, content_type: str = "application/octet-stream", final_url: str | None = None, content_length: int | None = None, ) -> None: self.content = content self.final_url = final_url self.headers = Message() self.headers["Content-Type"] = content_type self.headers["Content-Length"] = str(content_length if content_length is not None else len(content)) self.headers["ETag"] = '"statbel-test"' self.headers["Last-Modified"] = "Mon, 13 Jul 2026 08:22:54 GMT" def __enter__(self): return self def __exit__(self, *_args): return False def read(self, size: int = -1) -> bytes: return self.content if size < 0 else self.content[:size] def geturl(self) -> str: return self.final_url or STATBEL_DCAT_URL class _Query: def __init__(self, datasets: list[Dataset]) -> None: self.datasets = datasets def filter(self, *_args): return self def all(self) -> list[Dataset]: return self.datasets class _Db: def __init__(self, datasets: list[Dataset]) -> None: self.datasets = datasets def get(self, _model, identifier): return SimpleNamespace(id=identifier) def query(self, _model): return _Query(self.datasets) def _settings(**overrides) -> Settings: values = { "SOURCE_CATALOG_PROBE_ENABLED": True, "SOURCE_CATALOG_GRB_WFS_URL": "https://geo.api.vlaanderen.be/GRB/wfs", "SOURCE_CATALOG_ALZ_RELEASE_URL": ALZ_RELEASE_URL, "SOURCE_CATALOG_STATBEL_DCAT_URL": STATBEL_DCAT_URL, "SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB": 5, "SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS": 3, "SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB": 1, "SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS": 0, "ORTHOPHOTO_WMS_URL": "https://geo.api.vlaanderen.be/OMWRGBMRVL/wms", "ORTHOPHOTO_WMS_LAYER": "Ortho", } values.update(overrides) return Settings(**values) def _statbel_item(content: bytes, *, final_url: str | None = None, content_length: int | None = None, datasets=None): def opener(request, timeout): if request.full_url == STATBEL_DCAT_URL: return _Response(content, final_url=final_url, content_length=content_length) raise URLError("not needed for selected assertion") report = SourceCatalogProbeService.audit_project( _Db(datasets or []), uuid.uuid4(), settings=_settings(), opener=opener, now=NOW ) return next(item for item in report.items if item.source_name == "statbel") def test_statbel_parser_selects_latest_population_release_and_redegeo_variant() -> None: release = parse_statbel_population_catalog( _catalog(_dataset_block(2024, node_id=5510), _dataset_block(2025, node_id=6475)) ) assert release.version == "2025" assert release.identifier == "NodeID6475" assert release.current_distribution_variant == "new" assert release.legacy_distribution_available is True assert release.distribution_count == 3 assert release.catalog_modified_at == datetime(2026, 7, 7, tzinfo=timezone.utc) def test_statbel_probe_compares_latest_local_year_not_latest_import_order() -> None: older = Dataset( id=uuid.uuid4(), project_id=uuid.uuid4(), name="Statbel 2024", dataset_type="vector", source="statbel", source_name="statbel", source_version="2024", imported_at=NOW, status="ready", ) newer = Dataset( id=uuid.uuid4(), project_id=uuid.uuid4(), name="Statbel 2025", dataset_type="vector", source="statbel", source_name="statbel", source_version="2025", imported_at=NOW.replace(year=2025), status="ready", ) item = _statbel_item(_catalog(_dataset_block(2025, node_id=6475)), datasets=[older, newer]) assert item.status == "available" assert item.local_source_version == "2025" assert item.remote_version == "2025" assert item.comparison_status == "same" assert item.metadata_identifier == "NodeID6475" assert item.matched_layers == ["population_txt_current", "landing_page", "cc_by_4_0"] def test_statbel_parser_rejects_untrusted_distribution_host() -> None: with pytest.raises(StatbelCatalogError) as exc_info: parse_statbel_population_catalog(_catalog(_dataset_block(2025, node_id=6475, host="example.com"))) assert exc_info.value.code == "CATALOG_STATBEL_DISTRIBUTION_REJECTED" def test_statbel_parser_requires_new_2025_txt_distribution() -> None: with pytest.raises(StatbelCatalogError) as exc_info: parse_statbel_population_catalog( _catalog(_dataset_block(2025, node_id=6475, include_new_zip=False)) ) assert exc_info.value.code == "CATALOG_STATBEL_CURRENT_DISTRIBUTION_MISSING" def test_statbel_parser_rejects_conflicting_period_evidence() -> None: content = _catalog(_dataset_block(2025, node_id=6475)).replace( b'dcat:startDate "2025-01-01"', b'dcat:startDate "2024-01-01"', ) with pytest.raises(StatbelCatalogError) as exc_info: parse_statbel_population_catalog(content) assert exc_info.value.code == "CATALOG_STATBEL_PERIOD_AMBIGUOUS" def test_statbel_parser_rejects_duplicate_latest_release() -> None: with pytest.raises(StatbelCatalogError) as exc_info: parse_statbel_population_catalog( _catalog(_dataset_block(2025, node_id=6475), _dataset_block(2025, node_id=7000)) ) assert exc_info.value.code == "CATALOG_STATBEL_POPULATION_AMBIGUOUS" def test_statbel_parser_requires_cc_by_4_license() -> None: content = _catalog(_dataset_block(2025, node_id=6475)).replace( b"https://creativecommons.org/licenses/by/4.0/", b"https://example.com/unknown-license", ) with pytest.raises(StatbelCatalogError) as exc_info: parse_statbel_population_catalog(content) assert exc_info.value.code == "CATALOG_STATBEL_LICENSE_MISSING" def test_statbel_probe_revalidates_catalog_url_after_redirect() -> None: item = _statbel_item( _catalog(_dataset_block(2025, node_id=6475)), final_url="https://example.com/DCAT_opendata_datasets.ttl", ) assert item.status == "unavailable" assert item.error_code == "CATALOG_STATBEL_URL_REJECTED" def test_statbel_probe_rejects_noncanonical_configured_catalog_before_network() -> None: calls: list[str] = [] def opener(request, timeout): calls.append(request.full_url) raise URLError("network must not be reached for the rejected Statbel URL") report = SourceCatalogProbeService.audit_project( _Db([]), uuid.uuid4(), settings=_settings(SOURCE_CATALOG_STATBEL_DCAT_URL="https://example.com/catalog.ttl"), opener=opener, now=NOW, ) item = next(value for value in report.items if value.source_name == "statbel") assert item.status == "unavailable" assert item.error_code == "CATALOG_STATBEL_URL_REJECTED" assert "https://example.com/catalog.ttl" not in calls def test_statbel_probe_uses_separate_bounded_catalog_limit() -> None: item = _statbel_item( _catalog(_dataset_block(2025, node_id=6475)), content_length=6 * 1024 * 1024, ) assert item.status == "unavailable" assert item.error_code == "CATALOG_RESPONSE_TOO_LARGE" def test_statbel_probe_does_not_fetch_catalog_distributions() -> None: calls: list[str] = [] def opener(request, timeout): calls.append(request.full_url) if request.full_url == STATBEL_DCAT_URL: return _Response(_catalog(_dataset_block(2025, node_id=6475))) if "OPENDATA_SECTOREN" in request.full_url: raise AssertionError("Statbel distribution fetch attempted") raise URLError("unrelated provider offline") report = SourceCatalogProbeService.audit_project( _Db([]), uuid.uuid4(), settings=_settings(), opener=opener, now=NOW ) item = next(value for value in report.items if value.source_name == "statbel") assert item.status == "available" assert calls.count(STATBEL_DCAT_URL) == 1 assert all("OPENDATA_SECTOREN" not in value for value in calls)