Open raw ↗"""Tier 2: Boundary Value Analysis & Corner Case Tests.
Validates input validation, boundary handling, and error resilience:
- Boundary CTCAE severity scores (0, 1, 3, 4, 6, 7, 8, 9, 10, 11, out-of-bounds)
- Missing & malformed JSON payloads (empty objects, missing required keys)
- Type mismatches (string values in numeric fields)
- Account lockout boundaries (4 fails vs 5 fails)
- Query parameter boundaries (limit=0, limit=1, large limit, nonexistent/negative IDs)
- Character encoding and large text payloads (French accents, 10KB notes)
"""
import pytest
@pytest.mark.tier2
class TestCTCAEBoundaryScores:
"""CTCAE severity score boundaries (Nausea scale 1-11)."""
def test_boundary_score_0_below_threshold(self, patient_client):
# Severity score 0 is below grade 1 threshold [1, 3]
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 0,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
grading = resp.json().get("grading", {})
# Should result in grade None (no adverse event)
assert grading.get("grade") is None
def test_boundary_score_1_lower_bound_grade_1(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 1,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 1
def test_boundary_score_3_upper_bound_grade_1(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 3,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 1
def test_boundary_score_4_lower_bound_grade_2(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 4,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 2
def test_boundary_score_6_upper_bound_grade_2(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 6,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 2
def test_boundary_score_7_lower_bound_grade_3(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 7,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 3
def test_boundary_score_8_upper_bound_grade_3(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 8,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 3
def test_boundary_score_9_lower_bound_grade_4(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 9,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 4
def test_boundary_score_10_upper_bound_grade_4(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 10,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 4
def test_boundary_score_11_grade_5(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 11,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") == 5
def test_boundary_score_out_of_bounds_high(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 999,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
# Out of bounds value does not crash; returns grade None
assert resp.json().get("grading", {}).get("grade") is None
def test_boundary_score_negative(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": -5,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
assert resp.json().get("grading", {}).get("grade") is None
def test_boundary_string_severity_score(self, patient_client):
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": "not_a_number",
}
resp = patient_client.post("/api/reports", json=payload)
# Should either gracefully return 201 with grade None or 400
assert resp.status_code in (201, 400)
if resp.status_code == 201:
assert resp.json().get("grading", {}).get("grade") is None
@pytest.mark.tier2
class TestPayloadValidationBoundaries:
"""Missing fields and malformed payloads."""
def test_login_empty_payload(self, api_client):
resp = api_client.post("/api/login", json={})
assert resp.status_code == 400
def test_login_missing_username(self, api_client):
resp = api_client.post("/api/login", json={"password": "demo123"})
assert resp.status_code == 400
def test_login_missing_password(self, api_client):
resp = api_client.post("/api/login", json={"username": "patient.durand"})
assert resp.status_code == 400
def test_report_missing_patient_id_for_clinician(self, oncologist_client):
# Clinician has no patient_id in user session, so missing patient_id triggers 400
resp = oncologist_client.post(
"/api/reports",
json={"symptom_id": "nausea", "symptom_category": "gastrointestinal", "severity_score": 2}
)
assert resp.status_code == 400
assert "patient_id" in resp.json().get("error", "")
def test_report_patient_id_session_fallback(self, patient_client):
# Patient user omitting patient_id safely defaults to session user's patient_id
resp = patient_client.post(
"/api/reports",
json={"symptom_id": "nausea", "severity_score": 2}
)
assert resp.status_code == 201
assert resp.json().get("report_id") is not None
def test_report_missing_symptom_id(self, patient_client):
resp = patient_client.post(
"/api/reports",
json={"patient_id": 1, "symptom_category": "gastrointestinal", "severity_score": 2}
)
assert resp.status_code == 400
assert "symptom_id" in resp.json().get("error", "")
def test_report_auto_resolves_symptom_category(self, patient_client, session_server):
# When symptom_category is omitted, backend auto-resolves category from CTCAE rules
resp = patient_client.post(
"/api/reports",
json={"patient_id": 1, "symptom_id": "nausea", "severity_score": 2}
)
assert resp.status_code == 201
report_id = resp.json()["report_id"]
rows = session_server.query_db("SELECT symptom_category FROM toxicity_reports WHERE id = ?", (report_id,))
assert rows[0]["symptom_category"] == "gastrointestinal"
def test_report_empty_notes_and_null_notes(self, patient_client):
# Empty string notes
resp1 = patient_client.post(
"/api/reports",
json={"patient_id": 1, "symptom_id": "nausea", "symptom_category": "gastrointestinal", "severity_score": 1, "notes": ""}
)
assert resp1.status_code == 201
# Null notes
resp2 = patient_client.post(
"/api/reports",
json={"patient_id": 1, "symptom_id": "nausea", "symptom_category": "gastrointestinal", "severity_score": 1, "notes": None}
)
assert resp2.status_code == 201
@pytest.mark.tier2
class TestAccountLockoutBoundaries:
"""Account lockout: 4 failures allow subsequent login; 5 failures lock for 30 min."""
def test_lockout_boundary_4_failures_still_allows_login(self, isolated_server):
from conftest import HADClient
client = HADClient(isolated_server.base_url)
# 4 failed attempts
for _ in range(4):
r = client.login("patient.moreau", "wrongpass")
assert r.status_code == 401
# 5th attempt with correct password succeeds
r_success = client.login("patient.moreau", "demo123")
assert r_success.status_code == 200
assert r_success.json().get("user", {}).get("username") == "patient.moreau"
def test_lockout_boundary_5_failures_locks_account(self, isolated_server):
from conftest import HADClient
client = HADClient(isolated_server.base_url)
# 5 failed attempts
for i in range(5):
r = client.login("patient.durand", "wrongpass")
assert r.status_code == 401
# 6th attempt with the CORRECT password must be rejected due to lockout
r_locked = client.login("patient.durand", "demo123")
assert r_locked.status_code == 401
data = r_locked.json()
assert "locked" in data.get("error", "").lower() or "invalid" in data.get("error", "").lower()
# Verify locked_until is populated in database
rows = isolated_server.query_db(
"SELECT failed_attempts, locked_until FROM users WHERE username = 'patient.durand'"
)
assert len(rows) == 1
assert rows[0]["failed_attempts"] >= 5
assert rows[0]["locked_until"] is not None
@pytest.mark.tier2
class TestQueryParameterBoundaries:
"""Query parameter limits and nonexistent ID queries."""
def test_timeline_limit_boundary_zero(self, oncologist_client):
resp = oncologist_client.get("/api/timeline?patient_id=1&limit=0")
assert resp.status_code == 200
assert len(resp.json().get("events", [])) == 0
def test_timeline_limit_boundary_one(self, oncologist_client):
resp = oncologist_client.get("/api/timeline?patient_id=1&limit=1")
assert resp.status_code == 200
assert len(resp.json().get("events", [])) <= 1
def test_timeline_limit_boundary_large(self, oncologist_client):
resp = oncologist_client.get("/api/timeline?patient_id=1&limit=500")
assert resp.status_code == 200
events = resp.json().get("events", [])
assert len(events) >= 1
def test_patient_detail_nonexistent_id(self, oncologist_client):
resp = oncologist_client.get("/api/patients/99999")
assert resp.status_code == 404
def test_patient_detail_negative_id(self, oncologist_client):
resp = oncologist_client.get("/api/patients/-1")
assert resp.status_code == 404
@pytest.mark.tier2
class TestUnicodeAndPayloadSizeBoundaries:
"""Unicode French character fidelity and 10KB payload capacity."""
def test_unicode_french_accents_in_notes(self, patient_client, session_server):
french_notes = "Épisode de nausée aiguë avec fièvre modérée à 38,5°C. Patient très fatigué, prise d'ondansétron."
payload = {
"patient_id": 1,
"symptom_id": "nausea",
"symptom_category": "gastrointestinal",
"severity_score": 2,
"notes": french_notes,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
report_id = resp.json()["report_id"]
# Check SQLite row matches exactly
rows = session_server.query_db("SELECT notes FROM toxicity_reports WHERE id = ?", (report_id,))
assert len(rows) == 1
assert rows[0]["notes"] == french_notes
def test_large_notes_payload_10kb(self, patient_client, session_server):
large_notes = "Observation clinique détaillée: " + ("A" * 10000)
payload = {
"patient_id": 1,
"symptom_id": "fatigue",
"symptom_category": "constitutional",
"severity_score": 1,
"notes": large_notes,
}
resp = patient_client.post("/api/reports", json=payload)
assert resp.status_code == 201
report_id = resp.json()["report_id"]
rows = session_server.query_db("SELECT notes FROM toxicity_reports WHERE id = ?", (report_id,))
assert len(rows) == 1
assert len(rows[0]["notes"]) >= 10000