diff --git a/README.md b/README.md
index 315ca319..bedac817 100644
--- a/README.md
+++ b/README.md
@@ -30,3 +30,29 @@ UI development:
```terminal
export VITE_API_HOST=http://localhost:8000
```
+
+## Testing
+
+Black-box compliance tests (against any deployment):
+
+```
+pytest tests/api/ --api_root=https://api.bedbase.org
+```
+
+Integration tests (ephemeral Postgres + Qdrant + in-process TestClient, ~30s):
+
+```
+./tests/scripts/test-integration.sh
+```
+
+Manual service control:
+
+```
+./tests/scripts/services.sh start
+RUN_INTEGRATION_TESTS=true pytest tests/integration/ tests/api/
+./tests/scripts/services.sh stop
+```
+
+Integration tests require network access on first boot (bbconf downloads a
+licenses CSV from GitHub and may pull HuggingFace models unless
+`BEDHOST_INIT_ML=false` is set; `test-integration.sh` sets it).
diff --git a/bedhost/_version.py b/bedhost/_version.py
index 6ece8ade..5e44a42b 100644
--- a/bedhost/_version.py
+++ b/bedhost/_version.py
@@ -1 +1 @@
-__version__ = "0.12.7"
+__version__ = "0.12.8"
diff --git a/bedhost/dependencies.py b/bedhost/dependencies.py
index 171f68ca..ef215b9a 100644
--- a/bedhost/dependencies.py
+++ b/bedhost/dependencies.py
@@ -1,25 +1,68 @@
-from cachetools import TTLCache
+from statistics import StatisticsError
from bbconf.bbagent import BedBaseAgent
-from bbconf.models.base_models import FileStats, UsageModel
+from bbconf.models.base_models import BinValues, FileStats, GEOStatistics, UsageModel
from bedboss.refgenome_validator.main import ReferenceValidator
from fastapi import Request
-
-_stats_cache: TTLCache = TTLCache(maxsize=2, ttl=14 * 24 * 60 * 60)
+from . import _LOGGER
def get_bbagent(request: Request) -> BedBaseAgent:
return request.app.state.bbagent
-def get_usage_data(request: Request) -> UsageModel:
- return request.app.state.usage_data
+# def get_usage_data(request: Request) -> UsageModel:
+# return request.app.state.usage_data
def get_ref_validator(request: Request) -> ReferenceValidator:
return request.app.state.ref_validator
-def fetch_detailed_stats(bbagent: BedBaseAgent, concise: bool = False) -> FileStats:
- if concise not in _stats_cache:
- _stats_cache[concise] = bbagent.get_detailed_stats(concise=concise)
- return _stats_cache[concise]
+def _empty_bin_values() -> BinValues:
+ return BinValues(bins=[], counts=[], mean=0.0, median=0.0)
+
+
+def _empty_file_stats() -> FileStats:
+ """Return a well-formed FileStats with zeroed-out bins.
+
+ Used as a fallback when the underlying database has no bed records, in
+ which case bbconf's ``statistics.mean`` call on an empty list raises
+ ``StatisticsError``.
+ """
+ empty_bins = _empty_bin_values()
+ return FileStats(
+ bed_compliance={},
+ data_format={},
+ file_genome={},
+ file_organism={},
+ file_assay={},
+ cell_line={},
+ geo_status={},
+ bed_comments={},
+ mean_region_width=empty_bins,
+ file_size=empty_bins,
+ number_of_regions=empty_bins,
+ geo=GEOStatistics(
+ number_of_files={},
+ cumulative_number_of_files={},
+ file_sizes=empty_bins,
+ ),
+ )
+
+
+def fetch_detailed_stats(
+ request: Request, bbagent: BedBaseAgent, concise: bool = False
+) -> FileStats:
+ """
+ Fetch detailed file statistics from the BedBaseAgent.
+ """
+
+ if concise not in request.app.state.detailed_stats:
+ _LOGGER.info("Stats are not cached, fetching...")
+ try:
+ request.app.state.detailed_stats[concise] = bbagent.get_detailed_stats(
+ concise=concise
+ )
+ except StatisticsError:
+ return _empty_file_stats()
+ return request.app.state.detailed_stats[concise]
diff --git a/bedhost/helpers.py b/bedhost/helpers.py
index 0b209b81..692cdd11 100644
--- a/bedhost/helpers.py
+++ b/bedhost/helpers.py
@@ -66,10 +66,15 @@ def attach_routers(app):
def configure(bbconf_file_path: str) -> BedBaseAgent:
+ # Respect BEDHOST_INIT_ML for CI/smoke deployments that don't need the
+ # ML models (dense/sparse encoders, UMAP, region2vec) loaded. Default
+ # is to initialize them (unchanged behavior for production).
+ init_ml_env = os.environ.get("BEDHOST_INIT_ML", "true").lower()
+ init_ml = init_ml_env not in ("0", "false", "no")
try:
# bbconf_file_path = os.environ.get("BEDBASE_CONFIG") or None
- _LOGGER.info(f"Loading config: '{bbconf_file_path}'")
- bbc = BedBaseAgent(bbconf_file_path)
+ _LOGGER.info(f"Loading config: '{bbconf_file_path}' (init_ml={init_ml})")
+ bbc = BedBaseAgent(bbconf_file_path, init_ml=init_ml)
except Exception as e:
raise BedHostException(f"Bedbase config was not provided or is incorrect: {e}")
return bbc
@@ -125,17 +130,23 @@ async def wrapper(*args, **kwargs):
else:
usage_data.files[file_path] = 1
elif event == "bed_search":
- query = kwargs.get("query").strip()
- if query in usage_data.bed_search:
- usage_data.bed_search[query] += 1
- else:
- usage_data.bed_search[query] = 1
+ raw_query = kwargs.get("query")
+ # /v1/bedset/list accepts query=None; skip usage tracking in
+ # that case rather than crashing on ``None.strip()``.
+ if raw_query is not None:
+ query = raw_query.strip()
+ if query in usage_data.bed_search:
+ usage_data.bed_search[query] += 1
+ else:
+ usage_data.bed_search[query] = 1
elif event == "bedset_search":
- query = kwargs.get("query").strip()
- if query in usage_data.bedset_search:
- usage_data.bedset_search[query] += 1
- else:
- usage_data.bedset_search[query] = 1
+ raw_query = kwargs.get("query")
+ if raw_query is not None:
+ query = raw_query.strip()
+ if query in usage_data.bedset_search:
+ usage_data.bedset_search[query] += 1
+ else:
+ usage_data.bedset_search[query] = 1
elif event == "bed_meta":
bed_id = kwargs.get("bed_id")
if bed_id in usage_data.bed_meta:
diff --git a/bedhost/main.py b/bedhost/main.py
index 505dafc1..edcc280c 100644
--- a/bedhost/main.py
+++ b/bedhost/main.py
@@ -14,9 +14,12 @@
)
from bedboss.refgenome_validator.main import ReferenceValidator
-from fastapi import FastAPI, Request
+from cachetools import TTLCache
+
+from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import _LOGGER
@@ -84,6 +87,8 @@ async def lifespan(app: FastAPI):
app.state.bbagent = configure(bbconf_file_path)
app.state.usage_data = init_model_usage()
+ app.state.detailed_stats: TTLCache = TTLCache(maxsize=2, ttl=14 * 24 * 60 * 60)
+
# Respect BEDHOST_INIT_ML for CI/smoke deployments that don't need the
# reference genome validator loaded. Default is to initialize it.
init_ml_env = os.environ.get("BEDHOST_INIT_ML", "true").lower()
@@ -107,6 +112,7 @@ async def lifespan(app: FastAPI):
app.state.scheduler = scheduler
try:
+ _LOGGER.info("Starting app ...")
yield
finally:
app.state.scheduler.shutdown(wait=False)
@@ -117,6 +123,7 @@ async def lifespan(app: FastAPI):
description="BED file/sets statistics and image server API",
version=bedhost_version,
docs_url="/v1/docs",
+ openapi_url="/v1/openapi.json",
openapi_tags=tags_metadata,
lifespan=lifespan,
)
@@ -137,6 +144,12 @@ async def lifespan(app: FastAPI):
allow_headers=["*"],
)
+app.mount(
+ "/static",
+ StaticFiles(directory=str(Path(__file__).parent / "static")),
+ name="static",
+)
+
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
templates.env.autoescape = False
@@ -160,13 +173,19 @@ async def changelog(request: Request):
@app.get("/")
-def lending_page():
+def landing_page():
return RedirectResponse(url="v1/")
def render_markdown(filename: str, request: Request):
- with open(os.path.join(STATIC_PATH, filename), "r", encoding="utf-8") as input_file:
- text = input_file.read()
+ path = os.path.join(STATIC_PATH, filename)
+ try:
+ with open(path, "r", encoding="utf-8") as input_file:
+ text = input_file.read()
+ except FileNotFoundError:
+ # e.g. docs/changelog.md was removed from the repo in 2024 but the
+ # route is still registered. Return 404 rather than a 500.
+ raise HTTPException(status_code=404, detail=f"{filename} not found")
content = markdown.markdown(text)
return templates.TemplateResponse(request, "page.html", {"content": content})
diff --git a/bedhost/routers/base_api.py b/bedhost/routers/base_api.py
index 38271b8b..a1a3e8b0 100644
--- a/bedhost/routers/base_api.py
+++ b/bedhost/routers/base_api.py
@@ -48,14 +48,15 @@ async def get_bedbase_db_stats(
summary="Get detailed statistics for BEDbase platform, including number of files for each genome",
response_model=FileStats,
)
-async def get_detailed_stats(
+def get_detailed_stats(
+ request: Request,
concise: bool = False,
bbagent: BedBaseAgent = Depends(get_bbagent),
):
"""
Returns detailed statistics
"""
- return fetch_detailed_stats(bbagent, concise=concise)
+ return fetch_detailed_stats(request, bbagent=bbagent, concise=concise)
@router.get(
diff --git a/bedhost/routers/bed_api.py b/bedhost/routers/bed_api.py
index b15dac02..40902dae 100644
--- a/bedhost/routers/bed_api.py
+++ b/bedhost/routers/bed_api.py
@@ -101,7 +101,7 @@ async def list_beds(
summary="Get metadata for a single BED record",
response_model=BedMetadataAll,
response_model_by_alias=False,
- description=f"Example\n " f"bed_id: {EXAMPLE_BED}",
+ description=f"Example\n bed_id: {EXAMPLE_BED}",
)
@count_requests(event="bed_meta")
async def get_bed_metadata(
@@ -531,6 +531,15 @@ async def text_to_bed_search(
f"Searching for: '{query}' with limit='{limit}' and offset='{offset}' and genome='{genome}' and assay='{assay}'"
)
+ # Hybrid search depends on the dense encoder, which isn't loaded when
+ # bbconf is initialized with ``init_ml=False`` (CI / BEDHOST_INIT_ML=false).
+ # Return 503 up-front rather than a 500 AttributeError from deep in bbconf.
+ if getattr(bbagent.config, "dense_encoder", None) is None:
+ raise HTTPException(
+ status_code=503,
+ detail="Text search unavailable (ML models disabled)",
+ )
+
spaceless_query = query.replace(" ", "")
if len(spaceless_query) == 32 and spaceless_query == query:
try:
diff --git a/bedhost/static/UVA_CPHG.png b/bedhost/static/UVA_CPHG.png
new file mode 100644
index 00000000..027dbae7
Binary files /dev/null and b/bedhost/static/UVA_CPHG.png differ
diff --git a/bedhost/templates/page.html b/bedhost/templates/page.html
index f1e3dea7..5a712302 100644
--- a/bedhost/templates/page.html
+++ b/bedhost/templates/page.html
@@ -42,9 +42,7 @@
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..083eb7aa
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+testpaths = tests
+markers =
+ require_service: marks test as requiring a running bedhost service (deselect with '-m "not require_service"')
diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt
index 7f4fecb3..80da6c3d 100644
--- a/requirements/requirements-all.txt
+++ b/requirements/requirements-all.txt
@@ -1,5 +1,5 @@
# bbconf @ git+https://github.com/databio/bbconf.git@dev#egg=bbconf
-bbconf>=0.14.12
+bbconf>=0.14.13
fastapi>=0.103.0
sentence_transformers>=5.0
logmuse>=0.2.7
diff --git a/tests/api/__init__.py b/tests/api/__init__.py
new file mode 100644
index 00000000..68485eee
--- /dev/null
+++ b/tests/api/__init__.py
@@ -0,0 +1 @@
+"""Compliance tests for bedhost API."""
diff --git a/tests/api/conftest.py b/tests/api/conftest.py
new file mode 100644
index 00000000..162bab59
--- /dev/null
+++ b/tests/api/conftest.py
@@ -0,0 +1,27 @@
+"""API test fixtures and constants."""
+
+import pytest
+
+
+@pytest.fixture(scope="session")
+def example_bed_id(api_root):
+ """Get an example BED ID from the server."""
+ try:
+ res = api_root.get("/v1/bed/example", timeout=3)
+ if res.status_code == 200:
+ return res.json().get("id")
+ except Exception:
+ pass
+ return None
+
+
+@pytest.fixture(scope="session")
+def example_bedset_id(api_root):
+ """Get an example BEDSET ID from the server."""
+ try:
+ res = api_root.get("/v1/bedset/example", timeout=3)
+ if res.status_code == 200:
+ return res.json().get("id")
+ except Exception:
+ pass
+ return None
diff --git a/tests/api/test_compliance.py b/tests/api/test_compliance.py
new file mode 100644
index 00000000..18c9fd7f
--- /dev/null
+++ b/tests/api/test_compliance.py
@@ -0,0 +1,343 @@
+"""
+Compliance test suite for Bedhost API.
+
+Tests are invoked against a client object exposed by the ``api_root``
+fixture (see ``tests/conftest.py``). The same test bodies run in two modes:
+
+* Black-box mode (default):
+
+ pytest tests/api/ -v
+ pytest tests/api/ -v --api_root=https://staging.bedbase.org
+ pytest tests/api/ -v --api_root=https://api.bedbase.org
+
+* Integration mode (ephemeral Postgres + Qdrant + in-process TestClient):
+
+ ./tests/scripts/test-integration.sh
+"""
+
+import pytest
+
+
+@pytest.mark.require_service
+class TestServiceInfo:
+ """Test service info and metadata endpoints."""
+
+ def test_service_info_returns_200(self, api_root):
+ """Service info endpoint should return 200."""
+ res = api_root.get("/v1/service-info")
+ assert res.status_code == 200
+
+ def test_service_info_has_required_fields(self, api_root):
+ """Service info should contain required GA4GH fields."""
+ res = api_root.get("/v1/service-info")
+ data = res.json()
+ assert "id" in data
+ assert "name" in data
+ assert "version" in data
+ assert "type" in data
+
+ def test_root_redirects(self, api_root):
+ """Root endpoint should redirect to v1."""
+ res = api_root.get("/", allow_redirects=False)
+ assert res.status_code in [307, 308]
+
+
+@pytest.mark.require_service
+class TestStatsEndpoints:
+ """Test statistics endpoints."""
+
+ def test_stats_returns_200(self, api_root):
+ """Stats endpoint should return 200."""
+ res = api_root.get("/v1/stats")
+ assert res.status_code == 200
+
+ def test_stats_has_counts(self, api_root):
+ """Stats should include bed and bedset counts."""
+ res = api_root.get("/v1/stats")
+ data = res.json()
+ assert "bedfiles_number" in data
+ assert "bedsets_number" in data
+ assert "genomes_number" in data
+
+ def test_detailed_stats_returns_200(self, api_root):
+ """Detailed stats endpoint should return 200."""
+ res = api_root.get("/v1/detailed-stats")
+ assert res.status_code == 200
+
+ def test_genomes_returns_list(self, api_root):
+ """Genomes endpoint should return a list."""
+ res = api_root.get("/v1/genomes")
+ assert res.status_code == 200
+ data = res.json()
+ assert "results" in data
+ assert "count" in data
+ assert isinstance(data["results"], list)
+
+ def test_assays_returns_list(self, api_root):
+ """Assays endpoint should return a list."""
+ res = api_root.get("/v1/assays")
+ assert res.status_code == 200
+ data = res.json()
+ assert "results" in data
+ assert isinstance(data["results"], list)
+
+
+@pytest.mark.require_service
+class TestBedListEndpoints:
+ """Test BED listing endpoints."""
+
+ def test_bed_list_returns_200(self, api_root):
+ """BED list endpoint should return 200."""
+ res = api_root.get("/v1/bed/list")
+ assert res.status_code == 200
+
+ def test_bed_list_has_pagination(self, api_root):
+ """BED list should include pagination fields."""
+ res = api_root.get("/v1/bed/list")
+ data = res.json()
+ assert "results" in data
+ assert "count" in data
+ assert "limit" in data
+ assert "offset" in data
+
+ def test_bed_list_respects_limit(self, api_root):
+ """BED list should respect limit parameter."""
+ res = api_root.get("/v1/bed/list?limit=5")
+ data = res.json()
+ assert len(data["results"]) <= 5
+
+ def test_bed_list_filters_by_genome(self, api_root):
+ """BED list should filter by genome."""
+ res = api_root.get("/v1/bed/list?genome=hg38&limit=10")
+ assert res.status_code == 200
+ # If results exist, they should all be hg38
+ data = res.json()
+ for bed in data["results"]:
+ if "genome_alias" in bed:
+ assert bed["genome_alias"] == "hg38"
+
+
+@pytest.mark.require_service
+class TestBedExampleEndpoint:
+ """Test BED example endpoint."""
+
+ def test_bed_example_returns_200(self, api_root, example_bed_id):
+ """BED example endpoint should return 200 when records exist.
+
+ On an empty DB the endpoint returns 404; skip rather than fail so
+ this test is meaningful against both seeded and empty instances.
+ """
+ if not example_bed_id:
+ pytest.skip("No example BED record available (empty DB)")
+ res = api_root.get("/v1/bed/example")
+ assert res.status_code == 200
+
+ def test_bed_example_has_id(self, api_root, example_bed_id):
+ """BED example should return a record with an ID."""
+ if not example_bed_id:
+ pytest.skip("No example BED record available (empty DB)")
+ res = api_root.get("/v1/bed/example")
+ data = res.json()
+ assert "id" in data
+ assert len(data["id"]) == 32 # MD5 hash length
+
+
+@pytest.mark.require_service
+class TestBedMetadataEndpoints:
+ """Test BED metadata endpoints."""
+
+ def test_bed_metadata_returns_200(self, api_root, example_bed_id):
+ """BED metadata endpoint should return 200 for valid ID."""
+ if not example_bed_id:
+ pytest.skip("No example BED ID available")
+ res = api_root.get(f"/v1/bed/{example_bed_id}/metadata")
+ assert res.status_code == 200
+
+ def test_bed_metadata_has_required_fields(self, api_root, example_bed_id):
+ """BED metadata should contain required fields."""
+ if not example_bed_id:
+ pytest.skip("No example BED ID available")
+ res = api_root.get(f"/v1/bed/{example_bed_id}/metadata")
+ data = res.json()
+ assert "id" in data
+ assert data["id"] == example_bed_id
+
+ def test_bed_metadata_not_found(self, api_root):
+ """BED metadata should return 404 for invalid ID."""
+ fake_id = "00000000000000000000000000000000"
+ res = api_root.get(f"/v1/bed/{fake_id}/metadata")
+ assert res.status_code == 404
+
+ def test_bed_stats_returns_200(self, api_root, example_bed_id):
+ """BED stats endpoint should return 200."""
+ if not example_bed_id:
+ pytest.skip("No example BED ID available")
+ res = api_root.get(f"/v1/bed/{example_bed_id}/metadata/stats")
+ assert res.status_code == 200
+
+ def test_bed_plots_returns_200_or_404(self, api_root, example_bed_id):
+ """BED plots endpoint should return 200 or 404 (if no plots)."""
+ if not example_bed_id:
+ pytest.skip("No example BED ID available")
+ res = api_root.get(f"/v1/bed/{example_bed_id}/metadata/plots")
+ assert res.status_code in [200, 404]
+
+ def test_bed_files_returns_200(self, api_root, example_bed_id):
+ """BED files endpoint should return 200."""
+ if not example_bed_id:
+ pytest.skip("No example BED ID available")
+ res = api_root.get(f"/v1/bed/{example_bed_id}/metadata/files")
+ assert res.status_code == 200
+
+ def test_bed_classification_returns_200(self, api_root, example_bed_id):
+ """BED classification endpoint should return 200."""
+ if not example_bed_id:
+ pytest.skip("No example BED ID available")
+ res = api_root.get(f"/v1/bed/{example_bed_id}/metadata/classification")
+ assert res.status_code == 200
+
+
+@pytest.mark.require_service
+class TestBedSearchEndpoints:
+ """Test BED search endpoints."""
+
+ def test_text_search_returns_200(self, api_root):
+ """Text search endpoint should return 200 (or 503 when ML is disabled)."""
+ res = api_root.get("/v1/bed/search/text?query=cancer")
+ # 503 when BEDHOST_INIT_ML=false; 200 against a fully-initialized server.
+ assert res.status_code in [200, 503]
+
+ def test_text_search_has_results_structure(self, api_root):
+ """Text search should return proper result structure when available."""
+ res = api_root.get("/v1/bed/search/text?query=cancer")
+ if res.status_code == 503:
+ pytest.skip("Text search unavailable (ML models disabled)")
+ data = res.json()
+ assert "results" in data
+ assert "count" in data
+
+ def test_exact_search_returns_200(self, api_root):
+ """Exact search endpoint should return 200."""
+ res = api_root.get("/v1/bed/search/exact?query=test")
+ assert res.status_code == 200
+
+
+@pytest.mark.require_service
+class TestBedsetListEndpoints:
+ """Test BEDSET listing endpoints."""
+
+ def test_bedset_list_returns_200(self, api_root):
+ """BEDSET list endpoint should return 200."""
+ res = api_root.get("/v1/bedset/list")
+ assert res.status_code == 200
+
+ def test_bedset_list_has_pagination(self, api_root):
+ """BEDSET list should include pagination fields."""
+ res = api_root.get("/v1/bedset/list")
+ data = res.json()
+ assert "results" in data
+ assert "count" in data
+
+
+@pytest.mark.require_service
+class TestBedsetExampleEndpoint:
+ """Test BEDSET example endpoint."""
+
+ def test_bedset_example_returns_200(self, api_root, example_bedset_id):
+ """BEDSET example endpoint should return 200 when records exist."""
+ if not example_bedset_id:
+ pytest.skip("No example BEDSET record available (empty DB)")
+ res = api_root.get("/v1/bedset/example")
+ assert res.status_code == 200
+
+ def test_bedset_example_has_id(self, api_root, example_bedset_id):
+ """BEDSET example should return a record with an ID."""
+ if not example_bedset_id:
+ pytest.skip("No example BEDSET record available (empty DB)")
+ res = api_root.get("/v1/bedset/example")
+ data = res.json()
+ assert "id" in data
+
+
+@pytest.mark.require_service
+class TestBedsetMetadataEndpoints:
+ """Test BEDSET metadata endpoints."""
+
+ def test_bedset_metadata_returns_200(self, api_root, example_bedset_id):
+ """BEDSET metadata endpoint should return 200 for valid ID."""
+ if not example_bedset_id:
+ pytest.skip("No example BEDSET ID available")
+ res = api_root.get(f"/v1/bedset/{example_bedset_id}/metadata")
+ assert res.status_code == 200
+
+ def test_bedset_metadata_not_found(self, api_root):
+ """BEDSET metadata should return 404 for invalid ID."""
+ fake_id = "00000000000000000000000000000000"
+ res = api_root.get(f"/v1/bedset/{fake_id}/metadata")
+ assert res.status_code == 404
+
+ def test_bedset_bedfiles_returns_200(self, api_root, example_bedset_id):
+ """BEDSET bedfiles endpoint should return 200."""
+ if not example_bedset_id:
+ pytest.skip("No example BEDSET ID available")
+ res = api_root.get(f"/v1/bedset/{example_bedset_id}/bedfiles")
+ assert res.status_code == 200
+
+ def test_bedset_stats_returns_200(self, api_root, example_bedset_id):
+ """BEDSET stats endpoint should return 200."""
+ if not example_bedset_id:
+ pytest.skip("No example BEDSET ID available")
+ res = api_root.get(f"/v1/bedset/{example_bedset_id}/metadata/stats")
+ assert res.status_code == 200
+
+
+@pytest.mark.require_service
+class TestOpenAPIDocumentation:
+ """Test API documentation endpoints."""
+
+ def test_docs_available(self, api_root):
+ """OpenAPI docs should be available."""
+ res = api_root.get("/v1/docs")
+ assert res.status_code == 200
+
+ def test_openapi_json_available(self, api_root):
+ """OpenAPI JSON schema should be available at /v1/openapi.json."""
+ res = api_root.get("/v1/openapi.json")
+ assert res.status_code == 200
+ data = res.json()
+ assert "openapi" in data
+ assert "paths" in data
+
+
+@pytest.mark.require_service
+class TestMarkdownAndSchemaPages:
+ """Regression tests for pages that broke under Starlette 1.0."""
+
+ def test_v1_landing_page_returns_200(self, api_root):
+ """GET /v1 (markdown landing page via TemplateResponse) must return 200."""
+ res = api_root.get("/v1", timeout=3, allow_redirects=True)
+ assert res.status_code == 200
+ assert "text/html" in res.headers.get("content-type", "").lower()
+ # Body should be non-empty rendered HTML, not an error page
+ assert len(res.text) > 100
+
+ def test_v1_openapi_json_returns_200(self, api_root):
+ """GET /v1/openapi.json must return valid OpenAPI JSON within a strict timeout."""
+ res = api_root.get("/v1/openapi.json", timeout=3)
+ assert res.status_code == 200
+ data = res.json()
+ assert "openapi" in data
+ assert "paths" in data
+
+ def test_v1_changelog_returns_200_or_404(self, api_root):
+ """GET /v1/docs/changelog must render (200) or gracefully 404.
+
+ The docs/changelog.md source was removed from the repo in 2024 but
+ the route is still registered. When the file is present (prod), the
+ page renders as HTML. When it isn't (local dev / CI), the route
+ returns 404 instead of the previous 500.
+ """
+ res = api_root.get("/v1/docs/changelog", timeout=3, allow_redirects=True)
+ assert res.status_code in [200, 404]
+ if res.status_code == 200:
+ assert "text/html" in res.headers.get("content-type", "").lower()
diff --git a/tests/client.py b/tests/client.py
new file mode 100644
index 00000000..a54787c4
--- /dev/null
+++ b/tests/client.py
@@ -0,0 +1,50 @@
+"""HTTP client adapters for bedhost compliance tests.
+
+Two implementations share the same call surface so a single test body
+can run either against a live HTTP server (``RequestsClient``) or
+in-process via FastAPI's ``TestClient`` (``TestClientAdapter``).
+
+Both ``.get`` / ``.post`` return a response object exposing
+``.status_code``, ``.json()``, ``.text``, and ``.headers`` -- the subset
+used by the compliance suite.
+"""
+
+
+class RequestsClient:
+ """Thin wrapper around the ``requests`` library for black-box testing."""
+
+ def __init__(self, base_url: str):
+ self.base_url = base_url.rstrip("/")
+
+ def get(self, path, **kw):
+ import requests
+
+ return requests.get(f"{self.base_url}{path}", **kw)
+
+ def post(self, path, **kw):
+ import requests
+
+ return requests.post(f"{self.base_url}{path}", **kw)
+
+
+class TestClientAdapter:
+ """Wrap a ``fastapi.testclient.TestClient`` with the requests-like API.
+
+ FastAPI's TestClient uses ``follow_redirects`` while ``requests`` uses
+ ``allow_redirects``; this adapter translates so tests stay portable.
+ """
+
+ def __init__(self, client):
+ self._client = client
+
+ @staticmethod
+ def _translate_kwargs(kw):
+ if "allow_redirects" in kw:
+ kw["follow_redirects"] = kw.pop("allow_redirects")
+ return kw
+
+ def get(self, path, **kw):
+ return self._client.get(path, **self._translate_kwargs(kw))
+
+ def post(self, path, **kw):
+ return self._client.post(path, **self._translate_kwargs(kw))
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..13f6e2f4
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,94 @@
+"""Bedhost test suite configuration and shared fixtures.
+
+The ``api_root`` fixture dispatches between two modes:
+
+* Black-box mode (default): returns a ``RequestsClient`` pointing at whatever
+ URL was passed via ``--api_root`` (``http://localhost:8000`` by default).
+ Use for compliance testing live deployments.
+* Integration mode (``RUN_INTEGRATION_TESTS=true``): delegates to
+ ``tests/integration/conftest.py``'s ``integration_api_root`` fixture, which
+ wraps an in-process FastAPI ``TestClient`` backed by ephemeral services.
+
+All 37 compliance tests in ``tests/api/test_compliance.py`` run unchanged in
+both modes because both client implementations expose the same
+``.get(path, **kw)`` / ``.post(path, **kw)`` call surface.
+"""
+
+import os
+
+import pytest
+
+REQ_SERVICE_MARK = "require_service"
+
+
+def pytest_addoption(parser):
+ """Add CLI option to specify the API root."""
+ parser.addoption(
+ "--api_root",
+ "-R",
+ action="store",
+ default="http://localhost:8000",
+ help="API root URL for compliance tests (ignored in integration mode)",
+ )
+
+
+@pytest.fixture(scope="session")
+def api_root(pytestconfig, request):
+ """HTTP client pointed at a bedhost API.
+
+ Returns a client object (not a bare URL). Use ``api_root.get("/v1/...")``
+ in tests; never ``f"{api_root}/v1/..."``.
+ """
+ from tests.client import RequestsClient
+
+ if os.getenv("RUN_INTEGRATION_TESTS") == "true":
+ # Integration mode: delegate to tests/integration/conftest.py
+ return request.getfixturevalue("integration_api_root")
+ return RequestsClient(pytestconfig.getoption("api_root"))
+
+
+def check_server_is_running(api_root_url: str) -> bool:
+ """Check if a bedhost server is responding at the given URL."""
+ import requests
+
+ try:
+ print(f"Checking if service is running at {api_root_url}")
+ res = requests.get(f"{api_root_url}/v1/service-info", timeout=5)
+ if res.status_code == 200:
+ print("Server is running.")
+ return True
+ print(f"Server returned status {res.status_code}")
+ return False
+ except Exception as e:
+ print(f"Server is not running: {e}")
+ return False
+
+
+def pytest_configure(config):
+ """Register custom markers."""
+ config.addinivalue_line(
+ "markers",
+ f"{REQ_SERVICE_MARK}: mark test as requiring a running bedhost service",
+ )
+
+
+def pytest_collection_modifyitems(config, items):
+ """Skip ``require_service`` tests when no backend is reachable.
+
+ In integration mode (``RUN_INTEGRATION_TESTS=true``), the in-process
+ TestClient is always live, so no skip is needed.
+ """
+ if os.getenv("RUN_INTEGRATION_TESTS") == "true":
+ return
+
+ api_root_url = config.getoption("api_root")
+ if check_server_is_running(api_root_url):
+ return
+
+ print("Skipping tests that require a running server...")
+ skip_missing_service = pytest.mark.skip(
+ reason=f"Bedhost API not available at {api_root_url}"
+ )
+ for item in items:
+ if REQ_SERVICE_MARK in item.keywords:
+ item.add_marker(skip_missing_service)
diff --git a/tests/fixtures/bedbase_test_config.template.yaml b/tests/fixtures/bedbase_test_config.template.yaml
new file mode 100644
index 00000000..7a3ab31d
--- /dev/null
+++ b/tests/fixtures/bedbase_test_config.template.yaml
@@ -0,0 +1,21 @@
+database:
+ host: localhost
+ port: {DB_PORT}
+ user: testuser
+ password: testpass
+ database: bedbase_test
+ dialect: postgresql
+ driver: psycopg
+qdrant:
+ host: http://localhost
+ port: {QDRANT_PORT}
+ file_collection: bedbase_test
+ text_collection: bed_text_test
+ hybrid_collection: bedbase_query_search_test
+server:
+ host: 0.0.0.0
+ port: 8000
+path:
+ region2vec: databio/r2v_encoder-ChIP-atlas-hg38
+ text2vec: sentence-transformers/all-MiniLM-L6-v2
+ sparse_model: prithivida/Splade_PP_en_v2
diff --git a/tests/test_fastapi/__init__.py b/tests/integration/__init__.py
similarity index 100%
rename from tests/test_fastapi/__init__.py
rename to tests/integration/__init__.py
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
new file mode 100644
index 00000000..fd724047
--- /dev/null
+++ b/tests/integration/conftest.py
@@ -0,0 +1,138 @@
+"""Integration test fixtures for bedhost.
+
+Uses FastAPI's ``TestClient`` with ephemeral PostgreSQL + Qdrant services.
+All services are started/stopped by ``./tests/scripts/test-integration.sh``.
+
+Prerequisites:
+ ./tests/scripts/test-integration.sh
+
+Or manually:
+ 1. Start services: ./tests/scripts/services.sh start
+ 2. Run tests: RUN_INTEGRATION_TESTS=true pytest tests/integration/
+ 3. Stop services: ./tests/scripts/services.sh stop
+"""
+
+import os
+import signal as _signal_mod
+import threading as _threading_mod
+from pathlib import Path
+
+import pytest
+
+# yacman (pulled in transitively by bbconf/bedhost) registers SIGINT/SIGTERM
+# handlers in YacAttMap.__enter__, which raises ``ValueError: signal only
+# works in main thread of the main interpreter`` under FastAPI TestClient
+# (lifespan runs on an AnyIO worker thread). Swallow those so bbconf can
+# finish loading its config. Tests don't need graceful signal handling.
+_real_signal = _signal_mod.signal
+
+
+def _signal_main_thread_only(signum, handler):
+ try:
+ return _real_signal(signum, handler)
+ except ValueError:
+ return handler
+
+
+_signal_mod.signal = _signal_main_thread_only
+
+
+def pytest_collection_modifyitems(config, items):
+ """Skip every test collected under ``tests/integration/`` unless
+ ``RUN_INTEGRATION_TESTS=true``.
+
+ ``pytestmark = pytest.mark.skipif(...)`` only applies to the module it
+ is declared in; in a conftest it does NOT propagate to sibling test
+ modules. This hook is the reliable way to gate an entire directory.
+ """
+ if os.getenv("RUN_INTEGRATION_TESTS") == "true":
+ return
+ skip_marker = pytest.mark.skip(
+ reason="Integration tests disabled. Run ./tests/scripts/test-integration.sh"
+ )
+ integration_dir = Path(__file__).parent.resolve()
+ for item in items:
+ try:
+ item_path = Path(str(item.fspath)).resolve()
+ except Exception:
+ continue
+ try:
+ item_path.relative_to(integration_dir)
+ except ValueError:
+ continue
+ item.add_marker(skip_marker)
+
+
+TEMPLATE_PATH = (
+ Path(__file__).parent.parent / "fixtures" / "bedbase_test_config.template.yaml"
+)
+
+
+@pytest.fixture(scope="session")
+def bedbase_config_path(tmp_path_factory):
+ """Render the bedbase config template with ephemeral service ports."""
+ db_port = os.environ["BEDHOST_TEST_DB_PORT"]
+ qdrant_port = os.environ["BEDHOST_TEST_QDRANT_PORT"]
+
+ template = TEMPLATE_PATH.read_text()
+ rendered = template.replace("{DB_PORT}", str(db_port)).replace(
+ "{QDRANT_PORT}", str(qdrant_port)
+ )
+
+ config_dir = tmp_path_factory.mktemp("bedbase_config")
+ config_path = config_dir / "bedbase_test_config.yaml"
+ config_path.write_text(rendered)
+ return config_path
+
+
+@pytest.fixture(scope="session")
+def bedhost_app(bedbase_config_path):
+ """Import bedhost.main.app with BEDBASE_CONFIG pointing at the ephemeral stack.
+
+ Env vars MUST be set before import; clearing them on the way out keeps
+ the session pristine for any other fixtures that might follow.
+ """
+ prior_config = os.environ.get("BEDBASE_CONFIG")
+ prior_init_ml = os.environ.get("BEDHOST_INIT_ML")
+
+ os.environ["BEDBASE_CONFIG"] = str(bedbase_config_path)
+ os.environ.setdefault("BEDHOST_INIT_ML", "false")
+
+ try:
+ from bedhost.main import app # noqa: WPS433 (deliberate late import)
+
+ yield app
+ finally:
+ if prior_config is None:
+ os.environ.pop("BEDBASE_CONFIG", None)
+ else:
+ os.environ["BEDBASE_CONFIG"] = prior_config
+ if prior_init_ml is None:
+ os.environ.pop("BEDHOST_INIT_ML", None)
+ else:
+ os.environ["BEDHOST_INIT_ML"] = prior_init_ml
+
+
+@pytest.fixture(scope="session")
+def test_client(bedhost_app):
+ """Session-scoped TestClient. Entering the context runs FastAPI lifespan,
+ which constructs ``BedBaseAgent`` and triggers bbconf's
+ ``Base.metadata.create_all`` against the ephemeral Postgres.
+ """
+ from fastapi.testclient import TestClient
+
+ with TestClient(bedhost_app) as c:
+ yield c
+
+
+@pytest.fixture(scope="session")
+def integration_api_root(test_client):
+ """The integration-mode ``api_root`` fixture value.
+
+ Named distinctly from ``api_root`` to avoid a fixture name collision
+ with the top-level ``tests/conftest.py``; the top-level fixture
+ delegates via ``request.getfixturevalue`` when integration mode is on.
+ """
+ from tests.client import TestClientAdapter
+
+ return TestClientAdapter(test_client)
diff --git a/tests/integration/test_integration_smoke.py b/tests/integration/test_integration_smoke.py
new file mode 100644
index 00000000..8665af2c
--- /dev/null
+++ b/tests/integration/test_integration_smoke.py
@@ -0,0 +1,12 @@
+"""Smoke test: prove the ephemeral stack actually boots.
+
+Independent of the full compliance suite; if this passes, the TestClient
+lifespan ran, bbconf loaded the ephemeral config, and the database schema
+was created. If this fails, diagnose the stack before worrying about the
+37 compliance tests.
+"""
+
+
+def test_service_info_live(api_root):
+ res = api_root.get("/v1/service-info")
+ assert res.status_code == 200
diff --git a/tests/scripts/services.sh b/tests/scripts/services.sh
new file mode 100755
index 00000000..197ded62
--- /dev/null
+++ b/tests/scripts/services.sh
@@ -0,0 +1,173 @@
+#!/bin/bash
+# Test Services Management Script
+# Manages services required for integration tests (PostgreSQL, Qdrant)
+#
+# Supports parallel test runs via unique container names and ports.
+# Set BEDHOST_TEST_RUN_ID to share services across script invocations.
+#
+# Usage:
+# ./tests/scripts/services.sh start # Start all services
+# ./tests/scripts/services.sh stop # Stop all services
+# ./tests/scripts/services.sh status # Show service status
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$SCRIPT_DIR/../.."
+
+# Generate unique run ID if not provided (enables parallel test runs)
+RUN_ID="${BEDHOST_TEST_RUN_ID:-$$}"
+
+# Use environment variables or generate unique values
+CONTAINER_NAME="${BEDHOST_TEST_PG_CONTAINER:-bedhost-postgres-test-${RUN_ID}}"
+DB_PORT="${BEDHOST_TEST_DB_PORT:-$(( 5433 + (RANDOM % 1000) ))}"
+QDRANT_CONTAINER="${BEDHOST_TEST_QDRANT_CONTAINER:-bedhost-qdrant-test-${RUN_ID}}"
+QDRANT_PORT="${BEDHOST_TEST_QDRANT_PORT:-$(( 6334 + (RANDOM % 1000) ))}"
+
+DB_USER="testuser"
+DB_PASS="testpass"
+DB_NAME="bedbase_test"
+
+# Export for child processes and test-integration.sh
+export BEDHOST_TEST_RUN_ID="$RUN_ID"
+export BEDHOST_TEST_PG_CONTAINER="$CONTAINER_NAME"
+export BEDHOST_TEST_DB_PORT="$DB_PORT"
+export BEDHOST_TEST_QDRANT_CONTAINER="$QDRANT_CONTAINER"
+export BEDHOST_TEST_QDRANT_PORT="$QDRANT_PORT"
+
+start_postgres() {
+ echo "Starting PostgreSQL..."
+ echo " Container: $CONTAINER_NAME"
+ echo " Port: $DB_PORT"
+
+ # Remove existing container if it exists
+ docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
+
+ # Start PostgreSQL container with tmpfs for speed
+ docker run -d \
+ --name "$CONTAINER_NAME" \
+ -e POSTGRES_USER="$DB_USER" \
+ -e POSTGRES_PASSWORD="$DB_PASS" \
+ -e POSTGRES_DB="$DB_NAME" \
+ -p "${DB_PORT}:5432" \
+ --tmpfs /var/lib/postgresql/data \
+ postgres:17
+
+ # Wait for healthy status (up to 30 seconds)
+ echo "Waiting for PostgreSQL to be ready..."
+ for i in {1..30}; do
+ if docker exec "$CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" 2>/dev/null; then
+ echo "PostgreSQL is ready!"
+ return 0
+ fi
+ sleep 1
+ done
+
+ echo "Failed to start PostgreSQL"
+ docker logs "$CONTAINER_NAME"
+ return 1
+}
+
+stop_postgres() {
+ echo "Stopping PostgreSQL ($CONTAINER_NAME)..."
+ docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
+}
+
+start_qdrant() {
+ echo "Starting Qdrant..."
+ echo " Container: $QDRANT_CONTAINER"
+ echo " Port: $QDRANT_PORT"
+
+ # Remove existing container if it exists
+ docker rm -f "$QDRANT_CONTAINER" 2>/dev/null || true
+
+ docker run -d \
+ --name "$QDRANT_CONTAINER" \
+ -p "${QDRANT_PORT}:6333" \
+ --tmpfs /qdrant/storage \
+ qdrant/qdrant:latest
+
+ echo "Waiting for Qdrant to be ready..."
+ for i in {1..30}; do
+ if curl -sf "http://localhost:${QDRANT_PORT}/readyz" >/dev/null 2>&1; then
+ echo "Qdrant is ready!"
+ return 0
+ fi
+ sleep 1
+ done
+
+ echo "Failed to start Qdrant"
+ docker logs "$QDRANT_CONTAINER"
+ return 1
+}
+
+stop_qdrant() {
+ echo "Stopping Qdrant ($QDRANT_CONTAINER)..."
+ docker rm -f "$QDRANT_CONTAINER" 2>/dev/null || true
+}
+
+show_status() {
+ echo "=== Test Services Status (Run ID: $RUN_ID) ==="
+ echo ""
+ echo "PostgreSQL:"
+ if docker ps -f "name=$CONTAINER_NAME" --format " Container: {{.Names}} | Port: {{.Ports}} | Status: {{.Status}}" | grep -q .; then
+ docker ps -f "name=$CONTAINER_NAME" --format " Container: {{.Names}} | Port: {{.Ports}} | Status: {{.Status}}"
+ else
+ echo " Not running"
+ fi
+ echo ""
+ echo "Qdrant:"
+ if docker ps -f "name=$QDRANT_CONTAINER" --format " Container: {{.Names}} | Port: {{.Ports}} | Status: {{.Status}}" | grep -q .; then
+ docker ps -f "name=$QDRANT_CONTAINER" --format " Container: {{.Names}} | Port: {{.Ports}} | Status: {{.Status}}"
+ else
+ echo " Not running"
+ fi
+}
+
+print_exports() {
+ # Print export commands for manual use
+ echo ""
+ echo "To use these services manually, run:"
+ echo " export BEDHOST_TEST_RUN_ID=\"$RUN_ID\""
+ echo " export BEDHOST_TEST_DB_PORT=\"$DB_PORT\""
+ echo " export BEDHOST_TEST_QDRANT_PORT=\"$QDRANT_PORT\""
+ echo " export TEST_DB_URL=\"postgresql+psycopg://${DB_USER}:${DB_PASS}@localhost:${DB_PORT}/${DB_NAME}\""
+ echo " export TEST_QDRANT_URL=\"http://localhost:${QDRANT_PORT}\""
+}
+
+case "$1" in
+ start)
+ echo "=== Starting Test Services (Run ID: $RUN_ID) ==="
+ start_postgres
+ start_qdrant
+ print_exports
+ echo ""
+ echo "=== All services started ==="
+ ;;
+ stop)
+ echo "=== Stopping Test Services ==="
+ stop_qdrant
+ stop_postgres
+ echo "=== All services stopped ==="
+ ;;
+ restart)
+ $0 stop
+ $0 start
+ ;;
+ status)
+ show_status
+ ;;
+ *)
+ echo "Usage: $0 {start|stop|restart|status}"
+ echo ""
+ echo "Manages services required for integration tests."
+ echo ""
+ echo "Environment variables for parallel execution:"
+ echo " BEDHOST_TEST_RUN_ID - Unique identifier (default: PID)"
+ echo " BEDHOST_TEST_DB_PORT - PostgreSQL port (default: 5433 + random)"
+ echo " BEDHOST_TEST_QDRANT_PORT - Qdrant port (default: 6334 + random)"
+ echo " BEDHOST_TEST_PG_CONTAINER - Postgres container name (default: bedhost-postgres-test-\$RUN_ID)"
+ echo " BEDHOST_TEST_QDRANT_CONTAINER - Qdrant container name (default: bedhost-qdrant-test-\$RUN_ID)"
+ exit 1
+ ;;
+esac
diff --git a/tests/scripts/test-integration.sh b/tests/scripts/test-integration.sh
new file mode 100755
index 00000000..20416ac7
--- /dev/null
+++ b/tests/scripts/test-integration.sh
@@ -0,0 +1,103 @@
+#!/bin/bash
+# Run integration tests with ephemeral test services
+#
+# This script handles all setup and teardown automatically:
+# 1. Starts test services (PostgreSQL, Qdrant)
+# 2. Runs integration tests
+# 3. Tears down all services on exit (even on failure)
+#
+# Usage: ./tests/scripts/test-integration.sh [pytest args]
+# Example: ./tests/scripts/test-integration.sh -v -k "test_service_info"
+#
+# For manual service control (debugging):
+# ./tests/scripts/services.sh start
+# RUN_INTEGRATION_TESTS=true pytest tests/integration/
+# ./tests/scripts/services.sh stop
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$SCRIPT_DIR/../.."
+SERVICES_SCRIPT="$SCRIPT_DIR/services.sh"
+
+# Generate unique run ID for this test session (enables parallel test runs)
+export BEDHOST_TEST_RUN_ID="$$"
+
+# Pre-compute ports so cleanup still has them even if services.sh never ran
+export BEDHOST_TEST_DB_PORT=$(( 5433 + (RANDOM % 1000) ))
+export BEDHOST_TEST_QDRANT_PORT=$(( 6334 + (RANDOM % 1000) ))
+export BEDHOST_TEST_PG_CONTAINER="bedhost-postgres-test-${BEDHOST_TEST_RUN_ID}"
+export BEDHOST_TEST_QDRANT_CONTAINER="bedhost-qdrant-test-${BEDHOST_TEST_RUN_ID}"
+
+SERVICES_STARTED=false
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+NC='\033[0m'
+
+log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
+log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
+
+# Cleanup function - runs on exit (success or failure)
+cleanup() {
+ local exit_code=$?
+ echo ""
+ if [ "$SERVICES_STARTED" = true ]; then
+ log_info "Cleaning up..."
+ "$SERVICES_SCRIPT" stop
+ fi
+
+ if [ $exit_code -eq 0 ]; then
+ log_info "Integration tests completed successfully!"
+ else
+ log_error "Integration tests failed with exit code: $exit_code"
+ fi
+
+ exit $exit_code
+}
+
+trap cleanup EXIT INT TERM
+
+echo "=============================================="
+echo " Bedhost Integration Tests"
+echo " Run ID: $BEDHOST_TEST_RUN_ID"
+echo "=============================================="
+echo ""
+
+# Check prerequisites
+log_info "Checking prerequisites..."
+
+if ! command -v docker &> /dev/null; then
+ log_error "Docker is not installed or not in PATH"
+ exit 1
+fi
+
+if ! docker info &> /dev/null; then
+ log_error "Docker daemon is not running"
+ exit 1
+fi
+
+# Start services
+log_info "Starting test services..."
+"$SERVICES_SCRIPT" start
+SERVICES_STARTED=true
+
+# Export environment variables for tests
+export RUN_INTEGRATION_TESTS=true
+export TEST_DB_URL="postgresql+psycopg://testuser:testpass@localhost:${BEDHOST_TEST_DB_PORT}/bedbase_test"
+export TEST_QDRANT_URL="http://localhost:${BEDHOST_TEST_QDRANT_PORT}"
+export BEDHOST_INIT_ML=false
+
+# Run integration tests
+echo ""
+log_info "Running integration tests..."
+echo "----------------------------------------------"
+
+cd "$PROJECT_ROOT"
+
+python3 -m pytest tests/integration/ tests/api/ "$@"
+TEST_EXIT_CODE=$?
+
+echo "----------------------------------------------"
+exit $TEST_EXIT_CODE
diff --git a/tests/test_compliance.py b/tests/test_compliance.py
deleted file mode 100644
index b242184e..00000000
--- a/tests/test_compliance.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import requests
-
-API_ENDPOINT = "https://api.bedbase.org"
-
-
-def test_get_bedbase():
- response = requests.get(API_ENDPOINT)
- assert response.status_code == 200
-
-
-def test_get_example():
- response = requests.get(f"{API_ENDPOINT}/v1/bed/example")
- assert response.status_code == 200
- # assert response.json()
-
-
-def test_get_bed():
- response = requests.get(
- f"{API_ENDPOINT}/v1/bed/bbad85f21962bb8d972444f7f9a3a932/metadata"
- )
-
- assert response.status_code == 200
- assert response.json() is not None
-
-
-def test_get_bedset():
- response = requests.get(f"{API_ENDPOINT}/v1/bedset/example")
-
- assert response.status_code == 200
- assert response.json() is not None
diff --git a/tests/test_fastapi/README.md b/tests/test_fastapi/README.md
deleted file mode 100644
index 8a2fc2fc..00000000
--- a/tests/test_fastapi/README.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# How to set up the test environment
-
-# before running the tests, make sure you have the following installed:
-
-### Create database before running the tests:
-
-```
-docker run --rm -it --name bedbase-test \
- -e POSTGRES_USER=postgres \
- -e POSTGRES_PASSWORD=docker\
- -e POSTGRES_DB=bedbase \
- -p 5432:5432 postgres
-```
diff --git a/tests/test_fastapi/test_api.py b/tests/test_fastapi/test_api.py
deleted file mode 100644
index e7444987..00000000
--- a/tests/test_fastapi/test_api.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import os
-
-import pytest
-from fastapi.testclient import TestClient
-
-# os.environ.setdefault("BEDBASE_CONFIG", os.path.abspath("test_config.yaml"))
-# from bedhost.main import app
-#
-#
-# client = TestClient(app)
-
-
-@pytest.mark.skipif(True, reason="Database is not available")
-def test_read_main():
- response = client.get("/v1/docs/changelog")
- assert response.status_code == 200
- assert response.json() == {"msg": "Hello World"}
diff --git a/tests/test_fastapi/test_config.yaml b/tests/test_fastapi/test_config.yaml
deleted file mode 100644
index 19ae9a51..00000000
--- a/tests/test_fastapi/test_config.yaml
+++ /dev/null
@@ -1,41 +0,0 @@
-path:
- remote_url_base: http://data.bedbase.org/
- region2vec: databio/r2v-encode-hg38
- vec2vec: databio/v2v-geo-hg38
- text2vec: sentence-transformers/all-MiniLM-L6-v2
-database:
- host: localhost
- port: 5432
- password: docker
- user: postgres
- database: bedbase
-qdrant:
- host: $QDRANT_HOST
- port: 6333
- api_key: $QDRANT_API_KEY
- collection: bedbase2
-server:
- host: 0.0.0.0
- port: 8000
-s3:
- endpoint_url: $AWS_ENDPOINT_URL
- aws_access_key_id: $AWS_ACCESS_KEY_ID
- aws_secret_access_key: $AWS_SECRET_ACCESS_KEY
- bucket: bedbase
-phc:
- namespace: databio
- name: bedbase_all
- tag: default
-access_methods:
- http:
- type: "https"
- description: HTTP compatible path
- prefix: https://data2.bedbase.org/
- s3:
- type: "s3"
- description: S3 compatible path
- prefix: s3://data2.bedbase.org/
- local:
- type: "https"
- description: How to serve local files.
- prefix: /static/
diff --git a/ui/bedbase-types.d.ts b/ui/bedbase-types.d.ts
index e449f356..4fa2668f 100644
--- a/ui/bedbase-types.d.ts
+++ b/ui/bedbase-types.d.ts
@@ -1314,6 +1314,8 @@ export interface components {
summary?: string;
/** Bed Ids */
bed_ids?: string[];
+ /** Bedfile Count */
+ bedfile_count?: number;
/** Author */
author?: string | null;
/** Source */
@@ -1327,6 +1329,8 @@ export interface components {
name?: string | null;
/** Description */
description?: string | null;
+ /** Bedfile Count */
+ bedfile_count?: number;
};
/** BedSetPlots */
BedSetPlots: {
diff --git a/ui/src/components/search/search-bar.tsx b/ui/src/components/search/search-bar.tsx
index 7a676b0c..0af125de 100644
--- a/ui/src/components/search/search-bar.tsx
+++ b/ui/src/components/search/search-bar.tsx
@@ -213,6 +213,7 @@ export const SearchBar = (props: Props) => {
Genome: