grant-radar / tests /utils /test_validation.py
Riley Coleman
feat: add PDF & supporting materials integration to context
bfcc872
Raw
History Blame Contribute Delete
10.3 kB
"""
Tests for src/analyzer/utils/validation.py
Tests cover:
- validate_grant_id(): grant ID validation and normalization
- validate_url(): URL validation with allowlist support
- sanitize_filename(): safe filename generation
- validate_search_query(): search query validation
- validate_positive_int(): positive integer validation
- validate_date_string(): ISO date validation
"""
import pytest
from src.analyzer.utils.validation import (
validate_grant_id,
validate_url,
sanitize_filename,
validate_search_query,
validate_positive_int,
validate_date_string,
)
from src.analyzer.utils.errors import ValidationError
class TestValidateGrantId:
"""Test validate_grant_id() for grant ID validation."""
def test_validate_grant_id_numeric(self):
assert validate_grant_id("2315") == "2315"
def test_validate_grant_id_with_prefix(self):
assert validate_grant_id("competition-2315") == "2315"
def test_validate_grant_id_with_short_prefix(self):
assert validate_grant_id("comp-2315") == "2315"
def test_validate_grant_id_with_grant_prefix(self):
assert validate_grant_id("grant-2315") == "2315"
def test_validate_grant_id_case_insensitive(self):
assert validate_grant_id("COMPETITION-2315") == "2315"
assert validate_grant_id("Competition-2315") == "2315"
def test_validate_grant_id_with_whitespace(self):
assert validate_grant_id(" 2315 ") == "2315"
assert validate_grant_id(" competition-2315 ") == "2315"
def test_validate_grant_id_empty(self):
with pytest.raises(ValidationError, match="cannot be empty"):
validate_grant_id("")
def test_validate_grant_id_invalid_format(self):
with pytest.raises(ValidationError, match="Invalid grant ID format"):
validate_grant_id("invalid")
def test_validate_grant_id_too_short(self):
# Minimum 3 digits required
with pytest.raises(ValidationError, match="Invalid grant ID format"):
validate_grant_id("12")
def test_validate_grant_id_too_long(self):
# Maximum 7 digits
with pytest.raises(ValidationError, match="Invalid grant ID format"):
validate_grant_id("12345678")
def test_validate_grant_id_valid_lengths(self):
# 3-7 digits should all work
assert validate_grant_id("123") == "123"
assert validate_grant_id("1234567") == "1234567"
class TestValidateUrl:
"""Test validate_url() for URL validation."""
def test_validate_url_https(self):
url = "https://example.com/path"
assert validate_url(url) == url
def test_validate_url_http(self):
url = "http://example.com/path"
assert validate_url(url) == url
def test_validate_url_with_query(self):
url = "https://example.com/path?key=value"
assert validate_url(url) == url
def test_validate_url_empty(self):
with pytest.raises(ValidationError, match="cannot be empty"):
validate_url("")
def test_validate_url_no_protocol(self):
with pytest.raises(ValidationError, match="must start with http"):
validate_url("example.com")
def test_validate_url_invalid_protocol(self):
with pytest.raises(ValidationError, match="must start with http"):
validate_url("ftp://example.com")
def test_validate_url_no_hostname(self):
with pytest.raises(ValidationError, match="has no hostname"):
validate_url("https://")
def test_validate_url_with_allowlist(self):
url = "https://example.com/path"
allowed = {"example.com", "test.com"}
assert validate_url(url, allowed_hosts=allowed) == url
def test_validate_url_not_in_allowlist(self):
url = "https://evil.com/path"
allowed = {"example.com", "test.com"}
with pytest.raises(ValidationError, match="not in allowlist"):
validate_url(url, allowed_hosts=allowed)
def test_validate_url_strips_whitespace(self):
url = " https://example.com "
assert validate_url(url) == "https://example.com"
class TestSanitizeFilename:
"""Test sanitize_filename() for safe filename generation."""
def test_sanitize_filename_safe(self):
assert sanitize_filename("report.pdf") == "report.pdf"
def test_sanitize_filename_with_spaces(self):
result = sanitize_filename("my report.pdf")
assert result == "my_report.pdf"
def test_sanitize_filename_path_traversal(self):
result = sanitize_filename("../../../etc/passwd")
assert result == "etc_passwd"
assert ".." not in result
assert "/" not in result
def test_sanitize_filename_special_chars(self):
result = sanitize_filename("file@#$%.txt")
assert result == "file____.txt"
def test_sanitize_filename_max_length(self):
long_name = "a" * 300
result = sanitize_filename(long_name, max_length=50)
assert len(result) == 50
def test_sanitize_filename_empty(self):
with pytest.raises(ValidationError, match="cannot be empty"):
sanitize_filename("")
def test_sanitize_filename_only_special_chars(self):
with pytest.raises(ValidationError, match="empty result"):
sanitize_filename("@#$%")
def test_sanitize_filename_strips_dots(self):
result = sanitize_filename("...file.txt...")
assert result == "file.txt"
class TestValidateSearchQuery:
"""Test validate_search_query() for search query validation."""
def test_validate_search_query_normal(self):
query = "machine learning"
assert validate_search_query(query) == query
def test_validate_search_query_strips(self):
query = " machine learning "
assert validate_search_query(query) == "machine learning"
def test_validate_search_query_empty(self):
with pytest.raises(ValidationError, match="cannot be empty"):
validate_search_query("")
def test_validate_search_query_whitespace_only(self):
with pytest.raises(ValidationError, match="cannot be whitespace"):
validate_search_query(" ")
def test_validate_search_query_too_long(self):
query = "a" * 600
with pytest.raises(ValidationError, match="too long"):
validate_search_query(query, max_length=500)
def test_validate_search_query_at_max_length(self):
query = "a" * 500
result = validate_search_query(query, max_length=500)
assert len(result) == 500
def test_validate_search_query_with_special_chars(self):
query = "machine learning & AI (2024)"
assert validate_search_query(query) == query
class TestValidatePositiveInt:
"""Test validate_positive_int() for positive integer validation."""
def test_validate_positive_int_valid(self):
assert validate_positive_int(5) == 5
assert validate_positive_int("10") == 10
def test_validate_positive_int_zero(self):
with pytest.raises(ValidationError, match="must be positive"):
validate_positive_int(0)
def test_validate_positive_int_negative(self):
with pytest.raises(ValidationError, match="must be positive"):
validate_positive_int(-5)
def test_validate_positive_int_float(self):
# Should convert to int
assert validate_positive_int(5.9) == 5
def test_validate_positive_int_string(self):
assert validate_positive_int("42") == 42
def test_validate_positive_int_invalid_type(self):
with pytest.raises(ValidationError, match="must be an integer"):
validate_positive_int("not a number")
def test_validate_positive_int_custom_name(self):
with pytest.raises(ValidationError, match="limit must be positive"):
validate_positive_int(-1, name="limit")
class TestValidateDateString:
"""Test validate_date_string() for ISO date validation."""
def test_validate_date_string_valid(self):
date = "2024-12-31"
assert validate_date_string(date) == date
def test_validate_date_string_strips(self):
date = " 2024-12-31 "
assert validate_date_string(date) == "2024-12-31"
def test_validate_date_string_empty(self):
with pytest.raises(ValidationError, match="cannot be empty"):
validate_date_string("")
def test_validate_date_string_wrong_format(self):
with pytest.raises(ValidationError, match="must be in YYYY-MM-DD format"):
validate_date_string("31/12/2024")
def test_validate_date_string_invalid_month(self):
with pytest.raises(ValidationError, match="Invalid date"):
validate_date_string("2024-13-01")
def test_validate_date_string_invalid_day(self):
with pytest.raises(ValidationError, match="Invalid date"):
validate_date_string("2024-12-32")
def test_validate_date_string_leap_year(self):
# 2024 is a leap year
assert validate_date_string("2024-02-29") == "2024-02-29"
def test_validate_date_string_non_leap_year(self):
# 2023 is not a leap year
with pytest.raises(ValidationError, match="Invalid date"):
validate_date_string("2023-02-29")
def test_validate_date_string_custom_name(self):
with pytest.raises(ValidationError, match="deadline cannot be empty"):
validate_date_string("", name="deadline")
class TestIntegration:
"""Test validation functions work together correctly."""
def test_grant_workflow(self):
# Simulate validating grant-related inputs
grant_id = validate_grant_id("competition-2315")
assert grant_id == "2315"
url = validate_url("https://apply.innovateuk.org/competition/2315")
assert "apply.innovateuk.org" in url
deadline = validate_date_string("2024-12-31")
assert deadline == "2024-12-31"
def test_search_workflow(self):
# Simulate validating search inputs
query = validate_search_query(" AI funding ")
assert query == "AI funding"
limit = validate_positive_int("10", name="limit")
assert limit == 10
if __name__ == "__main__":
pytest.main([__file__, "-v"])