# pylint: disable=missing-docstring """Integration tests for the BOADICEA CanRisk endpoint. These tests exercise the live CanRisk service when credentials are provided via environment variables. They ensure that the pedigree fixtures we ship stay synchronised with the client implementation and that the end-to-end BOADICEA workflow returns a risk estimate within an expected range. """ import os from collections.abc import Callable from dataclasses import dataclass from pathlib import Path import pytest from sentinel.api_clients.canrisk import BOADICEAInput, CanRiskClient from sentinel.risk_models.boadicea import BOADICEARiskModel from sentinel.user_input import ( AlcoholConsumption, Anthropometrics, CancerType, Demographics, Ethnicity, FamilyMemberCancer, FamilyRelation, FamilySide, FemaleSpecific, GeneticMutation, HormoneUse, HormoneUseHistory, Lifestyle, MenstrualHistory, ParityHistory, PersonalMedicalHistory, RelationshipDegree, Sex, SmokingHistory, SmokingStatus, UserInput, ) CREDENTIALS_AVAILABLE = bool( os.getenv("CANRISK_USERNAME") and os.getenv("CANRISK_PASSWORD") ) pytestmark = pytest.mark.skipif( not CREDENTIALS_AVAILABLE, reason="CanRisk API credentials not available", ) FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" @dataclass(frozen=True) class Scenario: name: str fixture_filename: str build_user: Callable[[], UserInput] expected_range: tuple[float, float] def _high_risk_user() -> UserInput: return UserInput( demographics=Demographics( age_years=42, sex=Sex.FEMALE, ethnicity=Ethnicity.ASHKENAZI_JEWISH, anthropometrics=Anthropometrics(height_cm=165, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), alcohol_consumption=AlcoholConsumption.NONE, ), personal_medical_history=PersonalMedicalHistory( genetic_mutations=[GeneticMutation.BRCA1, GeneticMutation.BRCA2], ), female_specific=FemaleSpecific( menstrual=MenstrualHistory(age_at_menarche=13), parity=ParityHistory(age_at_first_live_birth=28, num_live_births=1), hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=52, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), FamilyMemberCancer( relation=FamilyRelation.SISTER, cancer_type=CancerType.OVARIAN, age_at_diagnosis=48, degree=RelationshipDegree.FIRST, side=FamilySide.UNKNOWN, ), ], ) def _moderate_risk_user() -> UserInput: return UserInput( demographics=Demographics( age_years=50, sex=Sex.FEMALE, ethnicity=Ethnicity.HISPANIC, anthropometrics=Anthropometrics(height_cm=160, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), alcohol_consumption=AlcoholConsumption.LIGHT, ), personal_medical_history=PersonalMedicalHistory( genetic_mutations=[GeneticMutation.BRCA1], ), female_specific=FemaleSpecific( menstrual=MenstrualHistory(age_at_menarche=12), parity=ParityHistory(age_at_first_live_birth=30, num_live_births=2), hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.FORMER), ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=60, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), FamilyMemberCancer( relation=FamilyRelation.MATERNAL_AUNT, cancer_type=CancerType.BREAST, age_at_diagnosis=55, degree=RelationshipDegree.SECOND, side=FamilySide.MATERNAL, ), ], ) def _average_risk_user() -> UserInput: return UserInput( demographics=Demographics( age_years=38, sex=Sex.FEMALE, ethnicity=Ethnicity.WHITE, anthropometrics=Anthropometrics(height_cm=168, weight_kg=62.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), alcohol_consumption=AlcoholConsumption.MODERATE, ), personal_medical_history=PersonalMedicalHistory(), female_specific=FemaleSpecific( menstrual=MenstrualHistory(age_at_menarche=12), parity=ParityHistory(num_live_births=0), hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.PATERNAL_GRANDMOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=67, degree=RelationshipDegree.SECOND, side=FamilySide.PATERNAL, ), ], ) SCENARIOS: tuple[Scenario, ...] = ( Scenario( name="high_risk_brca1_brca2", fixture_filename="canrisk_pedigree_high_risk.txt", build_user=_high_risk_user, expected_range=(25.0, 27.0), ), Scenario( name="moderate_risk_brca1", fixture_filename="canrisk_pedigree_moderate_risk.txt", build_user=_moderate_risk_user, expected_range=(29.0, 31.0), ), Scenario( name="average_risk_family_history", fixture_filename="canrisk_pedigree_average_risk.txt", build_user=_average_risk_user, expected_range=(2.0, 3.0), ), ) @pytest.fixture(scope="module") def canrisk_client() -> CanRiskClient: client = CanRiskClient() yield client client.close() @pytest.fixture(scope="module") def boadicea_model(canrisk_client: CanRiskClient) -> BOADICEARiskModel: return BOADICEARiskModel(client=canrisk_client) def _load_fixture_text(filename: str) -> str: path = FIXTURE_DIR / filename return path.read_text(encoding="utf-8").strip() def test_canrisk_authentication(canrisk_client: CanRiskClient) -> None: token = canrisk_client.authenticate() assert isinstance(token, str) and token, "Authentication returned an empty token" @pytest.mark.parametrize("scenario", SCENARIOS, ids=lambda scenario: scenario.name) def test_boadicea_scenarios( scenario: Scenario, canrisk_client: CanRiskClient, boadicea_model: BOADICEARiskModel, ) -> None: user = scenario.build_user() boadicea_input = BOADICEAInput.from_user_input(user) pedigree = canrisk_client._create_pedigree_file(boadicea_input).strip() expected_pedigree = _load_fixture_text(scenario.fixture_filename) assert pedigree == expected_pedigree, ( "Generated pedigree diverged from saved fixture" ) score = boadicea_model.compute_score(user) assert not score.startswith("N/A"), f"BOADICEA returned error: {score}" assert score.endswith("%"), f"Unexpected score format: {score}" risk_percentage = float(score.rstrip("%")) lower, upper = scenario.expected_range assert lower <= risk_percentage <= upper, ( f"Risk {risk_percentage}% outside expected range {lower}-{upper}% for scenario {scenario.name}" )