govbridge-api / tests /test_core.py
harshrawat18's picture
feat(sprint-18-19): bidirectional translation + neuro-symbolic eligibility bridge
60d1026
Raw
History Blame Contribute Delete
23.8 kB
"""
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")
@field_validator('language')
@classmethod
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()
@field_validator('question')
@classmethod
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")
@field_validator('question')
@classmethod
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")
@field_validator('language')
@classmethod
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)
@field_validator('question')
@classmethod
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."""
@staticmethod
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."""
@staticmethod
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."""
@pytest.fixture
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