Coverage for tests/test_packageVersions.py: 100%

101 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-10 10:21 +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/>. 

21 

22"""Tests for the science-package version data model and reading it from ConsDB. 

23 

24The JSON stored in ConsDB (``{"hash": ..., "versions": {...}}``) is a wire 

25format: rows already written use it, so `test_toDictPinsWireFormat` fails if it 

26changes. 

27""" 

28 

29import json 

30from types import SimpleNamespace 

31from typing import cast 

32 

33import pytest 

34import responses 

35 

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) 

45 

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() 

53 

54 

55@pytest.fixture 

56def client() -> ConsDbClient: 

57 """A ConsDbClient pointed at a fake url. 

58 

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") 

65 

66 

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 

72 

73 

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() 

78 

79 

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} 

86 

87 

88def test_dictRoundTrip() -> None: 

89 pv = PackageVersions(versions=dict(VERSIONS)) 

90 assert PackageVersions.fromDict(pv.toDict()) == pv 

91 

92 

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() 

99 

100 

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 

108 

109 

110def test_fromDictRejectsMissingVersions() -> None: 

111 with pytest.raises(ValueError, match="no 'versions' key"): 

112 PackageVersions.fromDict({"hash": "deadbeef"}) 

113 

114 

115def test_fromJsonRejectsNonObject() -> None: 

116 with pytest.raises(ValueError, match="Expected a JSON object"): 

117 PackageVersions.fromJson('["not", "an", "object"]') 

118 

119 

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)) 

133 

134 

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)) 

148 

149 

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 

157 

158 

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 

166 

167 

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 

185 

186 

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 

198 

199 

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 

218 

219 

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 

227 

228 

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; --")