Spaces:
Running
Running
| """ | |
| GovBridge India β Backend Test Suite | |
| Comprehensive pytest tests for CI/CD pipeline. | |
| Tests pure business logic without external dependencies. | |
| """ | |
| import pytest | |
| from unittest.mock import patch, MagicMock, AsyncMock | |
| from datetime import date, timedelta | |
| # ============================================================ | |
| # 1. CONFIG MODULE TESTS | |
| # ============================================================ | |
| class TestConfig: | |
| """Test pydantic-settings configuration module.""" | |
| def test_settings_loads_with_defaults(self): | |
| """Settings should initialize with empty defaults when no env vars set.""" | |
| with patch.dict("os.environ", {}, clear=True): | |
| from pydantic_settings import BaseSettings, SettingsConfigDict | |
| from pydantic import Field | |
| class TestSettings(BaseSettings): | |
| SUPABASE_URL: str = Field(default="") | |
| SUPABASE_KEY: str = Field(default="") | |
| model_config = SettingsConfigDict(extra="ignore") | |
| s = TestSettings() | |
| assert s.SUPABASE_URL == "" | |
| assert s.SUPABASE_KEY == "" | |
| def test_settings_reads_env_vars(self): | |
| """Settings should read from environment variables.""" | |
| with patch.dict("os.environ", { | |
| "SUPABASE_URL": "https://test.supabase.co", | |
| "SUPABASE_KEY": "test-key-123" | |
| }): | |
| from pydantic_settings import BaseSettings, SettingsConfigDict | |
| from pydantic import Field | |
| class TestSettings(BaseSettings): | |
| SUPABASE_URL: str = Field(default="") | |
| SUPABASE_KEY: str = Field(default="") | |
| model_config = SettingsConfigDict(extra="ignore") | |
| s = TestSettings() | |
| assert s.SUPABASE_URL == "https://test.supabase.co" | |
| assert s.SUPABASE_KEY == "test-key-123" | |
| # ============================================================ | |
| # 2. PYDANTIC MODEL VALIDATION TESTS | |
| # ============================================================ | |
| class TestSearchQueryValidation: | |
| """Test SearchQuery Pydantic model from api.py.""" | |
| def test_valid_query(self): | |
| """Valid search query should pass validation.""" | |
| from pydantic import BaseModel, Field, field_validator | |
| import re | |
| class SearchQuery(BaseModel): | |
| question: str = Field(..., min_length=3, max_length=500) | |
| language: str = Field(default="english") | |
| def validate_language(cls, v): | |
| valid = ["english", "hindi", "tamil", "bengali", "telugu", | |
| "marathi", "gujarati", "kannada", "malayalam", "punjabi"] | |
| if v.lower() not in valid: | |
| return "english" | |
| return v.lower() | |
| def clean_input(cls, v): | |
| v = re.sub(r'<[^>]+>', '', v) | |
| v = re.sub(r'\s+', ' ', v).strip() | |
| if not v: | |
| raise ValueError('Question is empty after cleaning') | |
| return v | |
| q = SearchQuery(question="What is PM Kisan?", language="hindi") | |
| assert q.question == "What is PM Kisan?" | |
| assert q.language == "hindi" | |
| def test_html_stripped_from_question(self): | |
| """HTML tags should be stripped from question input.""" | |
| from pydantic import BaseModel, Field, field_validator | |
| import re | |
| class SearchQuery(BaseModel): | |
| question: str = Field(..., min_length=3, max_length=500) | |
| language: str = Field(default="english") | |
| def clean_input(cls, v): | |
| v = re.sub(r'<[^>]+>', '', v) | |
| v = re.sub(r'\s+', ' ', v).strip() | |
| if not v: | |
| raise ValueError('Question is empty after cleaning') | |
| return v | |
| q = SearchQuery(question="<script>alert('xss')</script>What is PM Kisan?") | |
| assert "<script>" not in q.question | |
| assert "alert" in q.question # text content preserved | |
| def test_invalid_language_defaults_to_english(self): | |
| """Unknown language should default to english.""" | |
| from pydantic import BaseModel, Field, field_validator | |
| class SearchQuery(BaseModel): | |
| question: str = Field(..., min_length=3, max_length=500) | |
| language: str = Field(default="english") | |
| def validate_language(cls, v): | |
| valid = ["english", "hindi", "tamil", "bengali", "telugu", | |
| "marathi", "gujarati", "kannada", "malayalam", "punjabi"] | |
| if v.lower() not in valid: | |
| return "english" | |
| return v.lower() | |
| q = SearchQuery(question="Test query", language="french") | |
| assert q.language == "english" | |
| def test_question_too_short_fails(self): | |
| """Question shorter than 3 chars should fail validation.""" | |
| from pydantic import BaseModel, Field, ValidationError | |
| class SearchQuery(BaseModel): | |
| question: str = Field(..., min_length=3, max_length=500) | |
| with pytest.raises(ValidationError): | |
| SearchQuery(question="ab") | |
| def test_whitespace_only_question_fails(self): | |
| """Whitespace-only question should fail after cleaning.""" | |
| from pydantic import BaseModel, Field, field_validator, ValidationError | |
| import re | |
| class SearchQuery(BaseModel): | |
| question: str = Field(..., min_length=3, max_length=500) | |
| def clean_input(cls, v): | |
| v = re.sub(r'<[^>]+>', '', v) | |
| v = re.sub(r'\s+', ' ', v).strip() | |
| if not v: | |
| raise ValueError('Question is empty after cleaning') | |
| return v | |
| with pytest.raises(ValidationError): | |
| SearchQuery(question=" ") | |
| class TestIngestRequestValidation: | |
| """Test IngestRequest Pydantic model.""" | |
| def test_valid_ingest_request(self): | |
| """Valid ingest request should pass.""" | |
| from pydantic import BaseModel | |
| from typing import Optional | |
| class IngestRequest(BaseModel): | |
| title: str | |
| text: str | |
| ministry: Optional[str] = None | |
| doc_type: Optional[str] = "scheme" | |
| req = IngestRequest(title="PM Kisan", text="Financial benefit scheme") | |
| assert req.title == "PM Kisan" | |
| assert req.doc_type == "scheme" | |
| assert req.ministry is None | |
| def test_ingest_request_with_all_fields(self): | |
| """Ingest request with all optional fields.""" | |
| from pydantic import BaseModel | |
| from typing import Optional | |
| class IngestRequest(BaseModel): | |
| title: str | |
| text: str | |
| ministry: Optional[str] = None | |
| state: Optional[str] = None | |
| source_url: Optional[str] = None | |
| doc_type: Optional[str] = "scheme" | |
| req = IngestRequest( | |
| title="PM Kisan", | |
| text="Scheme details", | |
| ministry="Agriculture", | |
| state="Rajasthan", | |
| source_url="https://pmkisan.gov.in", | |
| doc_type="notification" | |
| ) | |
| assert req.ministry == "Agriculture" | |
| assert req.state == "Rajasthan" | |
| assert req.doc_type == "notification" | |
| class TestEligibilityRequestValidation: | |
| """Test EligibilityRequest Pydantic model.""" | |
| def test_valid_eligibility_request(self): | |
| """Valid eligibility check request.""" | |
| from pydantic import BaseModel, Field | |
| class EligibilityRequest(BaseModel): | |
| annual_income: float = Field(..., ge=0) | |
| age: int = Field(..., ge=0, le=120) | |
| is_farmer: bool = False | |
| state: str = "" | |
| caste_category: str = "General" | |
| req = EligibilityRequest(annual_income=100000, age=30, is_farmer=True, state="Rajasthan") | |
| assert req.annual_income == 100000 | |
| assert req.is_farmer is True | |
| assert req.caste_category == "General" | |
| def test_negative_income_fails(self): | |
| """Negative income should fail validation.""" | |
| from pydantic import BaseModel, Field, ValidationError | |
| class EligibilityRequest(BaseModel): | |
| annual_income: float = Field(..., ge=0) | |
| age: int = Field(..., ge=0, le=120) | |
| with pytest.raises(ValidationError): | |
| EligibilityRequest(annual_income=-5000, age=25) | |
| def test_age_over_120_fails(self): | |
| """Age over 120 should fail validation.""" | |
| from pydantic import BaseModel, Field, ValidationError | |
| class EligibilityRequest(BaseModel): | |
| annual_income: float = Field(..., ge=0) | |
| age: int = Field(..., ge=0, le=120) | |
| with pytest.raises(ValidationError): | |
| EligibilityRequest(annual_income=50000, age=150) | |
| # ============================================================ | |
| # 3. CHUNK_TEXT BUSINESS LOGIC TESTS | |
| # ============================================================ | |
| class TestChunkText: | |
| """Test the text chunking function β pure business logic, no dependencies.""" | |
| def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]: | |
| """Mirror of api.py chunk_text for isolated testing.""" | |
| paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] | |
| chunks = [] | |
| current = "" | |
| for para in paragraphs: | |
| if len(current) + len(para) < chunk_size: | |
| current += " " + para | |
| else: | |
| if current.strip(): | |
| chunks.append(current.strip()) | |
| current = para | |
| if current.strip(): | |
| chunks.append(current.strip()) | |
| if len(chunks) <= 1: | |
| return chunks | |
| overlapped = [chunks[0]] | |
| for i in range(1, len(chunks)): | |
| tail = chunks[i-1][-overlap:] if len(chunks[i-1]) > overlap else chunks[i-1] | |
| overlapped.append(tail + " " + chunks[i]) | |
| return overlapped | |
| def test_empty_text(self): | |
| """Empty text should return empty list.""" | |
| assert self.chunk_text("") == [] | |
| def test_short_text_single_chunk(self): | |
| """Text shorter than chunk_size should return single chunk.""" | |
| result = self.chunk_text("This is a short text about PM Kisan scheme.") | |
| assert len(result) == 1 | |
| assert "PM Kisan" in result[0] | |
| def test_long_text_multiple_chunks(self): | |
| """Long text should be split into multiple chunks.""" | |
| paragraphs = ["Paragraph " + str(i) + " " + ("x" * 300) for i in range(5)] | |
| text = "\n\n".join(paragraphs) | |
| result = self.chunk_text(text, chunk_size=400) | |
| assert len(result) > 1 | |
| def test_overlap_present(self): | |
| """Chunks after the first should contain overlap from previous chunk.""" | |
| p1 = "A" * 300 | |
| p2 = "B" * 300 | |
| text = p1 + "\n\n" + p2 | |
| result = self.chunk_text(text, chunk_size=350, overlap=50) | |
| if len(result) > 1: | |
| # Second chunk should start with tail of first chunk | |
| assert result[1].startswith(result[0][-50:]) | |
| def test_whitespace_only_paragraphs_ignored(self): | |
| """Paragraphs with only whitespace should be filtered out.""" | |
| text = "Real content\n\n \n\n \n\nMore content" | |
| result = self.chunk_text(text) | |
| assert len(result) == 1 # Both fit in one chunk | |
| assert "Real content" in result[0] | |
| assert "More content" in result[0] | |
| # ============================================================ | |
| # 4. WHATSAPP EXPIRY ALERTS TESTS | |
| # ============================================================ | |
| class TestExpiryAlerts: | |
| """Test WhatsApp expiry alert payload generation.""" | |
| def generate_alert_payload(scheme: dict, phone_number: str) -> dict: | |
| """Mirror of expiry_alerts.py generate_alert_payload for isolated testing.""" | |
| title = scheme.get("title", "Government Scheme") | |
| deadline = scheme.get("deadline_date", "Unknown Date") | |
| url = scheme.get("source_url", "https://myscheme.gov.in") | |
| return { | |
| "messaging_product": "whatsapp", | |
| "to": phone_number, | |
| "type": "template", | |
| "template": { | |
| "name": "scheme_expiry_alert", | |
| "language": {"code": "en_US"}, | |
| "components": [ | |
| { | |
| "type": "body", | |
| "parameters": [ | |
| {"type": "text", "text": title}, | |
| {"type": "text", "text": deadline} | |
| ] | |
| }, | |
| { | |
| "type": "button", | |
| "sub_type": "url", | |
| "index": "0", | |
| "parameters": [ | |
| {"type": "text", "text": url} | |
| ] | |
| } | |
| ] | |
| } | |
| } | |
| def test_payload_structure(self): | |
| """Alert payload should have correct WhatsApp API structure.""" | |
| scheme = { | |
| "title": "PM Kisan", | |
| "deadline_date": "2026-06-15", | |
| "source_url": "https://pmkisan.gov.in" | |
| } | |
| payload = self.generate_alert_payload(scheme, "+919876543210") | |
| assert payload["messaging_product"] == "whatsapp" | |
| assert payload["to"] == "+919876543210" | |
| assert payload["type"] == "template" | |
| assert payload["template"]["name"] == "scheme_expiry_alert" | |
| assert payload["template"]["language"]["code"] == "en_US" | |
| def test_payload_contains_scheme_title(self): | |
| """Payload body should contain the scheme title.""" | |
| scheme = {"title": "PM Awas Yojana", "deadline_date": "2026-07-01"} | |
| payload = self.generate_alert_payload(scheme, "+91111") | |
| body_params = payload["template"]["components"][0]["parameters"] | |
| assert body_params[0]["text"] == "PM Awas Yojana" | |
| def test_payload_contains_deadline(self): | |
| """Payload body should contain the deadline date.""" | |
| scheme = {"title": "Test", "deadline_date": "2026-12-31"} | |
| payload = self.generate_alert_payload(scheme, "+91222") | |
| body_params = payload["template"]["components"][0]["parameters"] | |
| assert body_params[1]["text"] == "2026-12-31" | |
| def test_payload_default_url(self): | |
| """Missing source_url should default to myscheme.gov.in.""" | |
| scheme = {"title": "Test"} | |
| payload = self.generate_alert_payload(scheme, "+91333") | |
| button_params = payload["template"]["components"][1]["parameters"] | |
| assert button_params[0]["text"] == "https://myscheme.gov.in" | |
| def test_payload_default_title(self): | |
| """Missing title should default to 'Government Scheme'.""" | |
| scheme = {} | |
| payload = self.generate_alert_payload(scheme, "+91444") | |
| body_params = payload["template"]["components"][0]["parameters"] | |
| assert body_params[0]["text"] == "Government Scheme" | |
| # ============================================================ | |
| # 5. ELIGIBILITY ENGINE TESTS (OpenFisca) | |
| # ============================================================ | |
| class TestEligibilityEngine: | |
| """Test eligibility engine deterministic logic.""" | |
| def engine(self): | |
| """Import eligibility engine β requires openfisca-core.""" | |
| try: | |
| from eligibility.engine import check_eligibility | |
| return check_eligibility | |
| except ImportError: | |
| pytest.skip("openfisca-core not installed") | |
| def test_farmer_eligible_for_pm_kisan(self, engine): | |
| """A farmer with low income should be eligible for PM Kisan.""" | |
| profile = { | |
| "annual_income": 100000, | |
| "age": 35, | |
| "is_farmer": True, | |
| "state": "Rajasthan", | |
| "caste_category": "General" | |
| } | |
| results = engine(profile) | |
| assert "eligible_pm_kisan" in results | |
| assert results["eligible_pm_kisan"] is True | |
| def test_non_farmer_ineligible_for_pm_kisan(self, engine): | |
| """A non-farmer should NOT be eligible for PM Kisan.""" | |
| profile = { | |
| "annual_income": 100000, | |
| "age": 35, | |
| "is_farmer": False, | |
| "state": "Rajasthan", | |
| "caste_category": "General" | |
| } | |
| results = engine(profile) | |
| assert results["eligible_pm_kisan"] is False | |
| def test_returns_all_eligibility_variables(self, engine): | |
| """Engine should return results for all 10 scheme variables.""" | |
| profile = { | |
| "annual_income": 200000, | |
| "age": 40, | |
| "is_farmer": True, | |
| "state": "Rajasthan", | |
| "caste_category": "SC" | |
| } | |
| results = engine(profile) | |
| expected_keys = [ | |
| "eligible_pm_kisan", | |
| "eligible_chiranjeevi", | |
| "eligible_palanhar", | |
| "eligible_ekal_nari", | |
| "eligible_devnarayan_scholarship", | |
| "eligible_incentive_to_girls", | |
| "eligible_widow_bed_scheme", | |
| "eligible_nirman_shramik_auzaar", | |
| "eligible_indira_mahila_shakti", | |
| "eligible_ayushman_arogya" | |
| ] | |
| for key in expected_keys: | |
| assert key in results, f"Missing eligibility variable: {key}" | |
| assert isinstance(results[key], bool), f"{key} should be boolean" | |
| def test_high_income_limits_eligibility(self, engine): | |
| """Very high income should reduce scheme eligibility.""" | |
| profile = { | |
| "annual_income": 5000000, # 50 lakh | |
| "age": 35, | |
| "is_farmer": False, | |
| "state": "Maharashtra", | |
| "caste_category": "General" | |
| } | |
| results = engine(profile) | |
| # At 50 lakh income, most welfare schemes should not apply | |
| eligible_count = sum(1 for v in results.values() if v) | |
| assert eligible_count < len(results), "High income should limit some eligibility" | |
| # ============================================================ | |
| # 6. BHASHINI LANGUAGE MAP TESTS | |
| # ============================================================ | |
| class TestBhashiniLanguageMaps: | |
| """Test language code mappings are complete and correct.""" | |
| def test_language_codes_mapping(self): | |
| """LANGUAGE_CODES should map all 10 supported languages + english.""" | |
| LANGUAGE_CODES = { | |
| "hindi": "hi", "tamil": "ta", "bengali": "bn", | |
| "telugu": "te", "marathi": "mr", "gujarati": "gu", | |
| "kannada": "kn", "malayalam": "ml", "punjabi": "pa", | |
| "odia": "or", "english": "en" | |
| } | |
| assert len(LANGUAGE_CODES) == 11 | |
| assert LANGUAGE_CODES["hindi"] == "hi" | |
| assert LANGUAGE_CODES["english"] == "en" | |
| assert LANGUAGE_CODES["tamil"] == "ta" | |
| def test_indictrans_lang_map(self): | |
| """IndicTrans language map should use BCP47-like codes.""" | |
| INDICTRANS_LANG_MAP = { | |
| "hindi": "hin_Deva", | |
| "tamil": "tam_Taml", | |
| "bengali": "ben_Beng", | |
| "telugu": "tel_Telu", | |
| "marathi": "mar_Deva", | |
| "gujarati": "guj_Gujr", | |
| "kannada": "kan_Knda", | |
| "malayalam": "mal_Mlym", | |
| "punjabi": "pan_Guru", | |
| "odia": "ory_Orya", | |
| "english": "eng_Latn" | |
| } | |
| assert len(INDICTRANS_LANG_MAP) == 11 | |
| assert INDICTRANS_LANG_MAP["hindi"] == "hin_Deva" | |
| # Verify all values follow the xxx_Yyyy pattern | |
| for lang, code in INDICTRANS_LANG_MAP.items(): | |
| assert "_" in code, f"Code for {lang} should contain underscore" | |
| parts = code.split("_") | |
| assert len(parts[0]) == 3, f"Script code for {lang} should be 3 chars" | |
| assert len(parts[1]) == 4, f"Script name for {lang} should be 4 chars" | |
| # ββ Sprint 19: OpenFisca Neuro-Symbolic Tests ββββββββββββββββββββββββββββ | |
| class TestOpenFiscaEngine: | |
| """Test the deterministic eligibility engine.""" | |
| def test_pm_kisan_eligible(self): | |
| """Farmer with low income should qualify for PM-KISAN.""" | |
| from eligibility.engine import check_eligibility | |
| profile = { | |
| "annual_income": 150000, | |
| "age": 45, | |
| "is_farmer": True, | |
| "state": "Rajasthan", | |
| "caste_category": "General", | |
| } | |
| results = check_eligibility(profile) | |
| assert results["eligible_pm_kisan"] is True | |
| def test_pm_kisan_not_eligible_high_income(self): | |
| """Non-farmer should NOT qualify for PM-KISAN.""" | |
| from eligibility.engine import check_eligibility | |
| profile = { | |
| "annual_income": 500000, | |
| "age": 30, | |
| "is_farmer": False, | |
| "state": "Delhi", | |
| "caste_category": "General", | |
| } | |
| results = check_eligibility(profile) | |
| assert results["eligible_pm_kisan"] is False | |
| def test_pmjay_eligible_low_income(self): | |
| """Low income citizen should qualify for PMJAY.""" | |
| from eligibility.engine import check_eligibility | |
| profile = { | |
| "annual_income": 300000, | |
| "age": 35, | |
| "is_farmer": False, | |
| "state": "Maharashtra", | |
| "caste_category": "OBC", | |
| } | |
| results = check_eligibility(profile) | |
| assert results["eligible_pmjay"] is True | |
| def test_pmjay_not_eligible_high_income(self): | |
| """High income citizen should NOT qualify for PMJAY.""" | |
| from eligibility.engine import check_eligibility | |
| profile = { | |
| "annual_income": 800000, | |
| "age": 35, | |
| "is_farmer": False, | |
| "state": "Maharashtra", | |
| "caste_category": "General", | |
| } | |
| results = check_eligibility(profile) | |
| assert results["eligible_pmjay"] is False | |
| def test_all_variables_present(self): | |
| """Engine should evaluate all registered variables without crashing.""" | |
| from eligibility.engine import check_eligibility, ELIGIBILITY_VARIABLES | |
| profile = { | |
| "annual_income": 200000, | |
| "age": 30, | |
| "is_farmer": True, | |
| "state": "Rajasthan", | |
| "caste_category": "SC", | |
| "gender": "Male", | |
| "is_bpl": True, | |
| "land_size_hectares": 1.5, | |
| "is_disabled": False, | |
| "occupation": "Farmer", | |
| } | |
| results = check_eligibility(profile) | |
| assert len(results) == len(ELIGIBILITY_VARIABLES) | |
| # All keys should be present | |
| for var in ELIGIBILITY_VARIABLES: | |
| assert var in results | |
| class TestNeuroSymbolicBridge: | |
| """Test the intent classification and parameter extraction.""" | |
| def test_missing_field_detection(self): | |
| """Should detect missing required fields.""" | |
| from neuro_symbolic import detect_missing_fields | |
| # Empty profile β everything is missing | |
| missing = detect_missing_fields({}) | |
| assert "annual_income" in missing | |
| assert "age" in missing | |
| assert "state" in missing | |
| def test_complete_profile_no_missing(self): | |
| """Complete profile should have no missing required fields.""" | |
| from neuro_symbolic import detect_missing_fields | |
| profile = {"annual_income": 200000, "age": 30, "state": "Delhi"} | |
| missing = detect_missing_fields(profile) | |
| assert len(missing) == 0 | |