From d9841561638f2ffb1e5477aebac50359c8938c6c Mon Sep 17 00:00:00 2001 From: matthewberry Date: Thu, 22 Oct 2026 09:33:48 -0500 Subject: [PATCH 1/3] fix: CSV search failing on removed columns param The columns field was removed from CLEANSearchQueryParams in a1ea901, but the CSV branch still read params.columns, so every format=csv request returned a 500. --- app/routers/search.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/routers/search.py b/app/routers/search.py index 3f9fca4..0a62328 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -220,9 +220,7 @@ async def get_data( output = StringIO() # Determine columns to include in CSV - if params.columns: - fieldnames = params.columns - elif data and len(data) > 0: + if data and len(data) > 0: fieldnames = list(data[0].keys()) else: fieldnames = [] From 6b731f2a9c5c5415f3efbcf9fb5410a9017aeb52 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Thu, 22 Oct 2026 09:33:48 -0500 Subject: [PATCH 2/3] fix: join array columns in CSV output with ';' Array cells were written as Python list reprs, e.g. ['2.7.10.2']. --- app/routers/search.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/routers/search.py b/app/routers/search.py index 0a62328..b91311a 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -231,7 +231,11 @@ async def get_data( ) writer.writeheader() for row in data: - writer.writerow(row) + # Join array columns into one ';'-separated cell + writer.writerow({ + k: ";".join(str(v) for v in value) if isinstance(value, list) else value + for k, value in row.items() + }) # Return streaming response output.seek(0) From ebafcd8e468a6ee941a21a963ec7f161a98fe9fb Mon Sep 17 00:00:00 2001 From: matthewberry Date: Thu, 22 Oct 2026 09:42:09 -0500 Subject: [PATCH 3/3] fix: always write CSV header; add search CSV tests Take fieldnames from CLEANColumn, which matches the SELECT list in get_filtered_data, so an empty result still has a header row. Add TestClient tests for the CSV branch with the DB calls mocked. --- app/routers/search.py | 10 ++----- pytest.ini | 1 + tests/conftest.py | 5 ++++ tests/test_search_csv.py | 56 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_search_csv.py diff --git a/app/routers/search.py b/app/routers/search.py index b91311a..d0b0ff9 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -11,7 +11,7 @@ from app.db.database import Database, get_db from app.db.queries import get_ec_suggestions, get_filtered_data, get_total_count, get_typeahead_suggestions from app.models.query_params import CLEANECLookupQueryParams, CLEANSearchQueryParams, CLEANTypeaheadQueryParams, ResponseFormat -from app.models.clean_data import CLEANDataBase, CLEANECLookupResponse, CLEANECLookupMatch, CLEANSearchResponse, CLEANTypeaheadResponse, CurationStatusOption, CLEANCurationStatusResponse +from app.models.clean_data import CLEANColumn, CLEANDataBase, CLEANECLookupResponse, CLEANECLookupMatch, CLEANSearchResponse, CLEANTypeaheadResponse, CurationStatusOption, CLEANCurationStatusResponse router = APIRouter(tags=["Search"]) @@ -219,15 +219,9 @@ async def get_data( # Create CSV response output = StringIO() - # Determine columns to include in CSV - if data and len(data) > 0: - fieldnames = list(data[0].keys()) - else: - fieldnames = [] - # Write CSV writer = csv.DictWriter( - output, fieldnames=fieldnames, extrasaction="ignore" + output, fieldnames=[c.value for c in CLEANColumn], extrasaction="ignore" ) writer.writeheader() for row in data: diff --git a/pytest.ini b/pytest.ini index b3e7b28..2fc5c9f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,6 +2,7 @@ asyncio_mode = auto asyncio_default_fixture_loop_scope = function testpaths = tests +pythonpath = . python_files = test_*.py python_classes = Test* python_functions = test_* diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f8dcf79 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import os + +# Settings require DB credentials at import time; tests mock the DB calls. +for key in ("CLEAN_DB_HOST", "CLEAN_DB_USER", "CLEAN_DB_PASSWORD", "CLEAN_DB_NAME"): + os.environ.setdefault(key, "test") diff --git a/tests/test_search_csv.py b/tests/test_search_csv.py new file mode 100644 index 0000000..8fd75ae --- /dev/null +++ b/tests/test_search_csv.py @@ -0,0 +1,56 @@ +import csv +from io import StringIO +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +import app.routers.search as search +from app.db.database import get_db +from app.main import app +from app.models.clean_data import CLEANColumn + +HEADER = [c.value for c in CLEANColumn] + + +@pytest.fixture +def client(): + app.dependency_overrides[get_db] = lambda: None + yield TestClient(app) + app.dependency_overrides.clear() + + +def get_csv(client, rows): + with ( + patch.object(search, "get_total_count", AsyncMock(return_value=len(rows))), + patch.object(search, "get_filtered_data", AsyncMock(return_value=rows)), + ): + response = client.get("/api/v1/search?accession=P51451&format=csv&limit=10") + assert response.status_code == 200, response.text + return list(csv.reader(StringIO(response.text))) + + +def make_row(**overrides): + row = {c.value: None for c in CLEANColumn} + row.update(accession="P51451", protein_name='Kinase, "BLK"') + row.update(overrides) + return row + + +def test_csv_joins_array_columns(client): + rows = get_csv(client, [make_row( + clean_ec_number_array=["2.7.10.2", "2.7.11.1"], + clean_ec_confidence_array=[0.98, 0.12], + annot_ec_number_array=None, + )]) + + assert rows[0] == HEADER + record = dict(zip(HEADER, rows[1])) + assert record["protein_name"] == 'Kinase, "BLK"' + assert record["clean_ec_number_array"] == "2.7.10.2;2.7.11.1" + assert record["clean_ec_confidence_array"] == "0.98;0.12" + assert record["annot_ec_number_array"] == "" + + +def test_csv_has_header_when_no_rows_match(client): + assert get_csv(client, []) == [HEADER]