Skip to content

Commit 8a30f82

Browse files
committed
feat: add integration tests for STAC workflows
- Create tests/integration/test_stac_api.py - Test STAC item registration (create, skip, upsert modes) - Test visualization link generation - Test projection handling - Test full augmentation pipeline - Update CI workflow to run integration tests - Copy conftest.py for shared test fixtures
1 parent 4bdd64c commit 8a30f82

File tree

4 files changed

+345
-0
lines changed

4 files changed

+345
-0
lines changed

.github/workflows/test.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,12 @@ jobs:
3434

3535
- name: Run pre-commit checks
3636
run: uv run pre-commit run --all-files
37+
38+
- name: Run integration tests
39+
run: uv run pytest tests/integration -v -m integration --tb=short
40+
41+
- name: Generate coverage report
42+
run: uv run pytest tests/integration --cov=scripts --cov-report=term-missing
43+
44+
- name: Generate integration test coverage
45+
run: uv run pytest tests/integration --cov=scripts --cov-report=term-missing --cov-report=html

tests/conftest.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Pytest configuration and shared fixtures for data-pipeline tests."""
2+
3+
import atexit
4+
import sys
5+
import warnings
6+
7+
import pytest
8+
9+
# Suppress noisy async context warnings from zarr/s3fs
10+
warnings.filterwarnings("ignore", category=ResourceWarning)
11+
warnings.filterwarnings("ignore", message="coroutine.*was never awaited")
12+
13+
14+
# Global stderr filter that stays active even after pytest teardown
15+
_original_stderr = sys.stderr
16+
_suppress_traceback = False
17+
18+
19+
class _FilteredStderr:
20+
def write(self, text):
21+
global _suppress_traceback
22+
23+
# Start suppressing when we see async context errors
24+
if any(
25+
marker in text
26+
for marker in [
27+
"Exception ignored",
28+
"Traceback (most recent call last)",
29+
"ValueError: <Token",
30+
"was created in a different Context",
31+
"zarr/storage/",
32+
"s3fs/core.py",
33+
"aiobotocore/context.py",
34+
]
35+
):
36+
_suppress_traceback = True
37+
38+
# Reset suppression on empty lines (between tracebacks)
39+
if not text.strip():
40+
_suppress_traceback = False
41+
42+
# Only write if not currently suppressing
43+
if not _suppress_traceback:
44+
_original_stderr.write(text)
45+
46+
def flush(self):
47+
_original_stderr.flush()
48+
49+
50+
def _restore_stderr():
51+
"""Restore original stderr at exit."""
52+
sys.stderr = _original_stderr
53+
54+
55+
# Install filter at module load time
56+
sys.stderr = _FilteredStderr()
57+
atexit.register(_restore_stderr)
58+
59+
60+
@pytest.fixture(autouse=True, scope="function")
61+
def clear_prometheus_registry():
62+
"""Clear Prometheus registry before each test to avoid duplicates."""
63+
import contextlib
64+
65+
try:
66+
from prometheus_client import REGISTRY
67+
68+
collectors = list(REGISTRY._collector_to_names.keys())
69+
for collector in collectors:
70+
with contextlib.suppress(Exception):
71+
REGISTRY.unregister(collector)
72+
except ImportError:
73+
pass
74+
yield
75+
76+
77+
@pytest.fixture
78+
def sample_stac_item():
79+
"""Return a minimal STAC item for testing."""
80+
return {
81+
"type": "Feature",
82+
"stac_version": "1.0.0",
83+
"id": "test-item",
84+
"properties": {
85+
"datetime": "2025-01-01T00:00:00Z",
86+
"proj:epsg": 32636,
87+
},
88+
"geometry": {
89+
"type": "Polygon",
90+
"coordinates": [
91+
[
92+
[600000, 6290220],
93+
[709800, 6290220],
94+
[709800, 6400020],
95+
[600000, 6400020],
96+
[600000, 6290220],
97+
]
98+
],
99+
},
100+
"links": [],
101+
"assets": {
102+
"B01": {
103+
"href": "s3://bucket/data/B01.tif",
104+
"type": "image/tiff; application=geotiff",
105+
"roles": ["data"],
106+
"proj:epsg": 32636,
107+
"proj:shape": [10980, 10980],
108+
"proj:transform": [10, 0, 600000, 0, -10, 6400020],
109+
}
110+
},
111+
"collection": "test-collection",
112+
}
113+
114+
115+
@pytest.fixture
116+
def stac_item_with_proj_code(sample_stac_item):
117+
"""Return a STAC item with proj:code (should be removed)."""
118+
item = sample_stac_item.copy()
119+
item["properties"]["proj:code"] = "EPSG:32636"
120+
item["assets"]["B01"]["proj:code"] = "EPSG:32636"
121+
return item
122+
123+
124+
@pytest.fixture
125+
def mock_zarr_url():
126+
"""Return a sample GeoZarr URL."""
127+
return "s3://bucket/path/to/dataset.zarr"
128+
129+
130+
@pytest.fixture
131+
def mock_stac_api_url():
132+
"""Return a mock STAC API URL."""
133+
return "https://api.example.com/stac"

tests/integration/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Integration tests package."""

tests/integration/test_stac_api.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Integration tests for STAC API workflows.
2+
3+
These tests verify end-to-end scenarios with mocked external dependencies.
4+
"""
5+
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
from pystac import Asset, Item
10+
11+
12+
@pytest.fixture
13+
def test_pystac_item():
14+
"""Sample pystac Item for testing."""
15+
item = Item(
16+
id="S2B_test",
17+
geometry=None,
18+
bbox=None,
19+
datetime="2025-01-01T00:00:00Z",
20+
properties={},
21+
collection="sentinel-2-l2a",
22+
)
23+
item.add_asset(
24+
"TCI_10m",
25+
Asset(
26+
href="https://example.com/data.zarr/TCI",
27+
media_type="application/vnd+zarr",
28+
roles=["visual"],
29+
),
30+
)
31+
return item
32+
33+
34+
@pytest.fixture
35+
def test_item_dict():
36+
"""Sample STAC item dictionary for registration tests."""
37+
return {
38+
"type": "Feature",
39+
"stac_version": "1.0.0",
40+
"id": "test-item",
41+
"collection": "test-collection",
42+
"geometry": None,
43+
"bbox": None,
44+
"properties": {"datetime": "2025-01-01T00:00:00Z"},
45+
"assets": {},
46+
"links": [],
47+
}
48+
49+
50+
@pytest.fixture
51+
def mock_stac_client():
52+
"""Mock pystac_client.Client for registration tests."""
53+
mock_client = MagicMock()
54+
mock_collection = MagicMock()
55+
mock_client.get_collection.return_value = mock_collection
56+
57+
# Mock the StacApiIO session (httpx client)
58+
mock_session = MagicMock()
59+
mock_stac_io = MagicMock()
60+
mock_stac_io.session = mock_session
61+
mock_stac_io.timeout = 30
62+
mock_client._stac_io = mock_stac_io
63+
64+
return mock_client
65+
66+
67+
@pytest.mark.integration
68+
def test_register_creates_new_item(test_item_dict, mock_stac_client):
69+
"""Test registration creates new STAC item when it doesn't exist."""
70+
from scripts.register_stac import register_item
71+
72+
# Mock item doesn't exist
73+
mock_stac_client.get_collection().get_item.side_effect = Exception("Not found")
74+
75+
# Mock successful POST
76+
mock_response = MagicMock()
77+
mock_response.status_code = 201
78+
mock_response.raise_for_status = MagicMock()
79+
mock_stac_client._stac_io.session.post.return_value = mock_response
80+
81+
with patch("pystac_client.Client.open", return_value=mock_stac_client):
82+
register_item(
83+
stac_url="https://stac.example.com",
84+
collection_id="test-collection",
85+
item_dict=test_item_dict,
86+
mode="create-or-skip",
87+
)
88+
89+
# Verify POST was called to create item
90+
assert mock_stac_client._stac_io.session.post.called
91+
92+
93+
@pytest.mark.integration
94+
def test_register_skips_existing_item(test_item_dict, mock_stac_client):
95+
"""Test registration skips when item already exists."""
96+
from scripts.register_stac import register_item
97+
98+
# Mock item exists
99+
mock_stac_client.get_collection().get_item.return_value = MagicMock()
100+
101+
with patch("pystac_client.Client.open", return_value=mock_stac_client):
102+
register_item(
103+
stac_url="https://stac.example.com",
104+
collection_id="test-collection",
105+
item_dict=test_item_dict,
106+
mode="create-or-skip",
107+
)
108+
109+
# Verify no POST/PUT/DELETE was called
110+
assert not mock_stac_client._stac_io.session.post.called
111+
assert not mock_stac_client._stac_io.session.put.called
112+
assert not mock_stac_client._stac_io.session.delete.called
113+
114+
115+
@pytest.mark.integration
116+
def test_register_updates_existing_item(test_item_dict, mock_stac_client):
117+
"""Test registration updates existing item in upsert mode."""
118+
from scripts.register_stac import register_item
119+
120+
# Mock item exists
121+
mock_stac_client.get_collection().get_item.return_value = MagicMock()
122+
123+
# Mock successful DELETE and POST
124+
mock_delete_response = MagicMock()
125+
mock_delete_response.status_code = 204
126+
mock_delete_response.raise_for_status = MagicMock()
127+
mock_stac_client._stac_io.session.delete.return_value = mock_delete_response
128+
129+
mock_post_response = MagicMock()
130+
mock_post_response.status_code = 201
131+
mock_post_response.raise_for_status = MagicMock()
132+
mock_stac_client._stac_io.session.post.return_value = mock_post_response
133+
134+
with patch("pystac_client.Client.open", return_value=mock_stac_client):
135+
register_item(
136+
stac_url="https://stac.example.com",
137+
collection_id="test-collection",
138+
item_dict=test_item_dict,
139+
mode="upsert",
140+
)
141+
142+
# Verify DELETE then POST was called
143+
assert mock_stac_client._stac_io.session.delete.called
144+
assert mock_stac_client._stac_io.session.post.called
145+
146+
147+
@pytest.mark.integration
148+
def test_augmentation_adds_visualization_links(test_pystac_item):
149+
"""Test augmentation workflow adds visualization links."""
150+
from scripts.augment_stac_item import add_visualization
151+
152+
# Add visualization links
153+
add_visualization(
154+
item=test_pystac_item,
155+
raster_base="https://titiler.example.com",
156+
collection_id="sentinel-2-l2a",
157+
)
158+
159+
# Verify XYZ tile link added
160+
xyz_links = [link for link in test_pystac_item.links if link.rel == "xyz"]
161+
assert len(xyz_links) > 0
162+
assert "titiler.example.com" in xyz_links[0].target
163+
164+
# Verify viewer link added
165+
viewer_links = [link for link in test_pystac_item.links if link.rel == "viewer"]
166+
assert len(viewer_links) > 0
167+
168+
169+
@pytest.mark.integration
170+
def test_augmentation_adds_projection(test_pystac_item):
171+
"""Test augmentation extracts projection information."""
172+
from scripts.augment_stac_item import add_projection
173+
174+
# Mock zarr group with spatial_ref
175+
# Note: This test validates the interface, actual zarr integration tested separately
176+
add_projection(item=test_pystac_item)
177+
178+
# Projection properties should be attempted (may not be set without real zarr)
179+
# This validates the function can be called and handles missing data gracefully
180+
assert test_pystac_item.properties is not None
181+
182+
183+
@pytest.mark.integration
184+
def test_full_augmentation_pipeline(test_pystac_item):
185+
"""Test complete augmentation pipeline."""
186+
from scripts.augment_stac_item import augment
187+
188+
# Run full augmentation
189+
result = augment(
190+
item=test_pystac_item,
191+
raster_base="https://titiler.example.com",
192+
collection_id="sentinel-2-l2a",
193+
verbose=False,
194+
)
195+
196+
# Verify item returned
197+
assert result.id == "S2B_test"
198+
199+
# Verify links added
200+
assert len(result.links) > 0
201+
xyz_count = sum(1 for link in result.links if link.rel == "xyz")
202+
assert xyz_count > 0

0 commit comments

Comments
 (0)