From e1b22e139ab7134bd26c58e867d403f871d0b33a Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Tue, 7 Jul 2026 21:12:16 +0200 Subject: [PATCH 1/4] fix: e2e tests and deployment --- .dockerignore | 2 + .github/.trivyignore | 9 ++ .github/tests/compose.yml | 2 +- .../{config.yml => config.opengatellm.yml} | 15 ++- .github/tests/e2e/conftest.py | 31 ++++++ .github/tests/e2e/test_documents.py | 103 ++++++++---------- .github/tests/e2e/test_search.py | 40 +++---- .../{setup_tests.sh => start_services.sh} | 28 ++++- .github/workflows/build_and_deploy.yml | 48 ++++---- .github/workflows/run_tests.yml | 37 +++---- README.md | 29 +---- compose.example.yml | 21 ++++ 12 files changed, 198 insertions(+), 167 deletions(-) rename .github/tests/{config.yml => config.opengatellm.yml} (79%) rename .github/tests/{setup_tests.sh => start_services.sh} (62%) diff --git a/.dockerignore b/.dockerignore index 9898902..6ce5a0b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ !opengaterag/ !opengaterag/api/** +!scripts/ +!scripts/** !.github/ !pyproject.toml !config.example.yml diff --git a/.github/.trivyignore b/.github/.trivyignore index e69de29..d6fe35b 100644 --- a/.github/.trivyignore +++ b/.github/.trivyignore @@ -0,0 +1,9 @@ +# perl-archive-tar: perl-archive-tar: Path traversal via +# crafted symlinks allows arbitrary file access +# https://avd.aquasec.com/nvd/cve-2026-42496 +CVE-2026-42496 exp:2026-09-01 + +# perl: Perl: Heap buffer overflow when compiling regular +# expressions on 32-bit builds... +# https://avd.aquasec.com/nvd/cve-2026-8376 +CVE-2026-8376 exp:2026-09-01 \ No newline at end of file diff --git a/.github/tests/compose.yml b/.github/tests/compose.yml index 32d0c43..415f324 100644 --- a/.github/tests/compose.yml +++ b/.github/tests/compose.yml @@ -33,7 +33,7 @@ services: environment: - ALBERT_ADMIN_API_KEY=${ALBERT_ADMIN_API_KEY} volumes: - - ".github/tests/config.yml:/config.yml:ro" + - "./config.opengatellm.yml:/config.yml:ro" healthcheck: test: [ "CMD-SHELL", "curl -sf http://localhost:8000/health || exit 1" ] interval: 4s diff --git a/.github/tests/config.yml b/.github/tests/config.opengatellm.yml similarity index 79% rename from .github/tests/config.yml rename to .github/tests/config.opengatellm.yml index 69a93e9..f216f9e 100644 --- a/.github/tests/config.yml +++ b/.github/tests/config.opengatellm.yml @@ -1,7 +1,7 @@ # -------------------------------- dependencies --------------------------------- dependencies: postgres: - url: postgresql+asyncpg://postgres:changeme@localhost:5432/postgres + url: postgresql+asyncpg://postgres:changeme@postgres:5432/postgres echo: False pool_size: 10 max_overflow: 5 @@ -14,7 +14,7 @@ dependencies: command_timeout: 60 redis: - url: redis://:changeme@localhost:6379 + url: redis://:changeme@redis:6379 max_connections: 200 socket_connect_timeout: 5 retry_on_timeout: True @@ -27,25 +27,24 @@ dependencies: number_of_shards: 1 number_of_replicas: 1 refresh_interval: 1s - hosts: http://localhost:9200 + hosts: http://elasticsearch:9200 basic_auth: - - elasticsearch + - elastic - changeme # ---------------------------------- settings ----------------------------------- settings: - vector_store_model: BAAI/bge-m3 + vector_store_model: openweight-embeddings # ----------------------------------- models ------------------------------------ models: - - name: BAAI/bge-m3 + - name: openweight-embeddings type: text-embeddings-inference - aliases: ["openweight-embedding"] cost_prompt_tokens: 10 cost_completion_tokens: 30 providers: - type: albert - key: ${ALBERT_API_ADMIN_KEY} + key: ${ALBERT_ADMIN_API_KEY} timeout: 120 model_name: BAAI/bge-m3 model_total_params: 8 diff --git a/.github/tests/e2e/conftest.py b/.github/tests/e2e/conftest.py index 17239c6..c833343 100755 --- a/.github/tests/e2e/conftest.py +++ b/.github/tests/e2e/conftest.py @@ -12,6 +12,25 @@ from opengaterag.api.utils.configuration import configuration +def refresh_elasticsearch_index() -> None: + """Force Elasticsearch to make recently indexed chunks visible to search.""" + + async def _refresh() -> None: + kwargs = configuration.dependencies.elasticsearch.model_dump() + index_name = kwargs.pop("index_name") + kwargs.pop("index_language") + kwargs.pop("number_of_shards") + kwargs.pop("number_of_replicas") + kwargs.pop("refresh_interval") + client = AsyncElasticsearch(**kwargs) + try: + await client.indices.refresh(index=index_name) + finally: + await client.close() + + asyncio.run(_refresh()) + + class AuthenticatedTestClient: """Wrap a shared TestClient and inject auth headers per request.""" @@ -98,3 +117,15 @@ def admin_client(test_client: TestClient) -> AuthenticatedTestClient: """Test client authenticated as an admin user.""" api_key = _validate_opengatellm_api_key("OPENGATELLM_ADMIN_API_KEY", "admin") return AuthenticatedTestClient(client=test_client, api_key=api_key) + + +@pytest.fixture(scope="function") +def es_refresh(): + """ + Callable fixture: call `es_refresh()` after writes so searches see fresh data. + + We return the callable (instead of auto-refresh) so tests can refresh exactly + after indexing (documents/chunks) and avoid timing-based sleeps. + """ + + return refresh_elasticsearch_index diff --git a/.github/tests/e2e/test_documents.py b/.github/tests/e2e/test_documents.py index d416113..08b71e9 100755 --- a/.github/tests/e2e/test_documents.py +++ b/.github/tests/e2e/test_documents.py @@ -1,5 +1,5 @@ import json -import os +from pathlib import Path from time import sleep from uuid import uuid4 @@ -11,6 +11,8 @@ from opengaterag.api.schemas.documents import Document, Documents from opengaterag.api.utils.variables import EndpointRoute +PDF_ASSET = Path(__file__).parent / "assets" / "pdf.pdf" + @pytest.fixture(scope="module") def collection(user_client: TestClient, admin_client: TestClient): @@ -26,10 +28,9 @@ def collection(user_client: TestClient, admin_client: TestClient): @pytest.fixture(scope="function") def document(user_client: TestClient, collection: int): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} - response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data={"collection": str(collection)}, files=files) + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} + response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data={"collection_id": str(collection)}, files=files) file.close() assert response.status_code == 201, response.text document_id = response.json()["id"] @@ -42,10 +43,8 @@ def document(user_client: TestClient, collection: int): @pytest.mark.usefixtures("user_client", "admin_client", "collection") class TestDocuments: def test_upload_file_with_metadata(self, user_client: TestClient, collection: int): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - data = { # with metadata - "collection": str(collection), + "collection_id": str(collection), "chunk_size": "1000", "chunk_overlap": "200", "chunk_min_size": "0", @@ -53,8 +52,8 @@ def test_upload_file_with_metadata(self, user_client: TestClient, collection: in "metadata": json.dumps({"source_title": "test", "source_tags": "tag-1,tag-2"}), } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() assert response.status_code == 201, response.text @@ -68,7 +67,7 @@ def test_upload_file_with_metadata(self, user_client: TestClient, collection: in assert document.id == document_id assert document.chunks > 0 nb_chunks = document.chunks - assert document.name == file_path.split("/")[-1] + assert document.name == PDF_ASSET.name response = user_client.get(url=f"/v1{EndpointRoute.DOCUMENTS}/{document_id}/chunks", params={"limit": nb_chunks}) assert response.status_code == 200, response.text @@ -82,10 +81,8 @@ def test_upload_file_with_metadata(self, user_client: TestClient, collection: in assert chunks.data[0].metadata == {"source_title": "test", "source_tags": "tag-1,tag-2"} def test_upload_file_with_overwrite_name(self, user_client: TestClient, admin_client: TestClient, collection): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - data = { # with metadata - "collection": str(collection), + "collection_id": str(collection), "name": "test_document.pdf", "chunk_size": "1000", "chunk_overlap": "200", @@ -94,8 +91,8 @@ def test_upload_file_with_overwrite_name(self, user_client: TestClient, admin_cl "metadata": json.dumps({"source_title": "test", "source_tags": "tag-1,tag-2"}), } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() assert response.status_code == 201, response.text @@ -111,15 +108,14 @@ def test_upload_file_with_overwrite_name(self, user_client: TestClient, admin_cl assert document.chunks > 0 def test_upload_file_disable_chunking(self, user_client: TestClient, admin_client: TestClient, collection): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" data = { - "collection": str(collection), + "collection_id": str(collection), "disable_chunking": "true", "chunk_size": "10", "metadata": json.dumps({"source_title": "test", "source_tags": "tag-1,tag-2"}), } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.seek(0) file.close() @@ -144,7 +140,7 @@ def test_upload_file_disable_chunking(self, user_client: TestClient, admin_clien def test_create_document_without_file(self, user_client: TestClient, admin_client: TestClient, collection): data = { - "collection": str(collection), + "collection_id": str(collection), "name": "test_document.pdf", "metadata": json.dumps({"source_title": "test", "source_tags": "tag-1,tag-2"}), } @@ -161,17 +157,15 @@ def test_create_document_without_file(self, user_client: TestClient, admin_clien def test_create_document_without_name_and_file(self, user_client: TestClient, admin_client: TestClient, collection): data = { - "collection": str(collection), + "collection_id": str(collection), "metadata": json.dumps({"source_title": "test", "source_tags": "tag-1,tag-2"}), } response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data) assert response.status_code == 422, response.text def test_post_document_empty_metadata(self, user_client: TestClient, admin_client: TestClient, collection): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - data = { # with metadata - "collection": str(collection), + "collection_id": str(collection), "output_format": "markdown", "force_ocr": "false", "chunk_size": "1000", @@ -183,8 +177,8 @@ def test_post_document_empty_metadata(self, user_client: TestClient, admin_clien "is_separator_regex": "false", } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() @@ -192,8 +186,8 @@ def test_post_document_empty_metadata(self, user_client: TestClient, admin_clien data["metadata"] = "" - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() @@ -201,8 +195,8 @@ def test_post_document_empty_metadata(self, user_client: TestClient, admin_clien data["metadata"] = None - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() @@ -210,18 +204,16 @@ def test_post_document_empty_metadata(self, user_client: TestClient, admin_clien data["metadata"] = "{}" - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() assert response.status_code == 422, response.text def test_post_document_invalid_metadata_malformed(self, user_client: TestClient, admin_client: TestClient, collection): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - data = { # with metadata - "collection": str(collection), + "collection_id": str(collection), "output_format": "markdown", "force_ocr": "false", "chunk_size": "1000", @@ -234,18 +226,16 @@ def test_post_document_invalid_metadata_malformed(self, user_client: TestClient, "metadata": "{test}", } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() assert response.status_code == 422, response.text def test_post_document_invalid_metadata_str_too_long(self, user_client: TestClient, admin_client: TestClient, collection): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - data = { # with metadata - "collection": str(collection), + "collection_id": str(collection), "output_format": "markdown", "force_ocr": "false", "chunk_size": "1000", @@ -258,8 +248,8 @@ def test_post_document_invalid_metadata_str_too_long(self, user_client: TestClie "metadata": json.dumps({"source_title": "o" * 300}), } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() @@ -267,10 +257,8 @@ def test_post_document_invalid_metadata_str_too_long(self, user_client: TestClie def test_get_documents(self, user_client: TestClient, admin_client: TestClient, collection): # Create document - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - data = { # with empty metadata - "collection": str(collection), + "collection_id": str(collection), "output_format": "markdown", "force_ocr": "false", "chunk_size": "1000", @@ -283,8 +271,8 @@ def test_get_documents(self, user_client: TestClient, admin_client: TestClient, "is_separator_regex": "false", } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() @@ -297,7 +285,7 @@ def test_get_documents(self, user_client: TestClient, admin_client: TestClient, documents = response.json() Documents(**documents) # test output format - response = user_client.get(url=f"/v1{EndpointRoute.DOCUMENTS}", params={"collection": collection}) + response = user_client.get(url=f"/v1{EndpointRoute.DOCUMENTS}", params={"collection_id": collection}) assert response.status_code == 200, response.text documents = response.json() @@ -311,10 +299,8 @@ def test_get_documents(self, user_client: TestClient, admin_client: TestClient, def test_delete_document(self, user_client: TestClient, admin_client: TestClient, collection): # Create document - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" - data = { # without metadata - "collection": str(collection), + "collection_id": str(collection), "output_format": "markdown", "force_ocr": "false", "chunk_size": "1000", @@ -327,8 +313,8 @@ def test_delete_document(self, user_client: TestClient, admin_client: TestClient "is_separator_regex": "false", } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() @@ -343,7 +329,7 @@ def test_delete_document(self, user_client: TestClient, admin_client: TestClient def test_create_chunks_into_empty_document(self, user_client: TestClient, admin_client: TestClient, collection): data = { - "collection": str(collection), + "collection_id": str(collection), "name": "test_document.pdf", "metadata": json.dumps({"source_title": "test", "source_tags": "tag-1,tag-2"}), } @@ -388,13 +374,12 @@ def test_create_chunks_into_empty_document(self, user_client: TestClient, admin_ assert chunks.data[1].metadata == {"source_title": "test_2", "source_tags": "tag-1,tag-2"} def test_create_chunks_into_document_with_content(self, user_client: TestClient, admin_client: TestClient, collection): - file_path = "opengaterag/api/tests/e2e/assets/pdf.pdf" data = { - "collection": str(collection), + "collection_id": str(collection), "name": "test_document.pdf", } - with open(file_path, "rb") as file: - files = {"file": (os.path.basename(file_path), file, "application/pdf")} + with open(PDF_ASSET, "rb") as file: + files = {"file": (PDF_ASSET.name, file, "application/pdf")} response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data=data, files=files) file.close() assert response.status_code == 201, response.text diff --git a/.github/tests/e2e/test_search.py b/.github/tests/e2e/test_search.py index 92de113..4fb2420 100644 --- a/.github/tests/e2e/test_search.py +++ b/.github/tests/e2e/test_search.py @@ -1,5 +1,4 @@ import logging -from time import sleep from uuid import uuid4 from fastapi.testclient import TestClient @@ -41,7 +40,7 @@ def document_id(user_client: TestClient, collection_id: int): @pytest.fixture(scope="function") -def chunks_ids(user_client: TestClient, document_id: int): +def chunks_ids(user_client: TestClient, document_id: int, es_refresh): data = { "chunks": [ {"content": "Qui est Albert ?", "metadata": {"source_title": "test", "source_page": "1"}}, @@ -52,7 +51,7 @@ def chunks_ids(user_client: TestClient, document_id: int): response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}/{document_id}/chunks", json=data) assert response.status_code == 201, response.text chunk_ids = response.json()["ids"] - sleep(2) + es_refresh() yield chunk_ids @@ -60,7 +59,7 @@ def chunks_ids(user_client: TestClient, document_id: int): @pytest.mark.usefixtures("user_client", "admin_client", "collection_id", "document_id", "chunks_ids") class TestSearch: @staticmethod - def _create_document_with_chunk(user_client: TestClient, collection_id: int, chunks: list[dict] = []) -> int: + def _create_document_with_chunk(user_client: TestClient, collection_id: int, es_refresh, chunks: list[dict] = []) -> int: response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}", data={"collection_id": collection_id, "name": "test.pdf"}) assert response.status_code == 201, response.text document_id = response.json()["id"] @@ -68,7 +67,7 @@ def _create_document_with_chunk(user_client: TestClient, collection_id: int, chu if chunks: response = user_client.post(url=f"/v1{EndpointRoute.DOCUMENTS}/{document_id}/chunks", json={"chunks": chunks}) assert response.status_code == 201, response.text - sleep(2) + es_refresh() return document_id @@ -104,10 +103,10 @@ def test_search_with_pagination_offset(self, user_client: TestClient, admin_clie assert searches.data[0].chunk.id == second_chunk_id assert first_chunk_id not in [search.chunk.id for search in searches.data] - def test_search_with_legacy_query_argument(self, user_client: TestClient, admin_client: TestClient): - """Test POST /search with a legacy query argument.""" + def test_search_with_query(self, user_client: TestClient, admin_client: TestClient): + """Test POST /search with a query argument.""" - data = {"prompt": "Voltaire", "limit": 1} + data = {"query": "Voltaire", "limit": 1} response = user_client.post(url=f"/v1{EndpointRoute.SEARCH}", json=data) assert response.status_code == 200, response.text searches = Searches(**response.json()) @@ -147,13 +146,14 @@ def test_search_with_empty_query(self, user_client: TestClient, admin_client: Te response = user_client.post(url=f"/v1{EndpointRoute.SEARCH}", json=data) assert response.status_code == 422, response.text - def test_search_with_collection_ids_filter(self, user_client: TestClient, collection_id: int): + def test_search_with_collection_ids_filter(self, user_client: TestClient, collection_id: int, es_refresh): # create a collection, document and chunks response = user_client.post(url=f"/v1{EndpointRoute.COLLECTIONS}", json={"name": f"test_collection_{uuid4()}"}) assert response.status_code == 201, response.text second_collection_id = response.json()["id"] - self._create_document_with_chunk(user_client=user_client, collection_id=second_collection_id, chunks=[{"content": "Test"}]) + self._create_document_with_chunk(user_client=user_client, collection_id=second_collection_id, es_refresh=es_refresh, chunks=[{"content": "Test"}]) + es_refresh() data = {"query": "Test", "limit": 100} response = user_client.post(url=f"/v1{EndpointRoute.SEARCH}", json=data) @@ -169,17 +169,9 @@ def test_search_with_collection_ids_filter(self, user_client: TestClient, collec assert collection_id in [search.chunk.collection_id for search in searches.data] assert second_collection_id not in [search.chunk.collection_id for search in searches.data] - # legacy alias: collections - data = {"query": "Test", "collections": [collection_id], "limit": 10} - response = user_client.post(url=f"/v1{EndpointRoute.SEARCH}", json=data) - assert response.status_code == 200, response.text - searches = Searches(**response.json()) - assert collection_id in [search.chunk.collection_id for search in searches.data] - assert second_collection_id not in [search.chunk.collection_id for search in searches.data] - - def test_search_with_document_ids_filter(self, user_client: TestClient, admin_client: TestClient, collection_id: int, document_id: int): + def test_search_with_document_ids_filter(self, user_client: TestClient, admin_client: TestClient, collection_id: int, document_id: int, es_refresh): # create a document and chunks - second_document_id = self._create_document_with_chunk(user_client=user_client, collection_id=collection_id, chunks=[{"content": "Test"}]) + second_document_id = self._create_document_with_chunk(user_client=user_client, collection_id=collection_id, es_refresh=es_refresh, chunks=[{"content": "Test"}]) data = {"query": "Test", "document_ids": [document_id], "limit": 10} response = user_client.post(url=f"/v1{EndpointRoute.SEARCH}", json=data) @@ -205,12 +197,14 @@ def test_search_with_metadata_comparison_filters( user_client: TestClient, collection_id: int, document_id: int, + es_refresh, filter_type: str, filter_value: str | int, ): second_document_id = self._create_document_with_chunk( user_client=user_client, collection_id=collection_id, + es_refresh=es_refresh, chunks=[ {"content": "Qui est Test Secondary ?", "metadata": {"source_title": "secondary title", "source_page": 1}}, {"content": "Qui est Test Secondary ?", "metadata": {"source_title": "secondary title", "source_page": 1}}, @@ -230,10 +224,11 @@ def test_search_with_metadata_comparison_filters( assert all(search.chunk.document_id == second_document_id for search in searches.data) assert document_id not in [search.chunk.document_id for search in searches.data] - def test_search_with_metadata_compound_filter_and(self, user_client: TestClient, admin_client: TestClient, collection_id: int, document_id: int): + def test_search_with_metadata_compound_filter_and(self, user_client: TestClient, admin_client: TestClient, collection_id: int, document_id: int, es_refresh): second_document_id = self._create_document_with_chunk( user_client=user_client, collection_id=collection_id, + es_refresh=es_refresh, chunks=[ {"content": "Qui est Test Secondary ?", "metadata": {"source_title": "test", "source_page": "42"}}, {"content": "Qui est Test Secondary ?", "metadata": {"source_title": "test", "source_page": "42"}}, @@ -260,10 +255,11 @@ def test_search_with_metadata_compound_filter_and(self, user_client: TestClient, assert all(search.chunk.document_id == second_document_id for search in searches.data) assert document_id not in [search.chunk.document_id for search in searches.data] - def test_search_with_metadata_compound_filter_or(self, user_client: TestClient, admin_client: TestClient, collection_id: int): + def test_search_with_metadata_compound_filter_or(self, user_client: TestClient, admin_client: TestClient, collection_id: int, es_refresh): second_document_id = self._create_document_with_chunk( user_client=user_client, collection_id=collection_id, + es_refresh=es_refresh, chunks=[ {"content": "Qui est Test Secondary ?", "metadata": {"source_title": "test", "source_page": "42"}}, {"content": "Qui est Test Secondary ?", "metadata": {"source_title": "test", "source_page": "42"}}, diff --git a/.github/tests/setup_tests.sh b/.github/tests/start_services.sh similarity index 62% rename from .github/tests/setup_tests.sh rename to .github/tests/start_services.sh index 5fc8597..6e02f52 100755 --- a/.github/tests/setup_tests.sh +++ b/.github/tests/start_services.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash set -euo pipefail +COMPOSE_FILE=".github/tests/compose.yml" + +dump_api_diagnostics() { + echo "::group::Docker Compose status" + docker compose --file "${COMPOSE_FILE}" ps || true + echo "::endgroup::" + + echo "::group::API container logs" + docker compose --file "${COMPOSE_FILE}" logs --no-color api || true + echo "::endgroup::" +} + +if ! docker compose --file "${COMPOSE_FILE}" up --detach --wait; then + echo "Services did not become healthy." >&2 + dump_api_diagnostics + exit 1 +fi + login_token="$( curl -sf -X POST "http://localhost:8000/v1/auth/login" \ -H "Content-Type: application/json" \ @@ -19,15 +37,21 @@ admin_api_key="$( router_id="$( curl -sf "http://localhost:8000/v1/admin/routers?limit=100" \ -H "Authorization: Bearer ${admin_api_key}" \ - | jq -r '.data[] | select(.name == "BAAI/bge-m3") | .id' \ + | jq -r '.data[] | select(.name == "openweight-embeddings") | .id' \ | head -n1 )" + role_id="$( curl -sf -X POST "http://localhost:8000/v1/admin/roles" \ -H "Authorization: Bearer ${admin_api_key}" \ -H "Content-Type: application/json" \ - -d "{\"name\":\"user\",\"permissions\":[],\"limits\":[{\"router_id\":${router_id},\"type\":\"tpm\",\"value\":0}]}" \ + -d "{\"name\":\"user\",\"permissions\":[],\"limits\":[ + {\"router_id\":${router_id},\"type\":\"tpm\",\"value\":null}, + {\"router_id\":${router_id},\"type\":\"tpd\",\"value\":null}, + {\"router_id\":${router_id},\"type\":\"rpm\",\"value\":null}, + {\"router_id\":${router_id},\"type\":\"rpd\",\"value\":null} + ]}" \ | jq -r '.id' )" diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index e7b621c..e754151 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -55,28 +55,28 @@ jobs: image-name: ghcr.io/etalab-ia/opengaterag image-tag: ${{ github.event_name == 'release' && github.event.release.tag_name || 'latest' }} - deploy-dev: - if: github.event_name == 'push' # Only deploy on push to main - name: Deploy from ${{ github.ref_name }}/${{ github.sha }} - runs-on: ubuntu-latest - needs: - - build-opengaterag - - trivy-scan - steps: - - name: Trigger dev deployment - run: | - RESPONSE="$(curl --request POST \ - --form token=${{ secrets.GITLAB_CI_TOKEN }} \ - --form ref=main \ - --form 'variables[pipeline_name]=${{ github.event.repository.name }} - ${{ needs.build-opengaterag.outputs.commit_title }}' \ - --form 'variables[docker_image_tag]=latest' \ - --form 'variables[application_to_deploy]=albert-api' \ - --form 'variables[deployment_environment]=dev' \ - 'https://gitlab.com/api/v4/projects/58117805/trigger/pipeline')" + # deploy-dev: + # if: github.event_name == 'push' # Only deploy on push to main + # name: Deploy from ${{ github.ref_name }}/${{ github.sha }} + # runs-on: ubuntu-latest + # needs: + # - build-opengaterag + # - trivy-scan + # steps: + # - name: Trigger dev deployment + # run: | + # RESPONSE="$(curl --request POST \ + # --form token=${{ secrets.GITLAB_CI_TOKEN }} \ + # --form ref=main \ + # --form 'variables[pipeline_name]=${{ github.event.repository.name }} - ${{ needs.build-opengaterag.outputs.commit_title }}' \ + # --form 'variables[docker_image_tag]=latest' \ + # --form 'variables[application_to_deploy]=albert-api' \ + # --form 'variables[deployment_environment]=dev' \ + # 'https://gitlab.com/api/v4/projects/58117805/trigger/pipeline')" - if echo "$RESPONSE" | grep -q '"status":"created"'; then - echo $RESPONSE - else - echo $RESPONSE - exit 1 - fi + # if echo "$RESPONSE" | grep -q '"status":"created"'; then + # echo $RESPONSE + # else + # echo $RESPONSE + # exit 1 + # fi diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 9f903e3..69de0a7 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -3,8 +3,8 @@ name: Testing on: pull_request: branches: [ main ] - paths: - - "opengaterag/api/**" + # paths: + # - "opengaterag/api/**" workflow_call: # Add this to make the workflow reusable workflow_dispatch: # Add this to allow manual triggering @@ -18,28 +18,23 @@ jobs: with: ref: ${{ github.head_ref || github.ref_name }} + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + - name: Set up Docker Compose run: | cp config.example.yml config.yml - name: Run unit tests with coverage and publish unit badge run: | - # Install uv - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> $GITHUB_PATH - # Create a local virtual environment and install dependencies - "$HOME/.local/bin/uv" venv - "$HOME/.local/bin/uv" pip install -p .venv/bin/python ".[test]" + uv venv + uv pip install -p .venv/bin/python ".[test]" # Activate venv for the test run . .venv/bin/activate # Run unit tests with coverage - pytest opengaterag/api/tests/unit - - # Export coverage data to XML for badge computation - python -m coverage xml -o coverage.xml + pytest opengaterag/api/tests/unit --cov=opengaterag --cov-report=xml:coverage.xml # Build unit coverage badge JSON mkdir -p .github/badges @@ -62,23 +57,19 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Start OpenGateLLM services - run: docker compose --file .github/tests/compose.yml up --detach + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + + - name: Start OpenGateLLM services and setup test users + run: .github/tests/start_services.sh env: ALBERT_ADMIN_API_KEY: ${{ secrets.ALBERT_ADMIN_API_KEY }} - - - name: Setup test users and API keys - run: .github/tests/setup_tests.sh + OPENGATELLM_MODEL_NAME: openweight-embeddings - name: Run e2e tests run: | - # Install uv - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> $GITHUB_PATH - # Create a local virtual environment and install dependencies - "$HOME/.local/bin/uv" venv - "$HOME/.local/bin/uv" pip install -p .venv/bin/python ".[test]" + uv venv + uv pip install -p .venv/bin/python ".[test]" # Activate venv for the test run . .venv/bin/activate diff --git a/README.md b/README.md index 6381a83..23ccf42 100644 --- a/README.md +++ b/README.md @@ -21,34 +21,7 @@ uvicorn opengaterag.api.app:app --reload ``` -## Run e2e tests - -* Install dependencies - - ```bas - pip install ".[test]" - ``` - -* Export environment variables - - We recommand to run e2e test on the official Albert API and with a local Elasticsearch and Postgres instances. - - Export a key of a user of your the setup OpenGateLLM API (`opengatellm.url` parameter in your configuration file) without `ADMIN` permission in `OPENGATELLM_USER_API_KEY` environment variable and a key of a user - with `ADMIN` permission in `OPENGATELLM_ADMIN_API_KEY` environment variable. - - Both users need to access to the setup embeddings model of the OpenGateLLM API (`opengatellm.model_name` parameter in your configuration file). - - ```bash - export OPENGATELLM_USER_API_KEY= - export OPENGATELLM_ADMIN_API_KEY= - ``` -* Run tests - - ``` - pytest opengaterag/api/tests/e2e/ - ``` - -## Run unit tests +## Run tests * Install dependencies diff --git a/compose.example.yml b/compose.example.yml index 291078c..9a9475a 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -49,6 +49,27 @@ services: retries: 5 start_period: 60s + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.0.2 + restart: always + ports: + - "${ELASTICSEARCH_PORT:-9200}:9200" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms1g -Xmx1g" + - "ELASTIC_USERNAME=elasticsearch" + - "ELASTIC_PASSWORD=changeme" + volumes: + - elasticsearch:/usr/share/elasticsearch/data + healthcheck: + test: [ "CMD-SHELL", "curl -fs 'http://127.0.0.1:9200/_cluster/health?wait_for_status=yellow&timeout=5s' || exit 1" ] + interval: 4s + timeout: 10s + retries: 5 + start_period: 60s + volumes: postgres: redis: + elasticsearch: From 734b02161c72888ca924fd1d945e344328b883a1 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 8 Jul 2026 08:29:39 +0000 Subject: [PATCH 2/4] Update unit coverage badge --- .github/badges/coverage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/coverage.json b/.github/badges/coverage.json index 435f33d..5f17e9b 100644 --- a/.github/badges/coverage.json +++ b/.github/badges/coverage.json @@ -1 +1 @@ -{"schemaVersion":1,"label":"coverage","message":"55.92%","color":"red"} +{"schemaVersion":1,"label":"coverage","message":"73.96%","color":"red"} From 420db38828ec249a1c6262b948632ae32a04f28e Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 8 Jul 2026 10:30:40 +0200 Subject: [PATCH 3/4] add readme badges --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 23ccf42..d056289 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # OpenGateRAG +[![Version](https://img.shields.io/github/v/release/etalab-ia/OpenGateRAG?color=blue&label=version)](https://github.com/etalab-ia/OpenGateRAG/releases) +[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/etalab-ia/OpenGateRAG/refs/heads/main/.github/badges/coverage.json)](https://github.com/etalab-ia/OpenGateRAG) +[![License](https://img.shields.io/github/license/etalab-ia/OpenGateRAG?color=green&label=license)](https://github.com/etalab-ia/OpenGateRAG/blob/main/LICENSE) +[![Stars](https://img.shields.io/github/stars/etalab-ia/OpenGateRAG?color=yellow&label=stars)](https://github.com/etalab-ia/OpenGateRAG/stargazers) + + ## Developer mode (local API) * Install dependencies From 0e1247245740c1538588d67e1364adc6a3666080 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 8 Jul 2026 10:31:30 +0200 Subject: [PATCH 4/4] change scope of run tests cicd --- .github/workflows/run_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 69de0a7..1e53657 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -3,8 +3,8 @@ name: Testing on: pull_request: branches: [ main ] - # paths: - # - "opengaterag/api/**" + paths: + - "opengaterag/api/**" workflow_call: # Add this to make the workflow reusable workflow_dispatch: # Add this to allow manual triggering