Coverage for tests/test_packageVersions.py: 100%
101 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 20:03 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 20:03 +0000
1# This file is part of summit_utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Tests for the science-package version data model and reading it from ConsDB.
24The JSON stored in ConsDB (``{"hash": ..., "versions": {...}}``) is a wire
25format: rows already written use it, so `test_toDictPinsWireFormat` fails if it
26changes.
27"""
29import json
30from types import SimpleNamespace
31from typing import cast
33import pytest
34import responses
36from lsst.daf.butler import DimensionRecord
37from lsst.summit.utils import ConsDbClient
38from lsst.summit.utils.packageVersions import (
39 PACKAGE_VERSIONS_COLUMN,
40 PackageVersions,
41 readPackageVersionsByHash,
42 readPackageVersionsForExposure,
43 readPackageVersionsFromConsDb,
44)
46VERSIONS = {
47 "ts_wep": "v17.6.1-alpha",
48 "donut_viz": "v4.4.0-alpha",
49 "rubintv_production": "abc123",
50}
51# The hash is a stable SHA-256 of the canonical (sorted-key) versions dict.
52EXPECTED_HASH = PackageVersions(versions=VERSIONS).versionHash()
55@pytest.fixture
56def client() -> ConsDbClient:
57 """A ConsDbClient pointed at a fake url.
59 The tests below use the ``responses`` library to mock the HTTP layer:
60 ``@responses.activate`` intercepts all requests, and ``responses.post(url,
61 json=...)`` registers the canned reply for the POST the client makes to
62 ``/query``.
63 """
64 return ConsDbClient("http://example.com/consdb")
67def test_versionHashStableAndOrderIndependent() -> None:
68 a = PackageVersions(versions={"ts_wep": "v1", "donut_viz": "v2"})
69 b = PackageVersions(versions={"donut_viz": "v2", "ts_wep": "v1"}) # different insertion order
70 assert a.versionHash() == b.versionHash()
71 assert len(a.versionHash()) == 64 # SHA-256 hex digest
74def test_versionHashChangesWhenAVersionChanges() -> None:
75 a = PackageVersions(versions={"ts_wep": "v1", "donut_viz": "v2"})
76 c = PackageVersions(versions={"ts_wep": "v1", "donut_viz": "v3"})
77 assert a.versionHash() != c.versionHash()
80def test_toDictPinsWireFormat() -> None:
81 # this is the wire format stored in the ConsDB JSONB column - an exact
82 # match, not a subset check, so any shape drift fails here. Rapid Analysis
83 # writes exactly this, so the same test is duplicated there.
84 pv = PackageVersions(versions=dict(VERSIONS))
85 assert pv.toDict() == {"hash": EXPECTED_HASH, "versions": VERSIONS}
88def test_dictRoundTrip() -> None:
89 pv = PackageVersions(versions=dict(VERSIONS))
90 assert PackageVersions.fromDict(pv.toDict()) == pv
93def test_jsonRoundTrip() -> None:
94 pv = PackageVersions(versions=dict(VERSIONS))
95 assert PackageVersions.fromJson(pv.toJson()) == pv
96 # canonical: sorted keys, so identical sets serialise identically
97 reordered = PackageVersions(versions=dict(reversed(list(VERSIONS.items()))))
98 assert reordered.toJson() == pv.toJson()
101def test_fromDictIgnoresHashAndTrustsVersions() -> None:
102 # hash is derived from versions, so a stale/absent hash in the blob is not
103 # authoritative: fromDict rebuilds from versions alone
104 fromNoHash = PackageVersions.fromDict({"versions": VERSIONS})
105 fromStaleHash = PackageVersions.fromDict({"hash": "deadbeef", "versions": VERSIONS})
106 assert fromNoHash == fromStaleHash == PackageVersions(versions=VERSIONS)
107 assert fromStaleHash.versionHash() == EXPECTED_HASH # recomputed, not the stale value
110def test_fromDictRejectsMissingVersions() -> None:
111 with pytest.raises(ValueError, match="no 'versions' key"):
112 PackageVersions.fromDict({"hash": "deadbeef"})
115def test_fromJsonRejectsNonObject() -> None:
116 with pytest.raises(ValueError, match="Expected a JSON object"):
117 PackageVersions.fromJson('["not", "an", "object"]')
120@responses.activate
121def test_readPackageVersionsFromConsDbParsesJsonObjectCell(
122 client: ConsDbClient,
123) -> None:
124 # the JSONB column coming back as an already-parsed JSON object. This
125 # registers the mock reply for the client's POST to /query.
126 blob = {"hash": EXPECTED_HASH, "versions": VERSIONS}
127 responses.post(
128 "http://example.com/consdb/query",
129 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": [[blob]]},
130 )
131 pv = readPackageVersionsFromConsDb(client, "LSSTCam", 20250624, 123)
132 assert pv == PackageVersions(versions=dict(VERSIONS))
135@responses.activate
136def test_readPackageVersionsFromConsDbParsesJsonStringCell(
137 client: ConsDbClient,
138) -> None:
139 # ...and coming back as a JSON string, in case the server serialises
140 # JSONB that way instead
141 blob = json.dumps({"hash": EXPECTED_HASH, "versions": VERSIONS})
142 responses.post(
143 "http://example.com/consdb/query",
144 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": [[blob]]},
145 )
146 pv = readPackageVersionsFromConsDb(client, "LSSTCam", 20250624, 123)
147 assert pv == PackageVersions(versions=dict(VERSIONS))
150@responses.activate
151def test_readPackageVersionsFromConsDbNoRowReturnsNone(client: ConsDbClient) -> None:
152 responses.post(
153 "http://example.com/consdb/query",
154 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": []},
155 )
156 assert readPackageVersionsFromConsDb(client, "LSSTCam", 20250624, 123) is None
159@responses.activate
160def test_readPackageVersionsFromConsDbNullCellReturnsNone(client: ConsDbClient) -> None:
161 responses.post(
162 "http://example.com/consdb/query",
163 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": [[None]]},
164 )
165 assert readPackageVersionsFromConsDb(client, "LSSTCam", 20250624, 123) is None
168@responses.activate
169def test_readPackageVersionsForExposure(client: ConsDbClient) -> None:
170 # the DimensionRecord wrapper must unpack the record's dataId into the
171 # query, so check the record's day_obs/seq_num reached the request
172 blob = {"hash": EXPECTED_HASH, "versions": VERSIONS}
173 responses.post(
174 "http://example.com/consdb/query",
175 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": [[blob]]},
176 )
177 record = cast(DimensionRecord, SimpleNamespace(instrument="LSSTCam", day_obs=20250624, seq_num=123))
178 pv = readPackageVersionsForExposure(client, record)
179 assert pv == PackageVersions(versions=dict(VERSIONS))
180 body = responses.calls[0].request.body
181 assert isinstance(body, (str, bytes))
182 sentQuery = json.loads(body)["query"]
183 assert "cdb_lsstcam" in sentQuery
184 assert "20250624" in sentQuery and "123" in sentQuery
187@responses.activate
188def test_readPackageVersionsByHash(client: ConsDbClient) -> None:
189 # look up a version set by its hash
190 blob = {"hash": EXPECTED_HASH, "versions": VERSIONS}
191 responses.post(
192 "http://example.com/consdb/query",
193 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": [[blob]]},
194 )
195 pv = readPackageVersionsByHash(client, "LSSTCam", EXPECTED_HASH)
196 assert pv == PackageVersions(versions=dict(VERSIONS))
197 assert pv is not None and pv.versionHash() == EXPECTED_HASH
200@responses.activate
201def test_readPackageVersionsByHashNormalisesCase(client: ConsDbClient) -> None:
202 # versionHash() always produces lowercase hex and the SQL string
203 # comparison is case-sensitive, so an uppercase paste of a valid hash must
204 # be lowercased before it reaches the query - it used to silently return
205 # None.
206 blob = {"hash": EXPECTED_HASH, "versions": VERSIONS}
207 responses.post(
208 "http://example.com/consdb/query",
209 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": [[blob]]},
210 )
211 pv = readPackageVersionsByHash(client, "LSSTCam", EXPECTED_HASH.upper())
212 assert pv == PackageVersions(versions=dict(VERSIONS))
213 body = responses.calls[0].request.body
214 assert isinstance(body, (str, bytes))
215 sentQuery = json.loads(body)["query"]
216 assert EXPECTED_HASH in sentQuery
217 assert EXPECTED_HASH.upper() not in sentQuery
220@responses.activate
221def test_readPackageVersionsByHashNoRowReturnsNone(client: ConsDbClient) -> None:
222 responses.post(
223 "http://example.com/consdb/query",
224 json={"columns": [PACKAGE_VERSIONS_COLUMN], "data": []},
225 )
226 assert readPackageVersionsByHash(client, "LSSTCam", EXPECTED_HASH) is None
229def test_readPackageVersionsByHashRejectsNonHexHash(client: ConsDbClient) -> None:
230 # the hash is interpolated into the query as a string literal, so a non-hex
231 # value (e.g. an injection attempt) must be rejected before any request
232 with pytest.raises(ValueError, match="Invalid version hash"):
233 readPackageVersionsByHash(client, "LSSTCam", "abc'; DROP TABLE x; --")