sentinel / tests /test_integration_canrisk_api.py
jeuko's picture
Sync from GitHub (main)
cc034ee verified
raw
history blame
7.73 kB
# 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}"
)