-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathpre_validation_test_utils.py
More file actions
598 lines (526 loc) · 24.7 KB
/
pre_validation_test_utils.py
File metadata and controls
598 lines (526 loc) · 24.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
"""Pre-validation test utilities"""
import unittest
from copy import deepcopy
from jsonpath_ng.ext import parse
from .generic_utils import (
test_invalid_values_rejected,
test_valid_values_accepted,
)
from .values_for_tests import InvalidDataTypes, InvalidValues, ValidValues
class ValidatorModelTests:
"""Generic tests for model validators"""
@staticmethod
def test_string_value(
test_instance: unittest.TestCase,
field_location: str,
valid_strings_to_test: list,
valid_json_data: dict = None,
defined_length: int = None,
max_length: int = None,
invalid_length_strings_to_test: list = None,
predefined_values: list = None,
invalid_strings_to_test: list = None,
spaces_allowed: bool = True,
invalid_strings_with_spaces_to_test: list = None,
is_mandatory_fhir: bool = False,
):
"""
Test that a FHIR model accepts valid string values and rejects the following invalid values:
* All invalid data types
* If there is a defined_string_length: Strings of invalid length (defined by the argument
invalid_length_strings_to_test), plus the empty string
* If there is no defined_string_length: Empty strings
* If there is a max_length: Strings longer than max length (defined by the argument
invalid_length_strings_to_test)
* If there are predefined values: Invalid strings (i.e. not one of the predefined values) as
defined by the argument invalid_strings_to_test
* If the field is manadatory in FHIR: Value of None
* If spaces are not allowed: Strings with spaces, which would be valid without the
spaces (defined by the argument invalid_strings_with_spaces_to_test)
* If is a postal code: Postal codes which are not separated into two parts by a single
space, or which exceed the maximum length of 8 characters (excluding spaces)
NOTE: No validation of optional arguments will occur if the method is not given a list of
values to test. This means that:
* When optional arguments defined_length and max_length are given, the optional argument
invalid_length_strings_to_test MUST also be given
* When optional argument predefined_values is given, the optional argument
invalid_strings_to_test MUST also be given.
* When optional argument spaces_allowed is given, the optional argument
invalid_strings_with_spaces_test must also be given
"""
valid_json_data = deepcopy(test_instance.json_data) if valid_json_data is None else valid_json_data
# Test that valid data is accepted
test_valid_values_accepted(test_instance, valid_json_data, field_location, valid_strings_to_test)
# Set list of invalid data types to test
invalid_data_types_for_strings = InvalidDataTypes.for_strings
if is_mandatory_fhir:
invalid_data_types_for_strings = filter(None, invalid_data_types_for_strings)
# Test invalid data types
for invalid_data_type_for_string in invalid_data_types_for_strings:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_data_type_for_string,
expected_error_message=f"{field_location} must be a string",
)
# Test whitespace
for invalid_whitespace_string in InvalidValues.for_whitespace_strings:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_whitespace_string,
expected_error_message=f"{field_location} must be a non-empty string",
)
# If there is a predefined string length, then test invalid string lengths,
# otherwise check the empty string only
if defined_length:
for invalid_length_string in invalid_length_strings_to_test:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_length_string,
expected_error_message=f"{field_location} must be {defined_length} characters",
)
else:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value="",
expected_error_message=f"{field_location} must be a non-empty string",
)
# If there is a max_length, test strings which exceed that length
if max_length:
for invalid_length_string in invalid_length_strings_to_test:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_length_string,
expected_error_message=f"{field_location} must be {max_length} " + "or fewer characters",
)
# If there are predefined values, then test strings which are
# not in the set of predefined values
if predefined_values:
for invalid_string in invalid_strings_to_test:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_string,
expected_error_message=f"{field_location} must be one of the following: "
+ str(", ".join(predefined_values)),
)
# If spaces are not allowed, then test strings with spaces
if not spaces_allowed:
for invalid_string_with_spaces in invalid_strings_with_spaces_to_test:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_string_with_spaces,
expected_error_message=f"{field_location} must not contain spaces",
)
@staticmethod
def test_list_value(
test_instance: unittest.TestCase,
field_location: str,
valid_lists_to_test: list,
predefined_list_length: int = None,
valid_list_element=None,
is_list_of_strings: bool = False,
is_list_of_dicts: bool = False,
):
"""
Test that a FHIR model accepts valid list values and rejects the following invalid values:
* All invalid data types
* If there is a predefined list length: Strings of invalid length, plus the empty list (note
that a valid list element must be supplied when a predefined list length is given as
the valid element will be used to populate lists of incorrect length to ensure
that the error is being raised due to length, not due to use of an invalid list element)
* If there is no predfined list length: Empty list
* If is a list of strings: Lists with non-string or empty string elements
* If is a list of dicts: Lists with non-dict or empty dict elements
NOTE: No validation of optional arguments will occur if the method is not given a list of
values to test. This means that:
* When optional arguments predefined_list_length is given, the optional argument
invalid_length_lists_to_test MUST also be given
"""
valid_json_data = deepcopy(test_instance.json_data)
# Test that valid data is accepted
test_valid_values_accepted(test_instance, valid_json_data, field_location, valid_lists_to_test)
# Test invalid data types
for invalid_data_type_for_list in InvalidDataTypes.for_lists:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_data_type_for_list,
expected_error_message=f"{field_location} must be an array",
)
# If there is a predefined list length, then test the empty list and a list which is
# larger than the predefined length, otherwise check the empty list only
if predefined_list_length:
# Set up list of invalid_length_lists
list_too_short = []
for _ in range(predefined_list_length - 1):
list_too_short.append(valid_list_element)
list_too_long = []
for _ in range(predefined_list_length + 1):
list_too_long.append(valid_list_element)
invalid_length_lists = [list_too_short, list_too_long]
if predefined_list_length != 1: # If is 1 then list_too_short = []
invalid_length_lists.append([])
# Test invalid list lengths
for invalid_length_list in invalid_length_lists:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_length_list,
expected_error_message=f"{field_location} must be an array of length " + f"{predefined_list_length}",
)
else:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=[],
expected_error_message=f"{field_location} must be a non-empty array",
)
# Tests lists with non-string or empty string elements (if applicable)
if is_list_of_strings:
# Test lists with non-string element
for invalid_list in InvalidValues.for_lists_of_strings_of_length_1:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_list,
expected_error_message=f"{field_location} must be an array of strings",
)
# Test empty string in list
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=[""],
expected_error_message=f"{field_location} must be an array of non-empty strings",
)
# Tests lists with non-dict or empty dict elements (if applicable)
if is_list_of_dicts:
# Test lists with non-dict element
for invalid_list in InvalidValues.for_lists_of_dicts_of_length_1:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_list,
expected_error_message=f"{field_location} must be an array of objects",
)
# Test empty dict in list
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=[{}],
expected_error_message=f"{field_location} must be an array of non-empty objects",
)
@staticmethod
def test_unique_list(
test_instance: unittest.TestCase,
field_location: str,
valid_lists_to_test: list,
invalid_list_with_duplicates_to_test: list,
expected_error_message: str,
):
"""
Test that a FHIR model accepts valid lists with unique values and rejects the following
invalid values:
* Lists with duplicate values
"""
valid_json_data = deepcopy(test_instance.json_data)
# Test that valid data is accepted
test_valid_values_accepted(test_instance, valid_json_data, field_location, valid_lists_to_test)
# Test lists with duplicate values
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_list_with_duplicates_to_test,
expected_error_message=expected_error_message,
)
@staticmethod
def test_date_value(
test_instance: unittest.TestCase,
field_location: str,
is_future_date_allowed: bool = False,
):
"""
Test that a FHIR model accepts valid date values and rejects the following invalid values:
* All invalid data types
* Invalid date formats
* Invalid dates
"""
valid_json_data = deepcopy(test_instance.json_data)
# Test that valid data is accepted
test_valid_values_accepted(test_instance, valid_json_data, field_location, ["2000-01-01", "1933-12-31"])
# Test invalid data types
for invalid_data_type_for_string in InvalidDataTypes.for_strings:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_data_type_for_string,
expected_error_message=f"{field_location} must be a string",
)
# Test invalid date string formats
for invalid_date_format in InvalidValues.for_date_string_formats:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_date_format,
expected_error_message=f"{field_location} must be a valid date string in the " + 'format "YYYY-MM-DD"',
)
if not is_future_date_allowed:
for invalid_date_format in InvalidValues.for_future_dates:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_date_format,
expected_error_message=f"{field_location} must not be in the future",
)
@staticmethod
def test_date_time_value(
test_instance: unittest.TestCase,
field_location: str,
is_occurrence_date_time: bool = False,
):
"""
Test that a FHIR model accepts valid date-time values and rejects the following invalid
values:
* All invalid data types
* Invalid date time string formats
* Invalid date-times
"""
expected_error_message = (
f"{field_location} must be a valid datetime in one of the following formats:"
"- 'YYYY-MM-DD' — Full date only"
"- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)"
"- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone"
"- Date must not be in the future."
)
if is_occurrence_date_time:
expected_error_message += (
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
f"Note that partial dates are not allowed for {field_location} in this service.\n"
)
valid_datetime_formats = ValidValues.for_date_times_strict_timezones
invalid_datetime_formats = InvalidValues.for_date_time_string_formats_for_strict_timezone
else:
# For recorded, skip values that are valid ISO with non-restricted timezone
valid_datetime_formats = ValidValues.for_date_times_relaxed_timezones
invalid_datetime_formats = InvalidValues.for_date_time_string_formats_for_relaxed_timezone
valid_json_data = deepcopy(test_instance.json_data)
# Test that valid data is accepted
test_valid_values_accepted(test_instance, valid_json_data, field_location, valid_datetime_formats)
# Set list of invalid data types to test
invalid_data_types_for_strings = InvalidDataTypes.for_strings
if is_occurrence_date_time:
invalid_data_types_for_strings = filter(None, invalid_data_types_for_strings)
# Test invalid data types
for invalid_data_type_for_string in invalid_data_types_for_strings:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_data_type_for_string,
expected_error_message=f"{field_location} must be a string",
)
# Test invalid date time string formats
for invalid_occurrence_date_time in invalid_datetime_formats:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_occurrence_date_time,
expected_error_message=expected_error_message,
)
# Test invalid date times
for invalid_occurrence_date_time in InvalidValues.for_date_times:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_occurrence_date_time,
expected_error_message=expected_error_message,
)
@staticmethod
def test_boolean_value(
test_instance: unittest.TestCase,
field_location: str,
):
"""Test that a FHIR model accepts valid boolean values and rejects non-boolean values."""
valid_json_data = deepcopy(test_instance.json_data)
# Test that valid data is accepted
test_valid_values_accepted(test_instance, valid_json_data, field_location, [True, False])
# Test invalid data types
for invalid_data_type_for_boolean in InvalidDataTypes.for_booleans:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_data_type_for_boolean,
expected_error_message=f"{field_location} must be a boolean",
)
@staticmethod
def test_positive_integer_value(
test_instance: unittest.TestCase,
field_location: str,
valid_positive_integers_to_test: list,
max_value: int = None,
):
"""
Test that a FHIR model accepts valid positive integer values and rejects the following
invalid values:
* All invalid data types
* Non-postive integers
* If there is a max value: a value which exceeds the maximum
"""
valid_json_data = deepcopy(test_instance.json_data)
# Test that valid data is accepted
test_valid_values_accepted(
test_instance,
valid_json_data,
field_location,
valid_positive_integers_to_test,
)
# Test invalid data types
for invalid_data_type_for_integer in InvalidDataTypes.for_integers:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_data_type_for_integer,
expected_error_message=f"{field_location} must be a positive integer",
)
# Test non-positive integers
for non_positive_integer in [-10, -1, 0]:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=non_positive_integer,
expected_error_message=f"{field_location} must be a positive integer",
)
# Test value exceeding the max value (if applicable)
if max_value:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=max_value + 1,
expected_error_message=f"{field_location} must be an integer in the range 1 to " + f"{str(max_value)}",
)
@staticmethod
def test_decimal_or_integer_value(
test_instance: unittest.TestCase,
field_location: str,
valid_decimals_and_integers_to_test: list,
):
"""
Test that a FHIR model accepts valid decimal or integer values and rejects the following
invalid values:
* All invalid data types
* If there is a max number of decimal places: a Decimal with too many decimal places
"""
valid_json_data = deepcopy(test_instance.json_data)
# Test that valid data is accepted
test_valid_values_accepted(
test_instance,
valid_json_data,
field_location,
valid_decimals_and_integers_to_test,
)
# Test invalid data types
for invalid_data_type_for_decimals_or_integers in InvalidDataTypes.for_decimals_or_integers:
test_invalid_values_rejected(
test_instance,
valid_json_data,
field_location=field_location,
invalid_value=invalid_data_type_for_decimals_or_integers,
expected_error_message=f"{field_location} must be a number",
)
@staticmethod
def test_valid_combinations_of_contained_and_performer_accepted(
test_instance: unittest.TestCase,
contained: list,
performer: dict,
):
"""
Takes a valid combination of contained and performer objects and ensures that no
validation error is raised
"""
valid_json_data = deepcopy(test_instance.json_data)
valid_json_data = parse("contained").update(valid_json_data, contained)
valid_json_data = parse("performer").update(valid_json_data, performer)
test_instance.assertIsNone(test_instance.validator.validate(valid_json_data))
@staticmethod
def test_invalid_performer_actor_reference_rejected(
test_instance: unittest.TestCase,
contained: list,
performer: dict,
expected_error_message: str,
):
"""
Takes a combination of contained and performer object which is invalid due to
either contained Practitioner ID, performer.actor.reference, or a combination of
the two, and checks that the appropriate error is raised
"""
invalid_json_data = deepcopy(test_instance.json_data)
invalid_json_data = parse("contained").update(invalid_json_data, contained)
invalid_json_data = parse("performer").update(invalid_json_data, performer)
with test_instance.assertRaises(ValueError) as error:
test_instance.validator.validate(invalid_json_data)
full_error_message = str(error.exception)
actual_error_messages = full_error_message.replace("Validation errors: ", "").split("; ")
test_instance.assertIn(expected_error_message, actual_error_messages)
@staticmethod
def test_valid_combinations_of_contained_and_patient_accepted(
test_instance: unittest.TestCase,
contained: list,
patient: dict,
):
"""
Takes a valid combination of contained and patient objects and ensures that no
validation error is raised
"""
valid_json_data = deepcopy(test_instance.json_data)
valid_json_data = parse("contained").update(valid_json_data, contained)
valid_json_data = parse("patient").update(valid_json_data, patient)
test_instance.assertIsNone(test_instance.validator.validate(valid_json_data))
@staticmethod
def test_invalid_patient_reference_rejected(
test_instance: unittest.TestCase,
contained: list,
patient: dict,
expected_error_message: str,
):
"""
Takes a combination of contained and patient object which is invalid due to
either contained Patient ID, patient.reference, or a combination of
the two, and checks that the appropriate error is raised
"""
invalid_json_data = deepcopy(test_instance.json_data)
invalid_json_data = parse("contained").update(invalid_json_data, contained)
invalid_json_data = parse("patient").update(invalid_json_data, patient)
with test_instance.assertRaises(ValueError) as error:
test_instance.validator.validate(invalid_json_data)
full_error_message = str(error.exception)
actual_error_messages = full_error_message.replace("Validation errors: ", "").split("; ")
test_instance.assertIn(expected_error_message, actual_error_messages)