init public release

This commit is contained in:
2026-09-08 10:59:05 +02:00
commit 9cdf012717
133 changed files with 2950635 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
"""Comparison tests: batched vs. unbatched runs of ``bundles_to_graph``.
Batching is driven by ``BATCH_SIZE``. The function accumulates patients into
a graph and flushes (process_references -> property_convolution ->
run_biocypher) every ``BATCH_SIZE`` patients, and once more at the end via
the ``c == n`` guard. "With batching" uses a small size; "without" uses a
size >= the patient count so there is a single flush.
We fake the collaborators: ``get_patient_everything`` returns a trivial
bundle, ``add_json_to_networkx`` adds exactly one node per patient (keyed by
the bundle name the pipeline builds, e.g. "p0_bundle"), and ``run_biocypher``
records a snapshot of the node set it is handed. That snapshot per flush is
what we compare.
"""
from unittest import mock
import pytest
import import_fhir_to_nx_diGraph as pipeline
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _run(ids, batch_size, monkeypatch):
"""Run bundles_to_graph and return the node set written at each flush."""
monkeypatch.setenv("BATCH_SIZE", str(batch_size))
monkeypatch.setattr(
pipeline, "get_patient_everything",
lambda pid: FakeResponse({"id": pid}),
)
def add_json(_bundle, name, graph):
graph.add_node(name) # one node per patient bundle
monkeypatch.setattr(
pipeline, "create_graph",
mock.Mock(add_json_to_networkx=add_json),
)
monkeypatch.setattr(pipeline, "process_references", lambda g: None)
monkeypatch.setattr(pipeline, "property_convolution", lambda g: None)
flushes = []
monkeypatch.setattr(
pipeline, "run_biocypher",
lambda graph: flushes.append(set(graph.nodes())),
)
pipeline.bundles_to_graph(ids, len(ids))
return flushes
# A big batch size relative to n => a single flush => "no batching".
NO_BATCHING = 10_000
def test_batched_and_unbatched_write_the_same_nodes(monkeypatch):
ids = [f"p{i}" for i in range(4)]
expected = {f"p{i}_bundle" for i in range(4)}
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
unbatched = _run(ids, batch_size=NO_BATCHING, monkeypatch=monkeypatch)
# Core invariant: total written content is identical either way.
assert set().union(*batched) == expected
assert unbatched[0] == expected
assert set().union(*batched) == unbatched[0]
def test_batching_changes_flush_count_only(monkeypatch):
ids = [f"p{i}" for i in range(4)]
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
unbatched = _run(ids, batch_size=NO_BATCHING, monkeypatch=monkeypatch)
assert len(batched) == 2 # 4 patients / batch of 2
assert len(unbatched) == 1 # single flush at the end
def test_batches_partition_patients_without_overlap(monkeypatch):
ids = [f"p{i}" for i in range(4)]
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
# Each batch is a fresh graph, so flushes must be disjoint and together
# cover every patient exactly once.
assert batched[0].isdisjoint(batched[1])
assert sum(len(f) for f in batched) == 4
def test_uneven_batch_flushes_the_remainder(monkeypatch):
# 5 patients, batch of 2 => flushes at c=2, c=4, and c==n (the leftover).
ids = [f"p{i}" for i in range(5)]
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
assert [len(f) for f in batched] == [2, 2, 1]
assert set().union(*batched) == {f"p{i}_bundle" for i in range(5)}
@pytest.mark.parametrize("batch_size", [1, 2, 3, 5, NO_BATCHING])
def test_no_patient_is_dropped_for_any_batch_size(batch_size, monkeypatch):
ids = [f"p{i}" for i in range(5)]
flushes = _run(ids, batch_size=batch_size, monkeypatch=monkeypatch)
# Regardless of batch size, the union of all flushes is the full set.
assert set().union(*flushes) == {f"p{i}_bundle" for i in range(5)}
def test_reference_resolution_scope_differs(monkeypatch):
# Each batched flush works on its own graph, so no single flush ever sees
# the whole graph in memory. This does NOT break correctness: cross-batch
# references are carried by dummy target nodes and resolved later by
# neo4j-admin import via id matching across all CSV part files (see
# test_dummy_node_reference_resolution.py). This test just documents the
# in-memory scope; the dummy-node tests cover the actual equivalence.
ids = [f"p{i}" for i in range(4)]
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
unbatched = _run(ids, batch_size=NO_BATCHING, monkeypatch=monkeypatch)
assert max(len(f) for f in batched) < len(ids) # never the full graph
assert len(unbatched[0]) == len(ids) # full graph at once
def test_batch_size_zero_raises(monkeypatch):
# SHARP EDGE: BATCH_SIZE=0 makes `c % batch_size` divide by zero.
ids = [f"p{i}" for i in range(3)]
with pytest.raises(ZeroDivisionError):
_run(ids, batch_size=0, monkeypatch=monkeypatch)
@@ -0,0 +1,129 @@
"""Tests for the dummy-node cross-batch reference mechanism.
The batching design: when a batch references a target that lives in another
batch, a *dummy* target node is created carrying the real target's id. On the
node side dummies are skipped (``node_generator`` drops ``label == 'dummy'``),
but the *edge* is still emitted with the real endpoint id. BioCypher appends
node/edge CSV part files after every batch, and a single ``neo4j-admin
import`` at the end stitches edges to nodes by id. So as long as the real
target lands in *some* batch, the edge resolves at import time.
These tests model that by emitting nodes/edges per batch and accumulating the
result, mimicking the appended CSVs that the final import consumes.
"""
import networkx as nx
import import_fhir_to_nx_diGraph as pipeline
def _emit_batch(graph):
"""Emit one batch the way run_biocypher does: nodes first, then edges."""
nodes = {nid: label for (nid, label, _props) in pipeline.node_generator(graph)}
edges = [
(eid, src, tgt, label)
for (eid, src, tgt, label, _attrs) in pipeline.edge_generator(graph)
]
return nodes, edges
def _accumulate(batches):
"""Union of all batches' CSV part files, as the final import would see."""
all_nodes = {}
all_edges = []
for graph in batches:
nodes, edges = _emit_batch(graph)
all_nodes.update(nodes) # neo4j --skip-duplicate-nodes: union by id
all_edges.extend(edges)
return all_nodes, all_edges
def _batch_with_dummy_target():
"""Batch 1: real Observation O1 referencing P1, which is elsewhere.
``edge_label`` mirrors what ``process_references.py`` always sets on a
real dummy node (the referenced resource type, e.g. "Patient").
"""
g = nx.DiGraph()
g.add_node("obs1", label="observation", unique_id="O1")
g.add_node("dummy_p1", label="dummy", unique_id="P1", edge_label="Patient")
g.add_edge("obs1", "dummy_p1", id="e1")
return g
def _batch_with_real_target():
"""Batch 2: the real Patient node P1 that the dummy stood in for."""
g = nx.DiGraph()
g.add_node("pat1", label="patient", unique_id="P1")
return g
def _unbatched_graph():
"""Everything in one graph: the reference target is the real node."""
g = nx.DiGraph()
g.add_node("obs1", label="observation", unique_id="O1")
g.add_node("pat1", label="patient", unique_id="P1")
g.add_edge("obs1", "pat1", id="e1")
return g
def test_dummy_target_skipped_as_node_but_edge_survives():
nodes, edges = _emit_batch(_batch_with_dummy_target())
assert set(nodes) == {"O1"} # dummy P1 not written as a node
assert len(edges) == 1
_, src, tgt, _ = edges[0]
assert (src, tgt) == ("O1", "P1") # edge keeps the real endpoint id
def test_cross_batch_edge_endpoint_supplied_by_later_batch():
nodes, edges = _accumulate(
[_batch_with_dummy_target(), _batch_with_real_target()]
)
# Real P1 arrives in batch 2; dummy was skipped. Every edge endpoint now
# corresponds to a real node, so the import connects them.
assert set(nodes) == {"O1", "P1"}
endpoints = {s for _, s, _, _ in edges} | {t for _, _, t, _ in edges}
assert endpoints <= set(nodes)
def test_batched_connectivity_matches_unbatched():
batched_nodes, batched_edges = _accumulate(
[_batch_with_dummy_target(), _batch_with_real_target()]
)
full_nodes, full_edges = _emit_batch(_unbatched_graph())
assert set(batched_nodes) == set(full_nodes)
# Connectivity (endpoint pairs) is identical, ignoring relationship type.
assert {(s, t) for _, s, t, _ in batched_edges} == {
(s, t) for _, s, t, _ in full_edges
}
def test_within_batch_edge_has_correct_relationship_label():
# When both endpoints are real in the same batch, the type is correct.
(_, _, _, label, _), = pipeline.edge_generator(_unbatched_graph())
assert label == "Observation_to_Patient"
def test_batched_and_unbatched_produce_identical_edges():
# The full equivalence check: batching must not change the OUTCOME, and
# that includes relationship *types*, not just connectivity. We compare
# the complete edge set (endpoints + relationship label) of a batched run
# against the unbatched run. Since the dummy node carries the real
# target's resource type via `edge_label`, the relationship label is
# derived correctly even while the target is still a dummy, so no
# cross-batch '..._to_Dummy' relationship should ever appear.
_, batched_edges = _accumulate(
[_batch_with_dummy_target(), _batch_with_real_target()]
)
_, full_edges = _emit_batch(_unbatched_graph())
def relationships(edges):
return {(src, tgt, label) for _eid, src, tgt, label in edges}
# No dummy-typed relationship should be produced at all.
assert not any(
label.endswith("_to_Dummy") for _e, _s, _t, label in batched_edges
)
# And the batched edge set must match the unbatched one exactly.
assert relationships(batched_edges) == relationships(full_edges)
+78
View File
@@ -0,0 +1,78 @@
"""Unit tests for ``generate_neo4j_import_script``.
It scans a directory of BioCypher CSV output, classifies each ``*-header.csv``
as a node or relationship, and emits a ``neo4j-admin import`` shell script.
We drive it with a temporary directory so no real ``/neo4j_import`` is needed.
"""
import os
import stat
import import_fhir_to_nx_diGraph as pipeline
def _touch(directory, name):
(directory / name).write_text("")
def _generate(tmp_path):
ret = pipeline.generate_neo4j_import_script(
directory_path=str(tmp_path) + os.sep,
output_file="neo4j-admin-import-call.sh",
)
out = tmp_path / "neo4j-admin-import-call.sh"
return out.read_text(), out, ret
def test_node_with_parts_is_included_as_nodes(tmp_path):
_touch(tmp_path, "Patient-header.csv")
_touch(tmp_path, "Patient-part0.csv")
script, _, _ = _generate(tmp_path)
assert '--nodes="/neo4j_import/Patient-header.csv,/neo4j_import/Patient-part.*"' in script
def test_association_is_classified_as_relationship(tmp_path):
_touch(tmp_path, "PatientToObservationAssociation-header.csv")
_touch(tmp_path, "PatientToObservationAssociation-part0.csv")
script, _, _ = _generate(tmp_path)
assert "--relationships=" in script
assert "PatientToObservationAssociation" in script
def test_has_pattern_is_classified_as_relationship(tmp_path):
_touch(tmp_path, "Encounter_has_Diagnosis-header.csv")
_touch(tmp_path, "Encounter_has_Diagnosis-part0.csv")
script, _, _ = _generate(tmp_path)
assert "--relationships=" in script
assert "--nodes=" not in script
def test_header_without_part_files_is_dropped(tmp_path):
# SHARP EDGE: entities are only emitted when a matching *-part* file
# exists. A lone header silently produces no import command. Pinning so
# the behaviour is intentional, not a surprise empty graph.
_touch(tmp_path, "Patient-header.csv") # no part file
script, _, _ = _generate(tmp_path)
assert "--nodes=" not in script
assert "--relationships=" not in script
def test_script_is_written_and_executable(tmp_path):
_touch(tmp_path, "Patient-header.csv")
_touch(tmp_path, "Patient-part0.csv")
_, out, ret = _generate(tmp_path)
assert out.exists()
mode = os.stat(out).st_mode
assert mode & stat.S_IXUSR # owner-executable
# SHARP EDGE: the docstring promises it returns the script path, but the
# function has no `return`, so callers actually get None. Pinning the
# real behaviour; fix the function (or the docstring) and flip this.
assert ret is None
def test_script_contains_version_branching(tmp_path):
_touch(tmp_path, "Patient-header.csv")
_touch(tmp_path, "Patient-part0.csv")
script, _, _ = _generate(tmp_path)
assert "neo4j-admin database import full" in script # v5+ branch
assert "neo4j-admin import --database=neo4j" in script # legacy branch
+113
View File
@@ -0,0 +1,113 @@
"""Unit tests for ``node_generator`` and ``edge_generator``.
Both take a populated ``networkx.DiGraph`` and yield the tuples BioCypher
expects. The label-normalisation logic (capitalize, ``resource`` ->
``resourceType``, skipping dummy/search/meta/link) is the interesting part.
"""
import networkx as nx
import pytest
import import_fhir_to_nx_diGraph as pipeline
def _emit(generator):
return list(generator)
# --- node_generator --------------------------------------------------------
def test_resource_node_uses_resource_type_as_label():
g = nx.DiGraph()
g.add_node("p1", label="resource", resourceType="Patient", unique_id="P-1")
(node_id, label, props), = _emit(pipeline.node_generator(g))
assert node_id == "P-1"
assert label == "Patient"
def test_label_is_capitalized():
g = nx.DiGraph()
g.add_node("o1", label="observation")
(_, label, _), = _emit(pipeline.node_generator(g))
assert label == "Observation"
def test_falls_back_to_node_key_without_unique_id():
g = nx.DiGraph()
g.add_node("node-key", label="observation")
(node_id, _, _), = _emit(pipeline.node_generator(g))
assert node_id == "node-key"
@pytest.mark.parametrize("label", ["dummy", "Dummy"])
def test_dummy_nodes_are_skipped(label):
g = nx.DiGraph()
g.add_node("d", label=label)
assert _emit(pipeline.node_generator(g)) == []
@pytest.mark.parametrize("label", ["search", "meta", "link"])
def test_metadata_nodes_are_skipped(label):
g = nx.DiGraph()
g.add_node("m", label=label)
assert _emit(pipeline.node_generator(g)) == []
def test_generator_mutates_graph_label_in_place():
# Documented side effect: the generator rewrites each node's 'label' to
# its normalised form. Worth pinning because anything that iterates the
# graph afterwards sees the mutated value.
g = nx.DiGraph()
g.add_node("o1", label="observation")
_emit(pipeline.node_generator(g))
assert g.nodes["o1"]["label"] == "Observation"
def test_node_without_label_raises():
# KNOWN SHARP EDGE: a node missing both 'label' and 'resourceType' makes
# label None, and None.capitalize() raises. Pinning it as expected
# behaviour; flip this test if you decide such nodes should be skipped.
g = nx.DiGraph()
g.add_node("orphan")
with pytest.raises(AttributeError):
_emit(pipeline.node_generator(g))
# --- edge_generator --------------------------------------------------------
def test_edge_label_combines_endpoint_labels():
g = nx.DiGraph()
g.add_node("a", label="patient")
g.add_node("b", label="observation")
g.add_edge("a", "b", id="e1")
(edge_id, source, target, label, _), = _emit(pipeline.edge_generator(g))
assert (edge_id, source, target, label) == (
"e1", "a", "b", "Patient_to_Observation",
)
def test_edge_resource_endpoints_use_resource_type():
g = nx.DiGraph()
g.add_node("a", label="resource", resourceType="Patient")
g.add_node("b", label="resource", resourceType="Encounter")
g.add_edge("a", "b", id="e1")
(_, _, _, label, _), = _emit(pipeline.edge_generator(g))
assert label == "Patient_to_Encounter"
def test_edge_without_id_gets_generated_uuid():
g = nx.DiGraph()
g.add_node("a", label="patient")
g.add_node("b", label="observation")
g.add_edge("a", "b")
(edge_id, *_), = _emit(pipeline.edge_generator(g))
assert isinstance(edge_id, str) and len(edge_id) == 36 # uuid4 string
def test_edge_uses_unique_id_for_endpoints_when_present():
g = nx.DiGraph()
g.add_node("a", label="patient", unique_id="P-1")
g.add_node("b", label="observation", unique_id="O-1")
g.add_edge("a", "b", id="e1")
(_, source, target, _, _), = _emit(pipeline.edge_generator(g))
assert (source, target) == ("P-1", "O-1")
+136
View File
@@ -0,0 +1,136 @@
"""Unit tests for ``graphCreation.create_graph``.
This module turns a JSON bundle into a networkx DiGraph:
- a dict value becomes a child node ``{parent}.{key}`` (label = key) with an
edge carrying ``edge_type = key``;
- a list with no dicts is stored as a plain attribute on the parent;
- a list containing dicts becomes one child node per dict (indexed when the
list has more than one element);
- any other scalar becomes an attribute on the parent.
Pure logic, only networkx -- no stubs or mocks needed. These tests import the
real module directly (conftest prefers the real package when present).
"""
import networkx as nx
import pytest
from graphCreation.create_graph import (
add_json_to_networkx,
add_nodes_from_dict,
process_dictionaries,
)
def _graph_from(json_data, name="root"):
g = nx.DiGraph()
add_json_to_networkx(json_data, name, g)
return g
# --- entry point / guards --------------------------------------------------
def test_rejects_non_digraph():
with pytest.raises(ValueError):
add_json_to_networkx({}, "root", nx.Graph()) # undirected, not DiGraph
def test_root_node_created_with_root_label():
g = _graph_from({})
assert g.nodes["root_bundle"]["label"] == "root"
def test_bundle_suffix_is_always_appended():
# SHARP EDGE: the function appends '_bundle' to whatever name it's given.
# bundles_to_graph already passes ``id + '_bundle'``, so the in-graph root
# ends up doubly suffixed (e.g. 'patient1_bundle_bundle'). Harmless while
# consistent, but surprising if anything looks the root up by id.
g = _graph_from({}, name="patient1_bundle")
assert "patient1_bundle_bundle" in g.nodes
# --- scalars ---------------------------------------------------------------
def test_scalar_values_become_parent_attributes():
g = _graph_from({"id": "123", "active": True, "count": 5})
attrs = g.nodes["root_bundle"]
assert attrs["id"] == "123"
assert attrs["active"] is True
assert attrs["count"] == 5
# --- nested dicts ----------------------------------------------------------
def test_nested_dict_becomes_child_node_with_edge():
g = _graph_from({"name": {"family": "Mueller", "given": "Anna"}})
assert "root_bundle.name" in g.nodes
assert g.nodes["root_bundle.name"]["label"] == "name"
assert g.nodes["root_bundle.name"]["family"] == "Mueller"
assert g.edges["root_bundle", "root_bundle.name"]["edge_type"] == "name"
def test_deeply_nested_dicts_chain_through_nodes():
g = _graph_from({"a": {"b": {"c": 1}}})
assert {"root_bundle", "root_bundle.a", "root_bundle.a.b"} <= set(g.nodes)
assert g.nodes["root_bundle.a.b"]["c"] == 1
assert g.has_edge("root_bundle.a", "root_bundle.a.b")
# --- lists -----------------------------------------------------------------
def test_list_of_scalars_stored_as_attribute():
g = _graph_from({"tags": ["a", "b", "c"]})
assert g.nodes["root_bundle"]["tags"] == ["a", "b", "c"]
assert list(g.nodes) == ["root_bundle"] # no child nodes created
def test_empty_list_stored_as_attribute():
g = _graph_from({"items": []})
assert g.nodes["root_bundle"]["items"] == []
def test_single_dict_list_has_no_index_suffix():
g = _graph_from({"contact": [{"phone": "555"}]})
assert "root_bundle.contact" in g.nodes
assert "root_bundle.contact[0]" not in g.nodes
assert g.nodes["root_bundle.contact"]["phone"] == "555"
def test_multi_dict_list_creates_indexed_nodes():
g = _graph_from({"entries": [{"x": 1}, {"y": 2}]})
assert "root_bundle.entries[0]" in g.nodes
assert "root_bundle.entries[1]" in g.nodes
assert g.nodes["root_bundle.entries[0]"]["x"] == 1
assert g.nodes["root_bundle.entries[1]"]["y"] == 2
def test_mixed_list_silently_drops_non_dict_items():
# FINDING: when a list contains at least one dict, process_dictionaries
# runs and *skips* every non-dict element -- it is neither stored as an
# attribute nor turned into a node. The scalar "lost" below disappears.
# Pinned as current behaviour; flip if such data should be retained.
g = _graph_from({"mixed": ["lost", {"k": "v"}]})
assert "root_bundle.mixed[1]" in g.nodes # the dict survives (indexed)
assert g.nodes["root_bundle.mixed[1]"]["k"] == "v"
assert "mixed" not in g.nodes["root_bundle"] # not stored as an attribute
# The scalar appears nowhere in the graph.
all_attr_values = [v for _, data in g.nodes(data=True) for v in data.values()]
assert "lost" not in all_attr_values
# --- helpers used directly -------------------------------------------------
def test_add_nodes_from_dict_attaches_to_given_parent():
g = nx.DiGraph()
g.add_node("p", label="root")
add_nodes_from_dict(g, "p", {"status": "final"})
assert g.nodes["p"]["status"] == "final"
def test_process_dictionaries_skips_when_no_dicts_present():
# Defensive: passing a dict-free list straight to process_dictionaries
# creates nothing (the inner isinstance check is never satisfied).
g = nx.DiGraph()
g.add_node("p", label="root")
process_dictionaries(["a", "b"], "p", "vals", g)
assert list(g.nodes) == ["p"]
@@ -0,0 +1,125 @@
"""Unit tests for ``load_multiple_fhir_patients``.
This is the meatiest piece of logic in the FHIR path: it pulls patient IDs
from a file *or* the FHIR server, handles pagination via the bundle's
``next`` link, honours TEST_MODE/TEST_DEPTH, and bails on an empty bundle.
The live FHIR client (``get_bundle``) is mocked, so no server is needed.
Note: the module reads TEST_MODE / TEST_DEPTH into module globals at import
time, so we override the globals (``pipeline.is_test`` / ``pipeline.test_depth``)
directly rather than via env vars. ``bundles_to_graph`` is stubbed so these
tests isolate the ID-collection logic.
"""
import pytest
import import_fhir_to_nx_diGraph as pipeline
class FakeResponse:
"""Stand-in for the requests.Response that get_bundle returns."""
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _bundle(ids, next_url=None):
payload = {
"entry": [{"resource": {"id": i}} for i in ids],
"link": [],
}
if next_url:
payload["link"].append({"relation": "next", "url": next_url})
return FakeResponse(payload)
@pytest.fixture
def captured(monkeypatch):
"""Isolate the function: capture get_bundle calls and the final hand-off."""
calls = {"get_bundle": [], "bundles_to_graph": []}
def fake_get_bundle(link, query):
calls["get_bundle"].append((link, query))
return calls["get_bundle_returns"].pop(0)
def fake_bundles_to_graph(ids, n):
calls["bundles_to_graph"].append((list(ids), n))
monkeypatch.setattr(pipeline, "get_bundle", fake_get_bundle)
monkeypatch.setattr(pipeline, "bundles_to_graph", fake_bundles_to_graph)
# Default: not in test mode (override per-test as needed).
monkeypatch.setattr(pipeline, "is_test", None)
monkeypatch.setattr(pipeline, "test_depth", None)
# Clear env that influences query selection / file loading.
for var in ("COMPLEX_PATIENTS", "SELECTED_PATIENTS_FILE"):
monkeypatch.delenv(var, raising=False)
return calls
def test_loads_ids_from_file_without_calling_server(captured, tmp_path, monkeypatch):
id_file = tmp_path / "patients.txt"
id_file.write_text("p1\np2\n\np3\n") # blank line is skipped
monkeypatch.setenv("SELECTED_PATIENTS_FILE", str(id_file))
pipeline.load_multiple_fhir_patients(10)
assert captured["get_bundle"] == [] # server never touched
ids, _ = captured["bundles_to_graph"][0]
assert ids == ["p1", "p2", "p3"]
def test_complex_disabled_uses_plain_count_query(captured, monkeypatch):
monkeypatch.setenv("COMPLEX_PATIENTS", "FALSE")
captured["get_bundle_returns"] = [_bundle(["a", "b"])]
pipeline.load_multiple_fhir_patients(2)
_, query = captured["get_bundle"][0]
assert query == "/Patient?_count=2"
def test_default_uses_complex_observation_query(captured):
captured["get_bundle_returns"] = [_bundle(["a", "b"])]
pipeline.load_multiple_fhir_patients(2)
_, query = captured["get_bundle"][0]
assert "_has:Observation:subject:status=final" in query
def test_follows_next_link_until_enough_ids(captured):
captured["get_bundle_returns"] = [
_bundle(["a", "b"], next_url="http://fhir/next"),
_bundle(["c", "d"]),
]
pipeline.load_multiple_fhir_patients(3)
# Two pages fetched; second call used the next link, no query.
assert len(captured["get_bundle"]) == 2
assert captured["get_bundle"][1] == ("http://fhir/next", None)
ids, _ = captured["bundles_to_graph"][0]
assert ids == ["a", "b", "c", "d"]
def test_empty_bundle_exits_with_error(captured):
captured["get_bundle_returns"] = [FakeResponse({"link": []})] # no 'entry'
with pytest.raises(SystemExit) as exc:
pipeline.load_multiple_fhir_patients(1)
assert exc.value.code == 1
assert captured["bundles_to_graph"] == [] # never reached graph build
def test_test_depth_one_exits_before_building_graph(captured, monkeypatch):
monkeypatch.setattr(pipeline, "is_test", "1")
monkeypatch.setattr(pipeline, "test_depth", "1")
captured["get_bundle_returns"] = [_bundle(["a", "b"])]
with pytest.raises(SystemExit) as exc:
pipeline.load_multiple_fhir_patients(2)
assert exc.value.code == 0
assert captured["bundles_to_graph"] == [] # short-circuited
+58
View File
@@ -0,0 +1,58 @@
"""Orchestration test for ``multiple_adapters.main``.
``main`` is thin glue, so we mock every collaborator and assert the wiring:
which adapter runs, the schema/script calls, and the sentinel file write.
LIMITATION: ``adapter_mode`` is a hardcoded local (= 0), so only the FHIR
branch is reachable. The mode-1 (XML) and mode--1 (both) tests below are
written but skipped — un-skip them once ``main`` accepts ``adapter_mode`` as
a parameter or reads it from the environment. They document the intended
behaviour and become live the moment the refactor lands.
"""
from unittest import mock
import pytest
import multiple_adapters
@pytest.fixture
def mocked(monkeypatch):
m = mock.Mock()
monkeypatch.setattr(multiple_adapters, "BioCypher", m.BioCypher)
monkeypatch.setattr(multiple_adapters, "write_automated_schema", m.write_automated_schema)
monkeypatch.setattr(multiple_adapters, "load_multiple_fhir_patients", m.load_fhir)
monkeypatch.setattr(multiple_adapters, "generate_neo4j_import_script", m.gen_script)
monkeypatch.setattr(multiple_adapters, "xp", m.xp)
monkeypatch.setenv("NUMBER_OF_PATIENTS", "5")
return m
def test_mode_zero_runs_fhir_loader_and_writes_sentinel(mocked):
open_mock = mock.mock_open()
with mock.patch("builtins.open", open_mock):
multiple_adapters.main()
mocked.load_fhir.assert_called_once_with(5)
mocked.xp.parse_xml_generate_nodes.assert_not_called() # XML branch off
mocked.write_automated_schema.assert_called_once()
mocked.gen_script.assert_called_once()
open_mock.assert_called_once_with("/neo4j_import/shell-scipt-complete", "w")
def test_number_of_patients_unset_raises(mocked, monkeypatch):
monkeypatch.delenv("NUMBER_OF_PATIENTS", raising=False)
with mock.patch("builtins.open", mock.mock_open()):
with pytest.raises(TypeError): # int(None)
multiple_adapters.main()
@pytest.mark.skip(reason="adapter_mode is hardcoded to 0; un-skip after making it configurable")
def test_mode_one_runs_xml_adapter(mocked):
open_mock = mock.mock_open()
with mock.patch("builtins.open", open_mock):
multiple_adapters.main() # would need adapter_mode == 1
mocked.xp.parse_xml_generate_nodes.assert_called_once()
mocked.xp.parse_xml_generate_edges.assert_called_once()
mocked.load_fhir.assert_not_called()
+63
View File
@@ -0,0 +1,63 @@
"""Unit tests for ``replace_single_quotes``.
This is the cleanest function to test: pure, recursive, no I/O. It escapes
single quotes (``'`` -> ``''``) for the Neo4j import, recursing through
dicts and lists and leaving non-strings untouched.
"""
import pytest
from hypothesis import given, strategies as st
import import_fhir_to_nx_diGraph as pipeline
def test_single_quote_in_string_is_doubled():
assert pipeline.replace_single_quotes("O'Brien") == "O''Brien"
def test_string_without_quotes_unchanged():
assert pipeline.replace_single_quotes("Mueller") == "Mueller"
def test_empty_string_unchanged():
assert pipeline.replace_single_quotes("") == ""
@pytest.mark.parametrize("value", [0, 1, 3.14, None, True, False])
def test_non_string_scalars_unchanged(value):
assert pipeline.replace_single_quotes(value) is value
def test_nested_dict_recurses():
data = {"name": "O'Hara", "nested": {"note": "it's fine"}}
assert pipeline.replace_single_quotes(data) == {
"name": "O''Hara",
"nested": {"note": "it''s fine"},
}
def test_list_recurses():
assert pipeline.replace_single_quotes(["a'b", 2, "c"]) == ["a''b", 2, "c"]
def test_dict_keys_are_not_escaped():
# Only values are recursed into; keys are left as-is.
result = pipeline.replace_single_quotes({"it's": "fine"})
assert list(result.keys()) == ["it's"]
assert result["it's"] == "fine"
# --- Property-based checks -------------------------------------------------
@given(st.text())
def test_every_quote_is_doubled(text):
# The function is intentionally NOT idempotent: each pass doubles the
# quote count. This pins that contract so a future "fix" is a conscious
# choice rather than a silent behaviour change.
result = pipeline.replace_single_quotes(text)
assert result.count("'") == 2 * text.count("'")
@given(st.lists(st.one_of(st.integers(), st.text())))
def test_list_length_preserved(items):
assert len(pipeline.replace_single_quotes(items)) == len(items)