diff --git a/.github/workflows/api_tests.yml b/.github/workflows/api_tests.yml new file mode 100644 index 000000000..98889d635 --- /dev/null +++ b/.github/workflows/api_tests.yml @@ -0,0 +1,90 @@ +name: API Tests + +on: + push: + branches: [main, dev] + pull_request: + +jobs: + api-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Docker Compose + uses: docker/setup-compose-action@v1 + with: + version: v2.34.0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install test dependencies + run: | + pip install pytest httpx pytest-timeout + + - name: Build and start services + working-directory: .github/workflows/api_tests + run: | + docker compose build + docker compose up -d + + echo "Waiting for services to be ready..." + + # Wait for mock-vllm + echo "Waiting for mock-vllm..." + if ! timeout 60 bash -c 'until curl -sf http://localhost:8000/health 2>/dev/null; do sleep 2; done'; then + echo "Mock VLLM failed to start" + docker compose logs mock-vllm + exit 1 + fi + + # Wait for OpenRAG API + echo "Waiting for OpenRAG API..." + for i in {1..60}; do + if curl -sf http://localhost:8080/health_check 2>/dev/null; then + echo "OpenRAG API ready after $i attempts" + break + fi + echo "Attempt $i/60 - waiting..." + sleep 5 + done + + # Final check + if ! curl -sf http://localhost:8080/health_check; then + echo "OpenRAG API failed to start" + docker compose logs openrag + exit 1 + fi + + echo "All services ready!" + + - name: Run API tests + env: + OPENRAG_API_URL: http://localhost:8080 + run: | + pytest tests/api_tests/ -v --timeout=120 --tb=short + + - name: Show logs on failure + if: failure() + working-directory: .github/workflows/api_tests + run: | + echo "=== OpenRAG Logs ===" + docker compose logs openrag --tail=100 + echo "=== Mock VLLM Logs ===" + docker compose logs mock-vllm --tail=50 + echo "=== Milvus Logs ===" + docker compose logs milvus --tail=50 + + - name: Cleanup + if: always() + working-directory: .github/workflows/api_tests + run: | + docker compose down -v diff --git a/.github/workflows/api_tests/Dockerfile.mock-vllm b/.github/workflows/api_tests/Dockerfile.mock-vllm new file mode 100644 index 000000000..f53e41b1b --- /dev/null +++ b/.github/workflows/api_tests/Dockerfile.mock-vllm @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir fastapi uvicorn pydantic +COPY mock_vllm.py . +CMD ["python", "mock_vllm.py"] diff --git a/.github/workflows/api_tests/docker-compose.yaml b/.github/workflows/api_tests/docker-compose.yaml new file mode 100644 index 000000000..a66b5a65b --- /dev/null +++ b/.github/workflows/api_tests/docker-compose.yaml @@ -0,0 +1,129 @@ +# Minimal compose for API testing without VLLM +# Uses mock embedding server instead + +services: + # Mock VLLM embedding server + mock-vllm: + build: + context: . + dockerfile: Dockerfile.mock-vllm + ports: + - "8000:8000" + networks: + default: + aliases: + - vllm + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 2s + timeout: 5s + retries: 10 + start_period: 5s + + # PostgreSQL for user/partition management + rdb: + image: postgres:15 + environment: + - POSTGRES_PASSWORD=root_password + - POSTGRES_USER=root + healthcheck: + test: ["CMD-SHELL", "pg_isready -U root"] + interval: 2s + timeout: 5s + retries: 10 + + # Milvus dependencies + etcd: + image: quay.io/coreos/etcd:v3.5.16 + environment: + - ETCD_AUTO_COMPACTION_MODE=revision + - ETCD_AUTO_COMPACTION_RETENTION=1000 + - ETCD_QUOTA_BACKEND_BYTES=4294967296 + command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd + healthcheck: + test: ["CMD", "etcdctl", "endpoint", "health"] + interval: 5s + timeout: 10s + retries: 5 + + minio: + image: minio/minio:RELEASE.2023-03-20T20-16-18Z + environment: + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + command: minio server /minio_data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 10s + retries: 5 + + milvus: + image: milvusdb/milvus:v2.5.4 + command: ["milvus", "run", "standalone"] + security_opt: + - seccomp:unconfined + environment: + ETCD_ENDPOINTS: etcd:2379 + MINIO_ADDRESS: minio:9000 + depends_on: + etcd: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] + interval: 5s + timeout: 10s + retries: 10 + start_period: 30s + + # OpenRAG API (build from repo root) + openrag: + build: + context: ../../../ + dockerfile: Dockerfile + environment: + - EMBEDDER_MODEL_NAME=mock-embedding-model + - EMBEDDER_BASE_URL=http://vllm:8000/v1 + - EMBEDDER_API_KEY=EMPTY + - POSTGRES_HOST=rdb + - POSTGRES_USER=root + - POSTGRES_PASSWORD=root_password + - VDB_HOST=milvus + - BASE_URL=http://localhost:8001/ + - API_KEY=sk-test + - MODEL=test-model + - VLM_BASE_URL=http://localhost:8002/ + - VLM_API_KEY=sk-test + - VLM_MODEL=test-vlm + - RERANKER_ENABLED=false + - WITH_CHAINLIT_UI=false + - WITH_OPENAI_API=true + - IMAGE_CAPTIONING=false + - LOG_LEVEL=INFO + - PDFLoader=PyMuPDFLoader + - RAY_POOL_SIZE=1 + - RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 + - RAY_memory_monitor_refresh_ms=0 + - PROMPTS_DIR=../prompts/example1 + ports: + - "8080:8080" + volumes: + - test_data:/app/data + depends_on: + mock-vllm: + condition: service_healthy + milvus: + condition: service_healthy + rdb: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health_check"] + interval: 5s + timeout: 10s + retries: 20 + start_period: 60s + +volumes: + test_data: diff --git a/.github/workflows/api_tests/fixtures/sample.txt b/.github/workflows/api_tests/fixtures/sample.txt new file mode 100644 index 000000000..73faa8385 --- /dev/null +++ b/.github/workflows/api_tests/fixtures/sample.txt @@ -0,0 +1,42 @@ +Sample Document for API Testing + +This is a sample document used for testing the OpenRAG API. + +Introduction +============ + +OpenRAG is a Retrieval-Augmented Generation system that combines document indexing +with semantic search capabilities. This document is used to verify that the API +endpoints work correctly. + +Content +======= + +The system supports various file formats including: +- Plain text files (.txt) +- Markdown documents (.md) +- PDF documents (.pdf) +- Microsoft Word documents (.docx) + +Features +======== + +1. Document Indexing: Upload and index documents for semantic search +2. Semantic Search: Find relevant documents using natural language queries +3. RAG Integration: Combine search results with language models +4. Partition Management: Organize documents into separate collections + +Testing +======= + +This fixture file is used in automated tests to verify: +- File upload functionality +- Document indexing pipeline +- Search capabilities +- API response formats + +Conclusion +========== + +The OpenRAG API provides a comprehensive set of endpoints for document management +and retrieval-augmented generation workflows. diff --git a/.github/workflows/api_tests/mock_vllm.py b/.github/workflows/api_tests/mock_vllm.py new file mode 100644 index 000000000..411390f13 --- /dev/null +++ b/.github/workflows/api_tests/mock_vllm.py @@ -0,0 +1,80 @@ +""" +Lightweight mock VLLM embedding server for CI testing. +Returns deterministic fake embeddings without loading actual models. +""" +import hashlib +from typing import List, Union + +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + +# Matches ibm-granite/granite-embedding-small-english-r2 dimension +EMBEDDING_DIM = 384 + + +class EmbeddingRequest(BaseModel): + model: str + input: Union[str, List[str]] + encoding_format: str = "float" + + +class EmbeddingData(BaseModel): + object: str = "embedding" + embedding: List[float] + index: int + + +class EmbeddingResponse(BaseModel): + object: str = "list" + data: List[EmbeddingData] + model: str + usage: dict + + +def generate_fake_embedding(text: str, dim: int = EMBEDDING_DIM) -> List[float]: + """Generate deterministic fake embedding based on text hash.""" + h = hashlib.md5(text.encode()).digest() + result = [] + for i in range(dim): + byte_val = h[i % len(h)] + result.append((byte_val / 128.0) - 1.0) + return result + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/v1/models") +async def list_models(): + return { + "object": "list", + "data": [{"id": "mock-embedding-model", "object": "model"}] + } + + +@app.post("/v1/embeddings") +async def create_embeddings(request: EmbeddingRequest) -> EmbeddingResponse: + inputs = request.input if isinstance(request.input, list) else [request.input] + + data = [ + EmbeddingData( + embedding=generate_fake_embedding(text), + index=i + ) + for i, text in enumerate(inputs) + ] + + return EmbeddingResponse( + data=data, + model=request.model, + usage={"prompt_tokens": len(inputs) * 10, "total_tokens": len(inputs) * 10} + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/openrag/components/indexer/vectordb/vectordb.py b/openrag/components/indexer/vectordb/vectordb.py index 2d22541b5..f6fd6c367 100644 --- a/openrag/components/indexer/vectordb/vectordb.py +++ b/openrag/components/indexer/vectordb/vectordb.py @@ -709,15 +709,22 @@ async def get_chunk_by_id(self, chunk_id: str): """ Retrieve a chunk by its ID. Args: - chunk_id (str): The ID of the chunk to retrieve. + chunk_id (str): The ID of the chunk to retrieve (Milvus Int64 _id as string). Returns: - Document: The retrieved chunk. + Document: The retrieved chunk, or None if not found or invalid ID format. """ log = self.logger.bind(chunk_id=chunk_id) + # Milvus _id is Int64, so we need to convert the string to int + try: + chunk_id_int = int(chunk_id) + except (ValueError, TypeError): + log.warning("Invalid chunk_id format - must be an integer") + return None + try: response = await self._async_client.query( collection_name=self.collection_name, - filter=f"_id == {chunk_id}", + filter=f"_id == {chunk_id_int}", limit=1, ) if response: diff --git a/openrag/routers/extract.py b/openrag/routers/extract.py index 0df83809f..be3f39751 100644 --- a/openrag/routers/extract.py +++ b/openrag/routers/extract.py @@ -66,6 +66,8 @@ async def get_extract( detail=f"User does not have access to extract '{extract_id}'.", ) log.info("Extract successfully retrieved.") + except HTTPException: + raise except Exception as e: log.exception("Failed to retrieve extract.", error=str(e)) raise HTTPException( diff --git a/tests/api_tests/__init__.py b/tests/api_tests/__init__.py new file mode 100644 index 000000000..ffe0808cd --- /dev/null +++ b/tests/api_tests/__init__.py @@ -0,0 +1 @@ +# API Tests Package diff --git a/tests/api_tests/conftest.py b/tests/api_tests/conftest.py new file mode 100644 index 000000000..b9491df43 --- /dev/null +++ b/tests/api_tests/conftest.py @@ -0,0 +1,96 @@ +""" +Pytest fixtures for OpenRAG API tests. +""" +import os +import time +import uuid +from pathlib import Path + +import httpx +import pytest + +API_BASE_URL = os.environ.get("OPENRAG_API_URL", "http://localhost:8080") + + +@pytest.fixture(scope="session") +def api_client(): + """Create HTTP client for API tests.""" + with httpx.Client(base_url=API_BASE_URL, timeout=30.0) as client: + yield client + + +@pytest.fixture(scope="session", autouse=True) +def wait_for_api(): + """Wait for OpenRAG API to be ready.""" + max_retries = 60 + for i in range(max_retries): + try: + response = httpx.get(f"{API_BASE_URL}/health_check", timeout=5.0) + if response.status_code == 200: + print(f"API ready after {i + 1} attempts") + return + except httpx.RequestError: + pass + time.sleep(2) + pytest.fail(f"API not ready after {max_retries * 2} seconds") + + +@pytest.fixture +def sample_text_file(tmp_path): + """Create a sample text file for upload tests.""" + content = """This is a test document about artificial intelligence and machine learning. + +Machine learning is a subset of artificial intelligence that enables systems to learn +and improve from experience without being explicitly programmed. + +Deep learning is a type of machine learning based on artificial neural networks. +It has revolutionized fields like computer vision and natural language processing. + +This document is used for testing the OpenRAG API file indexing capabilities. +""" + file_path = tmp_path / "test_doc.txt" + file_path.write_text(content) + return file_path + + +@pytest.fixture +def sample_markdown_file(tmp_path): + """Create a sample markdown file for upload tests.""" + content = """# Test Document + +## Introduction + +This is a **markdown** document for testing purposes. + +## Content + +- Item 1: Testing file upload +- Item 2: Testing indexing +- Item 3: Testing search + +## Conclusion + +This concludes our test document. +""" + file_path = tmp_path / "test_doc.md" + file_path.write_text(content) + return file_path + + +@pytest.fixture +def test_partition_name(): + """Generate unique partition name for test isolation.""" + return f"test-partition-{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +def created_partition(api_client, test_partition_name): + """Create a partition and clean it up after the test.""" + response = api_client.post(f"/partition/{test_partition_name}") + assert response.status_code in [200, 201], f"Failed to create partition: {response.text}" + yield test_partition_name + # Cleanup + try: + api_client.delete(f"/partition/{test_partition_name}") + except Exception: + pass diff --git a/tests/api_tests/test_actors.py b/tests/api_tests/test_actors.py new file mode 100644 index 000000000..014c314b1 --- /dev/null +++ b/tests/api_tests/test_actors.py @@ -0,0 +1,18 @@ +"""Ray actors API tests.""" +import pytest + + +class TestActorsAPI: + """Test Ray actors management endpoints.""" + + def test_list_actors(self, api_client): + """Test listing Ray actors.""" + # Use trailing slash to avoid redirect + response = api_client.get("/actors/") + # May require admin privileges or return redirect + assert response.status_code in [200, 307, 403] + + if response.status_code == 200: + data = response.json() + # Should return actor information + assert isinstance(data, (list, dict)) diff --git a/tests/api_tests/test_extract.py b/tests/api_tests/test_extract.py new file mode 100644 index 000000000..756602b53 --- /dev/null +++ b/tests/api_tests/test_extract.py @@ -0,0 +1,17 @@ +"""Extract (chunk retrieval) API tests.""" +import pytest + + +class TestExtract: + """Test chunk extraction functionality.""" + + def test_get_nonexistent_extract(self, api_client): + """Test retrieving non-existent extract returns error.""" + response = api_client.get("/extract/nonexistent-id-12345") + assert response.status_code == 404 + + def test_extract_invalid_id_format(self, api_client): + """Test extract with invalid ID format.""" + response = api_client.get("/extract/") + # Should return method not allowed or not found + assert response.status_code in [404, 405] diff --git a/tests/api_tests/test_health.py b/tests/api_tests/test_health.py new file mode 100644 index 000000000..0c4a58ee1 --- /dev/null +++ b/tests/api_tests/test_health.py @@ -0,0 +1,23 @@ +"""Health check endpoint tests.""" + + +def test_health_check(api_client): + """Test health check endpoint returns OK.""" + response = api_client.get("/health_check") + assert response.status_code == 200 + assert "RAG API is up" in response.text + + +def test_openapi_docs_accessible(api_client): + """Test OpenAPI documentation is accessible.""" + response = api_client.get("/docs") + assert response.status_code == 200 + + +def test_openapi_json_accessible(api_client): + """Test OpenAPI JSON schema is accessible.""" + response = api_client.get("/openapi.json") + assert response.status_code == 200 + data = response.json() + assert "openapi" in data + assert "paths" in data diff --git a/tests/api_tests/test_indexer.py b/tests/api_tests/test_indexer.py new file mode 100644 index 000000000..0d5e562f6 --- /dev/null +++ b/tests/api_tests/test_indexer.py @@ -0,0 +1,130 @@ +"""Indexer API tests - file upload and indexing.""" +import time + +import pytest + + +class TestSupportedTypes: + """Test supported file types endpoint.""" + + def test_get_supported_types(self, api_client): + """Test getting supported file types.""" + response = api_client.get("/indexer/supported/types") + assert response.status_code == 200 + data = response.json() + assert "extensions" in data + assert "mimetypes" in data + # Check common types are supported + assert "txt" in data["extensions"] + assert "pdf" in data["extensions"] + assert "md" in data["extensions"] + + +class TestFileIndexing: + """Test file upload and indexing operations.""" + + def test_upload_text_file(self, api_client, created_partition, sample_text_file): + """Test uploading and indexing a text file.""" + file_id = "test-file-001" + + with open(sample_text_file, "rb") as f: + response = api_client.post( + f"/indexer/partition/{created_partition}/file/{file_id}", + files={"file": ("test.txt", f, "text/plain")}, + data={"metadata": "{}"} + ) + + assert response.status_code in [200, 201, 202] + data = response.json() + assert "task_status_url" in data or "task_id" in data + + def test_upload_markdown_file(self, api_client, created_partition, sample_markdown_file): + """Test uploading and indexing a markdown file.""" + file_id = "test-md-001" + + with open(sample_markdown_file, "rb") as f: + response = api_client.post( + f"/indexer/partition/{created_partition}/file/{file_id}", + files={"file": ("test.md", f, "text/markdown")}, + data={"metadata": "{}"} + ) + + assert response.status_code in [200, 201, 202] + + def test_upload_with_metadata(self, api_client, created_partition, sample_text_file): + """Test uploading file with custom metadata.""" + file_id = "test-metadata-001" + metadata = '{"author": "test", "category": "documentation"}' + + with open(sample_text_file, "rb") as f: + response = api_client.post( + f"/indexer/partition/{created_partition}/file/{file_id}", + files={"file": ("test.txt", f, "text/plain")}, + data={"metadata": metadata} + ) + + assert response.status_code in [200, 201, 202] + + def test_upload_duplicate_file_replaces(self, api_client, created_partition, sample_text_file): + """Test uploading duplicate file ID - API may allow replacement or reject.""" + file_id = "duplicate-file" + + with open(sample_text_file, "rb") as f: + first_response = api_client.post( + f"/indexer/partition/{created_partition}/file/{file_id}", + files={"file": ("test.txt", f, "text/plain")}, + data={"metadata": "{}"} + ) + + assert first_response.status_code in [200, 201, 202] + + # Wait briefly for first upload to register + time.sleep(2) + + with open(sample_text_file, "rb") as f: + response = api_client.post( + f"/indexer/partition/{created_partition}/file/{file_id}", + files={"file": ("test.txt", f, "text/plain")}, + data={"metadata": "{}"} + ) + + # API may allow replacement (201) or reject duplicate (400/409) + assert response.status_code in [200, 201, 202, 400, 409] + + +class TestTaskStatus: + """Test task status endpoints.""" + + def test_get_task_status(self, api_client, created_partition, sample_text_file): + """Test getting task status after file upload.""" + file_id = "task-test-file" + + with open(sample_text_file, "rb") as f: + response = api_client.post( + f"/indexer/partition/{created_partition}/file/{file_id}", + files={"file": ("test.txt", f, "text/plain")}, + data={"metadata": "{}"} + ) + + data = response.json() + + # Extract task ID from response + if "task_status_url" in data: + task_url = data["task_status_url"] + # Get relative path + task_path = "/" + "/".join(task_url.split("/")[3:]) + elif "task_id" in data: + task_path = f"/indexer/task/{data['task_id']}" + else: + pytest.skip("No task ID in response") + + # Check task status + task_response = api_client.get(task_path) + assert task_response.status_code == 200 + task_data = task_response.json() + assert "task_state" in task_data + + def test_get_nonexistent_task(self, api_client): + """Test getting non-existent task returns error.""" + response = api_client.get("/indexer/task/nonexistent-task-12345") + assert response.status_code == 404 diff --git a/tests/api_tests/test_openai_compat.py b/tests/api_tests/test_openai_compat.py new file mode 100644 index 000000000..f32187d54 --- /dev/null +++ b/tests/api_tests/test_openai_compat.py @@ -0,0 +1,50 @@ +"""OpenAI-compatible API tests.""" +import pytest + + +class TestOpenAICompatibleAPI: + """Test OpenAI-compatible endpoints. + + Note: These endpoints may not be available if WITH_OPENAI_API=false + or if no LLM is configured. + """ + + def test_list_models(self, api_client): + """Test listing models - may not be available without LLM.""" + response = api_client.get("/v1/models") + # 200 if available, 404 if WITH_OPENAI_API=false or no LLM configured + assert response.status_code in [200, 404] + if response.status_code == 200: + data = response.json() + assert "data" in data + assert data["object"] == "list" + + def test_chat_completions_endpoint(self, api_client): + """Test chat completions endpoint exists or is disabled.""" + response = api_client.post( + "/v1/chat/completions", + json={"model": "openrag-all", "messages": []} + ) + # 404 if endpoint disabled, 400/422 if enabled but invalid input + assert response.status_code in [400, 404, 422] + + def test_chat_completions_invalid_model(self, api_client): + """Test chat completions with invalid model.""" + response = api_client.post( + "/v1/chat/completions", + json={ + "model": "nonexistent-model", + "messages": [{"role": "user", "content": "Hello"}] + } + ) + # Should return error for invalid model, 404 if endpoint disabled + assert response.status_code in [400, 404, 422] + + def test_completions_endpoint(self, api_client): + """Test completions endpoint exists or is disabled.""" + response = api_client.post( + "/v1/completions", + json={"model": "openrag-all", "prompt": "Test"} + ) + # 404 if endpoint disabled, other codes if enabled + assert response.status_code in [200, 400, 404, 422, 500] diff --git a/tests/api_tests/test_partition.py b/tests/api_tests/test_partition.py new file mode 100644 index 000000000..880861163 --- /dev/null +++ b/tests/api_tests/test_partition.py @@ -0,0 +1,56 @@ +"""Partition management API tests.""" +import pytest + + +class TestPartitionCRUD: + """Test partition create, read, update, delete operations.""" + + def test_list_partitions(self, api_client): + """Test listing partitions.""" + response = api_client.get("/partition/") + assert response.status_code == 200 + data = response.json() + assert "partitions" in data + + def test_create_partition(self, api_client, test_partition_name): + """Test creating a new partition.""" + response = api_client.post(f"/partition/{test_partition_name}") + assert response.status_code in [200, 201] + + # Verify it exists + response = api_client.get("/partition/") + partitions = [p["partition"] for p in response.json()["partitions"]] + assert test_partition_name in partitions + + # Cleanup + api_client.delete(f"/partition/{test_partition_name}") + + def test_create_duplicate_partition_fails(self, api_client, test_partition_name): + """Test creating duplicate partition returns error.""" + first_response = api_client.post(f"/partition/{test_partition_name}") + assert first_response.status_code in [200, 201] + + response = api_client.post(f"/partition/{test_partition_name}") + assert response.status_code in [400, 409] + + # Cleanup + api_client.delete(f"/partition/{test_partition_name}") + + def test_delete_partition(self, api_client, test_partition_name): + """Test deleting a partition.""" + api_client.post(f"/partition/{test_partition_name}") + response = api_client.delete(f"/partition/{test_partition_name}") + assert response.status_code in [200, 204] + + def test_list_partition_files_empty(self, api_client, created_partition): + """Test listing files in empty partition.""" + response = api_client.get(f"/partition/{created_partition}") + assert response.status_code == 200 + data = response.json() + assert "files" in data + assert data["files"] == [] + + def test_delete_nonexistent_partition(self, api_client): + """Test deleting non-existent partition returns error.""" + response = api_client.delete("/partition/nonexistent-partition-xyz123") + assert response.status_code == 404 diff --git a/tests/api_tests/test_queue.py b/tests/api_tests/test_queue.py new file mode 100644 index 000000000..d075063be --- /dev/null +++ b/tests/api_tests/test_queue.py @@ -0,0 +1,29 @@ +"""Queue and task management API tests.""" +import pytest + + +class TestQueueManagement: + """Test queue and task operations.""" + + def test_list_tasks(self, api_client): + """Test listing tasks.""" + response = api_client.get("/queue/tasks") + assert response.status_code == 200 + data = response.json() + assert "tasks" in data + + def test_list_active_tasks(self, api_client): + """Test listing active tasks.""" + response = api_client.get("/queue/tasks", params={"task_status": "active"}) + assert response.status_code == 200 + + def test_list_completed_tasks(self, api_client): + """Test listing completed tasks.""" + response = api_client.get("/queue/tasks", params={"task_status": "completed"}) + assert response.status_code == 200 + + def test_queue_info(self, api_client): + """Test getting queue info.""" + response = api_client.get("/queue/info") + # May require admin privileges + assert response.status_code in [200, 403] diff --git a/tests/api_tests/test_search.py b/tests/api_tests/test_search.py new file mode 100644 index 000000000..144abb3bb --- /dev/null +++ b/tests/api_tests/test_search.py @@ -0,0 +1,94 @@ +"""Search API tests.""" +import time + +import pytest + + +class TestSemanticSearch: + """Test semantic search functionality.""" + + @pytest.fixture + def indexed_partition(self, api_client, created_partition, sample_text_file): + """Create partition and index a document, wait for completion.""" + file_id = "search-test-doc" + + with open(sample_text_file, "rb") as f: + response = api_client.post( + f"/indexer/partition/{created_partition}/file/{file_id}", + files={"file": ("test.txt", f, "text/plain")}, + data={"metadata": "{}"} + ) + + data = response.json() + + # Wait for indexing to complete + if "task_status_url" in data: + task_url = data["task_status_url"] + task_path = "/" + "/".join(task_url.split("/")[3:]) + elif "task_id" in data: + task_path = f"/indexer/task/{data['task_id']}" + else: + # No task info, just wait + time.sleep(5) + return created_partition + + for _ in range(30): + task_response = api_client.get(task_path) + task_data = task_response.json() + state = task_data.get("task_state", "") + if state in ["SUCCESS", "COMPLETED", "success", "completed"]: + break + elif state in ["FAILED", "failed", "FAILURE", "failure"]: + pytest.skip(f"Indexing failed: {task_data}") + time.sleep(2) + + return created_partition + + def test_search_partition(self, api_client, indexed_partition): + """Test searching within a partition.""" + response = api_client.get( + f"/search/partition/{indexed_partition}", + params={"text": "artificial intelligence", "top_k": 5} + ) + assert response.status_code == 200 + data = response.json() + assert "documents" in data + + def test_search_multiple_partitions(self, api_client, indexed_partition): + """Test searching across partitions.""" + response = api_client.get( + "/search", + params={"text": "machine learning", "top_k": 5} + ) + assert response.status_code == 200 + data = response.json() + assert "documents" in data + + def test_search_with_top_k(self, api_client, indexed_partition): + """Test search with different top_k values.""" + response = api_client.get( + f"/search/partition/{indexed_partition}", + params={"text": "deep learning", "top_k": 10} + ) + assert response.status_code == 200 + data = response.json() + # Results should not exceed top_k + assert len(data.get("documents", [])) <= 10 + + def test_search_empty_query(self, api_client, indexed_partition): + """Test search with empty query.""" + response = api_client.get( + f"/search/partition/{indexed_partition}", + params={"text": "", "top_k": 5} + ) + # Should return error or empty results + assert response.status_code in [200, 400, 422] + + def test_search_nonexistent_partition(self, api_client): + """Test searching non-existent partition.""" + response = api_client.get( + "/search/partition/nonexistent-partition-xyz", + params={"text": "test", "top_k": 5} + ) + # May return empty results or error + assert response.status_code in [200, 404, 500] diff --git a/tests/api_tests/test_tools.py b/tests/api_tests/test_tools.py new file mode 100644 index 000000000..a1008716a --- /dev/null +++ b/tests/api_tests/test_tools.py @@ -0,0 +1,25 @@ +"""Tools API tests.""" +import pytest + + +class TestToolsAPI: + """Test tools endpoint functionality.""" + + def test_list_tools(self, api_client): + """Test listing available tools.""" + response = api_client.get("/v1/tools") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Check extractText tool exists + tool_names = [t["name"] for t in data] + assert "extractText" in tool_names + + def test_tool_has_required_fields(self, api_client): + """Test that tools have required fields.""" + response = api_client.get("/v1/tools") + data = response.json() + + for tool in data: + assert "name" in tool + assert "description" in tool diff --git a/tests/api_tests/test_users.py b/tests/api_tests/test_users.py new file mode 100644 index 000000000..900afc5bd --- /dev/null +++ b/tests/api_tests/test_users.py @@ -0,0 +1,35 @@ +"""User management API tests.""" +import pytest + + +class TestUserManagement: + """Test user CRUD operations.""" + + def test_get_current_user(self, api_client): + """Test getting current user info.""" + response = api_client.get("/users/info") + assert response.status_code == 200 + data = response.json() + assert "id" in data + + def test_list_users(self, api_client): + """Test listing users.""" + response = api_client.get("/users/") + # Without auth token, may return default user or all users + assert response.status_code == 200 + data = response.json() + assert "users" in data + + def test_get_user_by_id(self, api_client): + """Test getting user by ID.""" + # First get current user to know a valid ID + info_response = api_client.get("/users/info") + user_id = info_response.json().get("id", 1) + + response = api_client.get(f"/users/{user_id}") + assert response.status_code == 200 + + def test_get_nonexistent_user(self, api_client): + """Test getting non-existent user.""" + response = api_client.get("/users/99999") + assert response.status_code == 404