Skip to content

Commit 4b39b5d

Browse files
committed
refactor expression checker test
1 parent 8f7ccc5 commit 4b39b5d

File tree

3 files changed

+357
-251
lines changed

3 files changed

+357
-251
lines changed

lambdas/shared/tests/test_common/validator/test_expression_checker.py

Lines changed: 357 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,182 @@
44

55
from common.validator.enums.exception_messages import ExceptionLevels
66
from common.validator.expression_checker import ExpressionChecker
7+
from common.validator.record_error import ErrorReport
78

8-
# TODO this needs to be expanded to cover all expression types
9+
10+
class MockParser(unittest.TestCase):
11+
"""
12+
Mock parser used to simulate field value lookups
13+
for ExpressionChecker during testing.
14+
"""
15+
16+
def __init__(self, data=None):
17+
self._data = data or {}
18+
19+
def get_key_value(self, field_name):
20+
"""Return a list to mimic parser contract."""
21+
return [self._data.get(field_name, "")]
922

1023

1124
class TestExpressionChecker(unittest.TestCase):
25+
"""
26+
Unit tests for ExpressionChecker validation logic.
27+
Each test validates a specific expression rule type.
28+
"""
29+
30+
def make_checker(self, mock_data=None, summarise=False, report=True):
31+
"""Helper to create an ExpressionChecker with mock parser data."""
32+
return ExpressionChecker(MockParser(mock_data), summarise, report)
33+
34+
# --- DATETIME & UUID -------------------------------------------------
35+
36+
def test_datetime_valid(self):
37+
"""Valid ISO date should pass without error."""
38+
checker = self.make_checker({"date_field": "2025-01-01"})
39+
error = checker.validate_expression("DATETIME", None, "date_field", "2025-01-01", 1)
40+
self.assertIsNone(error)
41+
42+
def test_datetime_unexpected_exception(self):
43+
"""Passing incompatible type should raise an error report."""
44+
checker = self.make_checker()
45+
error = checker.validate_expression("DATETIME", None, "date_field", object(), 1)
46+
self.assertIsInstance(error, ErrorReport)
47+
48+
def test_uuid_valid_and_invalid(self):
49+
"""UUID validation should pass for valid UUIDs and fail for invalid ones."""
50+
checker = self.make_checker()
51+
valid_uuid = "12345678-1234-5678-1234-567812345678"
52+
self.assertIsNone(checker.validate_expression("UUID", None, "uuid_field", valid_uuid, 1))
53+
self.assertIsInstance(checker.validate_expression("UUID", None, "uuid_field", "not-a-uuid", 1), ErrorReport)
54+
55+
# --- NUMERIC, LENGTH, REGEX ------------------------------------------
56+
57+
def test_integer_length_and_regex_rules(self):
58+
"""Test integer, length, and regex-based validations."""
59+
checker = self.make_checker()
60+
61+
# INT should pass with numeric value
62+
self.assertIsNone(checker.validate_expression("INT", None, "int_field", "42", 1))
63+
64+
# LENGTH too long -> Error
65+
self.assertIsInstance(checker.validate_expression("LENGTH", "3", "str_field", "abcd", 1), ErrorReport)
66+
67+
# REGEX mismatch -> Error
68+
self.assertIsInstance(checker.validate_expression("REGEX", r"^abc$", "regex_field", "abcd", 1), ErrorReport)
69+
70+
# --- CASE & STRING POSITION RULES ------------------------------------
71+
72+
def test_upper_lower_startswith_endswith_rules(self):
73+
"""Validate case and string boundary conditions."""
74+
checker = self.make_checker()
75+
76+
# UPPER
77+
self.assertIsNone(checker.validate_expression("UPPER", None, "upper_field", "ABC", 1))
78+
self.assertIsInstance(checker.validate_expression("UPPER", None, "upper_field", "AbC", 1), ErrorReport)
79+
80+
# LOWER
81+
self.assertIsNone(checker.validate_expression("LOWER", None, "lower_field", "abc", 1))
82+
self.assertIsInstance(checker.validate_expression("LOWER", None, "lower_field", "abC", 1), ErrorReport)
83+
84+
# STARTSWITH
85+
self.assertIsNone(checker.validate_expression("STARTSWITH", "ab", "start_field", "abc", 1))
86+
self.assertIsInstance(checker.validate_expression("STARTSWITH", "zz", "start_field", "abc", 1), ErrorReport)
87+
88+
# ENDSWITH
89+
self.assertIsNone(checker.validate_expression("ENDSWITH", "bc", "end_field", "abc", 1))
90+
self.assertIsInstance(checker.validate_expression("ENDSWITH", "zz", "end_field", "abc", 1), ErrorReport)
91+
92+
# --- EMPTY & NOTEMPTY ------------------------------------------------
93+
94+
def test_empty_and_notempty_rules(self):
95+
"""Validate checks for empty and non-empty fields."""
96+
checker = self.make_checker()
97+
98+
# EMPTY
99+
self.assertIsNone(checker.validate_expression("EMPTY", None, "empty_field", "", 1))
100+
self.assertIsInstance(checker.validate_expression("EMPTY", None, "empty_field", "value", 1), ErrorReport)
101+
102+
# NOTEMPTY
103+
self.assertIsNone(checker.validate_expression("NOTEMPTY", None, "notempty_field", "value", 1))
104+
self.assertIsInstance(checker.validate_expression("NOTEMPTY", None, "notempty_field", "", 1), ErrorReport)
105+
106+
# --- NUMERIC RANGES --------------------------------------------------
107+
108+
def test_positive_and_nrange_rules(self):
109+
"""Check positive and numeric range validations."""
110+
checker = self.make_checker()
111+
112+
# POSITIVE
113+
self.assertIsNone(checker.validate_expression("POSITIVE", None, "positive_field", "1.2", 1))
114+
self.assertIsInstance(checker.validate_expression("POSITIVE", None, "positive_field", "-3", 1), ErrorReport)
115+
116+
# NRANGE
117+
self.assertIsNone(checker.validate_expression("NRANGE", "1,10", "range_field", "5", 1))
118+
self.assertIsInstance(checker.validate_expression("NRANGE", "a,b", "range_field", "5", 1), ErrorReport)
119+
120+
# --- COMPARISONS & LIST MEMBERSHIP -----------------------------------
121+
122+
def test_inarray_equal_notequal_rules(self):
123+
"""Test INARRAY, EQUAL, and NOTEQUAL expressions."""
124+
checker = self.make_checker()
125+
126+
# INARRAY
127+
self.assertIsNone(checker.validate_expression("INARRAY", "a,b", "array_field", "a", 1))
128+
self.assertIsInstance(checker.validate_expression("INARRAY", "a,b", "array_field", "z", 1), ErrorReport)
129+
130+
# EQUAL
131+
self.assertIsNone(checker.validate_expression("EQUAL", "x", "equal_field", "x", 1))
132+
self.assertIsInstance(checker.validate_expression("EQUAL", "x", "equal_field", "y", 1), ErrorReport)
133+
134+
# NOTEQUAL
135+
self.assertIsNone(checker.validate_expression("NOTEQUAL", "x", "notequal_field", "y", 1))
136+
self.assertIsInstance(checker.validate_expression("NOTEQUAL", "x", "notequal_field", "x", 1), ErrorReport)
137+
138+
# --- DOMAIN-SPECIFIC RULES -------------------------------------------
139+
140+
def test_postcode_gender_nhsnumber_rules(self):
141+
"""Check NHS number, gender, and postcode validations."""
142+
checker = self.make_checker()
143+
144+
# NHSNUMBER invalid
145+
self.assertIsInstance(checker.validate_expression("NHSNUMBER", None, "nhs_field", "123", 1), ErrorReport)
146+
147+
# GENDER
148+
self.assertIsNone(checker.validate_expression("GENDER", None, "gender_field", "0", 1))
149+
self.assertIsInstance(checker.validate_expression("GENDER", None, "gender_field", "x", 1), ErrorReport)
150+
151+
# POSTCODE
152+
self.assertIsInstance(checker.validate_expression("POSTCODE", None, "postcode_field", "XYZ", 1), ErrorReport)
153+
154+
# --- COLLECTION SIZE RULES -------------------------------------------
155+
156+
def test_maxobjects_rule(self):
157+
"""MAXOBJECTS validates maximum allowed length of list-like fields."""
158+
checker = self.make_checker()
159+
self.assertIsNone(checker.validate_expression("MAXOBJECTS", "1", "list_field", [], 1))
160+
self.assertIsInstance(checker.validate_expression("MAXOBJECTS", "1", "list_field", [1, 2], 1), ErrorReport)
161+
162+
# --- LOOKUP & CONDITIONAL RULES --------------------------------------
163+
164+
def test_lookup_and_keycheck_rules(self):
165+
"""Force unexpected or missing lookup paths."""
166+
checker = self.make_checker(report=True)
167+
self.assertIsInstance(checker.validate_expression("LOOKUP", None, "lookup_field", "unknown", 1), ErrorReport)
168+
169+
def test_onlyif_uses_parser_values(self):
170+
"""ONLYIF uses parser to conditionally validate based on another field."""
171+
mock_data = {"location_field": "VAL"}
172+
checker = self.make_checker(mock_data)
173+
174+
# expressionRule format: field|expected_value
175+
result_match = checker.validate_expression("ONLYIF", "location_field|VAL", "test_field", "any", 1)
176+
result_mismatch = checker.validate_expression("ONLYIF", "location_field|NOPE", "test_field", "any", 1)
177+
178+
self.assertIsNone(result_match)
179+
self.assertIsInstance(result_mismatch, ErrorReport)
180+
181+
182+
class TestExpressionLookUp(unittest.TestCase):
12183
def setUp(self):
13184
self.MockLookUpData = patch("common.validator.expression_checker.LookUpData").start()
14185
self.MockKeyData = patch("common.validator.expression_checker.KeyData").start()
@@ -64,3 +235,188 @@ def test_validate_expression_type_not_found(self):
64235
"UNKNOWN", rule="", field_name="field", field_value="value", row={}
65236
)
66237
self.assertIn("Schema expression not found", result)
238+
239+
240+
class DummyParserEx:
241+
"""A dummy parser that optionally raises exceptions when fetching values."""
242+
243+
def __init__(self, data=None, raise_on_get=False):
244+
self._data = data or {}
245+
self._raise_on_get = raise_on_get
246+
247+
def get_key_value(self, field_name):
248+
"""Simulate field lookup, optionally raising an error."""
249+
if self._raise_on_get:
250+
raise RuntimeError("boom")
251+
return [self._data.get(field_name, "")]
252+
253+
254+
class StubLookup:
255+
"""Stub object to simulate lookup behavior with optional exception raising."""
256+
257+
def __init__(self, raise_on_call=False):
258+
self._raise_on_call = raise_on_call
259+
260+
def find_lookup(self, value):
261+
if self._raise_on_call:
262+
raise RuntimeError("boom")
263+
return "" # always empty to force error path
264+
265+
266+
class StubKeyData:
267+
"""Stub for key data operations with optional exception raising."""
268+
269+
def __init__(self, raise_on_call=False):
270+
self._raise_on_call = raise_on_call
271+
272+
def findKey(self, key_source, field_value):
273+
if self._raise_on_call:
274+
raise RuntimeError("boom")
275+
return False # always fail to trigger error branch
276+
277+
278+
class TestExpressionCheckerExceptions(unittest.TestCase):
279+
"""
280+
Tests edge cases and exception-handling paths in ExpressionChecker.
281+
Focuses on behavior when report=True vs report=False.
282+
"""
283+
284+
def make_checker(self, data=None, report=True, raise_on_get=False):
285+
"""Helper to construct ExpressionChecker with dummy parser."""
286+
return ExpressionChecker(DummyParserEx(data, raise_on_get), False, report)
287+
288+
# --- REGEX, IN, LENGTH, FLOAT, UUID ----------------------------------
289+
290+
def test_regex_unexpected_true_false(self):
291+
"""REGEX should return ErrorReport when report=True, None when report=False."""
292+
checker = self.make_checker(report=True)
293+
self.assertIsInstance(checker.validate_expression("REGEX", None, "regex_field", "abc", 1), ErrorReport)
294+
295+
checker_no_report = self.make_checker(report=False)
296+
self.assertIsNone(checker_no_report.validate_expression("REGEX", None, "regex_field", "abc", 1))
297+
298+
def test_in_unexpected_true_false(self):
299+
"""IN should trigger error when report=True and pass silently when report=False."""
300+
checker = self.make_checker(report=True)
301+
self.assertIsInstance(checker.validate_expression("IN", "ab", "in_field", None, 1), ErrorReport)
302+
303+
checker_no_report = self.make_checker(report=False)
304+
self.assertIsNone(checker_no_report.validate_expression("IN", "ab", "in_field", None, 1))
305+
306+
def test_length_unexpected_true_false(self):
307+
"""LENGTH rule with invalid argument should trigger exception path."""
308+
checker = self.make_checker(report=True)
309+
self.assertIsInstance(checker.validate_expression("LENGTH", "x", "length_field", "abcd", 1), ErrorReport)
310+
311+
checker_no_report = self.make_checker(report=False)
312+
self.assertIsNone(checker_no_report.validate_expression("LENGTH", "x", "length_field", "abcd", 1))
313+
314+
def test_float_unexpected_true_false(self):
315+
"""FLOAT rule should fail when value cannot be parsed as float."""
316+
checker = self.make_checker(report=True)
317+
self.assertIsInstance(checker.validate_expression("FLOAT", None, "float_field", "abc", 1), ErrorReport)
318+
319+
checker_no_report = self.make_checker(report=False)
320+
self.assertIsNone(checker_no_report.validate_expression("FLOAT", None, "float_field", "abc", 1))
321+
322+
def test_uuid_unexpected_true_false(self):
323+
"""UUID rule should handle malformed UUIDs properly."""
324+
checker = self.make_checker(report=True)
325+
self.assertIsInstance(checker.validate_expression("UUID", None, "uuid_field", "not-a-uuid", 1), ErrorReport)
326+
327+
checker_no_report = self.make_checker(report=False)
328+
self.assertIsNone(checker_no_report.validate_expression("UUID", None, "uuid_field", "not-a-uuid", 1))
329+
330+
# --- MAXOBJECTS ------------------------------------------------------
331+
332+
def test_maxobjects_unexpected_true_false(self):
333+
"""MAXOBJECTS should handle non-iterable input gracefully."""
334+
335+
class NoLen:
336+
"""Dummy object without __len__."""
337+
338+
pass
339+
340+
checker = self.make_checker(report=True)
341+
self.assertIsInstance(checker.validate_expression("MAXOBJECTS", "1", "max_field", NoLen(), 1), ErrorReport)
342+
343+
checker_no_report = self.make_checker(report=False)
344+
self.assertIsNone(checker_no_report.validate_expression("MAXOBJECTS", "1", "max_field", NoLen(), 1))
345+
346+
# --- ONLYIF ----------------------------------------------------------
347+
348+
def test_onlyif_unexpected_true_false(self):
349+
"""ONLYIF rule should handle parser errors based on report flag."""
350+
checker = self.make_checker(report=True, raise_on_get=True)
351+
self.assertIsInstance(checker.validate_expression("ONLYIF", "loc|VAL", "field", "x", 1), ErrorReport)
352+
353+
checker_no_report = self.make_checker(report=False, raise_on_get=True)
354+
self.assertIsNone(checker_no_report.validate_expression("ONLYIF", "loc|VAL", "field", "x", 1))
355+
356+
# --- LOOKUP & KEYCHECK -----------------------------------------------
357+
358+
def test_lookup_unexpected_true_false(self):
359+
"""LOOKUP rule should handle raised exceptions gracefully."""
360+
checker = self.make_checker(report=True)
361+
checker.data_look_up = StubLookup(raise_on_call=True)
362+
self.assertIsInstance(checker.validate_expression("LOOKUP", None, "lookup_field", "x", 1), ErrorReport)
363+
364+
checker_no_report = self.make_checker(report=False)
365+
checker_no_report.data_look_up = StubLookup(raise_on_call=True)
366+
self.assertIsNone(checker_no_report.validate_expression("LOOKUP", None, "lookup_field", "x", 1))
367+
368+
def test_keycheck_unexpected_true_false(self):
369+
"""KEYCHECK rule should handle raised exceptions gracefully."""
370+
checker = self.make_checker(report=True)
371+
checker.key_data = StubKeyData(raise_on_call=True)
372+
self.assertIsInstance(checker.validate_expression("KEYCHECK", "Site", "key_field", "val", 1), ErrorReport)
373+
374+
checker_no_report = self.make_checker(report=False)
375+
checker_no_report.key_data = StubKeyData(raise_on_call=True)
376+
self.assertIsNone(checker_no_report.validate_expression("KEYCHECK", "Site", "key_field", "val", 1))
377+
378+
# --- DATE & STRING EDGE CASES ---------------------------------------
379+
380+
def test_date_alias_and_upper_lower_edges(self):
381+
"""DATE alias should behave like DATETIME; string rules should handle None gracefully."""
382+
checker = self.make_checker({"date_field": "2025-01-01"})
383+
self.assertIsNone(checker.validate_expression("DATE", None, "date_field", "2025-01-01", 1))
384+
385+
checker_none = self.make_checker()
386+
self.assertIsInstance(checker_none.validate_expression("UPPER", None, "upper_field", None, 1), ErrorReport)
387+
self.assertIsInstance(checker_none.validate_expression("LOWER", None, "lower_field", None, 1), ErrorReport)
388+
self.assertIsInstance(checker_none.validate_expression("STARTSWITH", "a", "start_field", None, 1), ErrorReport)
389+
self.assertIsInstance(checker_none.validate_expression("ENDSWITH", "a", "end_field", None, 1), ErrorReport)
390+
391+
# --- NUMERIC RANGE ---------------------------------------------------
392+
393+
def test_nrange_out_of_range(self):
394+
"""NRANGE rule should return error when value exceeds upper bound."""
395+
checker = self.make_checker()
396+
self.assertIsInstance(checker.validate_expression("NRANGE", "1,10", "range_field", "11", 1), ErrorReport)
397+
398+
# --- DOMAIN-SPECIFIC -------------------------------------------------
399+
400+
def test_postcode_valid_and_unexpected(self):
401+
"""POSTCODE should pass for valid UK postcode and fail otherwise."""
402+
checker = self.make_checker()
403+
self.assertIsNone(checker.validate_expression("POSTCODE", None, "postcode_field", "EC1A 1BB", 1))
404+
self.assertIsInstance(checker.validate_expression("POSTCODE", None, "postcode_field", None, 1), ErrorReport)
405+
406+
def test_nhsnumber_valid_and_unexpected(self):
407+
"""NHSNUMBER should pass for valid NHS number format and fail otherwise."""
408+
checker = self.make_checker()
409+
self.assertIsNone(checker.validate_expression("NHSNUMBER", None, "nhs_field", "61234567890", 1))
410+
self.assertIsInstance(checker.validate_expression("NHSNUMBER", None, "nhs_field", None, 1), ErrorReport)
411+
412+
# --- IN RULE NORMAL PATHS -------------------------------------------
413+
414+
def test_in_pass_fail(self):
415+
"""IN rule should detect substring presence correctly."""
416+
checker = self.make_checker()
417+
self.assertIsNone(checker.validate_expression("IN", "ab", "in_field", "zzabzz", 1))
418+
self.assertIsInstance(checker.validate_expression("IN", "ab", "in_field", "zz", 1), ErrorReport)
419+
420+
421+
if __name__ == "__main__":
422+
unittest.main()

0 commit comments

Comments
 (0)