diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..7709269 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,33 @@ +name: tests + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + env: + API_KEY: ${{ secrets.DID_API_KEY }} + PYTHONPATH: ${{ github.workspace }}/examples + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -r examples/requirements.txt + pip install pytest pytest-cov + + - name: Run pytests + run: | + pytest --cov=examples --cov-report=term-missing diff --git a/README.md b/README.md index 2500cec..3c3ccfa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![tests](https://github.com/abc-rp/asset-api-docs/actions/workflows/tests.yaml/badge.svg)](https://github.com/abc-rp/asset-api-docs/actions/workflows/tests.yaml) [![format](https://github.com/abc-rp/asset-api-docs/actions/workflows/format.yaml/badge.svg)](https://github.com/abc-rp/asset-api-docs/actions/workflows/format.yaml) # xRI Asset API (didapi.io) diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/requirements.txt b/examples/requirements.txt index ae0d4ce..7b73293 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -1,15 +1,17 @@ anyio==4.9.0 Brotli==1.1.0 -certifi==2025.1.31 -exceptiongroup==1.2.2 -h11==0.14.0 -httpcore==1.0.7 +certifi==2025.6.15 +h11==0.16.0 +httpcore==1.0.9 httpx==0.28.1 idna==3.10 -isodate==0.7.2 -oxrdflib==0.4.0 -pyoxigraph==0.4.9 +iniconfig==2.1.0 +logging==0.4.9.6 +packaging==25.0 +pluggy==1.6.0 +Pygments==2.19.2 pyparsing==3.2.3 +pytest==8.4.1 rdflib==7.1.4 sniffio==1.3.1 -typing_extensions==4.13.2 +typing_extensions==4.14.0 diff --git a/examples/tests/test_br_decompression.py b/examples/tests/test_br_decompression.py new file mode 100644 index 0000000..6eaf47c --- /dev/null +++ b/examples/tests/test_br_decompression.py @@ -0,0 +1,73 @@ +import br_decompress as br +import brotli +import query_assist as qa + + +def _make_compressed_pair(): + """Return (raw_bytes, brotli_compressed_bytes).""" + raw = b"FOR UNIT TEST ONLY - pretend this is a PCD header\n" + return raw, brotli.compress(raw) + + +class _DummyResponse: + def __init__(self, data): + self.status_code = 200 + self.headers = {"Content-Disposition": 'attachment; filename="cloud.pcd.br"'} + self.content = data + + def raise_for_status(self): + pass + + +class _DummyStore: + """Fake rdflib SPARQLStore that yields exactly one result row.""" + + def __init__(self, *_, **__): + pass + + def query(self, *_): + return [ + { + "uprnValue": "999", + "contentUrl": "https://example.com/cloud.pcd.br", + "enum": "did:lidar-pointcloud-merged", + } + ] + + +def test_download_and_decompress_brotli(tmp_path, monkeypatch): + raw, compressed = _make_compressed_pair() + + monkeypatch.setattr(qa, "SPARQLStore", _DummyStore) + + monkeypatch.setattr(qa.httpx, "get", lambda *a, **k: _DummyResponse(compressed)) + + monkeypatch.setenv("API_KEY", "DUMMY") + + monkeypatch.setattr( + qa, + "parse_args", + lambda: qa.argparse.Namespace( + uprn=["999"], + ods=None, + sensor=None, + types=None, + output_area=None, + db_url="http://dummy", + download_dir=str(tmp_path), + api_key_env="API_KEY", + ), + ) + + qa.main() + + p_br = tmp_path / "999" / "lidar-pointcloud-merged" / "cloud.pcd.br" + assert p_br.is_file(), "compressed asset should have been saved by query_assist" + + br.find_and_replace_pcd_br(str(tmp_path)) + + p_raw = p_br.with_suffix("") + assert p_raw.is_file(), "decompressed .pcd should exist" + assert not p_br.exists(), ".pcd.br should have been removed" + + assert p_raw.read_bytes() == raw, "decompressed bytes should match original" diff --git a/examples/tests/test_download_scripts.py b/examples/tests/test_download_scripts.py new file mode 100644 index 0000000..324a8e9 --- /dev/null +++ b/examples/tests/test_download_scripts.py @@ -0,0 +1,96 @@ +import importlib +from pathlib import Path + +import pytest + + +class _DummyRow(dict): + """row['uprnValue'] / row['contentUrl'] lookup just like ResultRow""" + + def __getitem__(self, key): + return super().get(key) + + +class _DummyEndpoint: + """Replaces SPARQLStore instance inside each script.""" + + def __init__(self, rows): + self._rows = rows + + def query(self, *_): + return self._rows + + +def _fake_response(): + class _R: + status_code = 200 + headers = {"Content-Disposition": 'attachment; filename="file.bin"'} + content = b"DUMMY" + + def raise_for_status(self): + pass + + return _R() + + +@pytest.mark.parametrize( + "mod_name, expects_uprn_subfolder", + [ + ("examples.get_all_assets_for_a_list_of_uprns", True), + ("examples.get_all_assets_for_a_uprn", False), + ("examples.get_all_assets_for_a_uprn_made_by_a_sensor", True), + ("examples.get_all_assets_of_type_for_list_of_uprns", True), + ], +) +def test_script_downloads(tmp_path, monkeypatch, mod_name, expects_uprn_subfolder): + """Import the script as a module, monkey-patch, run main(), check the file.""" + + import httpx + + monkeypatch.setattr(httpx, "get", lambda *a, **k: _fake_response()) + + monkeypatch.setenv("API_KEY", "UNIT-TEST-KEY") + + mod = importlib.import_module(mod_name) + + monkeypatch.setattr(mod, "DOWNLOAD_DIR", str(tmp_path)) + + monkeypatch.setattr(mod, "ResultRow", _DummyRow) + + dummy_rows = [_DummyRow({"uprnValue": "999", "contentUrl": "https://x/y.bin"})] + if hasattr(mod, "endpoint"): + monkeypatch.setattr(mod, "endpoint", _DummyEndpoint(dummy_rows)) + else: + monkeypatch.setattr(mod, "endpoint", _DummyEndpoint(dummy_rows)) + + mod.main() + + if expects_uprn_subfolder: + expected = Path(tmp_path) / "999" / "file.bin" + else: + expected = Path(tmp_path) / "file.bin" + + assert expected.is_file(), f"{mod_name}: expected {expected} to exist" + + +@pytest.mark.parametrize( + "mod_name, substrings", + [ + ("examples.get_all_assets_for_a_list_of_uprns", ["200003455212", "5045394"]), + ("examples.get_all_assets_for_a_uprn", ["5045394"]), + ( + "examples.get_all_assets_for_a_uprn_made_by_a_sensor", + ["5045394", "bess:OusterLidarSensor"], + ), + ( + "examples.get_all_assets_of_type_for_list_of_uprns", + ["did:rgb-image", "lidar-pointcloud-merged"], + ), + ], +) +def test_query_contains_expected_literals(mod_name, substrings): + """Make sure the hard-coded constants really appear in the QUERY string.""" + mod = importlib.import_module(mod_name) + q = mod.QUERY + for s in substrings: + assert s in q, f"{mod_name}: missing {s} in QUERY" diff --git a/examples/tests/test_query_assist.py b/examples/tests/test_query_assist.py new file mode 100644 index 0000000..a431aee --- /dev/null +++ b/examples/tests/test_query_assist.py @@ -0,0 +1,129 @@ +from types import SimpleNamespace + +import pytest +import query_assist as qa + + +@pytest.mark.parametrize( + "iri, expected", + [ + ("did:lidar-pointcloud-merged", "lidar-pointcloud-merged"), + ("https://w3id.org/foo/did:rgb-image", "rgb-image"), + ("did:ir-false-color-image", "ir-false-color-image"), + ("did:weird chars!*£$", "weird_chars____"), # sanitised + ], +) +def test_asset_subdir(iri, expected): + assert qa.asset_subdir(iri) == expected + + +def test_load_column_from_csv_good(tmp_path): + csv_file = tmp_path / "uprns.csv" + csv_file.write_text("uprn\n1\n2\n\n3\n") + assert qa.load_column_from_csv(csv_file, "uprn") == ["1", "2", "3"] + + +def test_load_column_from_csv_missing_col(tmp_path): + csv_file = tmp_path / "bad.csv" + csv_file.write_text("not_uprn\n123\n") + with pytest.raises(RuntimeError, match="missing required 'uprn'"): + qa.load_column_from_csv(csv_file, "uprn") + + +def test_build_asset_query_injects_everything(): + args = SimpleNamespace(sensor="bess:OusterLidarSensor", types="did:rgb-image") + q = qa.build_asset_query(["123"], args) + assert "bess:OusterLidarSensor" in q + assert "did:rgb-image" in q + assert '"123"' in q + # should ALWAYS bind ?enum + assert "?enum" in q and "dob:typeQualifier" in q + + +def test_build_output_area_query_formats_values(): + q = qa.build_output_area_query(["sid:E0001"]) + assert "VALUES ?outputArea { sid:E0001 }" in q + + +def test_build_ods_to_uprn_query_values_clause(): + q = qa.build_ods_to_uprn_query(["00ABC", "99XYZ"]) + assert 'VALUES ?odsValue { "00ABC" "99XYZ" }' in q + + +def _dummy_http_response(): + class _R: + status_code = 200 + headers = {"Content-Disposition": 'attachment; filename="file.bin"'} + content = b"PSEUDO-BINARY" + + def raise_for_status(self): + pass + + return _R() + + +class _DummyStore: + """Stand-in for rdflib SPARQLStore; returns a synthetic row list.""" + + def __init__(self, *a, **k): + pass + + def query(self, *_): + return [ + { + "uprnValue": "42", + "contentUrl": "https://example.com/file.bin", + "enum": "did:rgb-image", + } + ] + + +def test_cli_download_creates_nested_dir(tmp_path, monkeypatch): + """Full happy-path run – ensures ///file.bin is created.""" + monkeypatch.setattr(qa, "SPARQLStore", _DummyStore) + monkeypatch.setattr(qa.httpx, "get", lambda *a, **k: _dummy_http_response()) + monkeypatch.setenv("API_KEY", "FAKE-KEY") + + argv = ["query_assist", "--uprn", "42", "--download-dir", str(tmp_path)] + monkeypatch.setattr( + qa, + "parse_args", + lambda: qa.argparse.Namespace( + uprn=["42"], + ods=None, + sensor=None, + types=None, + output_area=None, + db_url="http://dummy", + download_dir=str(tmp_path), + api_key_env="API_KEY", + ), + ) + + qa.main() + + expected = tmp_path / "42" / "rgb-image" / "file.bin" + assert expected.is_file(), f"expected {expected} to exist" + + +def test_cli_fails_without_api_key(monkeypatch): + """Main should raise RuntimeError if API_KEY env var is missing.""" + monkeypatch.setattr(qa, "SPARQLStore", _DummyStore) + monkeypatch.delenv("API_KEY", raising=False) + + monkeypatch.setattr( + qa, + "parse_args", + lambda: qa.argparse.Namespace( + uprn=["1"], + ods=None, + sensor=None, + types=None, + output_area=None, + db_url="http://dummy", + download_dir=None, + api_key_env="API_KEY", + ), + ) + with pytest.raises(RuntimeError, match="Env var 'API_KEY' is not set"): + qa.main()