From 8f549507774649e610d9cc11259785d89c0c5dc0 Mon Sep 17 00:00:00 2001 From: cszsol Date: Tue, 17 Dec 2024 17:02:17 +0100 Subject: [PATCH] Added app_utils tests (#55) * Added app_utils tests * Added test_dependencies * Update test_dependencies.py * Conflict resolution * Update test_dependencies.py * run swarm copy tests * Added test_dependencies * Fixed breaking changes * Fixed settings test * lint * Remove unnecessary dependencies * Added get_vlab_and_project tests * Added test for get starting agent * Added test for get_kg_token * Added test lifespan * lint * unit tests * Revert conftest.py * Review comments * Fixed lifespan test * Fixed fixture * Fixed test --------- Co-authored-by: kanesoban --- CHANGELOG.md | 1 + swarm_copy/app/dependencies.py | 4 +- swarm_copy_tests/app/test_app_utils.py | 75 +++++ swarm_copy_tests/app/test_config.py | 71 ++++ swarm_copy_tests/app/test_dependencies.py | 387 ++++++++++++++++++++++ swarm_copy_tests/app/test_main.py | 75 +++++ swarm_copy_tests/conftest.py | 21 ++ 7 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 swarm_copy_tests/app/test_app_utils.py create mode 100644 swarm_copy_tests/app/test_config.py create mode 100644 swarm_copy_tests/app/test_dependencies.py create mode 100644 swarm_copy_tests/app/test_main.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 34dc6cf..0e33b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tool implementations without langchain or langgraph dependencies - CRUDs. - BlueNaas CRUD tools +- app unit tests - Tests of AgentsRoutine. - Unit tests for database diff --git a/swarm_copy/app/dependencies.py b/swarm_copy/app/dependencies.py index c64223a..087f9a1 100644 --- a/swarm_copy/app/dependencies.py +++ b/swarm_copy/app/dependencies.py @@ -204,9 +204,7 @@ async def get_vlab_and_project( if not thread: raise HTTPException( status_code=404, - detail={ - "detail": "Thread not found.", - }, + detail="Thread not found.", ) if thread and thread.vlab_id and thread.project_id: vlab_and_project = { diff --git a/swarm_copy_tests/app/test_app_utils.py b/swarm_copy_tests/app/test_app_utils.py new file mode 100644 index 0000000..70018f2 --- /dev/null +++ b/swarm_copy_tests/app/test_app_utils.py @@ -0,0 +1,75 @@ +"""Test app utils.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.exceptions import HTTPException +from httpx import AsyncClient + +from swarm_copy.app.app_utils import setup_engine, validate_project +from swarm_copy.app.config import Settings + + +@pytest.mark.asyncio +async def test_validate_project(patch_required_env, httpx_mock, monkeypatch): + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + httpx_client = AsyncClient() + token = "fake_token" + test_vp = {"vlab_id": "test_vlab_DB", "project_id": "project_id_DB"} + vlab_url = "https://openbluebrain.com/api/virtual-lab-manager/virtual-labs" + + # test with bad config + httpx_mock.add_response( + url=f'{vlab_url}/{test_vp["vlab_id"]}/projects/{test_vp["project_id"]}', + status_code=404, + ) + with pytest.raises(HTTPException) as error: + await validate_project( + httpx_client=httpx_client, + vlab_id=test_vp["vlab_id"], + project_id=test_vp["project_id"], + token=token, + vlab_project_url=vlab_url, + ) + assert error.value.status_code == 401 + + # test with good config + httpx_mock.add_response( + url=f'{vlab_url}/{test_vp["vlab_id"]}/projects/{test_vp["project_id"]}', + json="test_project_ID", + ) + await validate_project( + httpx_client=httpx_client, + vlab_id=test_vp["vlab_id"], + project_id=test_vp["project_id"], + token=token, + vlab_project_url=vlab_url, + ) + # we jsut want to assert that the httpx_mock was called. + + +@patch("neuroagent.app.app_utils.create_async_engine") +def test_setup_engine(create_engine_mock, monkeypatch, patch_required_env): + create_engine_mock.return_value = AsyncMock() + + monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "prefix") + + settings = Settings() + + connection_string = "postgresql+asyncpg://user:password@localhost/dbname" + retval = setup_engine(settings=settings, connection_string=connection_string) + assert retval is not None + + +@patch("neuroagent.app.app_utils.create_async_engine") +def test_setup_engine_no_connection_string( + create_engine_mock, monkeypatch, patch_required_env +): + create_engine_mock.return_value = AsyncMock() + + monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "prefix") + + settings = Settings() + + retval = setup_engine(settings=settings, connection_string=None) + assert retval is None diff --git a/swarm_copy_tests/app/test_config.py b/swarm_copy_tests/app/test_config.py new file mode 100644 index 0000000..5274b9c --- /dev/null +++ b/swarm_copy_tests/app/test_config.py @@ -0,0 +1,71 @@ +"""Test config""" + +import pytest +from pydantic import ValidationError + +from swarm_copy.app.config import Settings + + +def test_required(monkeypatch, patch_required_env): + settings = Settings() + + assert settings.tools.literature.url == "https://fake_url" + assert settings.knowledge_graph.base_url == "https://fake_url/api/nexus/v1" + assert settings.openai.token.get_secret_value() == "dummy" + + # make sure not case sensitive + monkeypatch.delenv("NEUROAGENT_TOOLS__LITERATURE__URL") + monkeypatch.setenv("neuroagent_tools__literature__URL", "https://new_fake_url") + + settings = Settings() + assert settings.tools.literature.url == "https://new_fake_url" + + +def test_no_settings(): + # We get an error when no custom variables provided + with pytest.raises(ValidationError): + Settings() + + +def test_setup_tools(monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_TOOLS__TRACE__SEARCH_SIZE", "20") + monkeypatch.setenv("NEUROAGENT_TOOLS__MORPHO__SEARCH_SIZE", "20") + monkeypatch.setenv("NEUROAGENT_TOOLS__KG_MORPHO_FEATURES__SEARCH_SIZE", "20") + + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__USERNAME", "user") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__PASSWORD", "pass") + + settings = Settings() + + assert settings.tools.morpho.search_size == 20 + assert settings.tools.trace.search_size == 20 + assert settings.tools.kg_morpho_features.search_size == 20 + assert settings.keycloak.username == "user" + assert settings.keycloak.password.get_secret_value() == "pass" + + +def test_check_consistency(monkeypatch): + # We get an error when no custom variables provided + url = "https://fake_url" + monkeypatch.setenv("NEUROAGENT_TOOLS__LITERATURE__URL", url) + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__URL", url) + + with pytest.raises(ValueError): + Settings() + + monkeypatch.setenv("NEUROAGENT_GENERATIVE__OPENAI__TOKEN", "dummy") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + + with pytest.raises(ValueError): + Settings() + + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "false") + + with pytest.raises(ValueError): + Settings() + + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__BASE_URL", "http://fake_nexus.com") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__PASSWORD", "Hello") + + Settings() diff --git a/swarm_copy_tests/app/test_dependencies.py b/swarm_copy_tests/app/test_dependencies.py new file mode 100644 index 0000000..ad8b8f2 --- /dev/null +++ b/swarm_copy_tests/app/test_dependencies.py @@ -0,0 +1,387 @@ +"""Test dependencies.""" + +import json +import os +from pathlib import Path +from typing import AsyncIterator +from unittest.mock import Mock, patch + +import pytest +from httpx import AsyncClient +from fastapi import Request, HTTPException + +from swarm_copy.app.app_utils import setup_engine +from swarm_copy.app.database.sql_schemas import Base, Threads +from swarm_copy.app.dependencies import ( + Settings, + get_cell_types_kg_hierarchy, + get_connection_string, + get_httpx_client, + get_settings, + get_update_kg_hierarchy, + get_user_id, get_session, get_vlab_and_project, get_starting_agent, get_kg_token, +) +from swarm_copy.new_types import Agent + + +def test_get_settings(patch_required_env): + settings = get_settings() + assert settings.tools.literature.url == "https://fake_url" + assert settings.knowledge_graph.url == "https://fake_url/api/nexus/v1/search/query/" + + +@pytest.mark.asyncio +async def test_get_httpx_client(): + request = Mock() + request.headers = {"x-request-id": "greatid"} + httpx_client_iterator = get_httpx_client(request=request) + assert isinstance(httpx_client_iterator, AsyncIterator) + async for httpx_client in httpx_client_iterator: + assert isinstance(httpx_client, AsyncClient) + assert httpx_client.headers["x-request-id"] == "greatid" + + +@pytest.mark.asyncio +async def test_get_user(httpx_mock, monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__USERNAME", "fake_username") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__PASSWORD", "fake_password") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__ISSUER", "https://great_issuer.com") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + + fake_response = { + "sub": "12345", + "email_verified": False, + "name": "Machine Learning Test User", + "groups": [], + "preferred_username": "sbo-ml", + "given_name": "Machine Learning", + "family_name": "Test User", + "email": "email@epfl.ch", + } + httpx_mock.add_response( + url="https://great_issuer.com/protocol/openid-connect/userinfo", + json=fake_response, + ) + + settings = Settings() + client = AsyncClient() + token = "eyJgreattoken" + user_id = await get_user_id(token=token, settings=settings, httpx_client=client) + + assert user_id == fake_response["sub"] + + +@pytest.mark.asyncio +async def test_get_update_kg_hierarchy( + tmp_path, httpx_mock, monkeypatch, patch_required_env +): + token = "fake_token" + file_name = "fake_file" + client = AsyncClient() + + file_url = "https://fake_file_url" + + monkeypatch.setenv( + "NEUROAGENT_KNOWLEDGE_GRAPH__HIERARCHY_URL", "http://fake_hierarchy_url.com" + ) + + settings = Settings( + knowledge_graph={"br_saving_path": tmp_path / "test_brain_region.json"} + ) + + json_response_url = { + "head": {"vars": ["file_url"]}, + "results": {"bindings": [{"file_url": {"type": "uri", "value": file_url}}]}, + } + with open( + Path(__file__).parent.parent.parent + / "tests" + / "data" + / "KG_brain_regions_hierarchy_test.json" + ) as fh: + json_response_file = json.load(fh) + + httpx_mock.add_response( + url=settings.knowledge_graph.sparql_url, json=json_response_url + ) + httpx_mock.add_response(url=file_url, json=json_response_file) + + await get_update_kg_hierarchy( + token, + client, + settings, + file_name, + ) + + assert os.path.exists(settings.knowledge_graph.br_saving_path) + + +@pytest.mark.asyncio +async def test_get_cell_types_kg_hierarchy( + tmp_path, httpx_mock, monkeypatch, patch_required_env +): + token = "fake_token" + file_name = "fake_file" + client = AsyncClient() + + file_url = "https://fake_file_url" + monkeypatch.setenv( + "NEUROAGENT_KNOWLEDGE_GRAPH__HIERARCHY_URL", "http://fake_hierarchy_url.com" + ) + + settings = Settings( + knowledge_graph={"ct_saving_path": tmp_path / "test_cell_types_region.json"} + ) + + json_response_url = { + "head": {"vars": ["file_url"]}, + "results": {"bindings": [{"file_url": {"type": "uri", "value": file_url}}]}, + } + with open( + Path(__file__).parent.parent.parent + / "tests" + / "data" + / "kg_cell_types_hierarchy_test.json" + ) as fh: + json_response_file = json.load(fh) + + httpx_mock.add_response( + url=settings.knowledge_graph.sparql_url, json=json_response_url + ) + httpx_mock.add_response(url=file_url, json=json_response_file) + + await get_cell_types_kg_hierarchy( + token, + client, + settings, + file_name, + ) + + assert os.path.exists(settings.knowledge_graph.ct_saving_path) + + +def test_get_connection_string_full(monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "http://") + monkeypatch.setenv("NEUROAGENT_DB__USER", "John") + monkeypatch.setenv("NEUROAGENT_DB__PASSWORD", "Doe") + monkeypatch.setenv("NEUROAGENT_DB__HOST", "localhost") + monkeypatch.setenv("NEUROAGENT_DB__PORT", "5000") + monkeypatch.setenv("NEUROAGENT_DB__NAME", "test") + + settings = Settings() + result = get_connection_string(settings) + assert ( + result == "http://John:Doe@localhost:5000/test" + ), "must return fully formed connection string" + + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +async def test_get_vlab_and_project( + patch_required_env, httpx_mock, db_connection, monkeypatch +): + # Setup DB with one thread to do the tests + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + test_settings = Settings( + db={"prefix": db_connection}, + ) + engine = setup_engine(test_settings, db_connection) + session = await anext(get_session(engine)) + user_id = "Super_user" + token = "fake_token" + httpx_client = AsyncClient() + httpx_mock.add_response( + url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project", + json="test_project_ID", + ) + + # create test thread table + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + new_thread = Threads( + user_id=user_id, + vlab_id="test_vlab_DB", + project_id="project_id_DB", + title="test_title", + ) + session.add(new_thread) + await session.commit() + + try: + # Test with info in headers. + good_request_headers = Request( + scope={ + "type": "http", + "method": "Get", + "url": "http://fake_url/thread_id", + "headers": [ + (b"x-virtual-lab-id", b"test_vlab"), + (b"x-project-id", b"test_project"), + ], + }, + ) + ids = await get_vlab_and_project( + user_id=user_id, + session=session, + request=good_request_headers, + settings=test_settings, + token=token, + httpx_client=httpx_client, + ) + assert ids == {"vlab_id": "test_vlab", "project_id": "test_project"} + finally: + # don't forget to close the session, otherwise the tests hangs. + await session.close() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_get_vlab_and_project_no_info_in_headers( + patch_required_env, db_connection, monkeypatch +): + # Setup DB with one thread to do the tests + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + test_settings = Settings( + db={"prefix": db_connection}, + ) + engine = setup_engine(test_settings, db_connection) + session = await anext(get_session(engine)) + user_id = "Super_user" + token = "fake_token" + httpx_client = AsyncClient() + + # create test thread table + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + new_thread = Threads( + user_id=user_id, + vlab_id="test_vlab_DB", + project_id="project_id_DB", + title="test_title", + ) + session.add(new_thread) + await session.commit() + + try: + # Test with no infos in headers. + bad_request = Request( + scope={ + "type": "http", + "method": "GET", + "scheme": "http", + "server": ("example.com", 80), + "path_params": {"dummy_patram": "fake_thread_id"}, + "headers": [ + (b"wong_header", b"wrong value"), + ], + } + ) + with pytest.raises(HTTPException) as error: + await get_vlab_and_project( + user_id=user_id, + session=session, + request=bad_request, + settings=test_settings, + token=token, + httpx_client=httpx_client, + ) + assert ( + error.value.detail == "Thread not found." + ) + finally: + # don't forget to close the session, otherwise the tests hangs. + await session.close() + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +async def test_get_vlab_and_project_valid_thread_id( + patch_required_env, httpx_mock, db_connection, monkeypatch +): + # Setup DB with one thread to do the tests + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + test_settings = Settings( + db={"prefix": db_connection}, + ) + engine = setup_engine(test_settings, db_connection) + session = await anext(get_session(engine)) + user_id = "Super_user" + token = "fake_token" + httpx_client = AsyncClient() + httpx_mock.add_response( + url=f"{test_settings.virtual_lab.get_project_url}/test_vlab_DB/projects/project_id_DB", + json="test_project_ID", + ) + + + # create test thread table + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + new_thread = Threads( + user_id=user_id, + vlab_id="test_vlab_DB", + project_id="project_id_DB", + title="test_title", + ) + session.add(new_thread) + await session.commit() + await session.refresh(new_thread) + + try: + # Test with no infos in headers, but valid thread_ID. + good_request_DB = Request( + scope={ + "type": "http", + "method": "GET", + "scheme": "http", + "server": ("example.com", 80), + "path_params": {"thread_id": new_thread.thread_id}, + "headers": [ + (b"wong_header", b"wrong value"), + ], + } + ) + ids_from_DB = await get_vlab_and_project( + user_id=user_id, + session=session, + request=good_request_DB, + settings=test_settings, + token=token, + httpx_client=httpx_client, + ) + assert ids_from_DB == {"vlab_id": "test_vlab_DB", "project_id": "project_id_DB"} + + finally: + # don't forget to close the session, otherwise the tests hangs. + await session.close() + await engine.dispose() + + +def test_get_starting_agent(patch_required_env): + settings = Settings() + agent = get_starting_agent(None, settings) + + assert isinstance(agent, Agent) + + +@pytest.mark.parametrize( + "input_token, expected_token", + [ + ("existing_token", "existing_token"), + (None, "new_token"), + ], +) +def test_get_kg_token(patch_required_env, input_token, expected_token): + settings = Settings() + mock = Mock() + mock.token.return_value = {"access_token": expected_token} + with ( + patch("swarm_copy.app.dependencies.KeycloakOpenID", return_value=mock), + ): + result = get_kg_token(settings, input_token) + assert result == expected_token diff --git a/swarm_copy_tests/app/test_main.py b/swarm_copy_tests/app/test_main.py new file mode 100644 index 0000000..23f4299 --- /dev/null +++ b/swarm_copy_tests/app/test_main.py @@ -0,0 +1,75 @@ +import logging +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from swarm_copy.app.dependencies import get_settings +from swarm_copy.app.main import app + + +def test_settings_endpoint(app_client, dont_look_at_env_file, settings): + response = app_client.get("/settings") + + replace_secretstr = settings.model_dump() + replace_secretstr["keycloak"]["password"] = "**********" + replace_secretstr["openai"]["token"] = "**********" + assert response.json() == replace_secretstr + + +def test_readyz(app_client): + response = app_client.get( + "/", + ) + + body = response.json() + assert isinstance(body, dict) + assert body["status"] == "ok" + + +def test_lifespan(caplog, monkeypatch, tmp_path, patch_required_env, db_connection): + get_settings.cache_clear() + caplog.set_level(logging.INFO) + + monkeypatch.setenv("NEUROAGENT_LOGGING__LEVEL", "info") + monkeypatch.setenv("NEUROAGENT_LOGGING__EXTERNAL_PACKAGES", "warning") + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__DOWNLOAD_HIERARCHY", "true") + monkeypatch.setenv("NEUROAGENT_DB__PREFIX", db_connection) + + save_path_brainregion = tmp_path / "fake.json" + + async def save_dummy(*args, **kwargs): + with open(save_path_brainregion, "w") as f: + f.write("test_text") + + with ( + patch("swarm_copy.app.main.get_update_kg_hierarchy", new=save_dummy), + patch("swarm_copy.app.main.get_cell_types_kg_hierarchy", new=save_dummy), + patch("swarm_copy.app.main.get_kg_token", new=lambda *args, **kwargs: "dev"), + ): + # The with statement triggers the startup. + with TestClient(app) as test_client: + test_client.get("/healthz") + # check if the brain region dummy file was created. + assert save_path_brainregion.exists() + + assert caplog.record_tuples[0][::2] == ( + "swarm_copy.app.dependencies", + "Reading the environment and instantiating settings", + ) + + assert ( + logging.getLevelName(logging.getLogger("swarm_copy").getEffectiveLevel()) + == "INFO" + ) + assert ( + logging.getLevelName(logging.getLogger("httpx").getEffectiveLevel()) + == "WARNING" + ) + assert ( + logging.getLevelName(logging.getLogger("fastapi").getEffectiveLevel()) + == "WARNING" + ) + assert ( + logging.getLevelName(logging.getLogger("bluepyefe").getEffectiveLevel()) + == "CRITICAL" + ) diff --git a/swarm_copy_tests/conftest.py b/swarm_copy_tests/conftest.py index 31deefb..48c5a59 100644 --- a/swarm_copy_tests/conftest.py +++ b/swarm_copy_tests/conftest.py @@ -167,3 +167,24 @@ def get_resolve_query_output(): def brain_region_json_path(): br_path = Path(__file__).parent / "data" / "brainregion_hierarchy.json" return br_path + + +@pytest.fixture(name="settings") +def settings(): + return Settings( + tools={ + "literature": { + "url": "fake_literature_url", + }, + }, + knowledge_graph={ + "base_url": "https://fake_url/api/nexus/v1", + }, + openai={ + "token": "fake_token", + }, + keycloak={ + "username": "fake_username", + "password": "fake_password", + }, + )