Coverage for tests/test_consdbClient.py: 100%

199 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-28 11:20 -0700

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# (http://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 <http://www.gnu.org/licenses/>. 

21 

22import json 

23from typing import Any 

24 

25import pytest 

26import responses 

27from astropy.table import Table 

28from requests import HTTPError, Response, Timeout 

29 

30from lsst.summit.utils import ConsDbClient, FlexibleMetadataInfo 

31from lsst.summit.utils.consdbClient import ( 

32 DEFAULT_CONNECT_TIMEOUT, 

33 DEFAULT_READ_TIMEOUT, 

34 getCcdVisitTableForDay, 

35) 

36 

37 

38@pytest.fixture(autouse=True) 

39def no_access_token(monkeypatch: pytest.MonkeyPatch) -> None: 

40 """Keep an ``ACCESS_TOKEN`` in the environment from leaking into 

41 the tests. 

42 """ 

43 monkeypatch.delenv("ACCESS_TOKEN", raising=False) 

44 

45 

46@pytest.fixture 

47def client() -> ConsDbClient: 

48 """Initialize client with a fake url 

49 Requires mocking connection with @responses.activate decorator 

50 """ 

51 return ConsDbClient("http://example.com/consdb") 

52 

53 

54def test_table_name() -> None: 

55 instrument = "latiss" 

56 obs_type = "exposure" 

57 assert ( 

58 ConsDbClient.compute_flexible_metadata_table_name(instrument, obs_type) 

59 == "cdb_latiss.exposure_flexdata" 

60 ) 

61 

62 

63@responses.activate 

64def test_add_flexible_metadata_key(client: ConsDbClient) -> None: 

65 instrument = "latiss" 

66 obs_type = "exposure" 

67 responses.post( 

68 "http://example.com/consdb/flex/latiss/exposure/addkey", 

69 json={ 

70 "message": "Key added to flexible metadata", 

71 "key": "foo", 

72 "instrument": "latiss", 

73 "obs_type": "exposure", 

74 }, 

75 match=[ 

76 responses.matchers.json_params_matcher({"key": "foo", "dtype": "bool", "doc": "bool key"}), 

77 ], 

78 ) 

79 responses.post( 

80 "http://example.com/consdb/flex/latiss/exposure/addkey", 

81 json={ 

82 "message": "Key added to flexible metadata", 

83 "key": "bar", 

84 "instrument": "latiss", 

85 "obs_type": "exposure", 

86 }, 

87 match=[ 

88 responses.matchers.json_params_matcher({"key": "bar", "dtype": "int", "doc": "int key"}), 

89 ], 

90 ) 

91 responses.post( 

92 "http://example.com/consdb/flex/bad_instrument/exposure/addkey", 

93 status=404, 

94 json={"message": "Unknown instrument", "value": "bad_instrument", "valid": ["latiss"]}, 

95 ) 

96 responses.post( 

97 "http://example.com/consdb/flex/latiss/bad_obs_type/addkey", 

98 status=404, 

99 json={"message": "Unknown observation type", "value": "bad_obs_type", "valid": ["exposure"]}, 

100 ) 

101 

102 assert ( 

103 client.add_flexible_metadata_key(instrument, obs_type, "foo", "bool", "bool key").json()["key"] 

104 == "foo" 

105 ) 

106 assert ( 

107 client.add_flexible_metadata_key(instrument, obs_type, "bar", "int", "int key").json()["instrument"] 

108 == "latiss" 

109 ) 

110 with pytest.raises(HTTPError, match="404") as e: 

111 client.add_flexible_metadata_key("bad_instrument", obs_type, "error", "int", "instrument error") 

112 assert "Unknown instrument" in str(e.value.__notes__) 

113 assert e.value.response is not None 

114 json_data = e.value.response.json() 

115 assert json_data["message"] == "Unknown instrument" 

116 assert json_data["value"] == "bad_instrument" 

117 assert json_data["valid"] == ["latiss"] 

118 with pytest.raises(HTTPError, match="404"): 

119 client.add_flexible_metadata_key(instrument, "bad_obs_type", "error", "int", "obs_type error") 

120 

121 

122@responses.activate 

123def test_get_flexible_metadata_keys(client: ConsDbClient) -> None: 

124 description = {"foo": ["bool", "a", None, None], "bar": ["float", "b", "deg", "pos.eq.ra"]} 

125 responses.get( 

126 "http://example.com/consdb/flex/latiss/exposure/schema", 

127 json=description, 

128 ) 

129 instrument = "latiss" 

130 obs_type = "exposure" 

131 assert client.get_flexible_metadata_keys(instrument, obs_type) == { 

132 "foo": FlexibleMetadataInfo("bool", "a"), 

133 "bar": FlexibleMetadataInfo("float", "b", "deg", "pos.eq.ra"), 

134 } 

135 

136 

137@responses.activate 

138def test_get_flexible_metadata(client: ConsDbClient) -> None: 

139 results = {"bool_key": True, "int_key": 42, "float_key": 3.14159, "str_key": "foo"} 

140 responses.get( 

141 "http://example.com/consdb/flex/latiss/exposure/obs/271828", 

142 json=results, 

143 ) 

144 responses.get( 

145 "http://example.com/consdb/flex/latiss/exposure/obs/271828?k=float_key", json={"float_key": 3.14159} 

146 ) 

147 responses.get( 

148 "http://example.com/consdb/flex/latiss/exposure/obs/271828?k=int_key&k=float_key", 

149 json={"float_key": 3.14159, "int_key": 42}, 

150 ) 

151 instrument = "latiss" 

152 obs_type = "exposure" 

153 obs_id = 271828 

154 assert client.get_flexible_metadata(instrument, obs_type, obs_id) == results 

155 assert client.get_flexible_metadata(instrument, obs_type, obs_id, ["float_key"]) == { 

156 "float_key": results["float_key"] 

157 } 

158 assert client.get_flexible_metadata(instrument, obs_type, obs_id, ["int_key", "float_key"]) == { 

159 "int_key": results["int_key"], 

160 "float_key": results["float_key"], 

161 } 

162 

163 

164@responses.activate 

165def test_insert_flexible_metadata(client: ConsDbClient) -> None: 

166 instrument = "latiss" 

167 obs_type = "exposure" 

168 with pytest.raises(ValueError): 

169 client.insert_flexible_metadata(instrument, obs_type, 271828) 

170 responses.post( 

171 "http://example.com/consdb/flex/latiss/exposure/obs/271828/update", 

172 json={"message": "Flexible metadata inserted"}, 

173 match=[responses.matchers.json_params_matcher({"values": {"int_key": 42}})], 

174 ) 

175 responses.post( 

176 "http://example.com/consdb/flex/latiss/exposure/obs/271828/update?u=1", 

177 json={"message": "Flexible metadata inserted"}, 

178 match=[responses.matchers.query_param_matcher({"u": "1"})], 

179 ) 

180 client.insert_flexible_metadata(instrument, obs_type, 271828, {"int_key": 42}) 

181 client.insert_flexible_metadata(instrument, obs_type, 271828, int_key=43, allow_update=True) 

182 assert len(responses.calls) == 2 

183 # TODO: more POST tests 

184 

185 

186@responses.activate 

187def test_schema(client: ConsDbClient) -> None: 

188 description = {"foo": ("bool", "a"), "bar": ("int", "b")} 

189 responses.get( 

190 "http://example.com/consdb/schema/latiss/misc_table", 

191 json=description, 

192 ) 

193 instrument = "latiss" 

194 table = "misc_table" 

195 assert client.schema(instrument, table) == description 

196 

197 

198@responses.activate 

199@pytest.mark.parametrize( 

200 "secret, redacted", 

201 [ 

202 ("usdf:v987wefVMPz", "us***:xxxxxxx"), 

203 ("u:v", "u***:xxxxxxx"), 

204 ("ulysses", "ul***"), 

205 (":alberta94", "***:xxxxxxx"), 

206 ("x:gt-abc123", "x***:gt***"), 

207 ], 

208) 

209def test_clean_token_url_response(secret: str, redacted: str) -> None: 

210 """Test tokens URL is cleaned when an error is thrown from requests 

211 Use with pytest raises assert an error' 

212 assert that url does not contain tokens 

213 """ 

214 domain = "@usdf-fake.slackers.stanford.edu/consdb" 

215 complex_client = ConsDbClient(f"https://{secret}{domain}") 

216 

217 obs_type = "exposure" 

218 responses.post( 

219 f"https://{secret}{domain}/flex/bad_instrument/exposure/addkey", 

220 status=404, 

221 ) 

222 with pytest.raises(HTTPError, match="404") as error: 

223 complex_client.add_flexible_metadata_key( 

224 "bad_instrument", obs_type, "error", "int", "instrument error" 

225 ) 

226 

227 url = error.value.args[0].split()[-1] 

228 sanitized = f"https://{redacted}{domain}/flex/bad_instrument/exposure/addkey" 

229 assert url == sanitized 

230 

231 

232def test_client(client: ConsDbClient) -> None: 

233 """Test ConsDbClient is initialized properly""" 

234 assert "clean_url" in str(client.session.hooks["response"]) 

235 assert client.connect_timeout == DEFAULT_CONNECT_TIMEOUT 

236 assert client.read_timeout == DEFAULT_READ_TIMEOUT 

237 assert client.timeout == (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT) 

238 

239 

240def test_no_token(client: ConsDbClient) -> None: 

241 """Without a token or ACCESS_TOKEN, no Authorization header is sent.""" 

242 assert "Authorization" not in client.session.headers 

243 

244 

245def test_explicit_token() -> None: 

246 """A valid explicit token is sent as a Bearer token.""" 

247 client = ConsDbClient("http://example.com/consdb", token="gt-abc123") 

248 assert client.session.headers["Authorization"] == "Bearer gt-abc123" 

249 

250 

251def test_explicit_bad_token() -> None: 

252 """An explicit token with an unknown prefix is rejected.""" 

253 with pytest.raises(ValueError, match="gt-"): 

254 ConsDbClient("http://example.com/consdb", token="abc123") 

255 

256 

257def test_env_token(monkeypatch: pytest.MonkeyPatch) -> None: 

258 """A valid ACCESS_TOKEN is used when no token is given.""" 

259 monkeypatch.setenv("ACCESS_TOKEN", "gt-abc123") 

260 client = ConsDbClient("http://example.com/consdb") 

261 assert client.session.headers["Authorization"] == "Bearer gt-abc123" 

262 

263 

264def test_explicit_token_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: 

265 """An explicit token takes precedence over ACCESS_TOKEN.""" 

266 monkeypatch.setenv("ACCESS_TOKEN", "gt-fromenv") 

267 client = ConsDbClient("http://example.com/consdb", token="gt-explicit") 

268 assert client.session.headers["Authorization"] == "Bearer gt-explicit" 

269 

270 

271@pytest.mark.parametrize("token", ["abc123", ""]) 

272def test_env_bad_token_ignored(token: str, monkeypatch: pytest.MonkeyPatch) -> None: 

273 """An invalid or empty ACCESS_TOKEN is ignored rather than raising.""" 

274 monkeypatch.setenv("ACCESS_TOKEN", token) 

275 client = ConsDbClient("http://example.com/consdb") 

276 assert "Authorization" not in client.session.headers 

277 

278 

279def test_timeout_override() -> None: 

280 """The timeouts are configurable, including disabling them entirely.""" 

281 tuned = ConsDbClient("http://example.com/consdb", connect_timeout=5, read_timeout=30) 

282 assert tuned.timeout == (5, 30) 

283 unbounded = ConsDbClient("http://example.com/consdb", connect_timeout=None, read_timeout=None) 

284 assert unbounded.timeout == (None, None) 

285 

286 

287def test_get_passes_timeout(client: ConsDbClient, monkeypatch: pytest.MonkeyPatch) -> None: 

288 """GET requests must carry a timeout so a stalled server cannot hang.""" 

289 captured: dict[str, Any] = {} 

290 

291 def fake_get(url: str, **kwargs: Any) -> Response: 

292 captured.update(kwargs) 

293 response = Response() 

294 response.status_code = 200 

295 response._content = b"{}" 

296 return response 

297 

298 monkeypatch.setattr(client.session, "get", fake_get) 

299 client.schema() 

300 assert captured["timeout"] == (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT) 

301 

302 

303def test_post_passes_timeout(client: ConsDbClient, monkeypatch: pytest.MonkeyPatch) -> None: 

304 """POST requests must carry a timeout so a stalled server cannot hang.""" 

305 captured: dict[str, Any] = {} 

306 

307 def fake_post(url: str, **kwargs: Any) -> Response: 

308 captured.update(kwargs) 

309 response = Response() 

310 response.status_code = 200 

311 response._content = b'{"message": "Data inserted"}' 

312 return response 

313 

314 monkeypatch.setattr(client.session, "post", fake_post) 

315 client.insert("latiss", "exposure", 271828, {"foo": 1}) 

316 assert captured["timeout"] == (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT) 

317 

318 

319def test_timeout_propagates(client: ConsDbClient, monkeypatch: pytest.MonkeyPatch) -> None: 

320 """A timed-out request surfaces as requests.Timeout to the caller.""" 

321 

322 def fake_get(url: str, **kwargs: Any) -> Response: 

323 raise Timeout("timed out") 

324 

325 monkeypatch.setattr(client.session, "get", fake_get) 

326 with pytest.raises(Timeout): 

327 client.schema() 

328 

329 

330@responses.activate 

331def test_insert_obs_id(client: ConsDbClient) -> None: 

332 """An integer obs_id targets the ``.../obs/{obs_id}`` endpoint.""" 

333 responses.post( 

334 "http://example.com/consdb/insert/latiss/exposure/obs/271828", 

335 json={"message": "Data inserted"}, 

336 match=[ 

337 responses.matchers.json_params_matcher( 

338 {"table": "exposure", "obs_id": 271828, "values": {"foo": 1}} 

339 ), 

340 ], 

341 ) 

342 assert client.insert("latiss", "exposure", 271828, {"foo": 1}).json()["message"] == "Data inserted" 

343 

344 

345@responses.activate 

346def test_insert_by_seq_num(client: ConsDbClient) -> None: 

347 """A 2-tuple targets the ``.../{day_obs}/{seq_num}`` endpoint.""" 

348 responses.post( 

349 "http://example.com/consdb/insert/latiss/exposure/by_seq_num/20240603/123", 

350 json={"message": "Data inserted"}, 

351 match=[ 

352 responses.matchers.json_params_matcher({"table": "exposure", "values": {"foo": 1}}), 

353 ], 

354 ) 

355 assert ( 

356 client.insert("latiss", "exposure", (20240603, 123), {"foo": 1}).json()["message"] == "Data inserted" 

357 ) 

358 

359 

360@responses.activate 

361def test_insert_by_seq_num_detector(client: ConsDbClient) -> None: 

362 """A 3-tuple targets the ``.../{day_obs}/{seq_num}/{detector}`` 

363 endpoint for per-detector (ccdexposure-level) tables.""" 

364 responses.post( 

365 "http://example.com/consdb/insert/lsstcam/ccdexposure/by_seq_num/20240603/123/94", 

366 json={"message": "Data inserted"}, 

367 match=[ 

368 responses.matchers.json_params_matcher({"table": "ccdexposure", "values": {"foo": 1}}), 

369 ], 

370 ) 

371 assert ( 

372 client.insert("lsstcam", "ccdexposure", (20240603, 123, 94), {"foo": 1}).json()["message"] 

373 == "Data inserted" 

374 ) 

375 

376 

377@responses.activate 

378def test_insert_allow_update(client: ConsDbClient) -> None: 

379 """allow_update appends ``?u=1`` to upsert against the addressed key.""" 

380 responses.post( 

381 "http://example.com/consdb/insert/lsstcam/ccdexposure/by_seq_num/20240603/123/94?u=1", 

382 json={"message": "Data inserted"}, 

383 match=[responses.matchers.query_param_matcher({"u": "1"})], 

384 ) 

385 assert ( 

386 client.insert("lsstcam", "ccdexposure", (20240603, 123, 94), {"foo": 1}, allow_update=True).json()[ 

387 "message" 

388 ] 

389 == "Data inserted" 

390 ) 

391 

392 

393def test_insert_bad_obs_id_tuple(client: ConsDbClient) -> None: 

394 """A tuple that is not length 2 or 3 is rejected before any request.""" 

395 with pytest.raises(AssertionError, match="obs_id tuple"): 

396 client.insert("latiss", "exposure", (20240603,), {"foo": 1}) # type: ignore[arg-type] 

397 with pytest.raises(AssertionError, match="obs_id tuple"): 

398 client.insert("latiss", "exposure", (20240603, 123, 94, 0), {"foo": 1}) # type: ignore[arg-type] 

399 

400 

401def test_insert_no_values(client: ConsDbClient) -> None: 

402 """Inserting with no values raises before any request.""" 

403 with pytest.raises(ValueError, match="No values to insert"): 

404 client.insert("latiss", "exposure", 271828, {}) 

405 

406 

407@responses.activate 

408def test_getCcdVisitTableForDay_dedupes_overlapping_columns(client: ConsDbClient) -> None: 

409 """Columns already present in ccdvisit1_quicklook must not be re-selected. 

410 

411 ``cvq.*`` pulls in every column of ccdvisit1_quicklook. As ConsDB 

412 denormalises identity columns (visit_id, detector, seq_num, ...) onto the 

413 quicklook table, naming those again explicitly from the joined tables makes 

414 the server return duplicate column names, which astropy refuses to build a 

415 Table from (DM-55152). They must be dropped from the SELECT instead. 

416 """ 

417 url = "http://example.com/consdb/query" 

418 

419 # First query is the LIMIT 0 schema probe: pretend the quicklook table has 

420 # denormalised visit_id, detector and seq_num onto itself. 

421 responses.post( 

422 url, 

423 json={"columns": ["ccdvisit_id", "visit_id", "detector", "seq_num", "psf_sigma"], "data": []}, 

424 ) 

425 # Second query is the real data query; its response only has to build 

426 # cleanly, the behaviour under test is the SELECT clause that was sent. 

427 responses.post( 

428 url, 

429 json={ 

430 "columns": ["ccdvisit_id", "visit_id", "detector", "seq_num", "psf_sigma", "band"], 

431 "data": [[1, 100, 7, 5, 1.2, "r"]], 

432 }, 

433 ) 

434 

435 table = getCcdVisitTableForDay(client, 20240101) 

436 assert isinstance(table, Table) 

437 

438 # Only inspect the SELECT clause; the WHERE clause legitimately references 

439 # cv.visit_id etc. in its join conditions. 

440 requestBody = responses.calls[1].request.body 

441 assert isinstance(requestBody, (str, bytes)) 

442 sentQuery = json.loads(requestBody)["query"] 

443 selectClause = sentQuery.split(" FROM ")[0] 

444 # Columns already provided by cvq.* must not be re-selected explicitly... 

445 assert "cvq.*" in selectClause 

446 assert "cv.visit_id" not in selectClause 

447 assert "cv.detector" not in selectClause 

448 assert "v.seq_num" not in selectClause 

449 # ...but columns the quicklook table lacks must still be pulled from visit1 

450 for col in ("v.band", "v.exp_time", "v.day_obs", "v.img_type"): 

451 assert col in selectClause 

452 

453 

454@responses.activate 

455def test_getCcdVisitTableForDay_keeps_columns_when_no_overlap(client: ConsDbClient) -> None: 

456 """When the quicklook table shares no names, every extra is selected.""" 

457 url = "http://example.com/consdb/query" 

458 responses.post(url, json={"columns": ["ccdvisit_id", "psf_sigma"], "data": []}) 

459 responses.post(url, json={"columns": ["ccdvisit_id", "psf_sigma"], "data": [[1, 1.2]]}) 

460 

461 getCcdVisitTableForDay(client, 20240101) 

462 

463 requestBody = responses.calls[1].request.body 

464 assert isinstance(requestBody, (str, bytes)) 

465 sentQuery = json.loads(requestBody)["query"] 

466 selectClause = sentQuery.split(" FROM ")[0] 

467 for col in ("cv.detector", "cv.visit_id", "v.band", "v.exp_time", "v.seq_num", "v.day_obs", "v.img_type"): 

468 assert col in selectClause 

469 

470 

471# TODO: more POST tests 

472# client.insert_multiple(instrument, table, obs_dict, allow_update) 

473# client.query(query)