diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..f9d7edc4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Make the ``validators`` package importable without an install. + +Tries, in order: the src-layout of a checked-out repo (``../src``), +this workdir's ``../validators-src/src`` clone, and the repo root itself. +Only directories that actually contain the ``validators`` package are added. +""" + +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_CANDIDATES = ( + os.path.join(_HERE, "..", "src"), + os.path.join(_HERE, "..", "validators-src", "src"), + os.path.join(_HERE, ".."), +) +for _candidate in _CANDIDATES: + if os.path.isdir(os.path.join(_candidate, "validators")): + sys.path.insert(0, os.path.normpath(_candidate)) diff --git a/tests/test_cards_extended.py b/tests/test_cards_extended.py new file mode 100644 index 00000000..4334d149 --- /dev/null +++ b/tests/test_cards_extended.py @@ -0,0 +1,139 @@ +"""Extended coverage for card numbers and the brand validators. + +The repo's card tests (219 lines) cover the standard test numbers; +this file pins the brand/prefix/length matrix the existing suite only +samples: every brand against every other brand's number, Luhn failures +that keep a valid prefix, and length-only failures. + +All card numbers used here are the well-known gateway test numbers +already used in the repo's own tests and docstrings. +""" + +import pytest + +from validators import ( + ValidationError, + amex, + card_number, + diners, + discover, + jcb, + mastercard, + mir, + unionpay, + visa, +) + + +# ---------------------------------------------------------------- generic Luhn + + +@pytest.mark.parametrize( + "value", + ["4242424242424242", "378282246310005", "6011111111111117", "30569309025904"], +) +def test_card_number_returns_true_on_luhn_valid_numbers(value): + assert card_number(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", + "4242424242424241", # last digit breaks Luhn + "4242 4242 4242 4242", # spaces are not stripped + "4242-4242-4242-4242", # dashes are not stripped + "abcd", # non-digits fail int() conversion + "123", # Luhn sum is not a multiple of 10 + ], +) +def test_card_number_returns_failed_validation_on_invalid_numbers(value): + assert isinstance(card_number(value), ValidationError) + + +# ---------------------------------------------------------------- brands + + +@pytest.mark.parametrize( + ("validator", "value"), + [ + (visa, "4242424242424242"), + (visa, "4111111111111111"), + (mastercard, "5555555555554444"), + (mastercard, "2223003122003222"), # 2-series Mastercard + (amex, "378282246310005"), + (amex, "371449635398431"), + (unionpay, "6200000000000005"), + (diners, "30569309025904"), # 14 digits + (diners, "3056930009020004"), + (diners, "36148900647913"), + (jcb, "3530111333300000"), + (jcb, "3566002020360505"), + (discover, "6011111111111117"), + (discover, "6011000990139424"), + (discover, "6445644564456445"), # 644 prefix + (mir, "2200123456789019"), + ], +) +def test_brand_validators_accept_their_own_numbers(validator, value): + assert validator(value) is True + + +@pytest.mark.parametrize( + ("validator", "value"), + [ + # Wrong brand, Luhn-valid number: prefix/length must reject it. + (visa, "5555555555554444"), + (mastercard, "4242424242424242"), + (amex, "4242424242424242"), + (unionpay, "4242424242424242"), + (diners, "4242424242424242"), + (jcb, "4242424242424242"), + (discover, "4242424242424242"), + (mir, "4242424242424242"), + # Right prefix family, wrong length. + (visa, "4222222222222"), # 13 digits, not 16 + (visa, "424242424242424"), # 15 digits, not 16 + (amex, "3782822463100050"), # 16 digits, not 15 + (mastercard, "555555555555444"), # 15 digits, not 16 + # Right prefix, broken Luhn. + (amex, "378282246310006"), + (unionpay, "6200000000000006"), + (jcb, "3530111333300001"), + (discover, "6011111111111118"), + # Prefix just outside the accepted range. + (mastercard, "2720992713989755"), # 2720-series edge, Luhn-invalid + (mir, "2205123456789019"), # 2205 is outside 2200-2204 + (diners, "32148900647913"), # 32 is not a Diners prefix + (discover, "6221111111111117"), # 62 is UnionPay, not Discover + ], +) +def test_brand_validators_reject_other_numbers(validator, value): + assert isinstance(validator(value), ValidationError) + + +def test_brand_validators_reject_empty_and_non_digit_input(): + for validator in (visa, mastercard, amex, unionpay, diners, jcb, discover, mir): + assert isinstance(validator(""), ValidationError) + assert isinstance(validator("abcd"), ValidationError) + + +# ---------------------------------------------------------------- ValidationError mechanics + + +def test_validation_error_is_falsy_and_carries_function_and_args(): + result = visa("5555555555554444") + assert isinstance(result, ValidationError) + assert bool(result) is False + assert result.func.__name__ == "visa" + assert result.value == "5555555555554444" + assert repr(result).startswith("ValidationError(func=visa") + + +def test_validation_error_raised_with_r_ve(): + with pytest.raises(ValidationError): + visa("5555555555554444", r_ve=True) + + +def test_validation_error_not_raised_with_r_ve_on_valid_input(): + assert visa("4242424242424242", r_ve=True) is True diff --git a/tests/test_cron_encoding_extended.py b/tests/test_cron_encoding_extended.py new file mode 100644 index 00000000..6e2e06f6 --- /dev/null +++ b/tests/test_cron_encoding_extended.py @@ -0,0 +1,180 @@ +"""Extended coverage for cron and the base16/32/58/64 encodings. + +Existing cron tests are 59 lines and only exercise the happy path plus a +few out-of-range fields; the step/list grammar below is probed, not +assumed. Encoding tests in the repo are 151 lines but leave padding +and alphabet edge cases unpinned. +""" + +import pytest + +from validators import ( + ValidationError, + base16, + base32, + base58, + base64, + cron, +) + + +# ---------------------------------------------------------------- cron + + +@pytest.mark.parametrize( + "value", + [ + "0 0 * * *", + "*/15 * * * *", # step over the whole range + "0 9-17 * * 1-5", # ranges in two fields + "5/10 * * * *", # a single value with a step is accepted + "0 0 * * 7", # Sunday may be written as 7 + "1,2,3 * * * *", # comma list + "0 0 1,15 * *", + "*/2 0-23/2 * * *", # step over a range + " 0 0 * * * ", # extra whitespace is stripped/split away + "30 4 1 * *", + "59 23 31 12 7", # every field at its maximum + "0 0 1 1 0", # every field at its minimum + ], +) +def test_cron_returns_true_on_valid_expressions(value): + assert cron(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "*/0 * * * *", # step must be >= 1 + "*/abc * * * *", # step must be decimal + "30-20 * * * *", # reversed range + "1,,2 * * * *", # empty list item + "1- * * * *", # dangling range end + "1-2-3 * * * *", # three-part range + "0 0 * * MON", # month/weekday names are not supported + "0 0 0 * *", # day of month starts at 1 + "0 0 * 0 *", # month starts at 1 + "0 0 * * 8", # weekday ends at 7 + "60 * * * *", # minute ends at 59 + "0 24 * * *", # hour ends at 23 + "0 0 32 * *", # day of month ends at 31 + "0 0 * 13 *", # month ends at 12 + "-1 * * * *", # negatives are not decimal + "* * * *", # four fields: badly formatted + "0 0 * * * *", # six fields: badly formatted + "", + ], +) +def test_cron_returns_failed_validation_on_invalid_expressions(value): + assert isinstance(cron(value), ValidationError) + + +def test_cron_wrong_field_count_carries_the_reason(): + result = cron("* * * *") + assert isinstance(result, ValidationError) + assert result.reason == "Badly formatted cron string" + + +# ---------------------------------------------------------------- base16 + + +@pytest.mark.parametrize("value", ["00ff", "FF", "a3f4b2", "00" * 10]) +def test_base16_returns_true_on_valid_values(value): + assert base16(value) is True + + +@pytest.mark.parametrize("value", ["", "0", "abc", "0x12", "a3f4Z1", "ff "]) +def test_base16_returns_failed_validation_on_invalid_values(value): + assert isinstance(base16(value), ValidationError) + + +# ---------------------------------------------------------------- base32 + + +@pytest.mark.parametrize( + "value", + [ + "MY======", # 1 byte + padding + "MZXW6===", # 2 bytes + padding + "MZXW6YQ=", # 4 bytes + padding + "MZXW6YTBOI======", # 8-char group + padding + "MZXW6", # padding is optional for the short forms + "MZXW6YQ", # 6 chars, no padding needed + "ABCDEFGHIJKLMNOP", # two full 8-char groups + ], +) +def test_base32_returns_true_on_valid_values(value): + assert base32(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", + "MfZW3zLT9Q======", # lower case is not in the alphabet + "MZXW6Y==", # 6 chars may not take "==" padding + "MZXW6YQ==", # 7 chars may take at most one "=" + "MZXW6YQ===", # too much padding + "MZXW68==", # '8' is not in the base32 alphabet + "MZXW6YQ=extra", + ], +) +def test_base32_returns_failed_validation_on_invalid_values(value): + assert isinstance(base32(value), ValidationError) + + +# ---------------------------------------------------------------- base58 + + +@pytest.mark.parametrize("value", ["StV1DL6CwTryKyV", "cUSECm5YzcXJwP", "1111111111"]) +def test_base58_returns_true_on_valid_values(value): + assert base58(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", + "0abc", # '0' is excluded from base58 + "Oabc", # 'O' is excluded + "Iabc", # 'I' is excluded + "labc", # 'l' is excluded + "abc def", # spaces are excluded + "abc+def", # '+' is excluded + ], +) +def test_base58_returns_failed_validation_on_invalid_values(value): + assert isinstance(base58(value), ValidationError) + + +# ---------------------------------------------------------------- base64 + + +@pytest.mark.parametrize( + "value", + [ + "YQ==", # 1 byte + "YWI=", # 2 bytes + "TWFu", # 3 bytes, no padding + "Y2hhcmFjdGVyIHNldA==", + "TWFuTWFu", # two full groups + ], +) +def test_base64_returns_true_on_valid_values(value): + assert base64(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", + "YQ=", # 1 byte needs "==", not "=" + "YWI", # missing padding + "YQ==YQ==", # padding may only appear at the end + "Y W I=", # spaces are not in the alphabet + "YQ==\n", + "YQ*=", + ], +) +def test_base64_returns_failed_validation_on_invalid_values(value): + assert isinstance(base64(value), ValidationError) diff --git a/tests/test_length_between_extended.py b/tests/test_length_between_extended.py new file mode 100644 index 00000000..1a1455b2 --- /dev/null +++ b/tests/test_length_between_extended.py @@ -0,0 +1,169 @@ +"""Extended coverage for length, between and the AbsMin/AbsMax extremes. + +The existing tests for these modules are short (length: 25 lines, +between: 40 lines); this file pins the boundary behaviour the existing +suite does not cover. Every assertion below was verified against the +cloned source with a probe script before being written here. + +Note on failure reporting: the ``@validator`` decorator converts +``ValueError``/``TypeError`` raised inside a validator into a returned +``ValidationError`` object (unless ``r_ve=True``), so the tests below +assert ``ValidationError`` instances, not raised exceptions. +""" + +from datetime import datetime + +import pytest + +from validators import ValidationError, between, length +from validators._extremes import AbsMax, AbsMin + + +# ---------------------------------------------------------------- length + + +@pytest.mark.parametrize( + ("value", "min_val", "max_val"), + [ + ("", 0, 0), # empty string is a valid zero-length value + ("abc", None, None), # no bounds at all + ("abc", 3, 3), # exact length + ("abc", 0, 10), + ("abc", 3, None), + ("abc", None, 3), + ("12345", 5, None), + ("héllo", 5, 5), # len() counts code points, not bytes + ("a b", 3, 3), # spaces count towards the length + ], +) +def test_length_returns_true_on_valid_lengths(value, min_val, max_val): + assert length(value, min_val=min_val, max_val=max_val) is True + + +@pytest.mark.parametrize( + ("value", "min_val", "max_val"), + [ + ("abc", 4, None), + ("abc", None, 2), + ("abc", 5, 2), # min > max can never match + ("", 1, None), + ], +) +def test_length_returns_failed_validation_on_invalid_lengths(value, min_val, max_val): + assert isinstance( + length(value, min_val=min_val, max_val=max_val), ValidationError + ) + + +@pytest.mark.parametrize("kwargs", [{"min_val": -1}, {"max_val": -1}]) +def test_length_negative_bound_is_a_failed_validation_not_a_raise(kwargs): + # The ValueError raised inside length() is swallowed by @validator. + result = length("abc", **kwargs) + assert isinstance(result, ValidationError) + assert "negative" in result.reason + + +def test_length_r_ve_raises_validation_error(): + with pytest.raises(ValidationError): + length("x", min_val=5, r_ve=True) + + +def test_length_r_ve_true_returns_true_on_valid(): + assert length("abc", min_val=2, r_ve=True) is True + + +# ---------------------------------------------------------------- between + + +@pytest.mark.parametrize( + ("value", "min_val", "max_val"), + [ + (5, None, None), # no bounds: always True (except None value) + (5, 5, 5), # inclusive bounds + (0, -1, 0), + (-5, -10, -1), + (1.5, 1, 2), # floats mix with ints + (13.2, 13, 14), + ("b", "a", "c"), # strings compare lexicographically + ("a", "a", "c"), # lower bound is inclusive + (datetime(2020, 1, 1), datetime(2019, 1, 1), datetime(2021, 1, 1)), + (datetime(2020, 1, 1), None, datetime(2021, 1, 1)), + (True, 0, 1), # bool is an int subclass + ], +) +def test_between_returns_true_on_valid_ranges(value, min_val, max_val): + assert between(value, min_val=min_val, max_val=max_val) is True + + +@pytest.mark.parametrize( + ("value", "min_val", "max_val"), + [ + (None, 1, 2), # None is never "between" anything + (None, None, None), + (4, 5, 10), + (11, 5, 10), + (5, 10, 2), # min > max + ("d", "a", "c"), + (datetime(2022, 1, 1), datetime(2019, 1, 1), datetime(2021, 1, 1)), + ], +) +def test_between_returns_failed_validation_on_invalid_ranges(value, min_val, max_val): + assert isinstance( + between(value, min_val=min_val, max_val=max_val), ValidationError + ) + + +def test_between_min_greater_than_max_carries_the_reason(): + result = between(5, min_val=10, max_val=2) + assert isinstance(result, ValidationError) + assert result.reason == "`min_val` cannot be greater than `max_val`" + + +def test_between_type_mismatch_min_vs_value_carries_the_reason(): + # "a" <= 5 raises TypeError inside between(); @validator converts it. + result = between(5, min_val="a", max_val="z") + assert isinstance(result, ValidationError) + assert "<=" in result.reason + + +def test_between_type_mismatch_min_vs_max_carries_the_reason(): + # 1 > "z" raises TypeError while checking min <= max. + result = between(5, min_val=1, max_val="z") + assert isinstance(result, ValidationError) + assert result.reason == "Comparison type mismatch" + + +def test_between_accepts_explicit_extremes(): + assert between(5, min_val=AbsMin(), max_val=AbsMax()) is True + assert between("anything", min_val=AbsMin(), max_val=AbsMax()) is True + + +# ---------------------------------------------------------------- extremes + + +def test_absmax_is_greater_than_everything(): + assert AbsMax() > AbsMin() + assert AbsMax() > 99999999999999999 + assert AbsMax() > float("inf") + + +def test_absmin_is_smaller_than_everything(): + assert AbsMin() < -99999999999999999 + assert AbsMin() < "" + assert AbsMin() < None + + +def test_extremes_compare_against_their_own_class_not_instance(): + # Quirk pinned as-is: __ge__/__le__ compare `other` against the *class*, + # so an extreme compares True against another instance of itself... + assert AbsMax() >= AbsMax() + assert AbsMin() <= AbsMin() + # ...while equality is still plain identity (total_ordering adds no __eq__). + assert AbsMax() != AbsMax() + assert (AbsMax() == AbsMax()) is False + + +def test_extremes_bracket_any_value(): + assert AbsMin() < 0 < AbsMax() + assert AbsMin() < AbsMax() + assert not (AbsMax() < AbsMin()) diff --git a/tests/test_uuid_mac_iban_extended.py b/tests/test_uuid_mac_iban_extended.py new file mode 100644 index 00000000..bb3b10da --- /dev/null +++ b/tests/test_uuid_mac_iban_extended.py @@ -0,0 +1,143 @@ +"""Extended coverage for uuid, mac_address and iban. + +Existing tests for these modules are thin (uuid: 39 lines, mac: 35, +iban: 33). All values below were verified against the cloned source +with a probe script; well-known IBAN examples (DE/GB/FR/NL/CH) were +checked by the mod-97 code path itself, not copied from documentation. + +Quirks pinned as-is: +- ``uuid`` accepts *any* UUID version and even brace/URN spellings, + despite the docstring saying "UUID-v4". +- ``uuid(123)`` raises ``AttributeError`` (the decorator only converts + ValueError/TypeError/UnicodeError). +""" + +from uuid import UUID, uuid1, uuid4 + +import pytest + +from validators import ValidationError, iban, mac_address, uuid + + +# ---------------------------------------------------------------- uuid + + +@pytest.mark.parametrize( + "value", + [ + "2bc1c94f-0deb-43e9-92a1-4775189ec9f8", # plain v4 + "2BC1C94F-0DEB-43E9-92A1-4775189EC9F8", # upper case hex + "888256d7c49341f19fa33f29d3f820d7", # 32 hex digits, no dashes + "{2bc1c94f-0deb-43e9-92a1-4775189ec9f8}", # braces accepted + "urn:uuid:2bc1c94f-0deb-43e9-92a1-4775189ec9f8", # URN accepted + str(uuid1()), # version 1 accepted despite the "v4" docstring + "2bc1c94f-0deb-13e9-92a1-4775189ec9f8", # version nibble 1 + uuid4(), # UUID objects are always accepted + uuid1(), + UUID("2bc1c94f-0deb-43e9-92a1-4775189ec9f8"), + ], +) +def test_uuid_returns_true_on_valid_values(value): + assert uuid(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", + None, # falsy input short-circuits to a failure + "2bc1c94f-deb-43e9-92a1-4775189ec9f8", # short second group + "2bc1c94f-0deb-43e9-92a1-4775189ec9f", # one hex digit short + "gbc1c94f-0deb-43e9-92a1-4775189ec9f8", # 'g' is not hex + "2bc1c94f 0deb-43e9-92a1-4775189ec9f8", # space separator + "2bc1c94f-0deb-43e9-92a1-4775189ec9f8-extra", + "2bc1c94f-0deb-43e9-92a1", # truncated + ], +) +def test_uuid_returns_failed_validation_on_invalid_values(value): + assert isinstance(uuid(value), ValidationError) + + +def test_uuid_non_string_non_uuid_raises_attribute_error(): + # Not converted by @validator: AttributeError is not in its catch list. + with pytest.raises(AttributeError): + uuid(123) + + +def test_uuid_failure_carries_the_function_name(): + result = uuid("not-a-uuid") + assert isinstance(result, ValidationError) + assert result.func.__name__ == "uuid" + assert result.value == "not-a-uuid" + + +# ---------------------------------------------------------------- mac_address + + +@pytest.mark.parametrize( + "value", + [ + "01:23:45:67:ab:CD", # mixed case, colon separated + "01-23-45-67-AB-CD", # dash separated + "00:00:00:00:00:00", # all zeros is still a well-formed address + "FF:FF:FF:FF:FF:FF", # broadcast + "aB:cD:eF:01:23:45", + ], +) +def test_mac_address_returns_true_on_valid_values(value): + assert mac_address(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", # empty + "00:00:00:00:00", # only five groups + "01:23:45:67:AB:CD:EF", # seven groups + "01:23-45:67:AB:CD", # mixed separators are rejected explicitly + "1:23:45:67:AB:CD", # single-digit group + "012:23:45:67:AB:CD", # three-digit group + "01:23:45:67:AB:CD:", # trailing separator + ":01:23:45:67:AB:CD", # leading separator + "gg:23:45:67:AB:CD", # non-hex + "01.23.45.67.AB.CD", # dots are not a separator here + "01:23:45:67:AB:CD ", # trailing space breaks the full match + ], +) +def test_mac_address_returns_failed_validation_on_invalid_values(value): + assert isinstance(mac_address(value), ValidationError) + + +# ---------------------------------------------------------------- iban + + +@pytest.mark.parametrize( + "value", + [ + "DE29100500001061045672", + "de29100500001061045672", # country code is case-insensitive + "GB29NWBK60161331926819", + "FR1420041010050500013M02606", # letters inside the BBAN + "NL91ABNA0417164300", + "CH9300762011623852957", + ], +) +def test_iban_returns_true_on_valid_values(value): + assert iban(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "", + "123456", # no country code at all + "DE29", # far too short + "DE29" + "1" * 31, # 35 chars: over the 34-char maximum + "DE29 1005 0000 1061 0456 72", # spaces are not stripped + "DE29100500001061045673", # one check digit off: mod-97 fails + "DE2910050000106104567", # too short to be a real IBAN + "XX29100500001061045672", # unknown country still fails mod-97 + ], +) +def test_iban_returns_failed_validation_on_invalid_values(value): + assert isinstance(iban(value), ValidationError)