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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_unittest.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28**Framework and support functions for unit tests.**
30"""
32import base64
33import copy
34import logging
35import os
36import sqlite3
37from typing import Any, List, Type, TYPE_CHECKING
38import unittest
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
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
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
75log = BraceStyleAdapter(logging.getLogger(__name__))
78# =============================================================================
79# Constants
80# =============================================================================
82DEMO_PNG_BYTES = base64.b64decode(
83 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" # noqa
84 # https://stackoverflow.com/questions/6018611
85 # 1x1 pixel, black
86)
89# =============================================================================
90# Unit testing
91# =============================================================================
94class ExtendedTestCase(unittest.TestCase):
95 """
96 A subclass of :class:`unittest.TestCase` that provides some additional
97 functionality.
98 """
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.
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)
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)
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 """
131 dbsession: "Session"
132 config_file: str
133 engine: Engine
134 database_on_disk: bool
135 db_filename: str
137 def setUp(self) -> None:
138 for factory in all_subclasses(BaseFactory):
139 factory._meta.sqlalchemy_session = self.dbsession
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)
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)
151 self.req.matched_route = unittest.mock.Mock()
152 self.recipdef = ExportRecipient()
154 def tearDown(self) -> None:
155 CamcopsRequest.config = self.old_config
157 def set_echo(self, echo: bool) -> None:
158 """
159 Changes the database echo status.
160 """
161 self.engine.echo = echo
163 def dump_database(self, loglevel: int = logging.INFO) -> None:
164 """
165 Writes the test in-memory SQLite database to the logging stream.
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)
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.
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)
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 """
223 def setUp(self) -> None:
224 super().setUp()
226 self.set_era("2010-07-07T13:40+0100")
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
265 # ... users
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
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
284 self.dbsession.flush() # sets PK fields
286 self.create_tasks()
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
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)
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
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()
327 return patient
329 def create_patient_with_one_idnum(self) -> "Patient":
330 from camcops_server.cc_modules.cc_patient import Patient
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)
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 )
347 return patient
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
354 patient_idnum = PatientIdNum()
355 self.apply_standard_db_fields(patient_idnum, era_now=as_server_patient)
357 for key, value in kwargs.items():
358 setattr(patient_idnum, key, value)
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)
367 self.dbsession.commit()
369 return patient_idnum
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
376 patient = Patient()
377 self.apply_standard_db_fields(patient, era_now=as_server_patient)
379 for key, value in kwargs.items():
380 setattr(patient, key, value)
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)
387 self.dbsession.commit()
389 return patient
391 def create_tasks(self) -> None:
392 # Override in subclass
393 pass
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
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.
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
419 def create_user(self, **kwargs) -> User:
420 user = User()
421 user.hashedpw = ""
423 for key, value in kwargs.items():
424 setattr(user, key, value)
426 self.dbsession.add(user)
428 return user
430 def create_group(self, name: str, **kwargs) -> Group:
431 group = Group()
432 group.name = name
434 for key, value in kwargs.items():
435 setattr(group, key, value)
437 self.dbsession.add(group)
439 return group
441 def create_membership(
442 self, user: User, group: Group, **kwargs
443 ) -> UserGroupMembership:
444 ugm = UserGroupMembership(user_id=user.id, group_id=group.id)
446 for key, value in kwargs.items():
447 setattr(ugm, key, value)
449 self.dbsession.add(ugm)
451 return ugm
453 def tearDown(self) -> None:
454 pass
457class DemoDatabaseTestCase(BasicDatabaseTestCase):
458 """
459 Test case that sets up a demonstration CamCOPS database with two tasks of
460 each type
461 """
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
468 patient_with_two_idnums = self.create_patient_with_two_idnums()
469 patient_with_one_idnum = self.create_patient_with_one_idnum()
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
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)
491 t1.photo_blobid = b.id
493 self.dbsession.add(t1)
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)
502 self.dbsession.commit()