|
| 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 | +"""Structural + unit tests for the NM_Wells Phase-1 staging mirror. |
| 17 | +
|
| 18 | +Covers SPEC §V invariants for the mirror schema, migrations and OGC views: |
| 19 | +
|
| 20 | + V1 - all 18 NMW_* mirror tables exist with a primary key |
| 21 | + V2 - mirror load order respects parent->child (FK parent precedes child) |
| 22 | + V3 - migrations build all 8 OGC views |
| 23 | + V5 - the temperature-profile OGC view is MATERIALIZED |
| 24 | + V6 - each geothermal pygeoapi collection maps to an existing DB relation |
| 25 | + V10 - FK enforcement lives in the migration (DB-level FK constraints exist) |
| 26 | +
|
| 27 | +Full data round-trip against a real SQL dump is out of scope here (SPEC §T.T14); |
| 28 | +the dump parser is unit-tested directly instead. |
| 29 | +""" |
| 30 | + |
| 31 | +import os |
| 32 | + |
| 33 | +import pytest |
| 34 | +import yaml |
| 35 | +from sqlalchemy import inspect as sa_inspect, text |
| 36 | + |
| 37 | +from db.engine import engine, session_ctx |
| 38 | +from transfers.nmw_mirror_transfer import NMW_MIRROR_SPECS |
| 39 | +from transfers.nmw_sql_dump import _parse_value, iter_table_rows |
| 40 | + |
| 41 | +ROOT = os.path.dirname(os.path.dirname(__file__)) |
| 42 | + |
| 43 | +# DB relations created by the OGC-view migrations (d1e2f3a4b5c6, e2f3a4b5c6d7). |
| 44 | +OGC_VIEWS = [ |
| 45 | + "ogc_geothermal_wells_bht", |
| 46 | + "ogc_geothermal_wells_temperature_profile", # MATERIALIZED |
| 47 | + "ogc_geothermal_wells_summary_heat_flow", |
| 48 | + "ogc_geothermal_wells_interval_heat_flow", |
| 49 | + "ogc_bht_measurements", |
| 50 | + "ogc_temp_depth_measurements", |
| 51 | + "ogc_heat_flow", |
| 52 | + "ogc_dst", |
| 53 | +] |
| 54 | +MATERIALIZED_VIEW = "ogc_geothermal_wells_temperature_profile" |
| 55 | + |
| 56 | +# pygeoapi collections added by this PR and the DB relation each is backed by. |
| 57 | +GEOTHERMAL_COLLECTIONS = { |
| 58 | + "geothermal_wells_bht": "ogc_geothermal_wells_bht", |
| 59 | + "geothermal_wells_temperature_profile": "ogc_geothermal_wells_temperature_profile", |
| 60 | + "bht_measurements": "ogc_bht_measurements", |
| 61 | + "temp_depth_measurements": "ogc_temp_depth_measurements", |
| 62 | + "heat_flow": "ogc_heat_flow", |
| 63 | + "dst": "ogc_dst", |
| 64 | +} |
| 65 | + |
| 66 | + |
| 67 | +def _mirror_tablenames() -> list[str]: |
| 68 | + return [spec.model.__tablename__ for spec in NMW_MIRROR_SPECS] |
| 69 | + |
| 70 | + |
| 71 | +# --------------------------------------------------------------------------- V1 |
| 72 | +def test_all_mirror_tables_present_with_pk(): |
| 73 | + """All 18 NMW_* mirror tables exist in the schema, each with a PK (V1).""" |
| 74 | + names = _mirror_tablenames() |
| 75 | + assert len(names) == 18, f"expected 18 mirror specs, got {len(names)}" |
| 76 | + |
| 77 | + insp = sa_inspect(engine) |
| 78 | + existing = set(insp.get_table_names()) |
| 79 | + for table in names: |
| 80 | + assert table in existing, f"mirror table {table} missing from schema" |
| 81 | + pk = insp.get_pk_constraint(table)["constrained_columns"] |
| 82 | + assert pk, f"mirror table {table} has no primary key" |
| 83 | + |
| 84 | + |
| 85 | +def test_well_headers_pk_is_well_data_id(): |
| 86 | + """Spot-check that original SQL Server column names are preserved (V1).""" |
| 87 | + insp = sa_inspect(engine) |
| 88 | + pk = insp.get_pk_constraint("NMW_WellHeaders")["constrained_columns"] |
| 89 | + assert pk == ["WellDataID"] |
| 90 | + |
| 91 | + |
| 92 | +# ----------------------------------------------------------------------- V2/V10 |
| 93 | +def test_mirror_tables_have_fk_constraints(): |
| 94 | + """The migration creates DB-level FK constraints (V10) - at least one |
| 95 | + child table must carry a foreign key.""" |
| 96 | + insp = sa_inspect(engine) |
| 97 | + total_fks = sum(len(insp.get_foreign_keys(t)) for t in _mirror_tablenames()) |
| 98 | + assert total_fks > 0, "no FK constraints found on NMW_* mirror tables" |
| 99 | + |
| 100 | + |
| 101 | +def test_fk_parent_loads_before_child(): |
| 102 | + """Every FK parent table is loaded before its child in NMW_MIRROR_SPECS so |
| 103 | + the parent row exists when the child is inserted (V2).""" |
| 104 | + order = {name: i for i, name in enumerate(_mirror_tablenames())} |
| 105 | + insp = sa_inspect(engine) |
| 106 | + checked = 0 |
| 107 | + for child in order: |
| 108 | + for fk in insp.get_foreign_keys(child): |
| 109 | + parent = fk["referred_table"] |
| 110 | + if parent not in order or parent == child: |
| 111 | + continue # self-ref or FK to a non-mirror table |
| 112 | + checked += 1 |
| 113 | + assert order[parent] <= order[child], ( |
| 114 | + f"{parent} (parent) must load before {child} (child) " |
| 115 | + f"in NMW_MIRROR_SPECS" |
| 116 | + ) |
| 117 | + assert checked > 0, "expected at least one intra-mirror FK to validate" |
| 118 | + |
| 119 | + |
| 120 | +# --------------------------------------------------------------------------- V3 |
| 121 | +def test_ogc_views_exist(): |
| 122 | + """All 8 OGC views built by the migrations exist as relations (V3).""" |
| 123 | + with session_ctx() as session: |
| 124 | + rows = session.execute( |
| 125 | + text( |
| 126 | + "SELECT table_name FROM information_schema.tables " |
| 127 | + "WHERE table_schema = 'public' " |
| 128 | + "UNION SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'" |
| 129 | + ) |
| 130 | + ).all() |
| 131 | + relations = {r[0] for r in rows} |
| 132 | + for view in OGC_VIEWS: |
| 133 | + assert view in relations, f"OGC view {view} missing" |
| 134 | + |
| 135 | + |
| 136 | +# --------------------------------------------------------------------------- V5 |
| 137 | +def test_temperature_profile_is_materialized(): |
| 138 | + """The temperature-profile view is MATERIALIZED so it can be refreshed |
| 139 | + after a mirror load (V5).""" |
| 140 | + with session_ctx() as session: |
| 141 | + names = { |
| 142 | + r[0] |
| 143 | + for r in session.execute( |
| 144 | + text("SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'") |
| 145 | + ).all() |
| 146 | + } |
| 147 | + assert MATERIALIZED_VIEW in names, f"{MATERIALIZED_VIEW} is not a materialized view" |
| 148 | + |
| 149 | + |
| 150 | +# --------------------------------------------------------------------------- V6 |
| 151 | +class _Default(dict): |
| 152 | + """format_map() helper: unknown placeholders render empty.""" |
| 153 | + |
| 154 | + def __missing__(self, key): # noqa: D401 |
| 155 | + return "" |
| 156 | + |
| 157 | + |
| 158 | +def _load_pygeoapi_config() -> dict: |
| 159 | + """pygeoapi-config.yml is a ``{placeholder}`` template (see core/pygeoapi.py |
| 160 | + _write_config); substitute dummy values before parsing as YAML.""" |
| 161 | + raw = open(os.path.join(ROOT, "core", "pygeoapi-config.yml")).read() |
| 162 | + rendered = raw.format_map( |
| 163 | + _Default( |
| 164 | + server_url="http://test", |
| 165 | + postgres_host="h", |
| 166 | + postgres_port="5432", |
| 167 | + postgres_db="d", |
| 168 | + postgres_user="u", |
| 169 | + postgres_password_env="p", |
| 170 | + thing_collections_block="", |
| 171 | + ) |
| 172 | + ) |
| 173 | + return yaml.safe_load(rendered) |
| 174 | + |
| 175 | + |
| 176 | +def test_geothermal_collections_back_existing_relations(): |
| 177 | + """Each new geothermal pygeoapi collection points at a DB relation that |
| 178 | + actually exists (V6).""" |
| 179 | + cfg = _load_pygeoapi_config() |
| 180 | + resources = cfg["resources"] |
| 181 | + |
| 182 | + with session_ctx() as session: |
| 183 | + rows = session.execute( |
| 184 | + text( |
| 185 | + "SELECT table_name FROM information_schema.tables " |
| 186 | + "WHERE table_schema = 'public' " |
| 187 | + "UNION SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'" |
| 188 | + ) |
| 189 | + ).all() |
| 190 | + relations = {r[0] for r in rows} |
| 191 | + |
| 192 | + for coll, expected_table in GEOTHERMAL_COLLECTIONS.items(): |
| 193 | + assert coll in resources, f"collection {coll} missing from pygeoapi config" |
| 194 | + tables = { |
| 195 | + p.get("table") |
| 196 | + for p in resources[coll].get("providers", []) |
| 197 | + if p.get("table") |
| 198 | + } |
| 199 | + assert ( |
| 200 | + expected_table in tables |
| 201 | + ), f"collection {coll} should be backed by {expected_table}, got {tables}" |
| 202 | + assert ( |
| 203 | + expected_table in relations |
| 204 | + ), f"backing relation {expected_table} for {coll} does not exist in DB" |
| 205 | + |
| 206 | + |
| 207 | +# ----------------------------------------------------------------- dump parser |
| 208 | +@pytest.mark.parametrize( |
| 209 | + "raw,expected", |
| 210 | + [ |
| 211 | + ("NULL", None), |
| 212 | + ("null", None), |
| 213 | + ("123", 123), |
| 214 | + ("-5", -5), |
| 215 | + ("-1.5", -1.5), |
| 216 | + ("'abc'", "abc"), |
| 217 | + ("N'abc'", "abc"), |
| 218 | + ("'O''Brien'", "O'Brien"), # doubled '' unescaped |
| 219 | + ("CAST(42 AS int)", 42), |
| 220 | + ("CAST(N'x' AS nvarchar(10))", "x"), |
| 221 | + ("0xDEADBEEF", None), # binary / rowversion not mirrored |
| 222 | + ], |
| 223 | +) |
| 224 | +def test_parse_value_coercion(raw, expected): |
| 225 | + assert _parse_value(raw) == expected |
| 226 | + |
| 227 | + |
| 228 | +def test_iter_table_rows_parses_inserts(tmp_path): |
| 229 | + """iter_table_rows decodes column/value pairs from SSMS INSERT statements.""" |
| 230 | + dump = tmp_path / "dump.sql" |
| 231 | + dump.write_text( |
| 232 | + "INSERT [dbo].[tbl_demo] ([OBJECTID], [Name], [Note]) " |
| 233 | + "VALUES (1, N'alpha', NULL), (2, 'beta', CAST(N'c' AS nvarchar(1)));\n", |
| 234 | + encoding="utf-8", |
| 235 | + ) |
| 236 | + rows = list(iter_table_rows(str(dump), "tbl_demo")) |
| 237 | + assert rows == [ |
| 238 | + {"OBJECTID": 1, "Name": "alpha", "Note": None}, |
| 239 | + {"OBJECTID": 2, "Name": "beta", "Note": "c"}, |
| 240 | + ] |
| 241 | + |
| 242 | + |
| 243 | +# ============= EOF ============================================= |
0 commit comments