Coverage for cc_modules/cc_unittest.py : 57%

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
3"""
4camcops_server/cc_modules/cc_unittest.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27**Framework and support functions for unit tests.**
29"""
31import base64
32import configparser
33from io import StringIO
34import logging
35import os
36import sqlite3
37from typing import Any, List, Type, TYPE_CHECKING
38import unittest
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
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
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
60log = BraceStyleAdapter(logging.getLogger(__name__))
63# =============================================================================
64# Constants
65# =============================================================================
67DEMO_PNG_BYTES = base64.b64decode(
68 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" # noqa
69 # https://stackoverflow.com/questions/6018611
70 # 1x1 pixel, black
71)
74# =============================================================================
75# Unit testing
76# =============================================================================
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.
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)
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)
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
116 self.req = get_unittest_request(self.dbsession)
117 self.recipdef = ExportRecipient()
119 def create_config_file(self) -> None:
120 from camcops_server.cc_modules.cc_baseconstants import ENVVAR_CONFIG_FILE # noqa: E402,E501
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.
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())
131 os.environ[ENVVAR_CONFIG_FILE] = tmpconfigfilename
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)
139 self.override_config_settings(parser)
141 with StringIO() as buffer:
142 parser.write(buffer)
143 config_text = buffer.getvalue()
145 return config_text
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`.
153 Example of code that could be used here:
155 .. code-block:: python
157 parser.set("site", "MY_CONFIG_SETTING", "my value")
158 """
160 def set_echo(self, echo: bool) -> None:
161 """
162 Changes the database echo status.
163 """
164 self.engine.echo = echo
166 def dump_database(self, loglevel: int = logging.INFO) -> None:
167 """
168 Writes the test in-memory SQLite database to the logging stream.
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)
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.
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)
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
223 self.set_era("2010-07-07T13:40+0100")
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
253 # ... users
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
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)
269 self.dbsession.flush() # sets PK fields
271 self.create_tasks()
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
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)
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()
311 return patient
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)
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 )
330 return patient
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)
338 for key, value in kwargs.items():
339 setattr(patient_idnum, key, value)
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)
347 self.dbsession.commit()
349 return patient_idnum
351 def create_patient(self, as_server_patient: bool = False,
352 **kwargs: Any) -> "Patient":
353 from camcops_server.cc_modules.cc_patient import Patient
355 patient = Patient()
356 self._apply_standard_db_fields(patient, era_now=as_server_patient)
358 for key, value in kwargs.items():
359 setattr(patient, key, value)
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)
366 self.dbsession.commit()
368 return patient
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
375 patient_with_two_idnums = self.create_patient_with_two_idnums()
376 patient_with_one_idnum = self.create_patient_with_one_idnum()
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
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)
398 t1.photo_blobid = b.id
400 self.dbsession.add(t1)
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)
409 self.dbsession.commit()
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
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.
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
435 def tearDown(self) -> None:
436 pass