From 512153bb0f48b0818bcef8da05338dbacd8183f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 09:16:43 +0000 Subject: [PATCH] test: cover admin drug listing and protocol tracing Two features reached the test suite only indirectly: - /admin/drug/attributes-list, the entry point of the drug curation screen. Its query in drugs_repository.get_admin_drug_list joins the outlier counters with the schema attributes and the public substance catalog and exposes ~20 filters, none of which were exercised end-to-end (repository at 38% coverage). - The protocol tracing endpoints (/protocol/prescription-trace, /protocol/test/sample, /protocol/test). Only the internal _evaluate_date_groups helper had unit tests; the orchestration, applicability rules, name resolution and per-prescription error isolation had none (service at 33%). Both are covered with integration tests, since the behaviour under test lives in the SQL and in the route/service/repository wiring rather than in isolated logic. Coverage: drugs_repository 38% -> 90%, protocol_trace_service 33% -> 92%, routes/protocol 87% -> 97%. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CjEbprG7BvzEUsrkjttd1j --- tests/integration/test_admin_drug_list.py | 524 +++++++++++++++++ tests/integration/test_protocol_trace.py | 664 ++++++++++++++++++++++ 2 files changed, 1188 insertions(+) create mode 100644 tests/integration/test_admin_drug_list.py create mode 100644 tests/integration/test_protocol_trace.py diff --git a/tests/integration/test_admin_drug_list.py b/tests/integration/test_admin_drug_list.py new file mode 100644 index 00000000..b9665043 --- /dev/null +++ b/tests/integration/test_admin_drug_list.py @@ -0,0 +1,524 @@ +"""Integration tests for the /admin/drug/attributes-list endpoint +(admin_drug_service.get_drug_list / drugs_repository.get_admin_drug_list). + +The listing is the entry point of the drug curation screen: it joins the +outlier counters with the schema drug attributes and the public substance +catalog, and exposes a large set of filters used to find drugs that still +need curation. +""" + +import pytest +from sqlalchemy import bindparam, text + +from tests.conftest import get_access, make_headers, session, session_commit + +# Test rows use a high id range so they never collide with seed data. +# demo.medicamento / demo.medatributos / demo.outlier ids >= 95000 and +# public.substancia sctid >= 9500000 belong to this file only. +_SUBSTANCE_ID = 9500001 +_SUBSTANCE_NAME = "ZZTEST Substancia Alfa" + +_DRUG_ALFA = 95001 # curated: substance confirmed, attributes on segment 1 +_DRUG_BETA = 95002 # no substance and no attributes row (inconsistency) +_DRUG_GAMA = 95003 # AI-suggested substance, attributes on segments 1 and 2 + +_DRUG_IDS = (_DRUG_ALFA, _DRUG_BETA, _DRUG_GAMA) + +_NAME_ALFA = "ZZTEST DRUG ALFA" +_NAME_BETA = "ZZTEST DRUG BETA" +_NAME_GAMA = "ZZTEST DRUG GAMA" + +_TERM = "%ZZTEST DRUG%" + +# demo.segmento seed rows +_SEGMENT_ADULT = 1 +_SEGMENT_CPOE = 2 + + +@pytest.fixture +def seed_admin_drugs(): + """Drugs, attributes, outlier counters and a substance for the listing.""" + session.execute( + text( + "INSERT INTO public.substancia " + "(sctid, nome, ativo, dosemax_adulto, dosemax_peso_adulto, " + "dosemax_pediatrico, dosemax_peso_pediatrico, unidadepadrao) " + "VALUES (:id, :name, true, 500, 10, 250, 5, 'mg')" + ), + {"id": _SUBSTANCE_ID, "name": _SUBSTANCE_NAME}, + ) + + for id_drug, name, sctid, accuracy in ( + (_DRUG_ALFA, _NAME_ALFA, _SUBSTANCE_ID, None), + (_DRUG_BETA, _NAME_BETA, None, None), + (_DRUG_GAMA, _NAME_GAMA, _SUBSTANCE_ID, 80), + ): + session.execute( + text( + "INSERT INTO demo.medicamento " + "(fkhospital, fkmedicamento, nome, sctid, ia_acuracia) " + "VALUES (1, :id, :name, :sctid, :accuracy)" + ), + {"id": id_drug, "name": name, "sctid": sctid, "accuracy": accuracy}, + ) + + # ALFA is fully curated on the adult segment + session.execute( + text( + "INSERT INTO demo.medatributos " + "(fkmedicamento, idsegmento, fkunidademedida, fkunidademedidacusto, " + "custo, dosemaxima, ref_dosemaxima, usapeso, antimicro, divisor) " + "VALUES (:id, :segment, '1', '1', 1.5, 100, 100, false, true, 2)" + ), + {"id": _DRUG_ALFA, "segment": _SEGMENT_ADULT}, + ) + # GAMA is only partially filled on the adult segment... + session.execute( + text( + "INSERT INTO demo.medatributos " + "(fkmedicamento, idsegmento, usapeso, antimicro) " + "VALUES (:id, :segment, true, false)" + ), + {"id": _DRUG_GAMA, "segment": _SEGMENT_ADULT}, + ) + # ...and curated on the CPOE segment, so it yields two listing rows + session.execute( + text( + "INSERT INTO demo.medatributos " + "(fkmedicamento, idsegmento, fkunidademedida, dosemaxima) " + "VALUES (:id, :segment, '1', 50)" + ), + {"id": _DRUG_GAMA, "segment": _SEGMENT_CPOE}, + ) + # BETA has no attributes row at all -> inconsistency + + # the listing is driven by the outlier counters: one group per + # (drug, segment) pair, with the summed prescription count + for id_outlier, id_drug, segment, count in ( + (95001, _DRUG_ALFA, _SEGMENT_ADULT, 50), + (95002, _DRUG_BETA, _SEGMENT_ADULT, 5), + (95003, _DRUG_GAMA, _SEGMENT_ADULT, 120), + (95004, _DRUG_GAMA, _SEGMENT_ADULT, 80), + (95005, _DRUG_GAMA, _SEGMENT_CPOE, 7), + ): + session.execute( + text( + "INSERT INTO demo.outlier " + "(idoutlier, fkmedicamento, idsegmento, contagem, doseconv, " + "frequenciadia) " + "VALUES (:id_outlier, :id_drug, :segment, :count, :id_outlier, 1)" + ), + { + "id_outlier": id_outlier, + "id_drug": id_drug, + "segment": segment, + "count": count, + }, + ) + session_commit() + + yield + + session.execute(text("DELETE FROM demo.outlier WHERE idoutlier >= 95001")) + session.execute( + text("DELETE FROM demo.medatributos WHERE fkmedicamento IN :ids").bindparams( + bindparam("ids", expanding=True) + ), + {"ids": list(_DRUG_IDS)}, + ) + session.execute( + text("DELETE FROM demo.medicamento WHERE fkmedicamento IN :ids").bindparams( + bindparam("ids", expanding=True) + ), + {"ids": list(_DRUG_IDS)}, + ) + session.execute( + text("DELETE FROM public.substancia WHERE sctid = :id"), {"id": _SUBSTANCE_ID} + ) + session_commit() + + +def _post(client, headers, **filters): + """Call the listing restricted to the seeded drugs unless told otherwise.""" + body = {"term": _TERM, "limit": 50} + body.update(filters) + + return client.post("/admin/drug/attributes-list", json=body, headers=headers) + + +def _names(response): + """Drug names of the returned rows, in response order.""" + return [item["name"] for item in response.get_json()["data"]["list"]] + + +def _pairs(response): + """(idDrug, idSegment) of the returned rows, as a set.""" + return { + (int(item["idDrug"]), item["idSegment"]) + for item in response.get_json()["data"]["list"] + } + + +def test_drug_list_permission_denied(client, analyst_headers): + """A user without ADMIN_DRUGS cannot list drug attributes [401].""" + response = _post(client, analyst_headers) + + assert response.status_code == 401 + + +def test_drug_list_permission_denied_config_manager(client, config_manager_headers): + """WRITE_DRUG_ATTRIBUTES alone does not grant access to the listing [401].""" + response = _post(client, config_manager_headers) + + assert response.status_code == 401 + + +def test_drug_list_curator_is_allowed(client, curator_headers, seed_admin_drugs): + """The curator role carries ADMIN_DRUGS, so the listing is available.""" + response = _post(client, curator_headers) + + assert response.status_code == 200 + assert _names(response) + + +def test_drug_list_returns_one_row_per_drug_segment_group( + client, admin_headers, seed_admin_drugs +): + """Each (drug, segment) outlier group produces one row, ordered by name.""" + response = _post(client, admin_headers) + + assert response.status_code == 200 + assert _pairs(response) == { + (_DRUG_ALFA, _SEGMENT_ADULT), + # BETA has no attributes row, so it carries no segment + (_DRUG_BETA, None), + (_DRUG_GAMA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_CPOE), + } + # ordered by drug name: ALFA < BETA < GAMA + assert _names(response) == [_NAME_ALFA, _NAME_BETA, _NAME_GAMA, _NAME_GAMA] + + +def test_drug_list_row_shape(client, admin_headers, seed_admin_drugs): + """A curated row exposes its attributes, substance and outlier counters.""" + response = _post(client, admin_headers, idDrugList=[_DRUG_ALFA]) + + assert response.status_code == 200 + row = response.get_json()["data"]["list"][0] + + assert row["idDrug"] == str(_DRUG_ALFA) + assert row["name"] == _NAME_ALFA + assert row["idSegment"] == _SEGMENT_ADULT + assert row["sctid"] == str(_SUBSTANCE_ID) + assert row["substance"] == _SUBSTANCE_NAME + assert row["idMeasureUnitDefault"] == "1" + assert row["idMeasureUnitPrice"] == "1" + assert row["price"] == 1.5 + assert row["maxDose"] == 100 + assert row["refMaxDose"] == 100 + assert row["useWeight"] is False + assert row["doseRange"] == 2 + # summed outlier count for the (drug, segment) group + assert row["drugCount"] == 50 + + +def test_drug_list_sums_outlier_count_per_segment( + client, admin_headers, seed_admin_drugs +): + """drugCount sums every outlier row of the (drug, segment) group.""" + response = _post(client, admin_headers, idDrugList=[_DRUG_GAMA]) + + assert response.status_code == 200 + counts = { + item["idSegment"]: item["drugCount"] + for item in response.get_json()["data"]["list"] + } + + assert counts[_SEGMENT_ADULT] == 200 # 120 + 80 + assert counts[_SEGMENT_CPOE] == 7 + + +def test_drug_list_exposes_substance_max_dose_by_segment_type( + client, admin_headers, seed_admin_drugs +): + """Substance reference doses follow the segment type (adult seed segments).""" + response = _post(client, admin_headers, idDrugList=[_DRUG_ALFA]) + + assert response.status_code == 200 + row = response.get_json()["data"]["list"][0] + + assert row["substanceMaxDose"] == 500 + assert row["substanceMaxDoseWeight"] == 10 + assert row["substanceMeasureUnit"] == "mg" + + +def test_drug_list_count_ignores_pagination(client, admin_headers, seed_admin_drugs): + """count reports every matching row while the page respects limit/offset.""" + first_page = _post(client, admin_headers, limit=2, offset=0) + second_page = _post(client, admin_headers, limit=2, offset=2) + + assert first_page.status_code == 200 + assert second_page.status_code == 200 + + assert first_page.get_json()["data"]["count"] == 4 + assert second_page.get_json()["data"]["count"] == 4 + assert _names(first_page) == [_NAME_ALFA, _NAME_BETA] + assert _names(second_page) == [_NAME_GAMA, _NAME_GAMA] + + +def test_drug_list_empty_result_reports_zero_count(client, admin_headers): + """A term matching nothing returns an empty list and a zero count.""" + response = _post(client, admin_headers, term="%ZZTEST NOTHING MATCHES%") + + assert response.status_code == 200 + data = response.get_json()["data"] + + assert data["list"] == [] + assert data["count"] == 0 + + +def test_drug_list_filter_by_term(client, admin_headers, seed_admin_drugs): + """term matches the drug name, case-insensitively.""" + response = _post(client, admin_headers, term="%zztest drug beta%") + + assert response.status_code == 200 + assert _names(response) == [_NAME_BETA] + + +def test_drug_list_filter_by_substance_name(client, admin_headers, seed_admin_drugs): + """substance matches the public substance name.""" + response = _post(client, admin_headers, substance="%substancia alfa%") + + assert response.status_code == 200 + assert _pairs(response) == { + (_DRUG_ALFA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_CPOE), + } + + +def test_drug_list_filter_has_substance(client, admin_headers, seed_admin_drugs): + """hasSubstance splits drugs already linked to a substance from the rest.""" + linked = _post(client, admin_headers, hasSubstance=True) + unlinked = _post(client, admin_headers, hasSubstance=False) + + assert {p[0] for p in _pairs(linked)} == {_DRUG_ALFA, _DRUG_GAMA} + assert _names(unlinked) == [_NAME_BETA] + + +def test_drug_list_filter_has_inconsistency(client, admin_headers, seed_admin_drugs): + """hasInconsistency isolates drugs prescribed without an attributes row.""" + inconsistent = _post(client, admin_headers, hasInconsistency=True) + consistent = _post(client, admin_headers, hasInconsistency=False) + + assert _names(inconsistent) == [_NAME_BETA] + assert {p[0] for p in _pairs(consistent)} == {_DRUG_ALFA, _DRUG_GAMA} + + +def test_drug_list_filter_has_default_unit(client, admin_headers, seed_admin_drugs): + """hasDefaultUnit finds the attribute rows still missing a measure unit.""" + with_unit = _post(client, admin_headers, hasDefaultUnit=True) + without_unit = _post(client, admin_headers, hasDefaultUnit=False) + + assert _pairs(with_unit) == { + (_DRUG_ALFA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_CPOE), + } + # GAMA on the adult segment has attributes but no unit; BETA has no row + assert _pairs(without_unit) == { + (_DRUG_BETA, None), + (_DRUG_GAMA, _SEGMENT_ADULT), + } + + +def test_drug_list_filter_has_price_unit(client, admin_headers, seed_admin_drugs): + """hasPriceUnit isolates rows carrying a price measure unit.""" + response = _post(client, admin_headers, hasPriceUnit=True) + + assert _pairs(response) == {(_DRUG_ALFA, _SEGMENT_ADULT)} + + +def test_drug_list_filter_has_price_conversion(client, admin_headers, seed_admin_drugs): + """A price unit equal to the default unit already counts as converted.""" + response = _post(client, admin_headers, hasPriceConversion=True) + + assert _pairs(response) == {(_DRUG_ALFA, _SEGMENT_ADULT)} + + +def test_drug_list_filter_has_max_dose(client, admin_headers, seed_admin_drugs): + """hasMaxDose separates the rows with a configured maximum dose.""" + with_max_dose = _post(client, admin_headers, hasMaxDose=True) + without_max_dose = _post(client, admin_headers, hasMaxDose=False) + + assert _pairs(with_max_dose) == { + (_DRUG_ALFA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_CPOE), + } + assert _pairs(without_max_dose) == { + (_DRUG_BETA, None), + (_DRUG_GAMA, _SEGMENT_ADULT), + } + + +def test_drug_list_filter_ref_max_dose_equal_and_diff( + client, admin_headers, seed_admin_drugs +): + """tpRefMaxDose compares the curated dose with the reference dose.""" + equal = _post(client, admin_headers, tpRefMaxDose="equal") + empty = _post(client, admin_headers, tpRefMaxDose="empty") + + # ALFA has ref_maxdose == maxDose (100) + assert _pairs(equal) == {(_DRUG_ALFA, _SEGMENT_ADULT)} + # GAMA/CPOE has no reference dose, GAMA/adult uses weight and has none + assert _pairs(empty) == { + (_DRUG_BETA, None), + (_DRUG_GAMA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_CPOE), + } + + +def test_drug_list_filter_has_ai_substance(client, admin_headers, seed_admin_drugs): + """hasAISubstance isolates substances suggested by the prediction job.""" + predicted = _post(client, admin_headers, hasAISubstance=True) + not_predicted = _post(client, admin_headers, hasAISubstance=False) + + assert {p[0] for p in _pairs(predicted)} == {_DRUG_GAMA} + assert {p[0] for p in _pairs(not_predicted)} == {_DRUG_ALFA, _DRUG_BETA} + + +def test_drug_list_filter_ai_accuracy_range(client, admin_headers, seed_admin_drugs): + """aiAccuracyRange narrows the prediction confidence window.""" + inside = _post(client, admin_headers, hasAISubstance=True, aiAccuracyRange=[70, 90]) + outside = _post( + client, admin_headers, hasAISubstance=True, aiAccuracyRange=[90, 100] + ) + + assert {p[0] for p in _pairs(inside)} == {_DRUG_GAMA} + assert _pairs(outside) == set() + + +def test_drug_list_filter_substance_status(client, admin_headers, seed_admin_drugs): + """substanceStatus groups drugs by how their substance link was obtained.""" + empty = _post(client, admin_headers, substanceStatus="empty") + confirmed = _post(client, admin_headers, substanceStatus="confirmed") + not_confirmed = _post(client, admin_headers, substanceStatus="not_confirmed") + below_75 = _post(client, admin_headers, substanceStatus="not_confirmed_75") + + assert {p[0] for p in _pairs(empty)} == {_DRUG_BETA} + assert {p[0] for p in _pairs(confirmed)} == {_DRUG_ALFA} + assert {p[0] for p in _pairs(not_confirmed)} == {_DRUG_GAMA} + # accuracy 80 is not below 75 + assert _pairs(below_75) == set() + + +def test_drug_list_filter_min_drug_count(client, admin_headers, seed_admin_drugs): + """minDrugCount drops rarely prescribed (drug, segment) groups.""" + response = _post(client, admin_headers, minDrugCount=50) + + assert _pairs(response) == { + (_DRUG_ALFA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_ADULT), + } + + +def test_drug_list_filter_by_segment(client, admin_headers, seed_admin_drugs): + """idSegmentList keeps only the attribute rows of the given segments.""" + response = _post(client, admin_headers, idSegmentList=[_SEGMENT_CPOE]) + + assert _pairs(response) == {(_DRUG_GAMA, _SEGMENT_CPOE)} + + +def test_drug_list_filter_by_substance_list(client, admin_headers, seed_admin_drugs): + """substanceList filters in or out the given substance ids.""" + included = _post(client, admin_headers, substanceList=[_SUBSTANCE_ID]) + excluded = _post( + client, admin_headers, substanceList=[_SUBSTANCE_ID], tpSubstanceList="notin" + ) + + assert {p[0] for p in _pairs(included)} == {_DRUG_ALFA, _DRUG_GAMA} + # the exclusion runs on the joined substance, so unlinked drugs drop out too + assert _pairs(excluded) == set() + + +def test_drug_list_filter_by_attribute_list(client, admin_headers, seed_admin_drugs): + """attributeList keeps (in) or drops (notin) rows carrying the attribute.""" + marked = _post(client, admin_headers, attributeList=["antimicro"]) + unmarked = _post( + client, admin_headers, attributeList=["antimicro"], tpAttributeList="notin" + ) + + assert _pairs(marked) == {(_DRUG_ALFA, _SEGMENT_ADULT)} + # a false or absent flag both count as "not marked" + assert _pairs(unmarked) == { + (_DRUG_BETA, None), + (_DRUG_GAMA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_CPOE), + } + + +def test_drug_list_filter_by_non_boolean_attribute( + client, admin_headers, seed_admin_drugs +): + """A numeric attribute counts as set when it holds any value.""" + response = _post(client, admin_headers, attributeList=["dosemaxima"]) + + assert _pairs(response) == { + (_DRUG_ALFA, _SEGMENT_ADULT), + (_DRUG_GAMA, _SEGMENT_CPOE), + } + + +def test_drug_list_group_by_drug_collapses_segments( + client, admin_headers, seed_admin_drugs +): + """groupByDrug returns one row per drug and counts distinct drugs.""" + response = _post(client, admin_headers, groupByDrug=True) + + assert response.status_code == 200 + data = response.get_json()["data"] + + assert _names(response) == [_NAME_ALFA, _NAME_BETA, _NAME_GAMA] + assert data["count"] == 3 + + +def test_drug_list_filter_has_substance_max_dose_weight( + client, admin_headers, seed_admin_drugs +): + """The substance weight-based reference doses are filterable.""" + response = _post(client, admin_headers, hasSubstanceMaxDoseWeightAdult=True) + + assert {p[0] for p in _pairs(response)} == {_DRUG_ALFA, _DRUG_GAMA} + + response = _post(client, admin_headers, hasSubstanceMaxDoseWeightPediatric=False) + + assert {p[0] for p in _pairs(response)} == {_DRUG_BETA} + + +def test_drug_list_rejects_invalid_params(client, admin_headers): + """A malformed filter is rejected by the request model [400].""" + response = client.post( + "/admin/drug/attributes-list", + json={"limit": "not-a-number"}, + headers=admin_headers, + ) + + assert response.status_code == 400 + + +def test_drug_list_unauthenticated(client): + """The endpoint requires a valid token [401].""" + response = client.post( + "/admin/drug/attributes-list", + json={}, + headers=make_headers("invalid-token"), + ) + + assert response.status_code == 401 + + +def test_drug_list_denied_for_plain_user(client): + """A user with no roles at all cannot reach the listing [401].""" + headers = make_headers(get_access(client, roles=[])) + response = _post(client, headers) + + assert response.status_code == 401 diff --git a/tests/integration/test_protocol_trace.py b/tests/integration/test_protocol_trace.py new file mode 100644 index 00000000..e57305ef --- /dev/null +++ b/tests/integration/test_protocol_trace.py @@ -0,0 +1,664 @@ +"""Integration tests for the protocol tracing and testing endpoints +(protocol_trace_service): /protocol/prescription-trace, /protocol/test/sample +and /protocol/test. + +Tracing replays the protocol evaluation of a real prescription and explains, +per protocol and per expire-date group, why it did or did not activate. The +test endpoints do the same for a protocol config that is still being edited +and has not been saved yet. +""" + +import json + +import pytest +from sqlalchemy import bindparam, text + +from models.enums import ProtocolStatusTypeEnum, ProtocolTypeEnum +from tests.conftest import session, session_commit +from tests.utils import utils_test_prescription + +# create_basic_prescription always prescribes these two drugs +_DRUG_IN_PRESCRIPTION = 3 +_DRUG_NOT_IN_PRESCRIPTION = 999999 + +# Protocol rows live in the shared public.protocolo table; the 991xxx range +# belongs to this file only. +_ACTIVATED_ID = 991001 +_NOT_ACTIVATED_ID = 991002 +_INACTIVE_ID = 991003 +_AGG_TYPE_ID = 991004 +_BROKEN_CONFIG_ID = 991005 +_OTHER_SCHEMA_ID = 991006 + +_ALL_IDS = ( + _ACTIVATED_ID, + _NOT_ACTIVATED_ID, + _INACTIVE_ID, + _AGG_TYPE_ID, + _BROKEN_CONFIG_ID, + _OTHER_SCHEMA_ID, +) + +_RESULT = {"type": "SHOW_MESSAGE", "level": "high", "message": "ZZTest alerta"} + + +def _config(trigger: str, variables: list[dict]) -> dict: + """Protocol configuration in the shape stored in protocolo.configuracao.""" + return {"trigger": trigger, "variables": variables, "result": _RESULT} + + +def _drug_variable(name: str, id_drug: int) -> dict: + """Variable that is true when the prescription contains the drug.""" + return {"name": name, "field": "idDrug", "operator": "IN", "value": [str(id_drug)]} + + +_ACTIVATED_CONFIG = _config( + "{{presente}}", [_drug_variable("presente", _DRUG_IN_PRESCRIPTION)] +) +_NOT_ACTIVATED_CONFIG = _config( + "{{presente}} and {{ausente}}", + [ + _drug_variable("presente", _DRUG_IN_PRESCRIPTION), + _drug_variable("ausente", _DRUG_NOT_IN_PRESCRIPTION), + ], +) +# an unknown field makes the evaluation raise, which the trace reports per group +_BROKEN_CONFIG = _config( + "{{quebrado}}", + [{"name": "quebrado", "field": "unknownField", "operator": "IN", "value": ["1"]}], +) + +# (id, schema, name, protocol type, status, config) +_PROTOCOL_ROWS = ( + ( + _ACTIVATED_ID, + "demo", + "ZZTest Ativado", + ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + ProtocolStatusTypeEnum.ACTIVE.value, + _ACTIVATED_CONFIG, + ), + ( + _NOT_ACTIVATED_ID, + "demo", + "ZZTest Nao Ativado", + ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + ProtocolStatusTypeEnum.ACTIVE.value, + _NOT_ACTIVATED_CONFIG, + ), + ( + _INACTIVE_ID, + "demo", + "ZZTest Inativo", + ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + ProtocolStatusTypeEnum.INACTIVE.value, + _ACTIVATED_CONFIG, + ), + ( + _AGG_TYPE_ID, + "demo", + "ZZTest Agregada", + ProtocolTypeEnum.PRESCRIPTION_AGG.value, + ProtocolStatusTypeEnum.ACTIVE.value, + _ACTIVATED_CONFIG, + ), + ( + _BROKEN_CONFIG_ID, + "demo", + "ZZTest Config Invalida", + ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + ProtocolStatusTypeEnum.ACTIVE.value, + _BROKEN_CONFIG, + ), + ( + _OTHER_SCHEMA_ID, + "other-schema", + "ZZTest Outro Schema", + ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + ProtocolStatusTypeEnum.ACTIVE.value, + _ACTIVATED_CONFIG, + ), +) + + +@pytest.fixture(scope="module") +def traced_prescription(): + """An individual prescription of the current day, with two drugs.""" + return utils_test_prescription.create_basic_prescription() + + +@pytest.fixture +def seed_trace_protocols(): + """Protocols covering activation, non-activation and applicability cases.""" + for id_protocol, schema, name, protocol_type, status_type, config in _PROTOCOL_ROWS: + session.execute( + text( + "INSERT INTO public.protocolo " + "(idprotocolo, schema_name, nome, tp_protocolo, tp_situacao, " + "configuracao, created_at, created_by) " + "VALUES (:id, :schema, :name, :protocol_type, :status_type, " + "CAST(:config AS json), now(), 1)" + ), + { + "id": id_protocol, + "schema": schema, + "name": name, + "protocol_type": protocol_type, + "status_type": status_type, + "config": json.dumps(config), + }, + ) + session_commit() + + yield + + session.execute( + text("DELETE FROM public.protocolo WHERE idprotocolo IN :ids").bindparams( + bindparam("ids", expanding=True) + ), + {"ids": list(_ALL_IDS)}, + ) + session_commit() + + +def _trace(client, headers, id_prescription, id_protocol=None): + """Call the trace endpoint for one prescription.""" + url = f"/protocol/prescription-trace?idPrescription={id_prescription}" + if id_protocol is not None: + url += f"&idProtocol={id_protocol}" + + return client.get(url, headers=headers) + + +def _protocols_by_id(response): + """Traced protocols indexed by protocol id.""" + return { + item["idProtocol"]: item for item in response.get_json()["data"]["protocols"] + } + + +def _single_group(protocol): + """The only expire-date group of a non-aggregated prescription.""" + assert len(protocol["dateGroups"]) == 1 + return protocol["dateGroups"][0] + + +# --- /protocol/prescription-trace --------------------------------------------- + + +def test_trace_allowed_for_viewer(client, viewer_headers, traced_prescription): + """VIEWER carries READ_PRESCRIPTION, so the trace is readable.""" + response = _trace(client, viewer_headers, traced_prescription.id) + + assert response.status_code == 200 + + +def test_trace_denied_without_read_prescription( + client, user_manager_headers, traced_prescription +): + """A user without READ_PRESCRIPTION cannot trace a prescription [401].""" + response = _trace(client, user_manager_headers, traced_prescription.id) + + assert response.status_code == 401 + + +def test_trace_unknown_prescription(client, analyst_headers): + """An unknown prescription id is rejected [400].""" + response = _trace(client, analyst_headers, 999999999) + + assert response.status_code == 400 + + +def test_trace_requires_id_prescription(client, analyst_headers): + """idPrescription is mandatory [400].""" + response = client.get("/protocol/prescription-trace", headers=analyst_headers) + + assert response.status_code == 400 + + +def test_trace_returns_visible_protocols( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """Every protocol that runs for the prescription type is traced.""" + response = _trace(client, analyst_headers, traced_prescription.id) + + assert response.status_code == 200 + data = response.get_json()["data"] + + assert data["idPrescription"] == str(traced_prescription.id) + assert data["evaluatedAt"] + + by_id = _protocols_by_id(response) + # individual-type protocols of the schema are evaluated... + assert _ACTIVATED_ID in by_id + assert _NOT_ACTIVATED_ID in by_id + # ...an inactive one, another schema's and an aggregated-type one are not + assert _INACTIVE_ID not in by_id + assert _OTHER_SCHEMA_ID not in by_id + assert _AGG_TYPE_ID not in by_id + + +def test_trace_explains_an_activated_protocol( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """An activated protocol reports its trigger, result and variables.""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=_ACTIVATED_ID + ) + + assert response.status_code == 200 + protocol = _protocols_by_id(response)[_ACTIVATED_ID] + + assert protocol["name"] == "ZZTest Ativado" + assert protocol["applicable"] is True + assert protocol["applicabilityNotes"] == [] + + group = _single_group(protocol) + assert group["activated"] is True + assert group["trigger"]["expression"] == "{{presente}}" + assert group["trigger"]["substituted"] == "True" + assert group["result"]["message"] == _RESULT["message"] + assert [v["name"] for v in group["variables"]] == ["presente"] + assert group["variables"][0]["result"] is True + + +def test_trace_explains_a_non_activated_protocol( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """A non-activated protocol points at the variable that turned false.""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=_NOT_ACTIVATED_ID + ) + + assert response.status_code == 200 + group = _single_group(_protocols_by_id(response)[_NOT_ACTIVATED_ID]) + + assert group["activated"] is False + assert group["result"] is None + assert group["trigger"]["substituted"] == "True and False" + + false_variables = [v for v in group["variables"] if not v["result"]] + assert [v["name"] for v in false_variables] == ["ausente"] + + +def test_trace_resolves_drug_names( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """Ids in the trace messages are replaced by the drug names.""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=_ACTIVATED_ID + ) + + assert response.status_code == 200 + variable = _single_group(_protocols_by_id(response)[_ACTIVATED_ID])["variables"][0] + + # the id is rendered as the drug name taken from the prescription + assert str(_DRUG_IN_PRESCRIPTION) not in variable["message"] + assert "ANLODIPINO" in variable["message"].upper() + + +def test_trace_reports_inactive_protocol_as_not_applicable( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """Tracing an inactive protocol explains it never runs automatically.""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=_INACTIVE_ID + ) + + assert response.status_code == 200 + protocol = _protocols_by_id(response)[_INACTIVE_ID] + + assert protocol["applicable"] is False + assert any("inativo" in note for note in protocol["applicabilityNotes"]) + # it is still evaluated, so the user can see what it would have done + assert _single_group(protocol)["activated"] is True + + +def test_trace_reports_incompatible_protocol_type( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """An aggregated-type protocol does not apply to an individual prescription.""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=_AGG_TYPE_ID + ) + + assert response.status_code == 200 + protocol = _protocols_by_id(response)[_AGG_TYPE_ID] + + assert protocol["applicable"] is False + assert any("tipo" in note for note in protocol["applicabilityNotes"]) + + +def test_trace_reports_invalid_config_per_group( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """A config the evaluator cannot run is reported instead of failing [200].""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=_BROKEN_CONFIG_ID + ) + + assert response.status_code == 200 + group = _single_group(_protocols_by_id(response)[_BROKEN_CONFIG_ID]) + + assert "error" in group + assert "Configuração do protocolo inválida" in group["error"] + + +def test_trace_unknown_protocol(client, analyst_headers, traced_prescription): + """An unknown protocol id is rejected [400].""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=999999999 + ) + + assert response.status_code == 400 + + +def test_trace_hides_other_schema_protocol( + client, analyst_headers, traced_prescription, seed_trace_protocols +): + """A protocol owned by another schema cannot be traced [400].""" + response = _trace( + client, analyst_headers, traced_prescription.id, id_protocol=_OTHER_SCHEMA_ID + ) + + assert response.status_code == 400 + + +# --- /protocol/test/sample ---------------------------------------------------- + + +def test_sample_permission_denied(client, analyst_headers): + """Sampling prescriptions requires WRITE_PROTOCOLS [401].""" + response = client.post( + "/protocol/test/sample", + json={"protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value}, + headers=analyst_headers, + ) + + assert response.status_code == 401 + + +def test_sample_returns_individual_prescriptions( + client, admin_headers, traced_prescription +): + """An individual-type protocol samples non-aggregated prescriptions.""" + response = client.post( + "/protocol/test/sample", + json={ + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "idSegment": traced_prescription.idSegment, + "limit": 200, + }, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.get_json()["data"] + + assert str(traced_prescription.id) in data["idPrescriptionList"] + assert data["total"] == len(data["idPrescriptionList"]) + + +def test_sample_excludes_aggregated_when_type_is_individual( + client, admin_headers, traced_prescription +): + """An aggregated-type protocol does not sample individual prescriptions.""" + response = client.post( + "/protocol/test/sample", + json={ + "protocolType": ProtocolTypeEnum.PRESCRIPTION_AGG.value, + "idSegment": traced_prescription.idSegment, + "limit": 200, + }, + headers=admin_headers, + ) + + assert response.status_code == 200 + assert ( + str(traced_prescription.id) + not in response.get_json()["data"]["idPrescriptionList"] + ) + + +def test_sample_respects_limit(client, admin_headers, traced_prescription): + """limit caps how many prescriptions come back.""" + response = client.post( + "/protocol/test/sample", + json={ + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "limit": 1, + }, + headers=admin_headers, + ) + + assert response.status_code == 200 + assert len(response.get_json()["data"]["idPrescriptionList"]) <= 1 + + +def test_sample_rejects_limit_above_maximum(client, admin_headers): + """The sample size is bounded by the request model [400].""" + response = client.post( + "/protocol/test/sample", + json={ + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "limit": 500, + }, + headers=admin_headers, + ) + + assert response.status_code == 400 + + +# --- /protocol/test ----------------------------------------------------------- + + +def _test_body(config, id_prescription, **extra): + """Body for the unsaved-config test endpoint.""" + body = { + "config": config, + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "idPrescriptionList": [id_prescription], + } + body.update(extra) + + return body + + +def test_test_protocol_permission_denied(client, analyst_headers, traced_prescription): + """Testing a config requires WRITE_PROTOCOLS [401].""" + response = client.post( + "/protocol/test", + json=_test_body(_ACTIVATED_CONFIG, traced_prescription.id), + headers=analyst_headers, + ) + + assert response.status_code == 401 + + +def test_test_protocol_compact_result(client, admin_headers, traced_prescription): + """By default each prescription reports only whether the config activated.""" + response = client.post( + "/protocol/test", + json=_test_body(_ACTIVATED_CONFIG, traced_prescription.id), + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.get_json()["data"] + + assert data["evaluatedAt"] + assert len(data["results"]) == 1 + + result = data["results"][0] + assert result["idPrescription"] == str(traced_prescription.id) + assert result["activated"] is True + assert result["typeMatch"] is True + assert result["error"] is None + # the compact shape carries no trace and no variable detail + assert "trace" not in result + assert set(result["dateGroups"][0]) == {"date", "activated", "summary", "error"} + + +def test_test_protocol_not_activated(client, admin_headers, traced_prescription): + """A config whose trigger stays false reports activated=False.""" + response = client.post( + "/protocol/test", + json=_test_body(_NOT_ACTIVATED_CONFIG, traced_prescription.id), + headers=admin_headers, + ) + + assert response.status_code == 200 + assert response.get_json()["data"]["results"][0]["activated"] is False + + +def test_test_protocol_detailed_result(client, admin_headers, traced_prescription): + """detailed adds the full trace, in the same shape as the trace endpoint.""" + response = client.post( + "/protocol/test", + json=_test_body(_ACTIVATED_CONFIG, traced_prescription.id, detailed=True), + headers=admin_headers, + ) + + assert response.status_code == 200 + result = response.get_json()["data"]["results"][0] + + trace = result["trace"] + assert trace["idPrescription"] == str(traced_prescription.id) + + protocol = trace["protocols"][0] + # an unsaved config has no id yet and is reported as staging + assert protocol["idProtocol"] == 0 + assert protocol["name"] == "Protocolo em teste" + assert protocol["statusType"] == ProtocolStatusTypeEnum.STAGING.value + assert protocol["applicable"] is True + + group = protocol["dateGroups"][0] + assert group["activated"] is True + assert [v["name"] for v in group["variables"]] == ["presente"] + + +def test_test_protocol_uses_the_given_name(client, admin_headers, traced_prescription): + """The name under test shows up in the trace and in the group summary.""" + response = client.post( + "/protocol/test", + json=_test_body( + _ACTIVATED_CONFIG, + traced_prescription.id, + detailed=True, + name="ZZTest Rascunho", + ), + headers=admin_headers, + ) + + assert response.status_code == 200 + protocol = response.get_json()["data"]["results"][0]["trace"]["protocols"][0] + + assert protocol["name"] == "ZZTest Rascunho" + assert "ZZTest Rascunho" in protocol["dateGroups"][0]["summary"] + + +def test_test_protocol_reports_incompatible_type( + client, admin_headers, traced_prescription +): + """A type mismatch is informational: the config still runs.""" + response = client.post( + "/protocol/test", + json=_test_body( + _ACTIVATED_CONFIG, + traced_prescription.id, + protocolType=ProtocolTypeEnum.PRESCRIPTION_AGG.value, + detailed=True, + ), + headers=admin_headers, + ) + + assert response.status_code == 200 + result = response.get_json()["data"]["results"][0] + + assert result["typeMatch"] is False + assert result["activated"] is True + assert result["trace"]["protocols"][0]["applicabilityNotes"] + + +def test_test_protocol_reports_invalid_config_per_group( + client, admin_headers, traced_prescription +): + """A config the evaluator cannot run reports the error, not a 500.""" + response = client.post( + "/protocol/test", + json=_test_body(_BROKEN_CONFIG, traced_prescription.id), + headers=admin_headers, + ) + + assert response.status_code == 200 + result = response.get_json()["data"]["results"][0] + + assert result["activated"] is False + assert "Configuração do protocolo inválida" in result["dateGroups"][0]["error"] + + +def test_test_protocol_isolates_a_bad_prescription_id( + client, admin_headers, traced_prescription +): + """One unknown id does not break the rest of the chunk.""" + response = client.post( + "/protocol/test", + json={ + "config": _ACTIVATED_CONFIG, + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "idPrescriptionList": [traced_prescription.id, 999999999], + }, + headers=admin_headers, + ) + + assert response.status_code == 200 + results = response.get_json()["data"]["results"] + + assert results[0]["activated"] is True + assert results[1]["idPrescription"] == "999999999" + assert results[1]["error"] + + +def test_test_protocol_requires_a_prescription(client, admin_headers): + """An empty prescription list is rejected by the request model [400].""" + response = client.post( + "/protocol/test", + json={ + "config": _ACTIVATED_CONFIG, + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "idPrescriptionList": [], + }, + headers=admin_headers, + ) + + assert response.status_code == 400 + + +def test_test_protocol_limits_the_chunk_size(client, admin_headers): + """At most ten prescriptions can be evaluated per call [400].""" + response = client.post( + "/protocol/test", + json={ + "config": _ACTIVATED_CONFIG, + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "idPrescriptionList": list(range(1, 12)), + }, + headers=admin_headers, + ) + + assert response.status_code == 400 + + +def test_test_protocol_rejects_incomplete_config(client, admin_headers): + """A config missing the trigger is rejected by the request model [400].""" + response = client.post( + "/protocol/test", + json={ + "config": {"variables": [], "result": _RESULT}, + "protocolType": ProtocolTypeEnum.PRESCRIPTION_INDIVIDUAL.value, + "idPrescriptionList": [1], + }, + headers=admin_headers, + ) + + assert response.status_code == 400