Investment Plans workspace
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