Coverage for cc_modules/cc_unittest.py: 42%

248 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_unittest.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28**Framework and support functions for unit tests.** 

29 

30""" 

31 

32import base64 

33import copy 

34import logging 

35import os 

36import sqlite3 

37from typing import Any, List, Type, TYPE_CHECKING 

38import unittest 

39 

40from cardinal_pythonlib.classes import all_subclasses 

41from cardinal_pythonlib.dbfunc import get_fieldnames_from_cursor 

42from cardinal_pythonlib.httpconst import MimeType 

43from cardinal_pythonlib.logs import BraceStyleAdapter 

44import pendulum 

45import pytest 

46from sqlalchemy.engine.base import Engine 

47 

48from camcops_server.cc_modules.cc_baseconstants import ENVVAR_CONFIG_FILE 

49from camcops_server.cc_modules.cc_constants import ERA_NOW 

50from camcops_server.cc_modules.cc_device import Device 

51from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

52from camcops_server.cc_modules.cc_group import Group 

53from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition 

54from camcops_server.cc_modules.cc_ipuse import IpUse 

55from camcops_server.cc_modules.cc_request import ( 

56 CamcopsRequest, 

57 get_unittest_request, 

58) 

59from camcops_server.cc_modules.cc_sqlalchemy import sql_from_sqlite_database 

60from camcops_server.cc_modules.cc_user import User 

61from camcops_server.cc_modules.cc_membership import UserGroupMembership 

62from camcops_server.cc_modules.cc_testfactories import ( 

63 BaseFactory, 

64 DeviceFactory, 

65) 

66from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION 

67 

68if TYPE_CHECKING: 

69 from sqlalchemy.orm import Session 

70 from camcops_server.cc_modules.cc_db import GenericTabletRecordMixin 

71 from camcops_server.cc_modules.cc_patient import Patient 

72 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

73 from camcops_server.cc_modules.cc_task import Task 

74 

75log = BraceStyleAdapter(logging.getLogger(__name__)) 

76 

77 

78# ============================================================================= 

79# Constants 

80# ============================================================================= 

81 

82DEMO_PNG_BYTES = base64.b64decode( 

83 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" # noqa 

84 # https://stackoverflow.com/questions/6018611 

85 # 1x1 pixel, black 

86) 

87 

88 

89# ============================================================================= 

90# Unit testing 

91# ============================================================================= 

92 

93 

94class ExtendedTestCase(unittest.TestCase): 

95 """ 

96 A subclass of :class:`unittest.TestCase` that provides some additional 

97 functionality. 

98 """ 

99 

100 # Logging in unit tests: 

101 # https://stackoverflow.com/questions/7472863/pydev-unittesting-how-to-capture-text-logged-to-a-logging-logger-in-captured-o # noqa 

102 # https://stackoverflow.com/questions/7472863/pydev-unittesting-how-to-capture-text-logged-to-a-logging-logger-in-captured-o/15969985#15969985 

103 # ... but actually, my code below is simpler and works fine. 

104 

105 @classmethod 

106 def announce(cls, msg: str) -> None: 

107 """ 

108 Logs a message to the Python log. 

109 """ 

110 log.info("{}.{}:{}", cls.__module__, cls.__name__, msg) 

111 

112 def assertIsInstanceOrNone( 

113 self, obj: object, cls: Type, msg: str = None 

114 ) -> None: 

115 """ 

116 Asserts that ``obj`` is an instance of ``cls`` or is None. The 

117 parameter ``msg`` is used as part of the failure message if it isn't. 

118 """ 

119 if obj is None: 

120 return 

121 self.assertIsInstance(obj, cls, msg) 

122 

123 

124@pytest.mark.usefixtures("setup") 

125class DemoRequestTestCase(ExtendedTestCase): 

126 """ 

127 Test case that creates a demo Pyramid request that refers to a bare 

128 in-memory SQLite database. 

129 """ 

130 

131 dbsession: "Session" 

132 config_file: str 

133 engine: Engine 

134 database_on_disk: bool 

135 db_filename: str 

136 

137 def setUp(self) -> None: 

138 for factory in all_subclasses(BaseFactory): 

139 factory._meta.sqlalchemy_session = self.dbsession 

140 

141 # config file has already been set up for the session in conftest.py 

142 os.environ[ENVVAR_CONFIG_FILE] = self.config_file 

143 self.req = get_unittest_request(self.dbsession) 

144 

145 # request.config is a class property. We want to be able to override 

146 # config settings in a test by setting them directly on the config 

147 # object (e.g. self.req.config.foo = "bar"), then restore the defaults 

148 # afterwards. 

149 self.old_config = copy.copy(self.req.config) 

150 

151 self.req.matched_route = unittest.mock.Mock() 

152 self.recipdef = ExportRecipient() 

153 

154 def tearDown(self) -> None: 

155 CamcopsRequest.config = self.old_config 

156 

157 def set_echo(self, echo: bool) -> None: 

158 """ 

159 Changes the database echo status. 

160 """ 

161 self.engine.echo = echo 

162 

163 def dump_database(self, loglevel: int = logging.INFO) -> None: 

164 """ 

165 Writes the test in-memory SQLite database to the logging stream. 

166 

167 Args: 

168 loglevel: log level to use 

169 """ 

170 if not self.database_on_disk: 

171 log.warning("Cannot dump database (use database_on_disk for that)") 

172 return 

173 log.info("Dumping database; please wait...") 

174 connection = sqlite3.connect(self.db_filename) 

175 sql_text = sql_from_sqlite_database(connection) 

176 connection.close() 

177 log.log(loglevel, "SQLite database:\n{}", sql_text) 

178 

179 def dump_table( 

180 self, 

181 tablename: str, 

182 column_names: List[str] = None, 

183 loglevel: int = logging.INFO, 

184 ) -> None: 

185 """ 

186 Writes one table of the in-memory SQLite database to the logging 

187 stream. 

188 

189 Args: 

190 tablename: table to dump 

191 column_names: column names to dump, or ``None`` for all 

192 loglevel: log level to use 

193 """ 

194 if not self.database_on_disk: 

195 log.warning("Cannot dump database (use database_on_disk for that)") 

196 return 

197 connection = sqlite3.connect(self.db_filename) 

198 cursor = connection.cursor() 

199 columns = ",".join(column_names) if column_names else "*" 

200 sql = f"SELECT {columns} FROM {tablename}" 

201 cursor.execute(sql) 

202 # noinspection PyTypeChecker 

203 fieldnames = get_fieldnames_from_cursor(cursor) 

204 results = ( 

205 ",".join(fieldnames) 

206 + "\n" 

207 + "\n".join( 

208 ",".join(str(value) for value in row) 

209 for row in cursor.fetchall() 

210 ) 

211 ) 

212 connection.close() 

213 log.log(loglevel, "Contents of table {}:\n{}", tablename, results) 

214 

215 

216class BasicDatabaseTestCase(DemoRequestTestCase): 

217 """ 

218 Test case that sets up some useful database records for testing: 

219 ID numbers, user, group, devices etc and has helper methods for 

220 creating patients and tasks 

221 """ 

222 

223 def setUp(self) -> None: 

224 super().setUp() 

225 

226 self.set_era("2010-07-07T13:40+0100") 

227 

228 # Set up groups, users, etc. 

229 # ... ID number definitions 

230 idnum_type_nhs = 1 

231 idnum_type_rio = 2 

232 idnum_type_study = 3 

233 self.nhs_iddef = IdNumDefinition( 

234 which_idnum=idnum_type_nhs, 

235 description="NHS number", 

236 short_description="NHS#", 

237 hl7_assigning_authority="NHS", 

238 hl7_id_type="NHSN", 

239 ) 

240 self.dbsession.add(self.nhs_iddef) 

241 self.rio_iddef = IdNumDefinition( 

242 which_idnum=idnum_type_rio, 

243 description="RiO number", 

244 short_description="RiO", 

245 hl7_assigning_authority="CPFT", 

246 hl7_id_type="CPRiO", 

247 ) 

248 self.dbsession.add(self.rio_iddef) 

249 self.study_iddef = IdNumDefinition( 

250 which_idnum=idnum_type_study, 

251 description="Study number", 

252 short_description="Study", 

253 ) 

254 self.dbsession.add(self.study_iddef) 

255 # ... group 

256 self.group = Group() 

257 self.group.name = "testgroup" 

258 self.group.description = "Test group" 

259 self.group.upload_policy = "sex AND anyidnum" 

260 self.group.finalize_policy = "sex AND idnum1" 

261 self.group.ip_use = IpUse() 

262 self.dbsession.add(self.group) 

263 self.dbsession.flush() # sets PK fields 

264 

265 # ... users 

266 

267 self.user = User.get_system_user(self.dbsession) 

268 self.user.upload_group_id = self.group.id 

269 self.req._debugging_user = self.user # improve our debugging user 

270 

271 # ... devices 

272 self.server_device = Device.get_server_device(self.dbsession) 

273 DeviceFactory.reset_sequence(self.server_device.id + 1) 

274 self.other_device = DeviceFactory( 

275 name="other_device", 

276 friendly_name="Test device that may upload", 

277 registered_by_user=self.user, 

278 when_registered_utc=self.era_time_utc, 

279 camcops_version=CAMCOPS_SERVER_VERSION, 

280 ) 

281 # ... export recipient definition (the minimum) 

282 self.recipdef.primary_idnum = idnum_type_nhs 

283 

284 self.dbsession.flush() # sets PK fields 

285 

286 self.create_tasks() 

287 

288 def set_era(self, iso_datetime: str) -> None: 

289 from cardinal_pythonlib.datetimefunc import ( 

290 convert_datetime_to_utc, 

291 format_datetime, 

292 ) 

293 from camcops_server.cc_modules.cc_constants import DateFormat 

294 

295 self.era_time = pendulum.parse(iso_datetime) 

296 self.era_time_utc = convert_datetime_to_utc(self.era_time) 

297 self.era = format_datetime(self.era_time, DateFormat.ISO8601) 

298 

299 def create_patient_with_two_idnums(self) -> "Patient": 

300 from camcops_server.cc_modules.cc_patient import Patient 

301 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

302 

303 # Populate database with two of everything 

304 patient = Patient() 

305 patient.id = 1 

306 self.apply_standard_db_fields(patient) 

307 patient.forename = "Forename1" 

308 patient.surname = "Surname1" 

309 patient.dob = pendulum.parse("1950-01-01") 

310 self.dbsession.add(patient) 

311 patient_idnum1 = PatientIdNum() 

312 patient_idnum1.id = 1 

313 self.apply_standard_db_fields(patient_idnum1) 

314 patient_idnum1.patient_id = patient.id 

315 patient_idnum1.which_idnum = self.nhs_iddef.which_idnum 

316 patient_idnum1.idnum_value = 333 

317 self.dbsession.add(patient_idnum1) 

318 patient_idnum2 = PatientIdNum() 

319 patient_idnum2.id = 2 

320 self.apply_standard_db_fields(patient_idnum2) 

321 patient_idnum2.patient_id = patient.id 

322 patient_idnum2.which_idnum = self.rio_iddef.which_idnum 

323 patient_idnum2.idnum_value = 444 

324 self.dbsession.add(patient_idnum2) 

325 self.dbsession.commit() 

326 

327 return patient 

328 

329 def create_patient_with_one_idnum(self) -> "Patient": 

330 from camcops_server.cc_modules.cc_patient import Patient 

331 

332 patient = Patient() 

333 patient.id = 2 

334 self.apply_standard_db_fields(patient) 

335 patient.forename = "Forename2" 

336 patient.surname = "Surname2" 

337 patient.dob = pendulum.parse("1975-12-12") 

338 self.dbsession.add(patient) 

339 

340 self.create_patient_idnum( 

341 id=3, 

342 patient_id=patient.id, 

343 which_idnum=self.nhs_iddef.which_idnum, 

344 idnum_value=555, 

345 ) 

346 

347 return patient 

348 

349 def create_patient_idnum( 

350 self, as_server_patient: bool = False, **kwargs: Any 

351 ) -> "PatientIdNum": 

352 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

353 

354 patient_idnum = PatientIdNum() 

355 self.apply_standard_db_fields(patient_idnum, era_now=as_server_patient) 

356 

357 for key, value in kwargs.items(): 

358 setattr(patient_idnum, key, value) 

359 

360 if "id" not in kwargs: 

361 patient_idnum.save_with_next_available_id( 

362 self.req, patient_idnum._device_id 

363 ) 

364 else: 

365 self.dbsession.add(patient_idnum) 

366 

367 self.dbsession.commit() 

368 

369 return patient_idnum 

370 

371 def create_patient( 

372 self, as_server_patient: bool = False, **kwargs: Any 

373 ) -> "Patient": 

374 from camcops_server.cc_modules.cc_patient import Patient 

375 

376 patient = Patient() 

377 self.apply_standard_db_fields(patient, era_now=as_server_patient) 

378 

379 for key, value in kwargs.items(): 

380 setattr(patient, key, value) 

381 

382 if "id" not in kwargs: 

383 patient.save_with_next_available_id(self.req, patient._device_id) 

384 else: 

385 self.dbsession.add(patient) 

386 

387 self.dbsession.commit() 

388 

389 return patient 

390 

391 def create_tasks(self) -> None: 

392 # Override in subclass 

393 pass 

394 

395 def apply_standard_task_fields(self, task: "Task") -> None: 

396 """ 

397 Writes some default values to an SQLAlchemy ORM object representing 

398 a task. 

399 """ 

400 self.apply_standard_db_fields(task) 

401 task.when_created = self.era_time 

402 

403 def apply_standard_db_fields( 

404 self, obj: "GenericTabletRecordMixin", era_now: bool = False 

405 ) -> None: 

406 """ 

407 Writes some default values to an SQLAlchemy ORM object representing a 

408 record uploaded from a client (tablet) device. 

409 

410 Though we use the server device ID. 

411 """ 

412 obj._device_id = self.server_device.id 

413 obj._era = ERA_NOW if era_now else self.era 

414 obj._group_id = self.group.id 

415 obj._current = True 

416 obj._adding_user_id = self.user.id 

417 obj._when_added_batch_utc = self.era_time_utc 

418 

419 def create_user(self, **kwargs) -> User: 

420 user = User() 

421 user.hashedpw = "" 

422 

423 for key, value in kwargs.items(): 

424 setattr(user, key, value) 

425 

426 self.dbsession.add(user) 

427 

428 return user 

429 

430 def create_group(self, name: str, **kwargs) -> Group: 

431 group = Group() 

432 group.name = name 

433 

434 for key, value in kwargs.items(): 

435 setattr(group, key, value) 

436 

437 self.dbsession.add(group) 

438 

439 return group 

440 

441 def create_membership( 

442 self, user: User, group: Group, **kwargs 

443 ) -> UserGroupMembership: 

444 ugm = UserGroupMembership(user_id=user.id, group_id=group.id) 

445 

446 for key, value in kwargs.items(): 

447 setattr(ugm, key, value) 

448 

449 self.dbsession.add(ugm) 

450 

451 return ugm 

452 

453 def tearDown(self) -> None: 

454 pass 

455 

456 

457class DemoDatabaseTestCase(BasicDatabaseTestCase): 

458 """ 

459 Test case that sets up a demonstration CamCOPS database with two tasks of 

460 each type 

461 """ 

462 

463 def create_tasks(self) -> None: 

464 from camcops_server.cc_modules.cc_blob import Blob 

465 from camcops_server.tasks.photo import Photo 

466 from camcops_server.cc_modules.cc_task import Task 

467 

468 patient_with_two_idnums = self.create_patient_with_two_idnums() 

469 patient_with_one_idnum = self.create_patient_with_one_idnum() 

470 

471 for cls in Task.all_subclasses_by_tablename(): 

472 t1 = cls() 

473 t1.id = 1 

474 self.apply_standard_task_fields(t1) 

475 if t1.has_patient: 

476 t1.patient_id = patient_with_two_idnums.id 

477 

478 if isinstance(t1, Photo): 

479 b = Blob() 

480 b.id = 1 

481 self.apply_standard_db_fields(b) 

482 b.tablename = t1.tablename 

483 b.tablepk = t1.id 

484 b.fieldname = "photo_blobid" 

485 b.filename = "some_picture.png" 

486 b.mimetype = MimeType.PNG 

487 b.image_rotation_deg_cw = 0 

488 b.theblob = DEMO_PNG_BYTES 

489 self.dbsession.add(b) 

490 

491 t1.photo_blobid = b.id 

492 

493 self.dbsession.add(t1) 

494 

495 t2 = cls() 

496 t2.id = 2 

497 self.apply_standard_task_fields(t2) 

498 if t2.has_patient: 

499 t2.patient_id = patient_with_one_idnum.id 

500 self.dbsession.add(t2) 

501 

502 self.dbsession.commit()