Skip to content

Commit de296fd

Browse files
jirhikerclaude
andcommitted
test(ingestion): cover the Dagster assets
ingest.py was at 16%. Every part of san_acacia_observations was covered on its own -- matching, resolving, watermarks, the adapter, the loader -- but the orchestration between them was not, which is where the decisions live: which wells get skipped, what the metadata reports, and whether one unresolvable well costs the others. Now 87%, and automated_ingestion overall 83% to 88%. Fakes stand in at the process boundaries only -- the vendor client, the database session, the dlt pipeline. The reconciler, resolver and adapter run for real, so a change in their behaviour surfaces here rather than being absorbed by a mock. Covered: a well with no match is skipped rather than invented; two wells sharing a name are skipped rather than picked between; a well with no open transducer, and one whose transducer was removed, are skipped; a refused reading is counted rather than vanishing; one bad well does not cost the others; both raw assets write parquet; a point the vendor refuses is counted and reported. What remains uncovered is _client and the two SQLAlchemy query helpers. They are thin wrappers around a live database and a real HTTP session, and testing them would mean asserting that mocks were called. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8c02977 commit de296fd

1 file changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
# ===============================================================================
2+
# Copyright 2026 ross
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
# ===============================================================================
16+
"""
17+
The Dagster assets, which is where the pieces meet.
18+
19+
Every part of `san_acacia_observations` is covered on its own -- matching,
20+
resolving, watermarks, the adapter, the loader. What was not covered is the
21+
orchestration between them: which wells get skipped, what the metadata says, and
22+
whether one unresolvable well costs the others.
23+
24+
Fakes stand in at the process boundaries -- the vendor client, the database
25+
session, the dlt pipeline -- and nowhere else. The reconciler, resolver and
26+
adapter run for real, so a change in their behaviour shows up here.
27+
"""
28+
29+
from contextlib import contextmanager
30+
from datetime import date
31+
32+
import pytest
33+
from dagster import build_asset_context
34+
35+
from automated_ingestion.ocotillo import loader as loader_module
36+
from automated_ingestion.ocotillo.loader import LoadResult
37+
from automated_ingestion.shared import watermark as watermark_module
38+
from automated_ingestion.sources.san_acacia import ingest
39+
from automated_ingestion.sources.san_acacia.reconcile import ThingCandidate
40+
from automated_ingestion.sources.san_acacia.resolve import DeploymentCandidate
41+
42+
READING = {"dateAndTime": "2026-04-15T22:45:00", "level": 471.518}
43+
44+
45+
class FakeClient:
46+
"""The vendor, reduced to what the assets ask of it."""
47+
48+
def __init__(self, points, readings=None, approved=()):
49+
self._points = points
50+
self._readings = READING if readings is None else readings
51+
self._approved = approved
52+
self.water_level_calls = []
53+
54+
def monitoring_points(self, project_id):
55+
return self._points
56+
57+
def water_levels(self, point_id, start, end, reference, approved=None, span=None):
58+
self.water_level_calls.append((point_id, start, end, approved))
59+
if approved:
60+
return iter(self._approved)
61+
return iter(
62+
self._readings if isinstance(self._readings, list) else [self._readings]
63+
)
64+
65+
66+
class FakeDatabase:
67+
"""Stands in for OcotilloDatabase. The session is never really used --
68+
every function that would touch it is replaced."""
69+
70+
@contextmanager
71+
def session(self):
72+
yield object()
73+
74+
75+
class NoWatermark:
76+
def __init__(self, session):
77+
pass
78+
79+
def get(self, thing_id, parameter_id):
80+
return None
81+
82+
83+
@pytest.fixture()
84+
def wired(monkeypatch):
85+
"""Wire the asset to fakes, returning the recorded loads."""
86+
loaded = []
87+
88+
def fake_load(session, records, deployment_id, parameter_id, release_status, **kw):
89+
records = list(records)
90+
loaded.append(
91+
{
92+
"deployment_id": deployment_id,
93+
"parameter_id": parameter_id,
94+
"release_status": release_status,
95+
"rows": len(records),
96+
}
97+
)
98+
return LoadResult(rows_seen=len(records), rows_written=len(records), batches=1)
99+
100+
monkeypatch.setattr(loader_module, "load_observations", fake_load)
101+
monkeypatch.setattr(loader_module, "ensure_block", lambda *a, **k: 1)
102+
monkeypatch.setattr(watermark_module, "PostgresWatermarkStore", NoWatermark)
103+
monkeypatch.setattr(ingest, "_parameter_id", lambda session, name: 1)
104+
return loaded
105+
106+
107+
def _run(monkeypatch, client, candidates, deployments):
108+
monkeypatch.setattr(ingest, "_client", lambda: client)
109+
monkeypatch.setattr(ingest, "_well_candidates", lambda session: candidates)
110+
monkeypatch.setattr(ingest, "_deployments", lambda session, thing_id: deployments)
111+
return ingest.san_acacia_observations(build_asset_context(), FakeDatabase())
112+
113+
114+
TRANSDUCER = DeploymentCandidate(437, "Pressure Transducer")
115+
116+
117+
class TestObservationsAsset:
118+
def test_a_resolvable_well_is_loaded(self, monkeypatch, wired):
119+
output = _run(
120+
monkeypatch,
121+
FakeClient([{"id": 39, "name": "SO-0125"}]),
122+
[ThingCandidate(2343, "SO-0125")],
123+
[TRANSDUCER],
124+
)
125+
assert output.value == 1
126+
assert wired[0]["deployment_id"] == 437
127+
assert wired[0]["release_status"] == "public"
128+
assert output.metadata["wells_skipped"].value == 0
129+
130+
def test_an_unmatched_well_is_skipped_not_invented(self, monkeypatch, wired):
131+
# No Ocotillo well by that name. Ingestion does not create wells.
132+
output = _run(
133+
monkeypatch,
134+
FakeClient([{"id": 39, "name": "SO-9999"}]),
135+
[ThingCandidate(2343, "SO-0125")],
136+
[TRANSDUCER],
137+
)
138+
assert output.value == 0
139+
assert wired == []
140+
assert output.metadata["wells_skipped"].value == 1
141+
assert "unmatched" in str(output.metadata["skipped"].data)
142+
143+
def test_an_ambiguous_well_is_skipped(self, monkeypatch, wired):
144+
# Two wells share the name -- picking one would be a silent guess.
145+
output = _run(
146+
monkeypatch,
147+
FakeClient([{"id": 39, "name": "SO-0125"}]),
148+
[ThingCandidate(1, "SO-0125"), ThingCandidate(2, "SO-0125")],
149+
[TRANSDUCER],
150+
)
151+
assert wired == []
152+
assert "ambiguous" in str(output.metadata["skipped"].data)
153+
154+
def test_a_well_without_a_transducer_is_skipped(self, monkeypatch, wired):
155+
# SO-0246 is in this state in production.
156+
output = _run(
157+
monkeypatch,
158+
FakeClient([{"id": 39, "name": "SO-0125"}]),
159+
[ThingCandidate(2343, "SO-0125")],
160+
[DeploymentCandidate(436, "DiverLink")],
161+
)
162+
assert wired == []
163+
assert "missing" in str(output.metadata["skipped"].data)
164+
165+
def test_a_removed_transducer_does_not_qualify(self, monkeypatch, wired):
166+
output = _run(
167+
monkeypatch,
168+
FakeClient([{"id": 39, "name": "SO-0125"}]),
169+
[ThingCandidate(2343, "SO-0125")],
170+
[
171+
DeploymentCandidate(
172+
437, "Pressure Transducer", removal_date=date(2024, 1, 1)
173+
)
174+
],
175+
)
176+
assert wired == []
177+
178+
def test_one_bad_well_does_not_cost_the_others(self, monkeypatch, wired):
179+
# The point of skipping rather than raising.
180+
output = _run(
181+
monkeypatch,
182+
FakeClient(
183+
[
184+
{"id": 39, "name": "SO-9999"},
185+
{"id": 40, "name": "SO-0125"},
186+
]
187+
),
188+
[ThingCandidate(2343, "SO-0125")],
189+
[TRANSDUCER],
190+
)
191+
assert output.value == 1
192+
assert output.metadata["wells_attempted"].value == 2
193+
assert output.metadata["wells_skipped"].value == 1
194+
195+
def test_a_reading_the_adapter_refuses_is_counted(self, monkeypatch, wired):
196+
# A null level has nothing to store; it should surface, not vanish.
197+
client = FakeClient(
198+
[{"id": 39, "name": "SO-0125"}],
199+
readings=[{"dateAndTime": "2026-04-15T22:45:00", "level": None}],
200+
)
201+
output = _run(
202+
monkeypatch, client, [ThingCandidate(2343, "SO-0125")], [TRANSDUCER]
203+
)
204+
assert output.value == 0
205+
assert output.metadata["adapter_failures"].value == 1
206+
207+
def test_no_wells_at_all(self, monkeypatch, wired):
208+
output = _run(monkeypatch, FakeClient([]), [], [TRANSDUCER])
209+
assert output.value == 0
210+
assert output.metadata["wells_attempted"].value == 0
211+
212+
213+
class TestParameterLookup:
214+
def test_a_missing_parameter_is_a_clear_error(self):
215+
# Ingestion does not create parameters, so the message has to say what
216+
# to do instead of surfacing an integrity error later.
217+
class Empty:
218+
def scalar(self, *_):
219+
return None
220+
221+
with pytest.raises(RuntimeError, match="does not create parameters"):
222+
ingest._parameter_id(Empty(), "groundwater level")
223+
224+
def test_a_found_parameter_is_returned(self):
225+
class Found:
226+
def scalar(self, *_):
227+
return 7
228+
229+
assert ingest._parameter_id(Found(), "groundwater level") == 7
230+
231+
232+
class TestRowCount:
233+
def test_malformed_load_info_reports_zero(self):
234+
# Metadata must never fail a load that worked.
235+
assert ingest._row_count(object()) == 0
236+
237+
def test_none_reports_zero(self):
238+
assert ingest._row_count(None) == 0
239+
240+
241+
class FakePipeline:
242+
"""Stands in for the dlt pipeline. Records what it was asked to run."""
243+
244+
def __init__(self):
245+
self.runs = []
246+
247+
def run(self, resource, loader_file_format=None):
248+
# Consume the resource so the generator body actually executes.
249+
rows = list(resource) if hasattr(resource, "__iter__") else []
250+
self.runs.append({"format": loader_file_format, "rows": len(rows)})
251+
return object()
252+
253+
254+
class TestRawAssets:
255+
def test_locations_reports_what_it_landed(self, monkeypatch):
256+
from automated_ingestion.sources.san_acacia import dlt_pipeline
257+
258+
pipeline = FakePipeline()
259+
client = FakeClient(
260+
[{"id": 39, "name": "SO-0125"}, {"id": 40, "name": "SO-0131"}]
261+
)
262+
monkeypatch.setattr(ingest, "_client", lambda: client)
263+
monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
264+
265+
output = ingest.raw_san_acacia_locations(build_asset_context())
266+
267+
assert output.value == 2
268+
assert output.metadata["monitoring_points"].value == 2
269+
assert "SO-0125" in output.metadata["names"].value
270+
271+
def test_locations_are_written_as_parquet(self, monkeypatch):
272+
# dlt writes gzipped JSONL unless told otherwise, and Mode B replay
273+
# assumes parquet.
274+
from automated_ingestion.sources.san_acacia import dlt_pipeline
275+
276+
pipeline = FakePipeline()
277+
monkeypatch.setattr(ingest, "_client", lambda: FakeClient([]))
278+
monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
279+
280+
ingest.raw_san_acacia_locations(build_asset_context())
281+
assert pipeline.runs[0]["format"] == "parquet"
282+
283+
def test_readings_report_per_point_failures(self, monkeypatch):
284+
from automated_ingestion.sources.san_acacia import dlt_pipeline
285+
286+
pipeline = FakePipeline()
287+
client = FakeClient([{"id": 39, "name": "SO-0125"}])
288+
monkeypatch.setattr(ingest, "_client", lambda: client)
289+
monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
290+
291+
output = ingest.raw_san_acacia_readings(build_asset_context())
292+
293+
assert output.metadata["points_attempted"].value == 1
294+
assert output.metadata["points_failed"].value == 0
295+
296+
def test_readings_are_written_as_parquet(self, monkeypatch):
297+
from automated_ingestion.sources.san_acacia import dlt_pipeline
298+
299+
pipeline = FakePipeline()
300+
monkeypatch.setattr(ingest, "_client", lambda: FakeClient([]))
301+
monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
302+
303+
ingest.raw_san_acacia_readings(build_asset_context())
304+
assert pipeline.runs[0]["format"] == "parquet"
305+
306+
def test_readings_count_a_point_the_vendor_refuses(self, monkeypatch):
307+
# One diver failing must cost that diver, not the run. The count is how
308+
# anyone finds out it happened.
309+
from automated_ingestion.sources.san_acacia import dlt_pipeline
310+
from automated_ingestion.sources.san_acacia.client import DiverHubError
311+
312+
class Refusing(FakeClient):
313+
def water_levels(self, *a, **kw):
314+
raise DiverHubError("500 at the minimum window")
315+
316+
pipeline = FakePipeline()
317+
client = Refusing([{"id": 39, "name": "SO-0125"}])
318+
monkeypatch.setattr(ingest, "_client", lambda: client)
319+
monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
320+
321+
output = ingest.raw_san_acacia_readings(build_asset_context())
322+
323+
assert output.metadata["points_failed"].value == 1
324+
assert output.metadata["points_attempted"].value == 1
325+
assert "500" in str(output.metadata["failures"].data)
326+
327+
328+
# ============= EOF =============================================

0 commit comments

Comments
 (0)