Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_unittest.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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

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

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

15 (at your option) any later version. 

16 

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

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

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

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

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

24 

25=============================================================================== 

26 

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

28 

29""" 

30 

31import base64 

32import configparser 

33from io import StringIO 

34import logging 

35import os 

36import sqlite3 

37from typing import Any, List, Type, TYPE_CHECKING 

38import unittest 

39 

40from cardinal_pythonlib.dbfunc import get_fieldnames_from_cursor 

41from cardinal_pythonlib.httpconst import MimeType 

42from cardinal_pythonlib.logs import BraceStyleAdapter 

43import pendulum 

44import pytest 

45 

46from camcops_server.cc_modules.cc_constants import ERA_NOW 

47from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition 

48from camcops_server.cc_modules.cc_ipuse import IpUse 

49from camcops_server.cc_modules.cc_sqlalchemy import ( 

50 sql_from_sqlite_database, 

51) 

52from camcops_server.cc_modules.cc_version import CAMCOPS_SERVER_VERSION 

53 

54if TYPE_CHECKING: 

55 from camcops_server.cc_modules.cc_db import GenericTabletRecordMixin 

56 from camcops_server.cc_modules.cc_patient import Patient 

57 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

58 from camcops_server.cc_modules.cc_task import Task 

59 

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

61 

62 

63# ============================================================================= 

64# Constants 

65# ============================================================================= 

66 

67DEMO_PNG_BYTES = base64.b64decode( 

68 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" # noqa 

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

70 # 1x1 pixel, black 

71) 

72 

73 

74# ============================================================================= 

75# Unit testing 

76# ============================================================================= 

77 

78class ExtendedTestCase(unittest.TestCase): 

79 """ 

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

81 functionality. 

82 """ 

83 # Logging in unit tests: 

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

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

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

87 

88 @classmethod 

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

90 """ 

91 Logs a message to the Python log. 

92 """ 

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

94 

95 def assertIsInstanceOrNone(self, obj: object, cls: Type, msg: str = None): 

96 """ 

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

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

99 """ 

100 if obj is None: 

101 return 

102 self.assertIsInstance(obj, cls, msg) 

103 

104 

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

106class DemoRequestTestCase(ExtendedTestCase): 

107 """ 

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

109 in-memory SQLite database. 

110 """ 

111 def setUp(self) -> None: 

112 self.create_config_file() 

113 from camcops_server.cc_modules.cc_request import get_unittest_request 

114 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient # noqa 

115 

116 self.req = get_unittest_request(self.dbsession) 

117 self.recipdef = ExportRecipient() 

118 

119 def create_config_file(self) -> None: 

120 from camcops_server.cc_modules.cc_baseconstants import ENVVAR_CONFIG_FILE # noqa: E402,E501 

121 

122 # We're going to be using a test (SQLite) database, but we want to 

123 # be very sure that nothing writes to a real database! Also, we will 

124 # want to read from this dummy config at some point. 

125 

126 tmpconfigfilename = os.path.join(self.tmpdir_obj.name, 

127 "dummy_config.conf") 

128 with open(tmpconfigfilename, "w") as file: 

129 file.write(self.get_config_text()) 

130 

131 os.environ[ENVVAR_CONFIG_FILE] = tmpconfigfilename 

132 

133 def get_config_text(self) -> str: 

134 from camcops_server.cc_modules.cc_config import get_demo_config 

135 config_text = get_demo_config() 

136 parser = configparser.ConfigParser() 

137 parser.read_string(config_text) 

138 

139 self.override_config_settings(parser) 

140 

141 with StringIO() as buffer: 

142 parser.write(buffer) 

143 config_text = buffer.getvalue() 

144 

145 return config_text 

146 

147 def override_config_settings(self, 

148 parser: configparser.ConfigParser) -> None: 

149 """ 

150 Allows an individual test to override config settings 

151 called from :meth:`setUp`. 

152 

153 Example of code that could be used here: 

154 

155 .. code-block:: python 

156 

157 parser.set("site", "MY_CONFIG_SETTING", "my value") 

158 """ 

159 

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

161 """ 

162 Changes the database echo status. 

163 """ 

164 self.engine.echo = echo 

165 

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

167 """ 

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

169 

170 Args: 

171 loglevel: log level to use 

172 """ 

173 if not self.database_on_disk: 

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

175 return 

176 log.warning("Dumping database; please wait...") 

177 connection = sqlite3.connect(self.db_filename) 

178 sql_text = sql_from_sqlite_database(connection) 

179 connection.close() 

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

181 

182 def dump_table(self, 

183 tablename: str, 

184 column_names: List[str] = None, 

185 loglevel: int = logging.INFO) -> None: 

186 """ 

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

188 stream. 

189 

190 Args: 

191 tablename: table to dump 

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

193 loglevel: log level to use 

194 """ 

195 if not self.database_on_disk: 

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

197 return 

198 connection = sqlite3.connect(self.db_filename) 

199 cursor = connection.cursor() 

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

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

202 cursor.execute(sql) 

203 # noinspection PyTypeChecker 

204 fieldnames = get_fieldnames_from_cursor(cursor) 

205 results = ",".join(fieldnames) + "\n" + "\n".join( 

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

207 for row in cursor.fetchall() 

208 ) 

209 connection.close() 

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

211 

212 

213class DemoDatabaseTestCase(DemoRequestTestCase): 

214 """ 

215 Test case that sets up a demonstration CamCOPS database in memory. 

216 """ 

217 def setUp(self) -> None: 

218 super().setUp() 

219 from camcops_server.cc_modules.cc_device import Device 

220 from camcops_server.cc_modules.cc_group import Group 

221 from camcops_server.cc_modules.cc_user import User 

222 

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

224 

225 # Set up groups, users, etc. 

226 # ... ID number definitions 

227 self.nhs_iddef = IdNumDefinition(which_idnum=1, 

228 description="NHS number", 

229 short_description="NHS#", 

230 hl7_assigning_authority="NHS", 

231 hl7_id_type="NHSN") 

232 self.dbsession.add(self.nhs_iddef) 

233 self.rio_iddef = IdNumDefinition(which_idnum=2, 

234 description="RiO number", 

235 short_description="RiO", 

236 hl7_assigning_authority="CPFT", 

237 hl7_id_type="CPRiO") 

238 self.dbsession.add(self.rio_iddef) 

239 self.study_iddef = IdNumDefinition(which_idnum=3, 

240 description="Study number", 

241 short_description="Study") 

242 self.dbsession.add(self.study_iddef) 

243 # ... group 

244 self.group = Group() 

245 self.group.name = "testgroup" 

246 self.group.description = "Test group" 

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

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

249 self.group.ip_use = IpUse() 

250 self.dbsession.add(self.group) 

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

252 

253 # ... users 

254 

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

256 self.user.upload_group_id = self.group.id 

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

258 

259 # ... devices 

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

261 self.other_device = Device() 

262 self.other_device.name = "other_device" 

263 self.other_device.friendly_name = "Test device that may upload" 

264 self.other_device.registered_by_user = self.user 

265 self.other_device.when_registered_utc = self.era_time_utc 

266 self.other_device.camcops_version = CAMCOPS_SERVER_VERSION 

267 self.dbsession.add(self.other_device) 

268 

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

270 

271 self.create_tasks() 

272 

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

274 from cardinal_pythonlib.datetimefunc import ( 

275 convert_datetime_to_utc, 

276 format_datetime, 

277 ) 

278 from camcops_server.cc_modules.cc_constants import DateFormat 

279 

280 self.era_time = pendulum.parse(iso_datetime) 

281 self.era_time_utc = convert_datetime_to_utc(self.era_time) 

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

283 

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

285 from camcops_server.cc_modules.cc_patient import Patient 

286 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

287 # Populate database with two of everything 

288 patient = Patient() 

289 patient.id = 1 

290 self._apply_standard_db_fields(patient) 

291 patient.forename = "Forename1" 

292 patient.surname = "Surname1" 

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

294 self.dbsession.add(patient) 

295 patient_idnum1 = PatientIdNum() 

296 patient_idnum1.id = 1 

297 self._apply_standard_db_fields(patient_idnum1) 

298 patient_idnum1.patient_id = patient.id 

299 patient_idnum1.which_idnum = self.nhs_iddef.which_idnum 

300 patient_idnum1.idnum_value = 333 

301 self.dbsession.add(patient_idnum1) 

302 patient_idnum2 = PatientIdNum() 

303 patient_idnum2.id = 2 

304 self._apply_standard_db_fields(patient_idnum2) 

305 patient_idnum2.patient_id = patient.id 

306 patient_idnum2.which_idnum = self.rio_iddef.which_idnum 

307 patient_idnum2.idnum_value = 444 

308 self.dbsession.add(patient_idnum2) 

309 self.dbsession.commit() 

310 

311 return patient 

312 

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

314 from camcops_server.cc_modules.cc_patient import Patient 

315 patient = Patient() 

316 patient.id = 2 

317 self._apply_standard_db_fields(patient) 

318 patient.forename = "Forename2" 

319 patient.surname = "Surname2" 

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

321 self.dbsession.add(patient) 

322 

323 self.create_patient_idnum( 

324 id=3, 

325 patient_id=patient.id, 

326 which_idnum=self.nhs_iddef.which_idnum, 

327 idnum_value=555 

328 ) 

329 

330 return patient 

331 

332 def create_patient_idnum(self, as_server_patient: bool = False, 

333 **kwargs: Any) -> "PatientIdNum": 

334 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum 

335 patient_idnum = PatientIdNum() 

336 self._apply_standard_db_fields(patient_idnum, era_now=as_server_patient) 

337 

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

339 setattr(patient_idnum, key, value) 

340 

341 if "id" not in kwargs: 

342 patient_idnum.save_with_next_available_id(self.req, 

343 patient_idnum._device_id) 

344 else: 

345 self.dbsession.add(patient_idnum) 

346 

347 self.dbsession.commit() 

348 

349 return patient_idnum 

350 

351 def create_patient(self, as_server_patient: bool = False, 

352 **kwargs: Any) -> "Patient": 

353 from camcops_server.cc_modules.cc_patient import Patient 

354 

355 patient = Patient() 

356 self._apply_standard_db_fields(patient, era_now=as_server_patient) 

357 

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

359 setattr(patient, key, value) 

360 

361 if "id" not in kwargs: 

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

363 else: 

364 self.dbsession.add(patient) 

365 

366 self.dbsession.commit() 

367 

368 return patient 

369 

370 def create_tasks(self) -> None: 

371 from camcops_server.cc_modules.cc_blob import Blob 

372 from camcops_server.tasks.photo import Photo 

373 from camcops_server.cc_modules.cc_task import Task 

374 

375 patient_with_two_idnums = self.create_patient_with_two_idnums() 

376 patient_with_one_idnum = self.create_patient_with_one_idnum() 

377 

378 for cls in Task.all_subclasses_by_tablename(): 

379 t1 = cls() 

380 t1.id = 1 

381 self.apply_standard_task_fields(t1) 

382 if t1.has_patient: 

383 t1.patient_id = patient_with_two_idnums.id 

384 

385 if isinstance(t1, Photo): 

386 b = Blob() 

387 b.id = 1 

388 self._apply_standard_db_fields(b) 

389 b.tablename = t1.tablename 

390 b.tablepk = t1.id 

391 b.fieldname = 'photo_blobid' 

392 b.filename = "some_picture.png" 

393 b.mimetype = MimeType.PNG 

394 b.image_rotation_deg_cw = 0 

395 b.theblob = DEMO_PNG_BYTES 

396 self.dbsession.add(b) 

397 

398 t1.photo_blobid = b.id 

399 

400 self.dbsession.add(t1) 

401 

402 t2 = cls() 

403 t2.id = 2 

404 self.apply_standard_task_fields(t2) 

405 if t2.has_patient: 

406 t2.patient_id = patient_with_one_idnum.id 

407 self.dbsession.add(t2) 

408 

409 self.dbsession.commit() 

410 

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

412 """ 

413 Writes some default values to an SQLAlchemy ORM object representing 

414 a task. 

415 """ 

416 self._apply_standard_db_fields(task) 

417 task.when_created = self.era_time 

418 

419 def _apply_standard_db_fields(self, 

420 obj: "GenericTabletRecordMixin", 

421 era_now: bool = False) -> None: 

422 """ 

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

424 record uploaded from a client (tablet) device. 

425 

426 Though we use the server device ID. 

427 """ 

428 obj._device_id = self.server_device.id 

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

430 obj._group_id = self.group.id 

431 obj._current = True 

432 obj._adding_user_id = self.user.id 

433 obj._when_added_batch_utc = self.era_time_utc 

434 

435 def tearDown(self) -> None: 

436 pass