""" 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"])