Client defaults to /api/v3; _typeFilter on start_export/export with type_filter_tag() for the System-Type tag; jobs() sends the bulk-data headers BCDA requires (Prefer: respond-async, Accept: application/fhir+json — the old call 400'd on v2 and v3) and unwraps the Task Bundle; completed_jobs() lists reusable exports; every non-2xx raises BcdaHttpError carrying the OperationOutcome diagnostics (still an httpx.HTTPStatusError); a poll that hits 404 'Job has been cancelled' raises JobCancelledError; /_version and /_health. Store.adopt(client, job_url) records and downloads a job this store did not start (completed jobs stay downloadable 24 h and the public sandbox entity is shared); export(cancel_retries=N) restarts after server-side cancellation, recording each 'cancelled' transition. bcda.express.v3 flattens C4BB 2.2.0 resources into the existing bcda.* tables, table-driven from the v3 Data Dictionary: v3 moved every BlueButton code off resources/variables/<name> onto fhir/StructureDefinition|CodeSystem/<CODE> and consolidated amounts into adjudication/total/item.adjudication, so the v2 suffix match returned NULL for almost everything. Dispatch is per resource (is_v3); v2 payloads are unchanged. Identity follows the migration guide: bene_id = Patient.id (BENE_SK), current MBI = us-mbi identifier without period.end, clm_id = C4BB 'uc', clm_cntl_num pairs SharedSystem with NCH. Race crosswalked from US Core OMB codes; Coverage is one row per plan+period (Part A/B/C/D + Dual) with v3-native columns; EOB gains System-Type / Final-Action / EOB-TYPE, IDR dates, header totals and PPS/IME/DSH amounts; items gain NDC quantity, revenue-centre indicators, ANSI codes, Part D/DME amounts. flatten_export streams <Type>.N.ndjson parts to Parquet with model-derived Arrow schemas (a Large-entity EOB export is 192 files / 2.2 GB); load_bcda() gathers the newest export dir per resource type (a v3 pull is three single-type jobs). cclf.stg_beneficiary_demographics prefers the Part A row and joins the Dual row; eligibility reads coalesce(dual_01, dual_stus_cd). bcda package at 100% line coverage; tests run on CMS's official v3 samples. refs #1139
834 lines
27 KiB
Python
834 lines
27 KiB
Python
"""Tests for bcda.store — persistent storage and state management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pyarrow as pa
|
|
import pyarrow.compute # noqa: F401 — ensure pa.compute is loaded
|
|
import pyarrow.parquet as pq
|
|
import pytest
|
|
|
|
from bcda.store import (
|
|
FILES_SCHEMA,
|
|
JOBS_SCHEMA,
|
|
Store,
|
|
_current_state,
|
|
_generate_key,
|
|
_now,
|
|
)
|
|
|
|
# ── Module-level helpers ─────────────────────────────────────
|
|
|
|
|
|
class TestHelpers:
|
|
def test_now_returns_utc(self) -> None:
|
|
ts = _now()
|
|
assert ts.tzinfo is not None
|
|
assert ts.tzname() == "UTC"
|
|
|
|
def test_generate_key_length(self) -> None:
|
|
key = _generate_key()
|
|
assert len(key) == 8
|
|
assert key.isalnum()
|
|
|
|
def test_generate_key_unique(self) -> None:
|
|
keys = {_generate_key() for _ in range(50)}
|
|
# Extremely unlikely to get <40 unique keys from 50 trials.
|
|
assert len(keys) > 40
|
|
|
|
def test_current_state_empty(self) -> None:
|
|
table = JOBS_SCHEMA.empty_table()
|
|
result = _current_state(table, "key")
|
|
assert result.num_rows == 0
|
|
|
|
def test_current_state_dedup(self) -> None:
|
|
from datetime import datetime, timezone
|
|
|
|
rows = [
|
|
{
|
|
"ts": datetime(2025, 1, 1, tzinfo=timezone.utc),
|
|
"key": "A",
|
|
"job_url": "",
|
|
"endpoint": "",
|
|
"types": "",
|
|
"since": "",
|
|
"status": "pending",
|
|
"error": "",
|
|
},
|
|
{
|
|
"ts": datetime(2025, 1, 2, tzinfo=timezone.utc),
|
|
"key": "A",
|
|
"job_url": "url",
|
|
"endpoint": "",
|
|
"types": "",
|
|
"since": "",
|
|
"status": "complete",
|
|
"error": "",
|
|
},
|
|
{
|
|
"ts": datetime(2025, 1, 1, tzinfo=timezone.utc),
|
|
"key": "B",
|
|
"job_url": "",
|
|
"endpoint": "",
|
|
"types": "",
|
|
"since": "",
|
|
"status": "failed",
|
|
"error": "err",
|
|
},
|
|
]
|
|
table = pa.Table.from_pylist(rows, schema=JOBS_SCHEMA)
|
|
result = _current_state(table, "key")
|
|
assert result.num_rows == 2
|
|
pylist = result.to_pylist()
|
|
statuses = {r["key"]: r["status"] for r in pylist}
|
|
assert statuses["A"] == "complete"
|
|
assert statuses["B"] == "failed"
|
|
|
|
|
|
# ── Store filesystem ─────────────────────────────────────────
|
|
|
|
|
|
class TestStoreFilesystem:
|
|
def test_local_path(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
fs = store._fs()
|
|
assert fs is not None
|
|
# Cached on second call.
|
|
assert store._fs() is fs
|
|
|
|
def test_full(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
assert store._full("a", "b").endswith("/a/b")
|
|
|
|
def test_ensure_dir(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
store._ensure_dir(str(tmp_path / "store" / "nested"))
|
|
assert (tmp_path / "store" / "nested").exists()
|
|
|
|
|
|
# ── Parquet I/O ──────────────────────────────────────────────
|
|
|
|
|
|
class TestParquetIO:
|
|
def test_read_nonexistent(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
table = store._read_parquet(
|
|
str(tmp_path / "store" / "missing.parquet"),
|
|
JOBS_SCHEMA,
|
|
)
|
|
assert table.num_rows == 0
|
|
assert table.schema.equals(JOBS_SCHEMA)
|
|
|
|
def test_read_existing(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
path = str(tmp_path / "store" / "test.parquet")
|
|
(tmp_path / "store").mkdir()
|
|
table = JOBS_SCHEMA.empty_table()
|
|
with open(path, "wb") as f:
|
|
pq.write_table(table, f)
|
|
result = store._read_parquet(path, JOBS_SCHEMA)
|
|
assert result.num_rows == 0
|
|
|
|
def test_append_parquet_creates(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
path = str(tmp_path / "store" / "jobs.parquet")
|
|
store._append_parquet(
|
|
path,
|
|
JOBS_SCHEMA,
|
|
[
|
|
{
|
|
"ts": _now(),
|
|
"key": "X",
|
|
"job_url": "",
|
|
"endpoint": "Group/all",
|
|
"types": "",
|
|
"since": "",
|
|
"status": "pending",
|
|
"error": "",
|
|
}
|
|
],
|
|
)
|
|
table = pq.read_table(path, schema=JOBS_SCHEMA)
|
|
assert table.num_rows == 1
|
|
|
|
def test_append_parquet_appends(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
path = str(tmp_path / "store" / "jobs.parquet")
|
|
row = {
|
|
"ts": _now(),
|
|
"key": "Y",
|
|
"job_url": "",
|
|
"endpoint": "",
|
|
"types": "",
|
|
"since": "",
|
|
"status": "pending",
|
|
"error": "",
|
|
}
|
|
store._append_parquet(path, JOBS_SCHEMA, [row])
|
|
store._append_parquet(path, JOBS_SCHEMA, [row])
|
|
table = pq.read_table(path, schema=JOBS_SCHEMA)
|
|
assert table.num_rows == 2
|
|
|
|
|
|
class TestAppendJobFile:
|
|
def test_append_job(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(key="K1", status="pending")
|
|
table = pq.read_table(
|
|
str(tmp_path / "store" / "jobs.parquet"),
|
|
schema=JOBS_SCHEMA,
|
|
)
|
|
assert table.num_rows == 1
|
|
row = table.to_pylist()[0]
|
|
assert row["key"] == "K1"
|
|
assert row["status"] == "pending"
|
|
# Defaults fill in missing columns.
|
|
assert row["job_url"] == ""
|
|
|
|
def test_append_file(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
store._append_file(
|
|
job_key="K1",
|
|
resource_type="Patient",
|
|
url="https://x/data",
|
|
status="pending",
|
|
)
|
|
table = pq.read_table(
|
|
str(tmp_path / "store" / "files.parquet"),
|
|
schema=FILES_SCHEMA,
|
|
)
|
|
assert table.num_rows == 1
|
|
row = table.to_pylist()[0]
|
|
assert row["resource_type"] == "Patient"
|
|
assert row["file_size"] == 0
|
|
|
|
|
|
# ── Query state ──────────────────────────────────────────────
|
|
|
|
|
|
class TestQueryState:
|
|
def test_get_job(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(key="K1", status="pending")
|
|
store._append_job(key="K1", status="complete")
|
|
job = store.get_job("K1")
|
|
assert job["status"] == "complete"
|
|
|
|
def test_get_job_not_found(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
with pytest.raises(KeyError, match="Job not found"):
|
|
store.get_job("NOPE")
|
|
|
|
def test_list_jobs_all(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(key="A", status="complete")
|
|
store._append_job(key="B", status="failed")
|
|
jobs = store.list_jobs()
|
|
assert len(jobs) == 2
|
|
|
|
def test_list_jobs_filtered(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(key="A", status="complete")
|
|
store._append_job(key="B", status="failed")
|
|
jobs = store.list_jobs(status="failed")
|
|
assert len(jobs) == 1
|
|
assert jobs[0]["key"] == "B"
|
|
|
|
def test_get_files(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
store._append_file(
|
|
job_key="K1",
|
|
url="https://x/1",
|
|
resource_type="Patient",
|
|
status="pending",
|
|
)
|
|
store._append_file(
|
|
job_key="K1",
|
|
url="https://x/1",
|
|
resource_type="Patient",
|
|
status="complete",
|
|
)
|
|
store._append_file(
|
|
job_key="K2",
|
|
url="https://x/2",
|
|
resource_type="EOB",
|
|
status="pending",
|
|
)
|
|
files = store.get_files("K1")
|
|
assert len(files) == 1
|
|
assert files[0]["status"] == "complete"
|
|
|
|
|
|
# ── File storage ─────────────────────────────────────────────
|
|
|
|
|
|
class TestStoreFile:
|
|
def test_store_file(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
local = tmp_path / "local.ndjson"
|
|
local.write_text('{"id":"1"}\n')
|
|
path = store._store_file(local, "K1", "Patient.ndjson")
|
|
assert "exports/K1/Patient.ndjson" in path
|
|
# Verify the file exists in storage.
|
|
assert store._fs().exists(path)
|
|
|
|
def test_open(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
local = tmp_path / "local.ndjson"
|
|
local.write_text('{"id":"1"}\n')
|
|
path = store._store_file(local, "K1", "Patient.ndjson")
|
|
with store.open(path) as f:
|
|
content = f.read()
|
|
assert b'{"id":"1"}' in content
|
|
|
|
|
|
# ── Export with state tracking ───────────────────────────────
|
|
|
|
|
|
class TestExport:
|
|
def test_success(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {
|
|
"output": [
|
|
{"url": "https://x/data/Patient", "type": "Patient"},
|
|
],
|
|
}
|
|
|
|
# _download_file creates a file at the destination path.
|
|
def fake_download(url, dest):
|
|
dest.write_text('{"id":"1"}\n')
|
|
|
|
client._download_file.side_effect = fake_download
|
|
|
|
key = store.export(
|
|
client,
|
|
types=["Patient"],
|
|
since="2025-01-01",
|
|
)
|
|
assert len(key) == 8
|
|
|
|
job = store.get_job(key)
|
|
assert job["status"] == "complete"
|
|
|
|
files = store.get_files(key)
|
|
assert len(files) == 1
|
|
assert files[0]["status"] == "complete"
|
|
|
|
def test_start_export_failure(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
client = MagicMock()
|
|
client.start_export.side_effect = RuntimeError("network error")
|
|
|
|
with pytest.raises(RuntimeError, match="network error"):
|
|
store.export(client)
|
|
|
|
jobs = store.list_jobs(status="failed")
|
|
assert len(jobs) == 1
|
|
|
|
def test_poll_failure(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.side_effect = RuntimeError("timeout")
|
|
|
|
with pytest.raises(RuntimeError, match="timeout"):
|
|
store.export(client)
|
|
|
|
jobs = store.list_jobs(status="failed")
|
|
assert len(jobs) == 1
|
|
|
|
def test_download_failure(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {
|
|
"output": [
|
|
{"url": "https://x/data/P", "type": "Patient"},
|
|
],
|
|
}
|
|
client._download_file.side_effect = RuntimeError("disk full")
|
|
|
|
with pytest.raises(RuntimeError, match="disk full"):
|
|
store.export(client)
|
|
|
|
files = store.get_files(store.list_jobs(status="failed")[0]["key"])
|
|
assert any(f["status"] == "failed" for f in files)
|
|
|
|
def test_no_types(self, tmp_path) -> None:
|
|
"""Export with types=None passes empty string."""
|
|
store = Store(tmp_path / "store")
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {"output": []}
|
|
key = store.export(client)
|
|
job = store.get_job(key)
|
|
assert job["types"] == ""
|
|
|
|
def test_multiple_outputs(self, tmp_path) -> None:
|
|
"""Multiple output files with duplicate types."""
|
|
store = Store(tmp_path / "store")
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {
|
|
"output": [
|
|
{"url": "https://x/1", "type": "EOB"},
|
|
{"url": "https://x/2", "type": "EOB"},
|
|
],
|
|
}
|
|
|
|
def fake_download(url, dest):
|
|
dest.write_text("{}\n")
|
|
|
|
client._download_file.side_effect = fake_download
|
|
|
|
key = store.export(client)
|
|
files = store.get_files(key)
|
|
assert len(files) == 2
|
|
assert all(f["status"] == "complete" for f in files)
|
|
|
|
def test_unknown_type(self, tmp_path) -> None:
|
|
"""Output entry with missing type defaults to 'unknown'."""
|
|
store = Store(tmp_path / "store")
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {
|
|
"output": [{"url": "https://x/1"}],
|
|
}
|
|
|
|
def fake_download(url, dest):
|
|
dest.write_text("{}\n")
|
|
|
|
client._download_file.side_effect = fake_download
|
|
|
|
key = store.export(client)
|
|
files = store.get_files(key)
|
|
assert files[0]["resource_type"] == "unknown"
|
|
|
|
|
|
# ── Resume ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestResume:
|
|
def test_resume_pending(self, tmp_path) -> None:
|
|
"""Resume from pending status re-starts the export."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
endpoint="Group/all",
|
|
types="Patient",
|
|
since="2025-01-01",
|
|
status="pending",
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {"output": []}
|
|
|
|
key = store.resume(client, "K1")
|
|
assert key == "K1"
|
|
client.start_export.assert_called_once()
|
|
# Status should now be complete.
|
|
job = store.get_job("K1")
|
|
assert job["status"] == "complete"
|
|
|
|
def test_resume_polling(self, tmp_path) -> None:
|
|
"""Resume from polling status re-polls."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
job_url="https://x/jobs/1",
|
|
endpoint="Group/all",
|
|
types="",
|
|
since="",
|
|
status="polling",
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.poll.return_value = {"output": []}
|
|
|
|
key = store.resume(client, "K1")
|
|
assert key == "K1"
|
|
client.poll.assert_called_once()
|
|
|
|
def test_resume_failed(self, tmp_path) -> None:
|
|
"""Resume from failed status re-polls."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
job_url="https://x/jobs/1",
|
|
endpoint="Group/all",
|
|
types="",
|
|
since="",
|
|
status="failed",
|
|
error="timeout",
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.poll.return_value = {"output": []}
|
|
|
|
key = store.resume(client, "K1")
|
|
assert key == "K1"
|
|
job = store.get_job("K1")
|
|
assert job["status"] == "complete"
|
|
|
|
def test_resume_failed_poll_fails_again(self, tmp_path) -> None:
|
|
"""Resume from failed, but poll fails again."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
job_url="https://x/jobs/1",
|
|
endpoint="Group/all",
|
|
types="",
|
|
since="",
|
|
status="failed",
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.poll.side_effect = RuntimeError("still down")
|
|
|
|
with pytest.raises(RuntimeError, match="still down"):
|
|
store.resume(client, "K1")
|
|
|
|
job = store.get_job("K1")
|
|
assert job["status"] == "failed"
|
|
|
|
def test_resume_complete_with_pending_files(self, tmp_path) -> None:
|
|
"""Resume from complete downloads remaining files."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
job_url="https://x/jobs/1",
|
|
endpoint="Group/all",
|
|
types="",
|
|
since="",
|
|
status="complete",
|
|
)
|
|
# One file already complete.
|
|
store._append_file(
|
|
job_key="K1",
|
|
url="https://x/data/1",
|
|
resource_type="Patient",
|
|
status="complete",
|
|
storage_path="/store/exports/K1/Patient.ndjson",
|
|
file_size=100,
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.poll.return_value = {
|
|
"output": [
|
|
{"url": "https://x/data/1", "type": "Patient"},
|
|
{"url": "https://x/data/2", "type": "Coverage"},
|
|
],
|
|
}
|
|
|
|
def fake_download(url, dest):
|
|
dest.write_text("{}\n")
|
|
|
|
client._download_file.side_effect = fake_download
|
|
|
|
key = store.resume(client, "K1")
|
|
assert key == "K1"
|
|
# Should only download the missing file.
|
|
client._download_file.assert_called_once()
|
|
|
|
def test_resume_complete_all_done(self, tmp_path) -> None:
|
|
"""Resume from complete with all files done is a no-op."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
job_url="https://x/jobs/1",
|
|
endpoint="Group/all",
|
|
types="",
|
|
since="",
|
|
status="complete",
|
|
)
|
|
store._append_file(
|
|
job_key="K1",
|
|
url="https://x/data/1",
|
|
resource_type="Patient",
|
|
status="complete",
|
|
storage_path="/store/exports/K1/Patient.ndjson",
|
|
file_size=100,
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.poll.return_value = {
|
|
"output": [
|
|
{"url": "https://x/data/1", "type": "Patient"},
|
|
],
|
|
}
|
|
|
|
key = store.resume(client, "K1")
|
|
assert key == "K1"
|
|
client._download_file.assert_not_called()
|
|
|
|
def test_resume_pending_with_types_and_since(self, tmp_path) -> None:
|
|
"""Resume pending with empty types/since passes None."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
endpoint="Patient",
|
|
types="",
|
|
since="",
|
|
status="pending",
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {"output": []}
|
|
|
|
store.resume(client, "K1")
|
|
client.start_export.assert_called_once_with(
|
|
endpoint="Patient",
|
|
types=None,
|
|
since=None,
|
|
)
|
|
|
|
def test_resume_failed_no_job_url(self, tmp_path) -> None:
|
|
"""Resume failed with no job_url raises ValueError."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
endpoint="Group/all",
|
|
types="",
|
|
since="",
|
|
status="failed",
|
|
job_url="",
|
|
)
|
|
|
|
client = MagicMock()
|
|
|
|
with pytest.raises(ValueError, match="no job_url"):
|
|
store.resume(client, "K1")
|
|
|
|
def test_resume_pending_with_split_types(self, tmp_path) -> None:
|
|
"""Resume pending with comma-separated types splits them."""
|
|
store = Store(tmp_path / "store")
|
|
store._append_job(
|
|
key="K1",
|
|
endpoint="Group/all",
|
|
types="Patient,Coverage",
|
|
since="2025-01-01",
|
|
status="pending",
|
|
)
|
|
|
|
client = MagicMock()
|
|
client.start_export.return_value = "https://x/jobs/1"
|
|
client.poll.return_value = {"output": []}
|
|
|
|
store.resume(client, "K1")
|
|
client.start_export.assert_called_once_with(
|
|
endpoint="Group/all",
|
|
types=["Patient", "Coverage"],
|
|
since="2025-01-01",
|
|
)
|
|
|
|
|
|
# ── Parquet paths ────────────────────────────────────────────
|
|
|
|
|
|
class TestPaths:
|
|
def test_jobs_path(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
assert store._jobs_path.endswith("jobs.parquet")
|
|
|
|
def test_files_path(self, tmp_path) -> None:
|
|
store = Store(tmp_path / "store")
|
|
assert store._files_path.endswith("files.parquet")
|
|
|
|
|
|
# ── Storage options ──────────────────────────────────────────
|
|
|
|
|
|
class TestStorageOptions:
|
|
def test_default_empty(self) -> None:
|
|
store = Store("data/bcda")
|
|
assert store._storage_options == {}
|
|
|
|
def test_custom_options(self) -> None:
|
|
opts = {"key": "val"}
|
|
store = Store("s3://bucket", storage_options=opts)
|
|
assert store._storage_options == {"key": "val"}
|
|
|
|
def test_root_trailing_slash(self) -> None:
|
|
store = Store("data/bcda/")
|
|
assert store._root == "data/bcda"
|
|
|
|
|
|
# ── v3: adopt completed jobs, type_filter passthrough ───────────
|
|
|
|
|
|
class TestAdoptAndTypeFilter:
|
|
def _client(self, handler):
|
|
import httpx
|
|
|
|
from bcda.client import Client
|
|
|
|
c = Client("cid", "csec", output_dir="/tmp/bcda-store-test")
|
|
c._http = httpx.Client(transport=httpx.MockTransport(handler))
|
|
return c
|
|
|
|
def test_adopt_downloads_completed_job(self, tmp_path) -> None:
|
|
import httpx
|
|
|
|
from bcda.store import Store
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
url = str(req.url)
|
|
if "/auth/token" in url:
|
|
return httpx.Response(
|
|
200, json={"access_token": "t", "expires_in": 1200}
|
|
)
|
|
if url.endswith("/jobs/77"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"output": [
|
|
{"type": "Patient", "url": "https://s/data/77/p.ndjson"},
|
|
{"type": "Patient", "url": "https://s/data/77/p2.ndjson"},
|
|
]
|
|
},
|
|
)
|
|
return httpx.Response(200, content=b'{"resourceType":"Patient"}\n')
|
|
|
|
store = Store(path=str(tmp_path))
|
|
client = self._client(handler)
|
|
key = store.adopt(
|
|
client, "https://s/api/v3/jobs/77", types=["Patient"], poll_interval=0
|
|
)
|
|
job = store.get_job(key)
|
|
assert job["status"] == "complete"
|
|
assert job["job_url"].endswith("/jobs/77")
|
|
assert job["types"] == "Patient"
|
|
files = store.get_files(key)
|
|
assert sorted(Path(f["storage_path"]).name for f in files) == [
|
|
"Patient.1.ndjson",
|
|
"Patient.ndjson",
|
|
]
|
|
assert all(f["status"] == "complete" for f in files)
|
|
client.close()
|
|
|
|
def test_adopt_failure_recorded(self, tmp_path) -> None:
|
|
import httpx
|
|
import pytest
|
|
|
|
from bcda.client import BcdaHttpError
|
|
from bcda.store import Store
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
if "/auth/token" in str(req.url):
|
|
return httpx.Response(
|
|
200, json={"access_token": "t", "expires_in": 1200}
|
|
)
|
|
return httpx.Response(
|
|
404,
|
|
json={
|
|
"resourceType": "OperationOutcome",
|
|
"issue": [{"diagnostics": "Job has expired"}],
|
|
},
|
|
)
|
|
|
|
store = Store(path=str(tmp_path))
|
|
client = self._client(handler)
|
|
with pytest.raises(BcdaHttpError, match="expired"):
|
|
store.adopt(client, "https://s/api/v3/jobs/78", poll_interval=0)
|
|
failed = store.list_jobs(status="failed")
|
|
assert len(failed) == 1 and "expired" in failed[0]["error"]
|
|
client.close()
|
|
|
|
def test_export_records_type_filter(self, tmp_path) -> None:
|
|
import httpx
|
|
|
|
from bcda.store import Store
|
|
|
|
seen: dict = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
url = str(req.url)
|
|
if "/auth/token" in url:
|
|
return httpx.Response(
|
|
200, json={"access_token": "t", "expires_in": 1200}
|
|
)
|
|
if "$export" in url:
|
|
seen["url"] = url
|
|
return httpx.Response(
|
|
202, headers={"Content-Location": "https://s/api/v3/jobs/1"}
|
|
)
|
|
if url.endswith("/jobs/1"):
|
|
return httpx.Response(200, json={"output": []})
|
|
return httpx.Response(200, content=b"")
|
|
|
|
store = Store(path=str(tmp_path))
|
|
client = self._client(handler)
|
|
key = store.export(
|
|
client,
|
|
types=["ExplanationOfBenefit"],
|
|
type_filter="ExplanationOfBenefit?outcome=partial",
|
|
poll_interval=0,
|
|
)
|
|
assert "_typeFilter=" in seen["url"]
|
|
assert (
|
|
store.get_job(key)["types"]
|
|
== "ExplanationOfBenefit|_typeFilter=ExplanationOfBenefit?outcome=partial"
|
|
)
|
|
client.close()
|
|
|
|
def test_export_retries_after_server_cancellation(self, tmp_path) -> None:
|
|
import httpx
|
|
import pytest
|
|
|
|
from bcda.client import JobCancelledError
|
|
from bcda.store import Store
|
|
|
|
polls = {"n": 0}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
url = str(req.url)
|
|
if "/auth/token" in url:
|
|
return httpx.Response(
|
|
200, json={"access_token": "t", "expires_in": 1200}
|
|
)
|
|
if "$export" in url:
|
|
polls["n"] += 1
|
|
return httpx.Response(
|
|
202,
|
|
headers={"Content-Location": f"https://s/api/v3/jobs/{polls['n']}"},
|
|
)
|
|
if url.endswith("/jobs/1") or url.endswith("/jobs/2"):
|
|
return httpx.Response(
|
|
404,
|
|
json={
|
|
"resourceType": "OperationOutcome",
|
|
"issue": [{"diagnostics": "Job has been cancelled."}],
|
|
},
|
|
)
|
|
if url.endswith("/jobs/3"):
|
|
return httpx.Response(200, json={"output": []})
|
|
return httpx.Response(200, content=b"")
|
|
|
|
store = Store(path=str(tmp_path))
|
|
client = self._client(handler)
|
|
key = store.export(client, types=["Patient"], poll_interval=0, cancel_retries=2)
|
|
job = store.get_job(key)
|
|
assert job["status"] == "complete" and job["job_url"].endswith("/jobs/3")
|
|
history = store._read_parquet(
|
|
store._jobs_path, store._read_parquet.__globals__["JOBS_SCHEMA"]
|
|
)
|
|
statuses = [r["status"] for r in history.to_pylist() if r["key"] == key]
|
|
assert statuses.count("cancelled") == 2 and statuses[-1] == "complete"
|
|
client.close()
|
|
|
|
# no retries left → the cancellation propagates and is recorded
|
|
polls["n"] = 0
|
|
client = self._client(handler)
|
|
with pytest.raises(JobCancelledError):
|
|
store.export(client, types=["Patient"], poll_interval=0)
|
|
assert store.list_jobs(status="failed")[-1]["error"].startswith(
|
|
"https://s/api/v3/jobs/1"
|
|
)
|
|
client.close()
|