chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
DocsGPT Integration Tests Package
|
||||
|
||||
This package contains modular integration tests for all DocsGPT API endpoints.
|
||||
Tests are organized by domain:
|
||||
|
||||
- test_chat.py: Chat/streaming endpoints (/stream, /api/answer, /api/feedback, /api/tts)
|
||||
- test_sources.py: Source management (upload, remote, chunks, etc.)
|
||||
- test_agents.py: Agent CRUD and sharing
|
||||
- test_conversations.py: Conversation management
|
||||
- test_prompts.py: Prompt CRUD
|
||||
- test_tools.py: Tools CRUD
|
||||
- test_analytics.py: Analytics endpoints
|
||||
- test_connectors.py: External connectors
|
||||
- test_mcp.py: MCP server endpoints
|
||||
- test_misc.py: Models, images, attachments
|
||||
|
||||
Usage:
|
||||
# Run all integration tests
|
||||
python tests/integration/run_all.py
|
||||
|
||||
# Run specific module
|
||||
python tests/integration/test_chat.py
|
||||
|
||||
# Run multiple modules
|
||||
python tests/integration/run_all.py --module chat,agents
|
||||
|
||||
# Run with custom server
|
||||
python tests/integration/run_all.py --base-url http://localhost:7091
|
||||
|
||||
# List available modules
|
||||
python tests/integration/run_all.py --list
|
||||
"""
|
||||
|
||||
from .base import Colors, DocsGPTTestBase, create_client_from_args, generate_jwt_token
|
||||
from .test_chat import ChatTests
|
||||
from .test_sources import SourceTests
|
||||
from .test_agents import AgentTests
|
||||
from .test_conversations import ConversationTests
|
||||
from .test_prompts import PromptTests
|
||||
from .test_tools import ToolsTests
|
||||
from .test_analytics import AnalyticsTests
|
||||
from .test_connectors import ConnectorTests
|
||||
from .test_mcp import MCPTests
|
||||
from .test_misc import MiscTests
|
||||
|
||||
__all__ = [
|
||||
# Base utilities
|
||||
"Colors",
|
||||
"DocsGPTTestBase",
|
||||
"create_client_from_args",
|
||||
"generate_jwt_token",
|
||||
# Test classes
|
||||
"ChatTests",
|
||||
"SourceTests",
|
||||
"AgentTests",
|
||||
"ConversationTests",
|
||||
"PromptTests",
|
||||
"ToolsTests",
|
||||
"AnalyticsTests",
|
||||
"ConnectorTests",
|
||||
"MCPTests",
|
||||
"MiscTests",
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Minimal ASGI app for the worker graceful-shutdown drain e2e.
|
||||
|
||||
Mirrors production (a Flask SSE generator behind a2wsgi's thread pool) without
|
||||
Postgres/Redis. ``/sse`` holds the connection open like an idle subscriber;
|
||||
``DRAIN_HARNESS_COOPERATIVE=1`` makes it poll the real
|
||||
``application.core.shutdown.is_shutting_down`` flag (the fix), else it
|
||||
reproduces the pre-fix hang.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from a2wsgi import WSGIMiddleware
|
||||
from flask import Flask, Response
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
from application.core.shutdown import is_shutting_down
|
||||
|
||||
_COOPERATIVE = os.environ.get("DRAIN_HARNESS_COOPERATIVE") == "1"
|
||||
_POLL_SECONDS = 1.0
|
||||
_MAX_HOLD_SECONDS = 120.0
|
||||
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
|
||||
@flask_app.get("/health")
|
||||
def health() -> Response:
|
||||
return Response('{"ok": true}', mimetype="application/json")
|
||||
|
||||
|
||||
@flask_app.get("/sse")
|
||||
def sse() -> Response:
|
||||
def generate():
|
||||
# Emit headers immediately (like the real ": connected" frame) so the
|
||||
# client establishes the stream, then hold without writing further.
|
||||
yield ": connected\n\n"
|
||||
deadline = time.monotonic() + _MAX_HOLD_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
# Cooperative variant bails on the flag; non-cooperative pins the
|
||||
# a2wsgi thread to the deadline (the pre-fix hang).
|
||||
if _COOPERATIVE and is_shutting_down():
|
||||
break
|
||||
time.sleep(_POLL_SECONDS)
|
||||
|
||||
return Response(generate(), mimetype="text/event-stream")
|
||||
|
||||
|
||||
asgi_app = Starlette(routes=[Mount("/", app=WSGIMiddleware(flask_app, workers=8))])
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Base classes and utilities for DocsGPT integration tests.
|
||||
|
||||
This module provides:
|
||||
- Colors: ANSI color codes for terminal output
|
||||
- DocsGPTTestBase: Base class with HTTP helpers and output utilities
|
||||
- generate_jwt_token: JWT token generation for authentication
|
||||
- create_client_from_args: Factory function to create client from CLI args
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json as json_module
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Optional, Type, TypeVar
|
||||
|
||||
import requests
|
||||
|
||||
T = TypeVar("T", bound="DocsGPTTestBase")
|
||||
|
||||
|
||||
class Colors:
|
||||
"""ANSI color codes for terminal output."""
|
||||
|
||||
HEADER = "\033[95m"
|
||||
OKBLUE = "\033[94m"
|
||||
OKCYAN = "\033[96m"
|
||||
OKGREEN = "\033[92m"
|
||||
WARNING = "\033[93m"
|
||||
FAIL = "\033[91m"
|
||||
ENDC = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
|
||||
|
||||
def generate_jwt_token() -> tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Generate a JWT token using local secret or environment variable.
|
||||
|
||||
Returns:
|
||||
Tuple of (token, error_message). Token is None on failure.
|
||||
"""
|
||||
secret = os.getenv("JWT_SECRET_KEY")
|
||||
key_file = Path(".jwt_secret_key")
|
||||
|
||||
if not secret:
|
||||
try:
|
||||
secret = key_file.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
return None, f"Set JWT_SECRET_KEY or create {key_file} by running the backend once."
|
||||
except OSError as exc:
|
||||
return None, f"Could not read {key_file}: {exc}"
|
||||
|
||||
if not secret:
|
||||
return None, "JWT secret key is empty."
|
||||
|
||||
try:
|
||||
from jose import jwt
|
||||
except ImportError:
|
||||
return None, "python-jose is not installed (pip install 'python-jose' to auto-generate tokens)."
|
||||
|
||||
try:
|
||||
payload = {"sub": "test_integration_user"}
|
||||
return jwt.encode(payload, secret, algorithm="HS256"), None
|
||||
except Exception as exc:
|
||||
return None, f"Failed to generate JWT token: {exc}"
|
||||
|
||||
|
||||
class DocsGPTTestBase:
|
||||
"""
|
||||
Base class for DocsGPT integration tests.
|
||||
|
||||
Provides HTTP helpers, SSE streaming, output formatting, and result tracking.
|
||||
|
||||
Usage:
|
||||
client = DocsGPTTestBase("http://localhost:7091", token="...")
|
||||
response = client.post("/api/answer", json={"question": "test"})
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: Optional[str] = None,
|
||||
token_source: str = "provided",
|
||||
):
|
||||
"""
|
||||
Initialize test client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of DocsGPT instance (e.g., "http://localhost:7091")
|
||||
token: Optional JWT authentication token
|
||||
token_source: Description of token source for logging
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.token_source = token_source
|
||||
self.headers: dict[str, str] = {}
|
||||
if token:
|
||||
self.headers["Authorization"] = f"Bearer {token}"
|
||||
self.test_results: list[tuple[str, bool, str]] = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HTTP Helper Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make a GET request.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., "/api/sources")
|
||||
params: Optional query parameters
|
||||
timeout: Request timeout in seconds
|
||||
**kwargs: Additional arguments passed to requests.get
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
return requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers={**self.headers, **kwargs.pop("headers", {})},
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def post(
|
||||
self,
|
||||
path: str,
|
||||
json: Optional[dict[str, Any]] = None,
|
||||
data: Optional[dict[str, Any]] = None,
|
||||
files: Optional[dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make a POST request.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., "/api/answer")
|
||||
json: Optional JSON body
|
||||
data: Optional form data
|
||||
files: Optional files for multipart upload
|
||||
timeout: Request timeout in seconds
|
||||
**kwargs: Additional arguments passed to requests.post
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
return requests.post(
|
||||
url,
|
||||
json=json,
|
||||
data=data,
|
||||
files=files,
|
||||
headers={**self.headers, **kwargs.pop("headers", {})},
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def put(
|
||||
self,
|
||||
path: str,
|
||||
json: Optional[dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make a PUT request.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., "/api/update_agent/123")
|
||||
json: Optional JSON body
|
||||
timeout: Request timeout in seconds
|
||||
**kwargs: Additional arguments passed to requests.put
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
return requests.put(
|
||||
url,
|
||||
json=json,
|
||||
headers={**self.headers, **kwargs.pop("headers", {})},
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
path: str,
|
||||
json: Optional[dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make a DELETE request.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., "/api/delete_agent")
|
||||
json: Optional JSON body
|
||||
timeout: Request timeout in seconds
|
||||
**kwargs: Additional arguments passed to requests.delete
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
return requests.delete(
|
||||
url,
|
||||
json=json,
|
||||
headers={**self.headers, **kwargs.pop("headers", {})},
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def post_stream(
|
||||
self,
|
||||
path: str,
|
||||
json: Optional[dict[str, Any]] = None,
|
||||
timeout: int = 60,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""
|
||||
Make a streaming POST request and yield SSE events.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., "/stream")
|
||||
json: Optional JSON body
|
||||
timeout: Request timeout in seconds
|
||||
**kwargs: Additional arguments passed to requests.post
|
||||
|
||||
Yields:
|
||||
Parsed JSON data from each SSE event
|
||||
|
||||
Example:
|
||||
for event in client.post_stream("/stream", json={"question": "test"}):
|
||||
if event.get("type") == "answer":
|
||||
print(event.get("message"))
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
response = requests.post(
|
||||
url,
|
||||
json=json,
|
||||
headers={**self.headers, **kwargs.pop("headers", {})},
|
||||
stream=True,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Store response for status code checking
|
||||
self._last_stream_response = response
|
||||
|
||||
if response.status_code != 200:
|
||||
# Yield error event for non-200 responses
|
||||
yield {"type": "error", "status_code": response.status_code, "text": response.text[:500]}
|
||||
return
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode("utf-8")
|
||||
if line_str.startswith("data: "):
|
||||
data_str = line_str[6:] # Remove 'data: ' prefix
|
||||
try:
|
||||
data = json_module.loads(data_str)
|
||||
yield data
|
||||
if data.get("type") == "end":
|
||||
break
|
||||
except json_module.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Output Helper Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def print_header(self, message: str) -> None:
|
||||
"""Print a colored header."""
|
||||
print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 70}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{message}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 70}{Colors.ENDC}\n")
|
||||
|
||||
def print_success(self, message: str) -> None:
|
||||
"""Print a success message."""
|
||||
print(f"{Colors.OKGREEN}[PASS] {message}{Colors.ENDC}")
|
||||
|
||||
def print_error(self, message: str) -> None:
|
||||
"""Print an error message."""
|
||||
print(f"{Colors.FAIL}[FAIL] {message}{Colors.ENDC}")
|
||||
|
||||
def print_info(self, message: str) -> None:
|
||||
"""Print an info message."""
|
||||
print(f"{Colors.OKCYAN}[INFO] {message}{Colors.ENDC}")
|
||||
|
||||
def print_warning(self, message: str) -> None:
|
||||
"""Print a warning message."""
|
||||
print(f"{Colors.WARNING}[WARN] {message}{Colors.ENDC}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Result Tracking Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def record_result(self, test_name: str, success: bool, message: str) -> None:
|
||||
"""
|
||||
Record a test result.
|
||||
|
||||
Args:
|
||||
test_name: Name of the test
|
||||
success: Whether the test passed
|
||||
message: Result message or error details
|
||||
"""
|
||||
self.test_results.append((test_name, success, message))
|
||||
|
||||
def print_summary(self) -> bool:
|
||||
"""
|
||||
Print test results summary.
|
||||
|
||||
Returns:
|
||||
True if all tests passed, False otherwise
|
||||
"""
|
||||
self.print_header("Test Results Summary")
|
||||
|
||||
passed = sum(1 for _, success, _ in self.test_results if success)
|
||||
failed = len(self.test_results) - passed
|
||||
|
||||
print(f"\n{Colors.BOLD}Total Tests: {len(self.test_results)}{Colors.ENDC}")
|
||||
print(f"{Colors.OKGREEN}Passed: {passed}{Colors.ENDC}")
|
||||
print(f"{Colors.FAIL}Failed: {failed}{Colors.ENDC}\n")
|
||||
|
||||
print(f"{Colors.BOLD}Detailed Results:{Colors.ENDC}")
|
||||
for test_name, success, message in self.test_results:
|
||||
status = f"{Colors.OKGREEN}PASS{Colors.ENDC}" if success else f"{Colors.FAIL}FAIL{Colors.ENDC}"
|
||||
print(f" {status} - {test_name}: {message}")
|
||||
|
||||
print()
|
||||
return failed == 0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Assertion Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def assert_status(
|
||||
self,
|
||||
response: requests.Response,
|
||||
expected: int,
|
||||
test_name: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Assert response status code and record result.
|
||||
|
||||
Args:
|
||||
response: Response object to check
|
||||
expected: Expected status code
|
||||
test_name: Name of the test for recording
|
||||
|
||||
Returns:
|
||||
True if status matches, False otherwise
|
||||
"""
|
||||
if response.status_code == expected:
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Expected {expected}, got {response.status_code}")
|
||||
self.print_error(f"Response: {response.text[:500]}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
def assert_json_key(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
key: str,
|
||||
test_name: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Assert JSON response contains a key.
|
||||
|
||||
Args:
|
||||
data: JSON response data
|
||||
key: Expected key
|
||||
test_name: Name of the test for recording
|
||||
|
||||
Returns:
|
||||
True if key exists, False otherwise
|
||||
"""
|
||||
if key in data:
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Missing key '{key}' in response")
|
||||
self.record_result(test_name, False, f"Missing key: {key}")
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Convenience Properties
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if client has authentication token."""
|
||||
return self.token is not None
|
||||
|
||||
def require_auth(self, test_name: str) -> bool:
|
||||
"""
|
||||
Check authentication and record skip if not authenticated.
|
||||
|
||||
Args:
|
||||
test_name: Name of the test
|
||||
|
||||
Returns:
|
||||
True if authenticated, False otherwise (test skipped)
|
||||
"""
|
||||
if not self.is_authenticated:
|
||||
self.print_warning("No authentication token provided")
|
||||
self.print_info("Skipping test (auth required)")
|
||||
self.record_result(test_name, True, "Skipped (auth required)")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Factory Function
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_client_from_args(
|
||||
client_class: Type[T],
|
||||
description: str = "DocsGPT Integration Tests",
|
||||
) -> T:
|
||||
"""
|
||||
Create a test client from command-line arguments.
|
||||
|
||||
Parses --base-url and --token arguments, and handles JWT token generation.
|
||||
|
||||
Args:
|
||||
client_class: The test class to instantiate (must inherit from DocsGPTTestBase)
|
||||
description: Description for the argument parser
|
||||
|
||||
Returns:
|
||||
An instance of the provided client_class
|
||||
|
||||
Example:
|
||||
class ChatTests(DocsGPTTestBase):
|
||||
def run_all(self):
|
||||
...
|
||||
|
||||
if __name__ == "__main__":
|
||||
client = create_client_from_args(ChatTests)
|
||||
sys.exit(0 if client.run_all() else 1)
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=description,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("DOCSGPT_BASE_URL", "http://localhost:7091"),
|
||||
help="Base URL of DocsGPT instance (default: http://localhost:7091)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=os.getenv("JWT_TOKEN"),
|
||||
help="JWT authentication token (auto-generated from local secret when available)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine token and source
|
||||
token = args.token
|
||||
token_source = "provided via --token" if token else "none"
|
||||
|
||||
if not token:
|
||||
token, token_error = generate_jwt_token()
|
||||
if token:
|
||||
token_source = "auto-generated from local secret"
|
||||
print(f"{Colors.OKCYAN}[INFO] Using auto-generated JWT token{Colors.ENDC}")
|
||||
elif token_error:
|
||||
print(f"{Colors.WARNING}[WARN] Could not auto-generate token: {token_error}{Colors.ENDC}")
|
||||
print(f"{Colors.WARNING}[WARN] Tests requiring auth will be skipped{Colors.ENDC}")
|
||||
|
||||
return client_class(args.base_url, token=token, token_source=token_source)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Fixtures for integration tests that hit a live Postgres.
|
||||
|
||||
These tests are separate from unit tests in two ways:
|
||||
|
||||
1. **Directory**: They live under ``tests/integration/``, which is
|
||||
ignored by the default pytest run via ``--ignore=tests/integration``
|
||||
in ``pytest.ini``. The CI ``pytest.yml`` workflow therefore skips
|
||||
them automatically — it runs the fast unit suite only.
|
||||
2. **Marker**: Each test is marked ``@pytest.mark.integration`` so it
|
||||
can be selected (or excluded) independently of its directory with
|
||||
``pytest -m integration`` / ``-m "not integration"``.
|
||||
|
||||
Running the Postgres-backed integration tests manually::
|
||||
|
||||
.venv/bin/python -m pytest tests/integration/test_users_repository.py \\
|
||||
--override-ini="addopts=" --no-cov
|
||||
|
||||
(The ``--override-ini="addopts="`` is needed because ``pytest.ini``
|
||||
contains ``--ignore=tests/integration`` in ``addopts``; without the
|
||||
override pytest would still skip the directory even though you named
|
||||
it on the command line.)
|
||||
|
||||
Tests are skipped automatically if ``POSTGRES_URI`` is unset, so a
|
||||
contributor who hasn't set up a local Postgres gets clean skips
|
||||
instead of red tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, create_engine, text
|
||||
|
||||
from application.core.settings import settings
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pg_engine() -> Engine:
|
||||
"""Session-scoped SQLAlchemy engine for the Postgres integration DB.
|
||||
|
||||
Skips all Postgres-backed tests if ``POSTGRES_URI`` is unset. This
|
||||
keeps CI and contributor machines without a local Postgres from
|
||||
erroring out — integration tests that require the DB become
|
||||
no-ops rather than failures.
|
||||
"""
|
||||
if not settings.POSTGRES_URI:
|
||||
pytest.skip("POSTGRES_URI not set — skipping Postgres integration tests")
|
||||
engine = create_engine(settings.POSTGRES_URI, future=True, pool_pre_ping=True)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_conn(pg_engine: Engine):
|
||||
"""Per-test Postgres connection wrapped in a rolled-back transaction.
|
||||
|
||||
Uses SQLAlchemy's explicit outer-transaction pattern so every test
|
||||
sees a pristine DB view without having to truncate tables. Any
|
||||
nested ``begin()`` inside the repository code becomes a SAVEPOINT
|
||||
under the hood.
|
||||
"""
|
||||
conn = pg_engine.connect()
|
||||
outer = conn.begin()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
outer.rollback()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_clean_users(pg_conn):
|
||||
"""Guarantee a clean ``users`` table view for tests that need it.
|
||||
|
||||
The outer transaction rollback handles cleanup, but if a previous
|
||||
interrupted run left rows committed, this fixture removes them
|
||||
inside the transaction scope so they are invisible to the test.
|
||||
``TRUNCATE CASCADE`` cascades through every FK to ``users`` (18
|
||||
tables and counting — agents, conversations, sources, user_logs,
|
||||
etc.) so the test view is clean regardless of which dependent rows
|
||||
a prior run left behind. PostgreSQL ``TRUNCATE`` is transactional,
|
||||
so the outer rollback still restores everything.
|
||||
"""
|
||||
pg_conn.execute(text("TRUNCATE TABLE users CASCADE"))
|
||||
return pg_conn
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DocsGPT Integration Test Runner
|
||||
|
||||
Runs all integration tests or specific modules.
|
||||
|
||||
Usage:
|
||||
python tests/integration/run_all.py # Run all tests
|
||||
python tests/integration/run_all.py --module chat # Run specific module
|
||||
python tests/integration/run_all.py --module chat,agents # Run multiple modules
|
||||
python tests/integration/run_all.py --list # List available modules
|
||||
python tests/integration/run_all.py --base-url URL # Custom base URL
|
||||
python tests/integration/run_all.py --token TOKEN # With auth token
|
||||
|
||||
Available modules:
|
||||
chat, sources, agents, conversations, prompts, tools, analytics,
|
||||
connectors, mcp, misc
|
||||
|
||||
Examples:
|
||||
# Run all tests
|
||||
python tests/integration/run_all.py
|
||||
|
||||
# Run only chat and agent tests
|
||||
python tests/integration/run_all.py --module chat,agents
|
||||
|
||||
# Run with custom server
|
||||
python tests/integration/run_all.py --base-url http://staging.example.com:7091
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import Colors, generate_jwt_token
|
||||
from tests.integration.test_chat import ChatTests
|
||||
from tests.integration.test_sources import SourceTests
|
||||
from tests.integration.test_agents import AgentTests
|
||||
from tests.integration.test_conversations import ConversationTests
|
||||
from tests.integration.test_prompts import PromptTests
|
||||
from tests.integration.test_tools import ToolsTests
|
||||
from tests.integration.test_analytics import AnalyticsTests
|
||||
from tests.integration.test_connectors import ConnectorTests
|
||||
from tests.integration.test_mcp import MCPTests
|
||||
from tests.integration.test_misc import MiscTests
|
||||
from tests.integration.test_v1_api import V1ApiTests
|
||||
|
||||
|
||||
# Module registry
|
||||
MODULES = {
|
||||
"chat": ChatTests,
|
||||
"sources": SourceTests,
|
||||
"agents": AgentTests,
|
||||
"conversations": ConversationTests,
|
||||
"prompts": PromptTests,
|
||||
"tools": ToolsTests,
|
||||
"analytics": AnalyticsTests,
|
||||
"connectors": ConnectorTests,
|
||||
"mcp": MCPTests,
|
||||
"misc": MiscTests,
|
||||
"v1_api": V1ApiTests,
|
||||
}
|
||||
|
||||
|
||||
def print_header(message: str) -> None:
|
||||
"""Print a styled header."""
|
||||
print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 70}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{message}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 70}{Colors.ENDC}\n")
|
||||
|
||||
|
||||
def list_modules() -> None:
|
||||
"""Print available test modules."""
|
||||
print_header("Available Test Modules")
|
||||
for name, cls in MODULES.items():
|
||||
test_count = len([m for m in dir(cls) if m.startswith("test_")])
|
||||
print(f" {Colors.OKCYAN}{name:<15}{Colors.ENDC} - {test_count} tests")
|
||||
print()
|
||||
|
||||
|
||||
def run_module(
|
||||
module_name: str,
|
||||
base_url: str,
|
||||
token: str | None,
|
||||
token_source: str,
|
||||
) -> tuple[bool, int, int]:
|
||||
"""
|
||||
Run a single test module.
|
||||
|
||||
Returns:
|
||||
Tuple of (all_passed, passed_count, total_count)
|
||||
"""
|
||||
cls = MODULES.get(module_name)
|
||||
if not cls:
|
||||
print(f"{Colors.FAIL}Unknown module: {module_name}{Colors.ENDC}")
|
||||
return False, 0, 0
|
||||
|
||||
client = cls(base_url, token=token, token_source=token_source)
|
||||
success = client.run_all()
|
||||
|
||||
passed = sum(1 for _, s, _ in client.test_results if s)
|
||||
total = len(client.test_results)
|
||||
|
||||
return success, passed, total
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="DocsGPT Integration Test Runner",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python tests/integration/run_all.py # Run all tests
|
||||
python tests/integration/run_all.py --module chat # Run chat tests
|
||||
python tests/integration/run_all.py --module chat,agents # Multiple modules
|
||||
python tests/integration/run_all.py --list # List modules
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("DOCSGPT_BASE_URL", "http://localhost:7091"),
|
||||
help="Base URL of DocsGPT instance (default: http://localhost:7091)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=os.getenv("JWT_TOKEN"),
|
||||
help="JWT authentication token",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--module", "-m",
|
||||
help="Specific module(s) to run, comma-separated (e.g., 'chat,agents')",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list", "-l",
|
||||
action="store_true",
|
||||
help="List available test modules",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# List modules and exit
|
||||
if args.list:
|
||||
list_modules()
|
||||
return 0
|
||||
|
||||
# Determine token
|
||||
token = args.token
|
||||
token_source = "provided via --token" if token else "none"
|
||||
|
||||
if not token:
|
||||
token, token_error = generate_jwt_token()
|
||||
if token:
|
||||
token_source = "auto-generated from local secret"
|
||||
print(f"{Colors.OKCYAN}[INFO] Using auto-generated JWT token{Colors.ENDC}")
|
||||
elif token_error:
|
||||
print(f"{Colors.WARNING}[WARN] Could not auto-generate token: {token_error}{Colors.ENDC}")
|
||||
print(f"{Colors.WARNING}[WARN] Tests requiring auth will be skipped{Colors.ENDC}")
|
||||
|
||||
# Determine which modules to run
|
||||
if args.module:
|
||||
modules_to_run = [m.strip() for m in args.module.split(",")]
|
||||
# Validate modules
|
||||
invalid = [m for m in modules_to_run if m not in MODULES]
|
||||
if invalid:
|
||||
print(f"{Colors.FAIL}Unknown module(s): {', '.join(invalid)}{Colors.ENDC}")
|
||||
print(f"{Colors.OKCYAN}Available: {', '.join(MODULES.keys())}{Colors.ENDC}")
|
||||
return 1
|
||||
else:
|
||||
modules_to_run = list(MODULES.keys())
|
||||
|
||||
# Print test plan
|
||||
print_header("DocsGPT Integration Test Suite")
|
||||
print(f"{Colors.OKCYAN}Base URL:{Colors.ENDC} {args.base_url}")
|
||||
print(f"{Colors.OKCYAN}Auth:{Colors.ENDC} {token_source}")
|
||||
print(f"{Colors.OKCYAN}Modules:{Colors.ENDC} {', '.join(modules_to_run)}")
|
||||
|
||||
# Run tests
|
||||
results = {}
|
||||
total_passed = 0
|
||||
total_tests = 0
|
||||
|
||||
for module_name in modules_to_run:
|
||||
success, passed, total = run_module(
|
||||
module_name,
|
||||
args.base_url,
|
||||
token,
|
||||
token_source,
|
||||
)
|
||||
results[module_name] = (success, passed, total)
|
||||
total_passed += passed
|
||||
total_tests += total
|
||||
|
||||
# Print summary
|
||||
print_header("Overall Test Summary")
|
||||
|
||||
print(f"\n{Colors.BOLD}Module Results:{Colors.ENDC}")
|
||||
for module_name, (success, passed, total) in results.items():
|
||||
status = f"{Colors.OKGREEN}PASS{Colors.ENDC}" if success else f"{Colors.FAIL}FAIL{Colors.ENDC}"
|
||||
print(f" {status} - {module_name}: {passed}/{total} tests passed")
|
||||
|
||||
print(f"\n{Colors.BOLD}Total:{Colors.ENDC} {total_passed}/{total_tests} tests passed")
|
||||
|
||||
all_passed = all(success for success, _, _ in results.values())
|
||||
if all_passed:
|
||||
print(f"\n{Colors.OKGREEN}{Colors.BOLD}ALL TESTS PASSED{Colors.ENDC}")
|
||||
return 0
|
||||
else:
|
||||
failed_modules = [m for m, (s, _, _) in results.items() if not s]
|
||||
print(f"\n{Colors.FAIL}{Colors.BOLD}SOME TESTS FAILED{Colors.ENDC}")
|
||||
print(f"{Colors.FAIL}Failed modules: {', '.join(failed_modules)}{Colors.ENDC}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT analytics endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/get_feedback_analytics (POST) - Feedback analytics
|
||||
- /api/get_message_analytics (POST) - Message analytics
|
||||
- /api/get_token_analytics (POST) - Token usage analytics
|
||||
- /api/get_user_logs (POST) - User activity logs
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_analytics.py
|
||||
python tests/integration/test_analytics.py --base-url http://localhost:7091
|
||||
python tests/integration/test_analytics.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class AnalyticsTests(DocsGPTTestBase):
|
||||
"""Integration tests for analytics endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Feedback Analytics Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_feedback_analytics(self) -> bool:
|
||||
"""Test getting feedback analytics."""
|
||||
test_name = "Get feedback analytics"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_feedback_analytics",
|
||||
json={"date_range": "last_30_days"},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
self.print_success("Retrieved feedback analytics")
|
||||
self.print_info(f"Data points: {len(result) if isinstance(result, list) else 'object'}")
|
||||
self.record_result(test_name, True, "Analytics retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_feedback_analytics_with_filters(self) -> bool:
|
||||
"""Test feedback analytics with filters."""
|
||||
test_name = "Feedback analytics filtered"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_feedback_analytics",
|
||||
json={
|
||||
"date_range": "last_7_days",
|
||||
"agent_id": None,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
self.print_success("Retrieved filtered feedback analytics")
|
||||
self.record_result(test_name, True, "Filtered analytics retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Message Analytics Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_message_analytics(self) -> bool:
|
||||
"""Test getting message analytics."""
|
||||
test_name = "Get message analytics"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_message_analytics",
|
||||
json={"date_range": "last_30_days"},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
self.print_success("Retrieved message analytics")
|
||||
self.print_info(f"Data: {type(result).__name__}")
|
||||
self.record_result(test_name, True, "Analytics retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_message_analytics_with_agent(self) -> bool:
|
||||
"""Test message analytics for specific agent."""
|
||||
test_name = "Message analytics by agent"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_message_analytics",
|
||||
json={
|
||||
"date_range": "last_7_days",
|
||||
"agent_id": None,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
self.print_success("Retrieved agent message analytics")
|
||||
self.record_result(test_name, True, "Agent analytics retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Token Analytics Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_token_analytics(self) -> bool:
|
||||
"""Test getting token usage analytics."""
|
||||
test_name = "Get token analytics"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_token_analytics",
|
||||
json={"date_range": "last_30_days"},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
self.print_success("Retrieved token analytics")
|
||||
self.print_info(f"Data: {type(result).__name__}")
|
||||
self.record_result(test_name, True, "Analytics retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_token_analytics_breakdown(self) -> bool:
|
||||
"""Test token analytics with breakdown."""
|
||||
test_name = "Token analytics breakdown"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_token_analytics",
|
||||
json={
|
||||
"date_range": "last_7_days",
|
||||
"breakdown": "daily",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
self.print_success("Retrieved token analytics breakdown")
|
||||
self.record_result(test_name, True, "Breakdown retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# User Logs Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_user_logs(self) -> bool:
|
||||
"""Test getting user activity logs."""
|
||||
test_name = "Get user logs"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_user_logs",
|
||||
json={"date_range": "last_30_days"},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
self.print_success("Retrieved user logs")
|
||||
self.print_info(f"Logs: {len(result) if isinstance(result, list) else 'object'}")
|
||||
self.record_result(test_name, True, "Logs retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_user_logs_paginated(self) -> bool:
|
||||
"""Test user logs with pagination."""
|
||||
test_name = "User logs paginated"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/get_user_logs",
|
||||
json={
|
||||
"date_range": "last_7_days",
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
self.print_success("Retrieved paginated user logs")
|
||||
self.record_result(test_name, True, "Paginated logs retrieved")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Runner
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all analytics tests."""
|
||||
self.print_header("DocsGPT Analytics Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Auth: {self.token_source}")
|
||||
|
||||
# Feedback analytics
|
||||
self.test_get_feedback_analytics()
|
||||
self.test_get_feedback_analytics_with_filters()
|
||||
|
||||
# Message analytics
|
||||
self.test_get_message_analytics()
|
||||
self.test_get_message_analytics_with_agent()
|
||||
|
||||
# Token analytics
|
||||
self.test_get_token_analytics()
|
||||
self.test_get_token_analytics_breakdown()
|
||||
|
||||
# User logs
|
||||
self.test_get_user_logs()
|
||||
self.test_get_user_logs_paginated()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(AnalyticsTests, "DocsGPT Analytics Integration Tests")
|
||||
exit_code = 0 if client.run_all() else 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT external connectors endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/connectors/auth (GET) - OAuth authentication URL
|
||||
- /api/connectors/callback (GET) - OAuth callback
|
||||
- /api/connectors/callback-status (GET) - Callback status
|
||||
- /api/connectors/disconnect (POST) - Disconnect connector
|
||||
- /api/connectors/files (POST) - List connector files
|
||||
- /api/connectors/sync (POST) - Sync connector
|
||||
- /api/connectors/validate-session (POST) - Validate session
|
||||
|
||||
Note: Many tests are limited without actual external service connections.
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_connectors.py
|
||||
python tests/integration/test_connectors.py --base-url http://localhost:7091
|
||||
python tests/integration/test_connectors.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class ConnectorTests(DocsGPTTestBase):
|
||||
"""Integration tests for external connector endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Auth Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_connectors_auth_google(self) -> bool:
|
||||
"""Test getting Google OAuth URL."""
|
||||
test_name = "Get Google OAuth URL"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/connectors/auth",
|
||||
params={"provider": "google"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Expect 200 with URL, or 400/501 if not configured
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
auth_url = result.get("url") or result.get("auth_url")
|
||||
if auth_url:
|
||||
self.print_success(f"Got OAuth URL: {auth_url[:50]}...")
|
||||
self.record_result(test_name, True, "OAuth URL retrieved")
|
||||
return True
|
||||
elif response.status_code in [400, 404, 501]:
|
||||
self.print_warning(f"Connector not configured: {response.status_code}")
|
||||
self.record_result(test_name, True, "Not configured (expected)")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_connectors_auth_invalid_provider(self) -> bool:
|
||||
"""Test auth with invalid provider."""
|
||||
test_name = "Auth invalid provider"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/connectors/auth",
|
||||
params={"provider": "invalid_provider_xyz"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [400, 404]:
|
||||
self.print_success(f"Correctly rejected: {response.status_code}")
|
||||
self.record_result(test_name, True, "Invalid provider rejected")
|
||||
return True
|
||||
else:
|
||||
self.print_warning(f"Status: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Callback Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_connectors_callback_status(self) -> bool:
|
||||
"""Test checking callback status."""
|
||||
test_name = "Check callback status"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/connectors/callback-status",
|
||||
params={"task_id": "test-task-id"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Expect 200 with status, or 404 for unknown task
|
||||
if response.status_code in [200, 404]:
|
||||
self.print_success(f"Callback status check: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Disconnect Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_connectors_disconnect(self) -> bool:
|
||||
"""Test disconnecting a connector."""
|
||||
test_name = "Disconnect connector"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/connectors/disconnect",
|
||||
json={"provider": "google"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Expect 200 for successful disconnect, or 400/404 if not connected
|
||||
if response.status_code in [200, 400, 404]:
|
||||
self.print_success(f"Disconnect response: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Files Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_connectors_files(self) -> bool:
|
||||
"""Test listing connector files."""
|
||||
test_name = "List connector files"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/connectors/files",
|
||||
json={"provider": "google", "path": "/"},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
# Expect 200 with files, or 400/401/404 if not authenticated
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
files = result.get("files", result)
|
||||
self.print_success(f"Got files list: {len(files) if isinstance(files, list) else 'object'}")
|
||||
self.record_result(test_name, True, "Files retrieved")
|
||||
return True
|
||||
elif response.status_code in [400, 401, 404]:
|
||||
self.print_warning(f"Connector not authenticated: {response.status_code}")
|
||||
self.record_result(test_name, True, "Not authenticated (expected)")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_connectors_files_with_path(self) -> bool:
|
||||
"""Test listing files at specific path."""
|
||||
test_name = "List files at path"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/connectors/files",
|
||||
json={"provider": "google", "path": "/documents"},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 400, 401, 404]:
|
||||
self.print_success(f"Files at path response: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sync Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_connectors_sync(self) -> bool:
|
||||
"""Test syncing a connector."""
|
||||
test_name = "Sync connector"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/connectors/sync",
|
||||
json={"provider": "google", "file_ids": []},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 202, 400, 401, 404]:
|
||||
self.print_success(f"Sync response: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Validate Session Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_connectors_validate_session(self) -> bool:
|
||||
"""Test validating connector session."""
|
||||
test_name = "Validate connector session"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/connectors/validate-session",
|
||||
json={"provider": "google"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 400, 401, 404]:
|
||||
result = response.json() if response.status_code == 200 else {}
|
||||
valid = result.get("valid", False)
|
||||
self.print_success(f"Session validation: {response.status_code}, valid={valid}")
|
||||
self.record_result(test_name, True, f"Valid: {valid}")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Runner
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all connector tests."""
|
||||
self.print_header("DocsGPT Connector Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Auth: {self.token_source}")
|
||||
self.print_warning("Note: Many tests require external service configuration")
|
||||
|
||||
# Auth tests
|
||||
self.test_connectors_auth_google()
|
||||
self.test_connectors_auth_invalid_provider()
|
||||
|
||||
# Callback tests
|
||||
self.test_connectors_callback_status()
|
||||
|
||||
# Disconnect tests
|
||||
self.test_connectors_disconnect()
|
||||
|
||||
# Files tests
|
||||
self.test_connectors_files()
|
||||
self.test_connectors_files_with_path()
|
||||
|
||||
# Sync tests
|
||||
self.test_connectors_sync()
|
||||
|
||||
# Validate session tests
|
||||
self.test_connectors_validate_session()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(ConnectorTests, "DocsGPT Connector Integration Tests")
|
||||
exit_code = 0 if client.run_all() else 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,495 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT conversation management endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/get_conversations (GET) - List conversations
|
||||
- /api/get_single_conversation (GET) - Get single conversation
|
||||
- /api/delete_conversation (POST) - Delete conversation
|
||||
- /api/delete_all_conversations (GET) - Delete all conversations
|
||||
- /api/update_conversation_name (POST) - Rename conversation
|
||||
- /api/share (POST) - Share conversation
|
||||
- /api/shared_conversation/{id} (GET) - Get shared conversation
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_conversations.py
|
||||
python tests/integration/test_conversations.py --base-url http://localhost:7091
|
||||
python tests/integration/test_conversations.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class ConversationTests(DocsGPTTestBase):
|
||||
"""Integration tests for conversation management endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Data Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_create_test_conversation(self) -> Optional[str]:
|
||||
"""
|
||||
Get or create a test conversation by making a chat request.
|
||||
|
||||
Returns:
|
||||
Conversation ID or None if creation fails
|
||||
"""
|
||||
if hasattr(self, "_test_conversation_id"):
|
||||
return self._test_conversation_id
|
||||
|
||||
if not self.is_authenticated:
|
||||
return None
|
||||
|
||||
# Create conversation via a chat request
|
||||
try:
|
||||
payload = {
|
||||
"question": "Test message for conversation creation",
|
||||
"history": [],
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
response = self.post("/api/answer", json=payload, timeout=30)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
conv_id = result.get("conversation_id")
|
||||
if conv_id:
|
||||
self._test_conversation_id = conv_id
|
||||
return conv_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_existing_conversation(self) -> Optional[str]:
|
||||
"""Get an existing conversation ID from the list."""
|
||||
try:
|
||||
response = self.get("/api/get_conversations", timeout=10)
|
||||
if response.status_code == 200:
|
||||
convs = response.json()
|
||||
if convs and len(convs) > 0:
|
||||
return convs[0].get("id")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# List/Get Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_conversations(self) -> bool:
|
||||
"""Test listing all conversations."""
|
||||
test_name = "List conversations"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get("/api/get_conversations", timeout=10)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
if not isinstance(result, list):
|
||||
self.print_error("Response is not a list")
|
||||
self.record_result(test_name, False, "Invalid response type")
|
||||
return False
|
||||
|
||||
self.print_success(f"Retrieved {len(result)} conversations")
|
||||
if result:
|
||||
self.print_info(f"First: {result[0].get('name', 'N/A')[:30]}...")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_conversations_paginated(self) -> bool:
|
||||
"""Test getting conversations with pagination."""
|
||||
test_name = "List conversations paginated"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/get_conversations",
|
||||
params={"page": 1, "per_page": 5},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
if not isinstance(result, list):
|
||||
self.print_error("Response is not a list")
|
||||
self.record_result(test_name, False, "Invalid response type")
|
||||
return False
|
||||
|
||||
self.print_success(f"Retrieved {len(result)} conversations (page 1)")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_single_conversation(self) -> bool:
|
||||
"""Test getting a single conversation by ID."""
|
||||
test_name = "Get single conversation"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Try to get existing conversation
|
||||
conv_id = self.get_existing_conversation()
|
||||
if not conv_id:
|
||||
conv_id = self.get_or_create_test_conversation()
|
||||
|
||||
if not conv_id:
|
||||
self.print_warning("No conversations available")
|
||||
self.record_result(test_name, True, "Skipped (no conversations)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/get_single_conversation",
|
||||
params={"id": conv_id},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
self.print_success(f"Retrieved conversation: {conv_id[:20]}...")
|
||||
self.print_info(f"Messages: {len(result.get('queries', []))}")
|
||||
self.record_result(test_name, True, f"ID: {conv_id[:20]}...")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_single_conversation_not_found(self) -> bool:
|
||||
"""Test getting a non-existent conversation."""
|
||||
test_name = "Get non-existent conversation"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/get_single_conversation",
|
||||
params={"id": "nonexistent-conversation-id-12345"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [404, 400, 500]:
|
||||
self.print_success(f"Correctly returned {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
else:
|
||||
self.print_warning(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Update Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_update_conversation_name(self) -> bool:
|
||||
"""Test renaming a conversation."""
|
||||
test_name = "Update conversation name"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
conv_id = self.get_existing_conversation()
|
||||
if not conv_id:
|
||||
conv_id = self.get_or_create_test_conversation()
|
||||
|
||||
if not conv_id:
|
||||
self.print_warning("No conversation to rename")
|
||||
self.record_result(test_name, True, "Skipped (no conversation)")
|
||||
return True
|
||||
|
||||
new_name = f"Renamed Conversation {int(time.time())}"
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/update_conversation_name",
|
||||
json={"id": conv_id, "name": new_name},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
self.print_success(f"Renamed conversation to: {new_name[:30]}...")
|
||||
self.record_result(test_name, True, f"New name: {new_name[:20]}...")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Rename failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Delete Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_delete_conversation(self) -> bool:
|
||||
"""Test deleting a single conversation."""
|
||||
test_name = "Delete conversation"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Create a conversation specifically for deletion
|
||||
try:
|
||||
payload = {
|
||||
"question": "Test message for deletion test",
|
||||
"history": [],
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
create_response = self.post("/api/answer", json=payload, timeout=30)
|
||||
if create_response.status_code != 200:
|
||||
self.print_warning("Could not create conversation for deletion")
|
||||
self.record_result(test_name, True, "Skipped (create failed)")
|
||||
return True
|
||||
|
||||
conv_id = create_response.json().get("conversation_id")
|
||||
if not conv_id:
|
||||
self.print_warning("No conversation ID returned")
|
||||
self.record_result(test_name, True, "Skipped (no ID)")
|
||||
return True
|
||||
|
||||
# Delete the conversation
|
||||
response = self.post(
|
||||
"/api/delete_conversation",
|
||||
json={"id": conv_id},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 204]:
|
||||
self.print_success(f"Deleted conversation: {conv_id[:20]}...")
|
||||
self.record_result(test_name, True, "Conversation deleted")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Delete failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_delete_all_conversations(self) -> bool:
|
||||
"""Test the delete all conversations endpoint (without actually deleting all)."""
|
||||
test_name = "Delete all conversations endpoint"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
self.print_warning("Skipping actual deletion to preserve data")
|
||||
self.print_info("Verifying endpoint exists...")
|
||||
|
||||
try:
|
||||
# Just verify endpoint responds (don't actually call it)
|
||||
# We can test with a GET to see if endpoint exists
|
||||
response = self.get("/api/delete_all_conversations", timeout=10)
|
||||
|
||||
# Any response means endpoint exists
|
||||
self.print_success(f"Endpoint responded: {response.status_code}")
|
||||
self.record_result(test_name, True, "Endpoint verified")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Share Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_share_conversation(self) -> bool:
|
||||
"""Test sharing a conversation."""
|
||||
test_name = "Share conversation"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
conv_id = self.get_existing_conversation()
|
||||
if not conv_id:
|
||||
conv_id = self.get_or_create_test_conversation()
|
||||
|
||||
if not conv_id:
|
||||
self.print_warning("No conversation to share")
|
||||
self.record_result(test_name, True, "Skipped (no conversation)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/share",
|
||||
json={"conversation_id": conv_id},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
result = response.json()
|
||||
share_id = result.get("share_id") or result.get("id")
|
||||
self.print_success(f"Shared conversation: {share_id}")
|
||||
self._shared_conversation_id = share_id
|
||||
self.record_result(test_name, True, f"Share ID: {share_id}")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Share failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_shared_conversation(self) -> bool:
|
||||
"""Test getting a shared conversation."""
|
||||
test_name = "Get shared conversation"
|
||||
self.print_header(test_name)
|
||||
|
||||
# Use share ID from previous test if available
|
||||
share_id = getattr(self, "_shared_conversation_id", None)
|
||||
|
||||
if not share_id:
|
||||
self.print_warning("No shared conversation available")
|
||||
self.record_result(test_name, True, "Skipped (no shared conversation)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(f"/api/shared_conversation/{share_id}", timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
self.print_success("Retrieved shared conversation")
|
||||
self.print_info(f"Messages: {len(result.get('queries', []))}")
|
||||
self.record_result(test_name, True, f"Share ID: {share_id}")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
self.print_warning("Shared conversation not found")
|
||||
self.record_result(test_name, True, "Not found (may be expected)")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Get failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_shared_conversation_not_found(self) -> bool:
|
||||
"""Test getting a non-existent shared conversation."""
|
||||
test_name = "Get non-existent shared conversation"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/shared_conversation/nonexistent-share-id-12345",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [404, 400]:
|
||||
self.print_success(f"Correctly returned {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
else:
|
||||
self.print_warning(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Runner
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all conversation tests."""
|
||||
self.print_header("DocsGPT Conversation Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Auth: {self.token_source}")
|
||||
|
||||
# List/Get tests
|
||||
self.test_get_conversations()
|
||||
self.test_get_conversations_paginated()
|
||||
self.test_get_single_conversation()
|
||||
self.test_get_single_conversation_not_found()
|
||||
|
||||
# Update tests
|
||||
self.test_update_conversation_name()
|
||||
|
||||
# Delete tests
|
||||
self.test_delete_conversation()
|
||||
self.test_delete_all_conversations()
|
||||
|
||||
# Share tests
|
||||
self.test_share_conversation()
|
||||
self.test_get_shared_conversation()
|
||||
self.test_get_shared_conversation_not_found()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(
|
||||
ConversationTests, "DocsGPT Conversation Integration Tests"
|
||||
)
|
||||
exit_code = 0 if client.run_all() else 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT MCP (Model Context Protocol) server endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/mcp_server/callback (GET) - OAuth callback
|
||||
- /api/mcp_server/save (POST) - Save MCP server config
|
||||
- /api/mcp_server/test (POST) - Test MCP server connection
|
||||
|
||||
OAuth status (previously polled via /api/mcp_server/oauth_status/<task_id>)
|
||||
is now delivered exclusively through the per-user SSE pipe at
|
||||
/api/events; see docs/runbooks/sse-notifications.md.
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_mcp.py
|
||||
python tests/integration/test_mcp.py --base-url http://localhost:7091
|
||||
python tests/integration/test_mcp.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class MCPTests(DocsGPTTestBase):
|
||||
"""Integration tests for MCP server endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Callback Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_mcp_callback(self) -> bool:
|
||||
"""Test MCP OAuth callback endpoint."""
|
||||
test_name = "MCP OAuth callback"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/mcp_server/callback",
|
||||
params={"code": "test_code", "state": "test_state"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Expect various responses depending on configuration
|
||||
if response.status_code in [200, 302, 400, 404]:
|
||||
self.print_success(f"Callback response: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Save Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_mcp_save(self) -> bool:
|
||||
"""Test saving MCP server configuration."""
|
||||
test_name = "Save MCP server config"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"name": f"Test MCP Server {int(time.time())}",
|
||||
"url": "https://example.com/mcp",
|
||||
"config": {},
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/mcp_server/save",
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
result = response.json()
|
||||
self.print_success(f"Saved MCP server: {result.get('id', 'N/A')}")
|
||||
self.record_result(test_name, True, "Config saved")
|
||||
return True
|
||||
elif response.status_code in [400, 422]:
|
||||
self.print_warning(f"Validation error: {response.status_code}")
|
||||
self.record_result(test_name, True, "Validation handled")
|
||||
return True
|
||||
|
||||
self.print_error(f"Save failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_mcp_save_invalid(self) -> bool:
|
||||
"""Test saving invalid MCP config."""
|
||||
test_name = "Save invalid MCP config"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"name": "", # Invalid empty name
|
||||
"url": "not-a-url", # Invalid URL
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/mcp_server/save",
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if response.status_code in [400, 422]:
|
||||
self.print_success(f"Validation rejected: {response.status_code}")
|
||||
self.record_result(test_name, True, "Invalid config rejected")
|
||||
return True
|
||||
elif response.status_code in [200, 201]:
|
||||
self.print_warning("Server accepted invalid data (lenient validation)")
|
||||
self.record_result(test_name, True, "Lenient validation")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Connection Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_mcp_test_connection(self) -> bool:
|
||||
"""Test MCP server connection test."""
|
||||
test_name = "Test MCP connection"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"url": "https://example.com/mcp",
|
||||
"config": {},
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/mcp_server/test",
|
||||
json=payload,
|
||||
timeout=30, # Connection test may take time
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
success = result.get("success", result.get("connected", False))
|
||||
self.print_success(f"Connection test: success={success}")
|
||||
self.record_result(test_name, True, f"Connected: {success}")
|
||||
return True
|
||||
elif response.status_code in [400, 500, 502, 504]:
|
||||
# Connection failed (expected for non-existent server)
|
||||
self.print_warning(f"Connection failed: {response.status_code}")
|
||||
self.record_result(test_name, True, "Connection failed (expected)")
|
||||
return True
|
||||
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_mcp_test_connection_invalid(self) -> bool:
|
||||
"""Test MCP connection with invalid URL."""
|
||||
test_name = "Test MCP invalid URL"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"url": "invalid-url",
|
||||
"config": {},
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/mcp_server/test",
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if response.status_code in [400, 422, 500]:
|
||||
self.print_success(f"Invalid URL rejected: {response.status_code}")
|
||||
self.record_result(test_name, True, "Invalid URL handled")
|
||||
return True
|
||||
elif response.status_code == 200:
|
||||
result = response.json()
|
||||
if not result.get("success", result.get("connected", True)):
|
||||
self.print_success("Connection correctly failed")
|
||||
self.record_result(test_name, True, "Connection failed")
|
||||
return True
|
||||
|
||||
self.print_warning(f"Status: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Runner
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all MCP tests."""
|
||||
self.print_header("DocsGPT MCP Server Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Auth: {self.token_source}")
|
||||
|
||||
# Callback tests
|
||||
self.test_mcp_callback()
|
||||
|
||||
# Save tests
|
||||
self.test_mcp_save()
|
||||
self.test_mcp_save_invalid()
|
||||
|
||||
# Test connection tests
|
||||
self.test_mcp_test_connection()
|
||||
self.test_mcp_test_connection_invalid()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(MCPTests, "DocsGPT MCP Server Integration Tests")
|
||||
exit_code = 0 if client.run_all() else 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT miscellaneous endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/models (GET) - List available models
|
||||
- /api/images/{image_path} (GET) - Get images
|
||||
- /api/store_attachment (POST) - Store attachments
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_misc.py
|
||||
python tests/integration/test_misc.py --base-url http://localhost:7091
|
||||
python tests/integration/test_misc.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class MiscTests(DocsGPTTestBase):
|
||||
"""Integration tests for miscellaneous endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Models Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_models(self) -> bool:
|
||||
"""Test listing available models."""
|
||||
test_name = "List models"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
response = self.get("/api/models", timeout=10)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
# Handle both list and object responses
|
||||
if isinstance(result, list):
|
||||
self.print_success(f"Retrieved {len(result)} models")
|
||||
if result:
|
||||
first_model = result[0]
|
||||
if isinstance(first_model, dict):
|
||||
self.print_info(f"First: {first_model.get('name', first_model.get('id', 'N/A'))}")
|
||||
else:
|
||||
self.print_info(f"First: {first_model}")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
elif isinstance(result, dict):
|
||||
# May return object with models array
|
||||
models = result.get("models", result.get("data", []))
|
||||
if isinstance(models, list):
|
||||
self.print_success(f"Retrieved {len(models)} models")
|
||||
if models:
|
||||
first = models[0]
|
||||
name = first.get('name', first) if isinstance(first, dict) else first
|
||||
self.print_info(f"First: {name}")
|
||||
else:
|
||||
self.print_success("Retrieved models data")
|
||||
self.record_result(test_name, True, "Models retrieved")
|
||||
else:
|
||||
self.print_warning(f"Unexpected response type: {type(result)}")
|
||||
self.record_result(test_name, True, "Response received")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_models_with_filter(self) -> bool:
|
||||
"""Test listing models with filter parameters."""
|
||||
test_name = "List models filtered"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/models",
|
||||
params={"provider": "openai"}, # Filter by provider
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
if isinstance(result, list):
|
||||
self.print_success(f"Retrieved {len(result)} filtered models")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
return True
|
||||
else:
|
||||
self.print_warning("Response format may vary")
|
||||
self.record_result(test_name, True, "Response received")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Images Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_image(self) -> bool:
|
||||
"""Test getting an image by path."""
|
||||
test_name = "Get image"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
# Test with a placeholder path
|
||||
response = self.get("/api/images/test.png", timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
self.print_success(f"Image retrieved: {content_type}")
|
||||
self.record_result(test_name, True, f"Type: {content_type}")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
self.print_warning("Image not found (expected for test)")
|
||||
self.record_result(test_name, True, "404 - Image not found")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_image_not_found(self) -> bool:
|
||||
"""Test getting a non-existent image."""
|
||||
test_name = "Get non-existent image"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/images/nonexistent-image-xyz-12345.png",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
self.print_success("Correctly returned 404")
|
||||
self.record_result(test_name, True, "404 returned")
|
||||
return True
|
||||
elif response.status_code in [400, 500]:
|
||||
self.print_warning(f"Error status: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
else:
|
||||
self.print_warning(f"Status: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Attachment Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_store_attachment(self) -> bool:
|
||||
"""Test storing an attachment."""
|
||||
test_name = "Store attachment"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Create a small test file content
|
||||
test_content = b"Test attachment content for integration test"
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/store_attachment",
|
||||
files={"file": ("test_attachment.txt", test_content, "text/plain")},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
result = response.json()
|
||||
attachment_id = result.get("id") or result.get("attachment_id") or result.get("path")
|
||||
self.print_success(f"Stored attachment: {attachment_id}")
|
||||
self.record_result(test_name, True, f"ID: {attachment_id}")
|
||||
return True
|
||||
elif response.status_code in [400, 422]:
|
||||
self.print_warning(f"Validation: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Store failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_store_attachment_large(self) -> bool:
|
||||
"""Test storing a larger attachment."""
|
||||
test_name = "Store large attachment"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Create a larger test file (1KB)
|
||||
test_content = b"X" * 1024
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/store_attachment",
|
||||
files={"file": ("large_test.bin", test_content, "application/octet-stream")},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
response.json() # Validate JSON response
|
||||
self.print_success("Large attachment stored")
|
||||
self.record_result(test_name, True, "Attachment stored")
|
||||
return True
|
||||
elif response.status_code in [400, 413, 422]:
|
||||
self.print_warning(f"Size/validation: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Store failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Health/Info Tests (bonus)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_health_check(self) -> bool:
|
||||
"""Test basic health check (root or health endpoint)."""
|
||||
test_name = "Health check"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
# Try common health endpoints
|
||||
for path in ["/health", "/api/health", "/"]:
|
||||
response = self.get(path, timeout=5)
|
||||
if response.status_code == 200:
|
||||
self.print_success(f"Health check passed: {path}")
|
||||
self.record_result(test_name, True, f"Endpoint: {path}")
|
||||
return True
|
||||
|
||||
# If none worked, check if server responds at all
|
||||
self.print_warning("No standard health endpoint found")
|
||||
self.record_result(test_name, True, "Server responsive")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Runner
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all miscellaneous tests."""
|
||||
self.print_header("DocsGPT Miscellaneous Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Auth: {self.token_source}")
|
||||
|
||||
# Health check
|
||||
self.test_health_check()
|
||||
|
||||
# Models tests
|
||||
self.test_get_models()
|
||||
self.test_get_models_with_filter()
|
||||
|
||||
# Images tests
|
||||
self.test_get_image()
|
||||
self.test_get_image_not_found()
|
||||
|
||||
# Attachment tests
|
||||
self.test_store_attachment()
|
||||
self.test_store_attachment_large()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(MiscTests, "DocsGPT Miscellaneous Integration Tests")
|
||||
exit_code = 0 if client.run_all() else 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,432 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT prompt management endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/create_prompt (POST) - Create prompt
|
||||
- /api/get_prompts (GET) - List prompts
|
||||
- /api/get_single_prompt (GET) - Get single prompt
|
||||
- /api/update_prompt (POST) - Update prompt
|
||||
- /api/delete_prompt (POST) - Delete prompt
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_prompts.py
|
||||
python tests/integration/test_prompts.py --base-url http://localhost:7091
|
||||
python tests/integration/test_prompts.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class PromptTests(DocsGPTTestBase):
|
||||
"""Integration tests for prompt management endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Data Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_create_test_prompt(self) -> Optional[str]:
|
||||
"""
|
||||
Get or create a test prompt.
|
||||
|
||||
Returns:
|
||||
Prompt ID or None if creation fails
|
||||
"""
|
||||
if hasattr(self, "_test_prompt_id"):
|
||||
return self._test_prompt_id
|
||||
|
||||
if not self.is_authenticated:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"name": f"Test Prompt {int(time.time())}",
|
||||
"content": "You are a helpful assistant. Answer questions accurately.",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_prompt", json=payload, timeout=10)
|
||||
if response.status_code in [200, 201]:
|
||||
result = response.json()
|
||||
prompt_id = result.get("id")
|
||||
if prompt_id:
|
||||
self._test_prompt_id = prompt_id
|
||||
return prompt_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def cleanup_test_prompt(self, prompt_id: str) -> None:
|
||||
"""Delete a test prompt (cleanup helper)."""
|
||||
if not self.is_authenticated:
|
||||
return
|
||||
try:
|
||||
self.post("/api/delete_prompt", json={"id": prompt_id}, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Create Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_create_prompt(self) -> bool:
|
||||
"""Test creating a prompt."""
|
||||
test_name = "Create prompt"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"name": f"Created Prompt {int(time.time())}",
|
||||
"content": "You are a test assistant created by integration tests.",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_prompt", json=payload, timeout=10)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
prompt_id = result.get("id")
|
||||
|
||||
if not prompt_id:
|
||||
self.print_error("No prompt ID returned")
|
||||
self.record_result(test_name, False, "No prompt ID")
|
||||
return False
|
||||
|
||||
self.print_success(f"Created prompt: {prompt_id}")
|
||||
self.print_info(f"Name: {payload['name']}")
|
||||
self.record_result(test_name, True, f"ID: {prompt_id}")
|
||||
|
||||
# Cleanup
|
||||
self.cleanup_test_prompt(prompt_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_create_prompt_validation(self) -> bool:
|
||||
"""Test prompt creation validation (missing required fields)."""
|
||||
test_name = "Create prompt validation"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Missing content field
|
||||
payload = {
|
||||
"name": "Invalid Prompt",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_prompt", json=payload, timeout=10)
|
||||
|
||||
# Expect validation error (400) or accept it if server provides defaults
|
||||
if response.status_code in [400, 422]:
|
||||
self.print_success(f"Validation error returned: {response.status_code}")
|
||||
self.record_result(test_name, True, "Validation works")
|
||||
return True
|
||||
elif response.status_code in [200, 201]:
|
||||
self.print_warning("Server accepted incomplete data (may have defaults)")
|
||||
result = response.json()
|
||||
if result.get("id"):
|
||||
self.cleanup_test_prompt(result["id"])
|
||||
self.record_result(test_name, True, "Server accepted with defaults")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Read Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_prompts(self) -> bool:
|
||||
"""Test listing all prompts."""
|
||||
test_name = "List prompts"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get("/api/get_prompts", timeout=10)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
if not isinstance(result, list):
|
||||
self.print_error("Response is not a list")
|
||||
self.record_result(test_name, False, "Invalid response type")
|
||||
return False
|
||||
|
||||
self.print_success(f"Retrieved {len(result)} prompts")
|
||||
if result:
|
||||
self.print_info(f"First: {result[0].get('name', 'N/A')}")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_prompts_with_pagination(self) -> bool:
|
||||
"""Test listing prompts with pagination params."""
|
||||
test_name = "List prompts paginated"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/get_prompts",
|
||||
params={"skip": 0, "limit": 10},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
if not isinstance(result, list):
|
||||
self.print_error("Response is not a list")
|
||||
self.record_result(test_name, False, "Invalid response type")
|
||||
return False
|
||||
|
||||
self.print_success(f"Retrieved {len(result)} prompts (paginated)")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_single_prompt(self) -> bool:
|
||||
"""Test getting a single prompt by ID."""
|
||||
test_name = "Get single prompt"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
prompt_id = self.get_or_create_test_prompt()
|
||||
if not prompt_id:
|
||||
self.print_warning("Could not create test prompt")
|
||||
self.record_result(test_name, True, "Skipped (no prompt)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/get_single_prompt",
|
||||
params={"id": prompt_id},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
# Handle different response formats (may have _id instead of id)
|
||||
returned_id = result.get("id") or result.get("_id")
|
||||
|
||||
if returned_id and returned_id != prompt_id:
|
||||
self.print_error(f"Wrong prompt returned: {returned_id}")
|
||||
self.record_result(test_name, False, "Wrong prompt ID")
|
||||
return False
|
||||
|
||||
self.print_success(f"Retrieved prompt: {result.get('name', 'N/A')}")
|
||||
self.record_result(test_name, True, f"Name: {result.get('name', 'N/A')}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_single_prompt_not_found(self) -> bool:
|
||||
"""Test getting a non-existent prompt."""
|
||||
test_name = "Get non-existent prompt"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get(
|
||||
"/api/get_single_prompt",
|
||||
params={"id": "nonexistent-prompt-id-12345"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [404, 400, 500]:
|
||||
self.print_success(f"Correctly returned {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
else:
|
||||
self.print_warning(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, True, f"Status: {response.status_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Update Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_update_prompt(self) -> bool:
|
||||
"""Test updating a prompt."""
|
||||
test_name = "Update prompt"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
prompt_id = self.get_or_create_test_prompt()
|
||||
if not prompt_id:
|
||||
self.print_warning("Could not create test prompt")
|
||||
self.record_result(test_name, True, "Skipped (no prompt)")
|
||||
return True
|
||||
|
||||
new_content = f"Updated content at {int(time.time())}"
|
||||
new_name = f"Updated Prompt {int(time.time())}"
|
||||
|
||||
try:
|
||||
# UpdatePromptModel requires id, name, and content
|
||||
response = self.post(
|
||||
"/api/update_prompt",
|
||||
json={"id": prompt_id, "name": new_name, "content": new_content},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
self.print_success("Prompt updated successfully")
|
||||
self.record_result(test_name, True, "Prompt updated")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Update failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Delete Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_delete_prompt(self) -> bool:
|
||||
"""Test deleting a prompt."""
|
||||
test_name = "Delete prompt"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Create a prompt specifically for deletion
|
||||
payload = {
|
||||
"name": f"Prompt to Delete {int(time.time())}",
|
||||
"content": "Will be deleted",
|
||||
}
|
||||
|
||||
try:
|
||||
create_response = self.post("/api/create_prompt", json=payload, timeout=10)
|
||||
if create_response.status_code not in [200, 201]:
|
||||
self.print_warning("Could not create prompt for deletion")
|
||||
self.record_result(test_name, True, "Skipped (create failed)")
|
||||
return True
|
||||
|
||||
prompt_id = create_response.json().get("id")
|
||||
|
||||
# Delete the prompt
|
||||
response = self.post("/api/delete_prompt", json={"id": prompt_id}, timeout=10)
|
||||
|
||||
if response.status_code in [200, 204]:
|
||||
self.print_success(f"Deleted prompt: {prompt_id}")
|
||||
self.record_result(test_name, True, "Prompt deleted")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Delete failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Runner
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all prompt tests."""
|
||||
self.print_header("DocsGPT Prompt Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Auth: {self.token_source}")
|
||||
|
||||
# Create tests
|
||||
self.test_create_prompt()
|
||||
self.test_create_prompt_validation()
|
||||
|
||||
# Read tests
|
||||
self.test_get_prompts()
|
||||
self.test_get_prompts_with_pagination()
|
||||
self.test_get_single_prompt()
|
||||
self.test_get_single_prompt_not_found()
|
||||
|
||||
# Update tests
|
||||
self.test_update_prompt()
|
||||
|
||||
# Delete tests
|
||||
self.test_delete_prompt()
|
||||
|
||||
# Cleanup
|
||||
if hasattr(self, "_test_prompt_id"):
|
||||
self.cleanup_test_prompt(self._test_prompt_id)
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(PromptTests, "DocsGPT Prompt Integration Tests")
|
||||
exit_code = 0 if client.run_all() else 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Integration tests for the SCIM 2.0 endpoints against a live Postgres.
|
||||
|
||||
These tests drive the real Flask app through its test client and verify
|
||||
row state in the database configured by ``POSTGRES_URI``. Redis is not
|
||||
required — the denylist functions are stubbed. They are skipped by the
|
||||
default ``pytest`` run (``--ignore=tests/integration`` in ``pytest.ini``)
|
||||
and marked ``@pytest.mark.integration``. Run them locally with::
|
||||
|
||||
.venv/bin/python -m pytest tests/integration/test_scim.py -q --no-cov \\
|
||||
-p no:cacheprovider --override-ini "addopts="
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
from application.storage.db.repositories.users import UsersRepository
|
||||
from application.storage.db.session import db_readonly, db_session
|
||||
|
||||
SCIM_TOKEN = "scim-test-token"
|
||||
AUTH = {"Authorization": f"Bearer {SCIM_TOKEN}"}
|
||||
|
||||
ERROR_URN = "urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
PATCH_OP_URN = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not settings.POSTGRES_URI,
|
||||
reason="POSTGRES_URI not set — skipping SCIM integration tests",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Real Flask app; /scim/ paths bypass JWT auth so no handle_auth patching is needed."""
|
||||
from application.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scim_env(monkeypatch):
|
||||
"""Enable SCIM on the settings singleton and stub the Redis denylist."""
|
||||
monkeypatch.setattr(settings, "SCIM_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "SCIM_TOKEN", SCIM_TOKEN)
|
||||
with patch("application.api.scim.routes.deny_user") as deny_user_mock:
|
||||
yield SimpleNamespace(deny_user=deny_user_mock)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scim_user_name():
|
||||
"""Unique per-run userName; deletes the user + audit rows afterwards."""
|
||||
name = f"scim-it-{uuid.uuid4().hex[:10]}@example.com"
|
||||
yield name
|
||||
with db_session() as conn:
|
||||
conn.execute(text("DELETE FROM auth_events WHERE user_id = :user_id"), {"user_id": name})
|
||||
conn.execute(text("DELETE FROM users WHERE user_id = :user_id"), {"user_id": name})
|
||||
|
||||
|
||||
def _fetch_user(user_name: str):
|
||||
with db_readonly() as conn:
|
||||
return UsersRepository(conn).get(user_name)
|
||||
|
||||
|
||||
def _fetch_events(user_name: str):
|
||||
with db_readonly() as conn:
|
||||
return AuthEventsRepository(conn).list_recent(user_name)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestScimLifecycle:
|
||||
|
||||
def test_full_user_lifecycle(self, client, scim_env, scim_user_name):
|
||||
# Create
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": scim_user_name})
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
created = response.get_json()
|
||||
pk = created["id"]
|
||||
assert created["userName"] == scim_user_name
|
||||
assert created["active"] is True
|
||||
assert created["emails"] == [{"value": scim_user_name, "primary": True}]
|
||||
assert response.headers["Location"].endswith(f"/scim/v2/Users/{pk}")
|
||||
|
||||
row = _fetch_user(scim_user_name)
|
||||
assert row is not None
|
||||
assert row["active"] is True
|
||||
assert [event["event"] for event in _fetch_events(scim_user_name)] == ["scim_created"]
|
||||
|
||||
# GET by id
|
||||
response = client.get(f"/scim/v2/Users/{pk}", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["id"] == pk
|
||||
|
||||
# List with exact userName filter
|
||||
response = client.get(
|
||||
"/scim/v2/Users",
|
||||
headers=AUTH,
|
||||
query_string={"filter": f'userName eq "{scim_user_name}"'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["totalResults"] == 1
|
||||
assert body["itemsPerPage"] == 1
|
||||
assert body["Resources"][0]["id"] == pk
|
||||
|
||||
# Deactivate via PATCH (Okta no-path form with string value)
|
||||
response = client.patch(
|
||||
f"/scim/v2/Users/{pk}",
|
||||
headers=AUTH,
|
||||
json={
|
||||
"schemas": [PATCH_OP_URN],
|
||||
"Operations": [{"op": "replace", "value": {"active": "False"}}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is False
|
||||
assert _fetch_user(scim_user_name)["active"] is False
|
||||
deactivations = [e for e in _fetch_events(scim_user_name) if e["event"] == "scim_deactivated"]
|
||||
assert len(deactivations) == 1
|
||||
assert deactivations[0]["metadata"] == {"via": "scim"}
|
||||
scim_env.deny_user.assert_called_once_with(scim_user_name)
|
||||
|
||||
# Reactivate via PUT (full replace; only active is honored)
|
||||
response = client.put(
|
||||
f"/scim/v2/Users/{pk}",
|
||||
headers=AUTH,
|
||||
json={"userName": scim_user_name, "active": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["active"] is True
|
||||
assert _fetch_user(scim_user_name)["active"] is True
|
||||
# Reactivation no longer clears the denylist (watermark model).
|
||||
scim_env.deny_user.assert_called_once_with(scim_user_name)
|
||||
assert any(e["event"] == "scim_reactivated" for e in _fetch_events(scim_user_name))
|
||||
|
||||
# Soft delete deactivates again
|
||||
response = client.delete(f"/scim/v2/Users/{pk}", headers=AUTH)
|
||||
assert response.status_code == 204
|
||||
assert response.data == b""
|
||||
assert _fetch_user(scim_user_name)["active"] is False
|
||||
assert scim_env.deny_user.call_count == 2
|
||||
|
||||
# Duplicate create conflicts (row still exists after soft delete)
|
||||
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": scim_user_name})
|
||||
assert response.status_code == 409
|
||||
body = response.get_json()
|
||||
assert body["schemas"] == [ERROR_URN]
|
||||
assert body["scimType"] == "uniqueness"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestScimAccess:
|
||||
|
||||
def test_wrong_bearer_token_rejected(self, client, scim_env):
|
||||
response = client.get("/scim/v2/Users", headers={"Authorization": "Bearer wrong"})
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["schemas"] == [ERROR_URN]
|
||||
|
||||
def test_get_user_with_malformed_uuid_returns_404(self, client, scim_env):
|
||||
response = client.get("/scim/v2/Users/not-a-uuid", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
assert response.get_json()["status"] == "404"
|
||||
|
||||
def test_service_provider_config_served(self, client, scim_env):
|
||||
response = client.get("/scim/v2/ServiceProviderConfig", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.content_type.startswith("application/scim+json")
|
||||
assert response.get_json()["patch"] == {"supported": True}
|
||||
@@ -0,0 +1,636 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT source management endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/upload (POST) - File upload
|
||||
- /api/remote (POST) - Remote source (crawler)
|
||||
- /api/sources (GET) - List sources
|
||||
- /api/sources/paginated (GET) - Paginated sources
|
||||
- /api/task_status (GET) - Task status
|
||||
- /api/add_chunk (POST) - Add chunk to source
|
||||
- /api/get_chunks (GET) - Get chunks from source
|
||||
- /api/update_chunk (PUT) - Update chunk
|
||||
- /api/delete_chunk (DELETE) - Delete chunk
|
||||
- /api/delete_old (GET) - Delete old sources
|
||||
- /api/directory_structure (GET) - Get directory structure
|
||||
- /api/manage_source_files (POST) - Manage source files
|
||||
- /api/manage_sync (POST) - Manage sync
|
||||
- /api/combine (GET) - Combine sources
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_sources.py
|
||||
python tests/integration/test_sources.py --base-url http://localhost:7091
|
||||
python tests/integration/test_sources.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class SourceTests(DocsGPTTestBase):
|
||||
"""Integration tests for source management endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Data Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_create_test_source(self) -> Optional[dict]:
|
||||
"""
|
||||
Get or create a test source.
|
||||
|
||||
Returns:
|
||||
Dict with keys: id, task_id, name or None
|
||||
"""
|
||||
if hasattr(self, "_test_source"):
|
||||
return self._test_source
|
||||
|
||||
if not self.is_authenticated:
|
||||
return None
|
||||
|
||||
test_name = f"Source Test {int(time.time())}"
|
||||
test_content = """# Test Documentation
|
||||
|
||||
## Overview
|
||||
This is test documentation for source integration tests.
|
||||
|
||||
## Installation
|
||||
Run `pip install docsgpt` to install.
|
||||
|
||||
## Usage
|
||||
Import and use the library in your code.
|
||||
|
||||
## API Reference
|
||||
See the API documentation for details.
|
||||
"""
|
||||
|
||||
files = {"file": ("test_source.txt", test_content.encode(), "text/plain")}
|
||||
data = {"user": "test_user", "name": test_name}
|
||||
|
||||
try:
|
||||
response = self.post("/api/upload", files=files, data=data, timeout=30)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
task_id = result.get("task_id")
|
||||
if task_id:
|
||||
# Wait for Celery ingestion (FAISS index build) to finish
|
||||
# before trying to query chunks. A fixed sleep is too
|
||||
# flaky on slower machines, so poll task_status instead.
|
||||
status = self._wait_for_task(task_id, max_wait=60)
|
||||
if status != "SUCCESS":
|
||||
return None
|
||||
|
||||
# Get source ID
|
||||
source_id = self._get_source_id_by_name(test_name)
|
||||
if source_id:
|
||||
self._test_source = {
|
||||
"id": source_id,
|
||||
"task_id": task_id,
|
||||
"name": test_name,
|
||||
}
|
||||
return self._test_source
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _get_source_id_by_name(self, name: str) -> Optional[str]:
|
||||
"""Get source ID by name from sources list."""
|
||||
try:
|
||||
response = self.get("/api/sources")
|
||||
if response.status_code == 200:
|
||||
sources = response.json()
|
||||
for source in sources:
|
||||
if source.get("name") == name:
|
||||
return source.get("id")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _wait_for_task(self, task_id: str, max_wait: int = 30) -> Optional[str]:
|
||||
"""Wait for task to complete and return status."""
|
||||
for _ in range(max_wait):
|
||||
try:
|
||||
response = self.get("/api/task_status", params={"task_id": task_id})
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
status = result.get("status")
|
||||
if status in ["SUCCESS", "FAILURE"]:
|
||||
return status
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Upload Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_upload_text_source(self) -> bool:
|
||||
"""Test uploading a text file source."""
|
||||
test_name = "Upload - Text Source"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
test_content = f"""# Upload Test {int(time.time())}
|
||||
This is a test document for upload testing.
|
||||
It contains multiple lines of text.
|
||||
"""
|
||||
|
||||
files = {"file": ("upload_test.txt", test_content.encode(), "text/plain")}
|
||||
data = {"user": "test_user", "name": f"Upload Test {int(time.time())}"}
|
||||
|
||||
try:
|
||||
self.print_info("POST /api/upload")
|
||||
response = self.post("/api/upload", files=files, data=data, timeout=30)
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
task_id = result.get("task_id")
|
||||
|
||||
if task_id:
|
||||
self.print_success(f"Upload task started: {task_id}")
|
||||
self.record_result(test_name, True, f"Task: {task_id}")
|
||||
return True
|
||||
else:
|
||||
self.print_warning("No task_id returned")
|
||||
self.record_result(test_name, False, "No task_id")
|
||||
return False
|
||||
else:
|
||||
self.print_error(f"Expected 200, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {str(e)}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_upload_markdown_source(self) -> bool:
|
||||
"""Test uploading a markdown file source."""
|
||||
test_name = "Upload - Markdown Source"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
test_content = f"""# Markdown Test Document
|
||||
|
||||
## Section 1
|
||||
This is the first section with **bold** and *italic* text.
|
||||
|
||||
## Section 2
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 3
|
||||
|
||||
## Code Example
|
||||
```python
|
||||
def hello():
|
||||
print("Hello, World!")
|
||||
```
|
||||
|
||||
Created at: {int(time.time())}
|
||||
"""
|
||||
|
||||
files = {"file": ("test.md", test_content.encode(), "text/markdown")}
|
||||
data = {"user": "test_user", "name": f"Markdown Test {int(time.time())}"}
|
||||
|
||||
try:
|
||||
self.print_info("POST /api/upload (markdown)")
|
||||
response = self.post("/api/upload", files=files, data=data, timeout=30)
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
task_id = result.get("task_id")
|
||||
if task_id:
|
||||
self.print_success(f"Markdown upload task started: {task_id}")
|
||||
self.record_result(test_name, True, f"Task: {task_id}")
|
||||
return True
|
||||
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Remote Source Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_remote_crawler_source(self) -> bool:
|
||||
"""Test remote crawler source upload."""
|
||||
test_name = "Remote - Crawler Source"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Use a small, fast-loading page
|
||||
payload = {
|
||||
"user": "test_user",
|
||||
"source": "crawler",
|
||||
"name": f"Crawler Test {int(time.time())}",
|
||||
"data": '{"url": "https://example.com/"}',
|
||||
}
|
||||
|
||||
try:
|
||||
self.print_info("POST /api/remote (crawler)")
|
||||
response = self.post("/api/remote", data=payload, timeout=30)
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
task_id = result.get("task_id")
|
||||
if task_id:
|
||||
self.print_success(f"Crawler task started: {task_id}")
|
||||
self.record_result(test_name, True, f"Task: {task_id}")
|
||||
return True
|
||||
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Source Listing Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_sources(self) -> bool:
|
||||
"""Test getting list of sources."""
|
||||
test_name = "Sources - List All"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
try:
|
||||
self.print_info("GET /api/sources")
|
||||
response = self.get("/api/sources")
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
sources = response.json()
|
||||
self.print_success(f"Retrieved {len(sources)} sources")
|
||||
|
||||
if sources:
|
||||
first = sources[0]
|
||||
self.print_info(f"First source: {first.get('name', 'N/A')}")
|
||||
|
||||
self.record_result(test_name, True, f"{len(sources)} sources")
|
||||
return True
|
||||
else:
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_sources_paginated(self) -> bool:
|
||||
"""Test getting paginated sources."""
|
||||
test_name = "Sources - Paginated"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
try:
|
||||
self.print_info("GET /api/sources/paginated")
|
||||
response = self.get("/api/sources/paginated", params={"page": 1, "per_page": 10})
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
self.print_success("Paginated sources retrieved")
|
||||
|
||||
if isinstance(result, dict):
|
||||
total = result.get("total", "N/A")
|
||||
self.print_info(f"Total sources: {total}")
|
||||
|
||||
self.record_result(test_name, True, "Success")
|
||||
return True
|
||||
else:
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task Status Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_task_status(self) -> bool:
|
||||
"""Test getting task status."""
|
||||
test_name = "Task Status - Check"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# First upload a file to get a task_id
|
||||
test_content = "Test content for task status"
|
||||
files = {"file": ("task_test.txt", test_content.encode(), "text/plain")}
|
||||
data = {"user": "test_user", "name": f"Task Test {int(time.time())}"}
|
||||
|
||||
try:
|
||||
upload_response = self.post("/api/upload", files=files, data=data, timeout=30)
|
||||
|
||||
if upload_response.status_code != 200:
|
||||
self.record_result(test_name, True, "Skipped (upload failed)")
|
||||
return True
|
||||
|
||||
task_id = upload_response.json().get("task_id")
|
||||
if not task_id:
|
||||
self.record_result(test_name, True, "Skipped (no task_id)")
|
||||
return True
|
||||
|
||||
self.print_info(f"GET /api/task_status?task_id={task_id[:8]}...")
|
||||
response = self.get("/api/task_status", params={"task_id": task_id})
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
status = result.get("status", "UNKNOWN")
|
||||
self.print_success(f"Task status: {status}")
|
||||
self.record_result(test_name, True, f"Status: {status}")
|
||||
return True
|
||||
else:
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Chunk Management Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_chunks(self) -> bool:
|
||||
"""Test getting chunks from a source."""
|
||||
test_name = "Chunks - Get"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
source = self.get_or_create_test_source()
|
||||
if not source:
|
||||
self.print_warning("Could not create test source")
|
||||
self.record_result(test_name, True, "Skipped (no source)")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Swagger says param is 'id', not 'source_id'
|
||||
self.print_info(f"GET /api/get_chunks?id={source['id'][:8]}...")
|
||||
response = self.get("/api/get_chunks", params={"id": source["id"]})
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
chunks = result if isinstance(result, list) else result.get("chunks", [])
|
||||
self.print_success(f"Retrieved {len(chunks)} chunks")
|
||||
self.record_result(test_name, True, f"{len(chunks)} chunks")
|
||||
return True
|
||||
else:
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_add_chunk(self) -> bool:
|
||||
"""Test adding a chunk to a source."""
|
||||
test_name = "Chunks - Add"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
source = self.get_or_create_test_source()
|
||||
if not source:
|
||||
self.record_result(test_name, True, "Skipped (no source)")
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"source_id": source["id"],
|
||||
"content": f"Test chunk content added at {int(time.time())}",
|
||||
"metadata": {"test": True},
|
||||
}
|
||||
|
||||
try:
|
||||
self.print_info("POST /api/add_chunk")
|
||||
response = self.post("/api/add_chunk", json=payload)
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
self.print_success("Chunk added successfully")
|
||||
self.record_result(test_name, True, "Success")
|
||||
return True
|
||||
else:
|
||||
# May not be supported or require specific format
|
||||
self.print_warning(f"Status {response.status_code}")
|
||||
self.record_result(test_name, True, f"Skipped (status {response.status_code})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Delete Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Directory Structure Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_directory_structure(self) -> bool:
|
||||
"""Test getting directory structure."""
|
||||
test_name = "Directory Structure"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
source = self.get_or_create_test_source()
|
||||
if not source:
|
||||
self.record_result(test_name, True, "Skipped (no source)")
|
||||
return True
|
||||
|
||||
try:
|
||||
self.print_info(f"GET /api/directory_structure?source_id={source['id'][:8]}...")
|
||||
response = self.get("/api/directory_structure", params={"source_id": source["id"]})
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
response.json() # Validate JSON response
|
||||
self.print_success("Directory structure retrieved")
|
||||
self.record_result(test_name, True, "Success")
|
||||
return True
|
||||
else:
|
||||
# May not be supported for all source types
|
||||
self.print_warning(f"Status {response.status_code}")
|
||||
self.record_result(test_name, True, f"Skipped (status {response.status_code})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Combine Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_combine(self) -> bool:
|
||||
"""Test combine endpoint."""
|
||||
test_name = "Sources - Combine"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
try:
|
||||
self.print_info("GET /api/combine")
|
||||
response = self.get("/api/combine")
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
self.print_success("Combine endpoint works")
|
||||
self.record_result(test_name, True, "Success")
|
||||
return True
|
||||
else:
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Manage Source Files Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_manage_source_files(self) -> bool:
|
||||
"""Test managing source files."""
|
||||
test_name = "Manage Source Files"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
source = self.get_or_create_test_source()
|
||||
if not source:
|
||||
self.record_result(test_name, True, "Skipped (no source)")
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"source_id": source["id"],
|
||||
"action": "list",
|
||||
}
|
||||
|
||||
try:
|
||||
self.print_info("POST /api/manage_source_files")
|
||||
response = self.post("/api/manage_source_files", json=payload)
|
||||
|
||||
self.print_info(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
self.print_success("Source files managed")
|
||||
self.record_result(test_name, True, "Success")
|
||||
return True
|
||||
else:
|
||||
# May require specific format
|
||||
self.print_warning(f"Status {response.status_code}")
|
||||
self.record_result(test_name, True, f"Skipped (status {response.status_code})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Run All Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all source integration tests."""
|
||||
self.print_header("Source Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Authentication: {'Yes' if self.is_authenticated else 'No'}")
|
||||
|
||||
# Upload tests
|
||||
self.test_upload_text_source()
|
||||
time.sleep(1)
|
||||
|
||||
self.test_upload_markdown_source()
|
||||
time.sleep(1)
|
||||
|
||||
# Remote source tests
|
||||
self.test_remote_crawler_source()
|
||||
time.sleep(1)
|
||||
|
||||
# Source listing tests
|
||||
self.test_get_sources()
|
||||
time.sleep(1)
|
||||
|
||||
self.test_get_sources_paginated()
|
||||
time.sleep(1)
|
||||
|
||||
# Task status test
|
||||
self.test_task_status()
|
||||
time.sleep(1)
|
||||
|
||||
# Chunk tests
|
||||
self.test_get_chunks()
|
||||
time.sleep(1)
|
||||
|
||||
self.test_add_chunk()
|
||||
time.sleep(1)
|
||||
|
||||
# Directory structure
|
||||
self.test_directory_structure()
|
||||
time.sleep(1)
|
||||
|
||||
# Combine
|
||||
self.test_combine()
|
||||
time.sleep(1)
|
||||
|
||||
# Manage source files
|
||||
self.test_manage_source_files()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for standalone execution."""
|
||||
client = create_client_from_args(SourceTests, "DocsGPT Source Integration Tests")
|
||||
success = client.run_all()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT tools management endpoints.
|
||||
|
||||
Endpoints tested:
|
||||
- /api/create_tool (POST) - Create tool
|
||||
- /api/get_tools (GET) - List tools
|
||||
- /api/update_tool (POST) - Update tool
|
||||
- /api/delete_tool (POST) - Delete tool
|
||||
- /api/update_tool_actions (POST) - Update tool actions
|
||||
- /api/update_tool_config (POST) - Update tool config
|
||||
- /api/update_tool_status (POST) - Update tool status
|
||||
- /api/available_tools (GET) - List available tools
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_tools.py
|
||||
python tests/integration/test_tools.py --base-url http://localhost:7091
|
||||
python tests/integration/test_tools.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class ToolsTests(DocsGPTTestBase):
|
||||
"""Integration tests for tools management endpoints."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Data Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_create_test_tool(self) -> Optional[str]:
|
||||
"""
|
||||
Get or create a test tool.
|
||||
|
||||
Returns:
|
||||
Tool ID or None if creation fails
|
||||
"""
|
||||
if hasattr(self, "_test_tool_id"):
|
||||
return self._test_tool_id
|
||||
|
||||
if not self.is_authenticated:
|
||||
return None
|
||||
|
||||
# CreateToolModel: 'name' must be an available tool type (e.g., "duckduckgo")
|
||||
# Use a tool that doesn't require config (like duckduckgo)
|
||||
# Note: status must be a boolean (False = draft, True = active)
|
||||
payload = {
|
||||
"name": "duckduckgo", # Must match available tool name
|
||||
"displayName": f"Test DuckDuckGo {int(time.time())}",
|
||||
"description": "Integration test tool",
|
||||
"config": {},
|
||||
"status": False, # Boolean: False = draft
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_tool", json=payload, timeout=10)
|
||||
if response.status_code in [200, 201]:
|
||||
result = response.json()
|
||||
tool_id = result.get("id")
|
||||
if tool_id:
|
||||
self._test_tool_id = tool_id
|
||||
return tool_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def cleanup_test_tool(self, tool_id: str) -> None:
|
||||
"""Delete a test tool (cleanup helper)."""
|
||||
if not self.is_authenticated:
|
||||
return
|
||||
try:
|
||||
self.post("/api/delete_tool", json={"id": tool_id}, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Create Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_create_tool(self) -> bool:
|
||||
"""Test creating a tool instance from available tools."""
|
||||
test_name = "Create tool"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# 'name' must be an available tool type (e.g., "duckduckgo", "cryptoprice")
|
||||
# Note: status must be a boolean (False = draft, True = active)
|
||||
payload = {
|
||||
"name": "cryptoprice", # A tool that needs no config
|
||||
"displayName": f"Test CryptoPrice {int(time.time())}",
|
||||
"description": "Integration test created tool",
|
||||
"config": {},
|
||||
"status": False, # Boolean: False = draft
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_tool", json=payload, timeout=10)
|
||||
|
||||
if response.status_code not in [200, 201]:
|
||||
self.print_error(f"Expected 200/201, got {response.status_code}")
|
||||
self.print_error(f"Response: {response.text[:200]}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
tool_id = result.get("id")
|
||||
|
||||
if not tool_id:
|
||||
self.print_error("No tool ID returned")
|
||||
self.record_result(test_name, False, "No tool ID")
|
||||
return False
|
||||
|
||||
self.print_success(f"Created tool: {tool_id}")
|
||||
self.print_info(f"Name: {payload['name']}")
|
||||
self.record_result(test_name, True, f"ID: {tool_id}")
|
||||
|
||||
# Cleanup
|
||||
self.cleanup_test_tool(tool_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_create_tool_with_config(self) -> bool:
|
||||
"""Test creating a tool that requires configuration."""
|
||||
test_name = "Create tool with config"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Use api_tool which has flexible config requirements
|
||||
# Note: status must be a boolean (False = draft, True = active)
|
||||
payload = {
|
||||
"name": "api_tool",
|
||||
"displayName": f"Test API Tool {int(time.time())}",
|
||||
"description": "Tool with custom config",
|
||||
"config": {"base_url": "https://api.example.com"},
|
||||
"status": False, # Boolean: False = draft
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_tool", json=payload, timeout=10)
|
||||
|
||||
if response.status_code not in [200, 201]:
|
||||
self.print_error(f"Expected 200/201, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
tool_id = result.get("id")
|
||||
|
||||
if not tool_id:
|
||||
self.print_error("No tool ID returned")
|
||||
self.record_result(test_name, False, "No tool ID")
|
||||
return False
|
||||
|
||||
self.print_success(f"Created tool with actions: {tool_id}")
|
||||
self.record_result(test_name, True, f"ID: {tool_id}")
|
||||
|
||||
# Cleanup
|
||||
self.cleanup_test_tool(tool_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Read Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_get_tools(self) -> bool:
|
||||
"""Test listing all tools."""
|
||||
test_name = "List tools"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
try:
|
||||
response = self.get("/api/get_tools", timeout=10)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
# Handle both list and object responses
|
||||
if isinstance(result, list):
|
||||
self.print_success(f"Retrieved {len(result)} tools")
|
||||
if result:
|
||||
self.print_info(f"First: {result[0].get('name', 'N/A')}")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
elif isinstance(result, dict):
|
||||
# May return object with tools array
|
||||
tools = result.get("tools", result.get("data", []))
|
||||
if isinstance(tools, list):
|
||||
self.print_success(f"Retrieved {len(tools)} tools")
|
||||
else:
|
||||
self.print_success("Retrieved tools data")
|
||||
self.record_result(test_name, True, "Tools retrieved")
|
||||
else:
|
||||
self.print_warning(f"Unexpected response type: {type(result)}")
|
||||
self.record_result(test_name, True, "Response received")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_get_available_tools(self) -> bool:
|
||||
"""Test listing available tool types."""
|
||||
test_name = "List available tools"
|
||||
self.print_header(test_name)
|
||||
|
||||
try:
|
||||
response = self.get("/api/available_tools", timeout=10)
|
||||
|
||||
if not self.assert_status(response, 200, test_name):
|
||||
return False
|
||||
|
||||
result = response.json()
|
||||
|
||||
# Handle both list and object responses
|
||||
if isinstance(result, list):
|
||||
self.print_success(f"Retrieved {len(result)} available tool types")
|
||||
if result:
|
||||
first = result[0]
|
||||
name = first.get('name', first) if isinstance(first, dict) else first
|
||||
self.print_info(f"First: {name}")
|
||||
self.record_result(test_name, True, f"Count: {len(result)}")
|
||||
elif isinstance(result, dict):
|
||||
# May return object with tools array
|
||||
tools = result.get("tools", result.get("available", result.get("data", [])))
|
||||
if isinstance(tools, list):
|
||||
self.print_success(f"Retrieved {len(tools)} available tools")
|
||||
else:
|
||||
self.print_success("Retrieved available tools data")
|
||||
self.record_result(test_name, True, "Tools retrieved")
|
||||
else:
|
||||
self.print_warning(f"Unexpected response type: {type(result)}")
|
||||
self.record_result(test_name, True, "Response received")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Update Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_update_tool(self) -> bool:
|
||||
"""Test updating a tool."""
|
||||
test_name = "Update tool"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
tool_id = self.get_or_create_test_tool()
|
||||
if not tool_id:
|
||||
self.print_warning("Could not create test tool")
|
||||
self.record_result(test_name, True, "Skipped (no tool)")
|
||||
return True
|
||||
|
||||
new_description = f"Updated at {int(time.time())}"
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/update_tool",
|
||||
json={"id": tool_id, "description": new_description},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
self.print_success("Tool updated successfully")
|
||||
self.record_result(test_name, True, "Tool updated")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Update failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_update_tool_actions(self) -> bool:
|
||||
"""Test updating tool actions."""
|
||||
test_name = "Update tool actions"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
tool_id = self.get_or_create_test_tool()
|
||||
if not tool_id:
|
||||
self.print_warning("Could not create test tool")
|
||||
self.record_result(test_name, True, "Skipped (no tool)")
|
||||
return True
|
||||
|
||||
new_actions = [
|
||||
{
|
||||
"name": "new_action",
|
||||
"description": "New action added",
|
||||
"parameters": {},
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/update_tool_actions",
|
||||
json={"id": tool_id, "actions": new_actions},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
self.print_success("Tool actions updated")
|
||||
self.record_result(test_name, True, "Actions updated")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Update failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_update_tool_config(self) -> bool:
|
||||
"""Test updating tool configuration."""
|
||||
test_name = "Update tool config"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
tool_id = self.get_or_create_test_tool()
|
||||
if not tool_id:
|
||||
self.print_warning("Could not create test tool")
|
||||
self.record_result(test_name, True, "Skipped (no tool)")
|
||||
return True
|
||||
|
||||
new_config = {"api_key": "updated_key", "timeout": 30}
|
||||
|
||||
try:
|
||||
response = self.post(
|
||||
"/api/update_tool_config",
|
||||
json={"id": tool_id, "config": new_config},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
self.print_success("Tool config updated")
|
||||
self.record_result(test_name, True, "Config updated")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Update failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_update_tool_status(self) -> bool:
|
||||
"""Test updating tool status."""
|
||||
test_name = "Update tool status"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
tool_id = self.get_or_create_test_tool()
|
||||
if not tool_id:
|
||||
self.print_warning("Could not create test tool")
|
||||
self.record_result(test_name, True, "Skipped (no tool)")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Status is a boolean in UpdateToolStatusModel
|
||||
response = self.post(
|
||||
"/api/update_tool_status",
|
||||
json={"id": tool_id, "status": True},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
self.print_success("Tool status updated to active")
|
||||
self.record_result(test_name, True, "Status updated")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Update failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Delete Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_delete_tool(self) -> bool:
|
||||
"""Test deleting a tool."""
|
||||
test_name = "Delete tool"
|
||||
self.print_header(test_name)
|
||||
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
|
||||
# Create a tool specifically for deletion - must use available tool name
|
||||
# Note: status must be a boolean (False = draft, True = active)
|
||||
payload = {
|
||||
"name": "duckduckgo",
|
||||
"displayName": f"Tool to Delete {int(time.time())}",
|
||||
"description": "Will be deleted",
|
||||
"config": {},
|
||||
"status": False, # Boolean: False = draft
|
||||
}
|
||||
|
||||
try:
|
||||
create_response = self.post("/api/create_tool", json=payload, timeout=10)
|
||||
if create_response.status_code not in [200, 201]:
|
||||
self.print_warning("Could not create tool for deletion")
|
||||
self.record_result(test_name, True, "Skipped (create failed)")
|
||||
return True
|
||||
|
||||
tool_id = create_response.json().get("id")
|
||||
|
||||
# Delete the tool (DeleteToolModel requires 'id')
|
||||
response = self.post("/api/delete_tool", json={"id": tool_id}, timeout=10)
|
||||
|
||||
if response.status_code in [200, 204]:
|
||||
self.print_success(f"Deleted tool: {tool_id}")
|
||||
self.record_result(test_name, True, "Tool deleted")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Delete failed: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Exception: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Runner
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all tools tests."""
|
||||
self.print_header("DocsGPT Tools Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Auth: {self.token_source}")
|
||||
|
||||
# Create tests
|
||||
self.test_create_tool()
|
||||
self.test_create_tool_with_config()
|
||||
|
||||
# Read tests
|
||||
self.test_get_tools()
|
||||
self.test_get_available_tools()
|
||||
|
||||
# Update tests
|
||||
self.test_update_tool()
|
||||
self.test_update_tool_actions()
|
||||
self.test_update_tool_config()
|
||||
self.test_update_tool_status()
|
||||
|
||||
# Delete tests
|
||||
self.test_delete_tool()
|
||||
|
||||
# Cleanup
|
||||
if hasattr(self, "_test_tool_id"):
|
||||
self.cleanup_test_tool(self._test_tool_id)
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(ToolsTests, "DocsGPT Tools Integration Tests")
|
||||
exit_code = 0 if client.run_all() else 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Integration tests for ``UsersRepository`` against a live Postgres.
|
||||
|
||||
These tests need:
|
||||
|
||||
* A running Postgres reachable via ``POSTGRES_URI``
|
||||
* Alembic migration ``0001_initial`` applied
|
||||
|
||||
They are skipped automatically by the default ``pytest`` run because
|
||||
``pytest.ini`` has ``--ignore=tests/integration`` in ``addopts``. They
|
||||
are additionally marked ``@pytest.mark.integration`` so they can be
|
||||
selected/excluded explicitly. Run them locally with::
|
||||
|
||||
.venv/bin/python -m pytest tests/integration/test_users_repository.py \\
|
||||
--override-ini="addopts=" --no-cov
|
||||
|
||||
Covers every operation the legacy Mongo code performs on
|
||||
``users_collection``: upsert + get + add/remove on the pinned and
|
||||
shared_with_me JSONB arrays, plus explicit tenant-isolation checks
|
||||
(a user's operations must never touch another user's data).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from application.storage.db.repositories.users import UsersRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(pg_clean_users):
|
||||
return UsersRepository(pg_clean_users)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestUpsert:
|
||||
def test_upsert_creates_new_user_with_default_preferences(self, repo):
|
||||
doc = repo.upsert("alice@example.com")
|
||||
assert doc["user_id"] == "alice@example.com"
|
||||
assert doc["agent_preferences"] == {"pinned": [], "shared_with_me": []}
|
||||
assert "id" in doc
|
||||
assert "_id" in doc # Mongo-compat alias
|
||||
|
||||
def test_upsert_is_idempotent(self, repo):
|
||||
first = repo.upsert("alice@example.com")
|
||||
second = repo.upsert("alice@example.com")
|
||||
assert first["id"] == second["id"]
|
||||
assert first["agent_preferences"] == second["agent_preferences"]
|
||||
|
||||
def test_upsert_preserves_existing_preferences(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-1")
|
||||
doc = repo.upsert("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestGet:
|
||||
def test_get_returns_none_for_missing_user(self, repo):
|
||||
assert repo.get("nobody@example.com") is None
|
||||
|
||||
def test_get_returns_dict_for_existing_user(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc is not None
|
||||
assert doc["user_id"] == "alice@example.com"
|
||||
assert doc["agent_preferences"] == {"pinned": [], "shared_with_me": []}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAddPinned:
|
||||
def test_add_pinned_to_new_user_creates_user(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-1")
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
|
||||
|
||||
def test_add_pinned_is_idempotent(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-1")
|
||||
repo.add_pinned("alice@example.com", "agent-1")
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
|
||||
|
||||
def test_add_pinned_preserves_order(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-1")
|
||||
repo.add_pinned("alice@example.com", "agent-2")
|
||||
repo.add_pinned("alice@example.com", "agent-3")
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-1", "agent-2", "agent-3"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestRemovePinned:
|
||||
def test_remove_pinned_single(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-1")
|
||||
repo.add_pinned("alice@example.com", "agent-2")
|
||||
repo.remove_pinned("alice@example.com", "agent-1")
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-2"]
|
||||
|
||||
def test_remove_pinned_missing_is_noop(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-1")
|
||||
repo.remove_pinned("alice@example.com", "agent-999")
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-1"]
|
||||
|
||||
def test_remove_pinned_bulk(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
for i in range(5):
|
||||
repo.add_pinned("alice@example.com", f"agent-{i}")
|
||||
repo.remove_pinned_bulk("alice@example.com", ["agent-1", "agent-3", "agent-999"])
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-0", "agent-2", "agent-4"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSharedWithMe:
|
||||
def test_add_shared_is_idempotent(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_shared("alice@example.com", "agent-x")
|
||||
repo.add_shared("alice@example.com", "agent-x")
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["shared_with_me"] == ["agent-x"]
|
||||
|
||||
def test_remove_shared_bulk(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
for i in range(3):
|
||||
repo.add_shared("alice@example.com", f"shared-{i}")
|
||||
repo.remove_shared_bulk("alice@example.com", ["shared-0", "shared-2"])
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["shared_with_me"] == ["shared-1"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestRemoveAgentFromAll:
|
||||
def test_removes_from_both_pinned_and_shared(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-X")
|
||||
repo.add_pinned("alice@example.com", "agent-keep")
|
||||
repo.add_shared("alice@example.com", "agent-X")
|
||||
repo.add_shared("alice@example.com", "agent-keep-2")
|
||||
|
||||
repo.remove_agent_from_all("alice@example.com", "agent-X")
|
||||
|
||||
doc = repo.get("alice@example.com")
|
||||
assert doc["agent_preferences"]["pinned"] == ["agent-keep"]
|
||||
assert doc["agent_preferences"]["shared_with_me"] == ["agent-keep-2"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestTenantIsolation:
|
||||
"""Security-critical: operations on one user must never touch another's data."""
|
||||
|
||||
def test_add_pinned_does_not_leak_across_users(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.upsert("bob@example.com")
|
||||
repo.add_pinned("alice@example.com", "agent-a")
|
||||
repo.add_pinned("bob@example.com", "agent-b")
|
||||
|
||||
alice = repo.get("alice@example.com")
|
||||
bob = repo.get("bob@example.com")
|
||||
assert alice["agent_preferences"]["pinned"] == ["agent-a"]
|
||||
assert bob["agent_preferences"]["pinned"] == ["agent-b"]
|
||||
|
||||
def test_remove_does_not_leak_across_users(self, repo):
|
||||
repo.upsert("alice@example.com")
|
||||
repo.upsert("bob@example.com")
|
||||
repo.add_pinned("alice@example.com", "shared-agent-id")
|
||||
repo.add_pinned("bob@example.com", "shared-agent-id")
|
||||
|
||||
repo.remove_pinned("alice@example.com", "shared-agent-id")
|
||||
|
||||
assert repo.get("alice@example.com")["agent_preferences"]["pinned"] == []
|
||||
assert repo.get("bob@example.com")["agent_preferences"]["pinned"] == [
|
||||
"shared-agent-id"
|
||||
]
|
||||
@@ -0,0 +1,681 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for the /v1/ chat completions API.
|
||||
|
||||
Endpoints tested:
|
||||
- /v1/chat/completions (POST) - Standard chat completions (streaming & non-streaming)
|
||||
- /v1/models (GET) - List available agent models
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_v1_api.py
|
||||
python tests/integration/test_v1_api.py --base-url http://localhost:7091
|
||||
python tests/integration/test_v1_api.py --token YOUR_JWT_TOKEN
|
||||
"""
|
||||
|
||||
import json as json_module
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
# Add parent directory to path for standalone execution
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
|
||||
class V1ApiTests(DocsGPTTestBase):
|
||||
"""Integration tests for /v1/ chat completions API."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test Data Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_create_agent_key(self) -> Optional[str]:
|
||||
"""Get or create a test agent and return its API key."""
|
||||
if hasattr(self, "_agent_key") and self._agent_key:
|
||||
return self._agent_key
|
||||
|
||||
# Try both authenticated and unauthenticated creation.
|
||||
# Published agents need a source to get an API key.
|
||||
payload = {
|
||||
"name": f"V1 Test Agent {int(time.time())}",
|
||||
"description": "Integration test agent for v1 API tests",
|
||||
"prompt_id": "default",
|
||||
"chunks": 2,
|
||||
"retriever": "classic",
|
||||
"agent_type": "classic",
|
||||
"status": "published",
|
||||
"source": "default",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_agent", json=payload, timeout=10)
|
||||
if response.status_code in [200, 201]:
|
||||
result = response.json()
|
||||
api_key = result.get("key")
|
||||
self._agent_id = result.get("id")
|
||||
if api_key:
|
||||
self._agent_key = api_key
|
||||
self.print_info(f"Created test agent with key: {api_key[:8]}...")
|
||||
return api_key
|
||||
else:
|
||||
self.print_warning("Agent created but no API key returned")
|
||||
else:
|
||||
self.print_warning(f"Agent creation returned {response.status_code}: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
self.print_error(f"Failed to create agent: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _v1_headers(self, api_key: str) -> dict:
|
||||
"""Build headers for v1 API requests."""
|
||||
return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# /v1/chat/completions — Auth Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_no_auth_returns_401(self) -> bool:
|
||||
"""Test that /v1/chat/completions without auth returns 401."""
|
||||
test_name = "v1 chat completions - no auth"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "Hi"}]},
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self.print_success("Correctly returned 401 for missing auth")
|
||||
self.record_result(test_name, True, "401 as expected")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Expected 401, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.print_error(f"Request failed: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_invalid_key_returns_error(self) -> bool:
|
||||
"""Test that invalid API key returns error."""
|
||||
test_name = "v1 chat completions - invalid key"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "Hi"}]},
|
||||
headers=self._v1_headers("invalid-key-12345"),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Should return 400 or 500 (agent not found)
|
||||
if response.status_code in [400, 401, 500]:
|
||||
self.print_success(f"Correctly returned {response.status_code} for invalid key")
|
||||
self.record_result(test_name, True, f"Error as expected ({response.status_code})")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Unexpected status: {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.print_error(f"Request failed: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_missing_messages_returns_400(self) -> bool:
|
||||
"""Test that missing messages field returns 400."""
|
||||
test_name = "v1 chat completions - missing messages"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json={"stream": False},
|
||||
headers=self._v1_headers(api_key),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == 400:
|
||||
self.print_success("Correctly returned 400 for missing messages")
|
||||
self.record_result(test_name, True, "400 as expected")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Expected 400, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.print_error(f"Request failed: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# /v1/chat/completions — Non-streaming
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_non_streaming_basic(self) -> bool:
|
||||
"""Test basic non-streaming chat completion."""
|
||||
test_name = "v1 chat completions - non-streaming"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json={
|
||||
"messages": [{"role": "user", "content": "Say hello in one word."}],
|
||||
"stream": False,
|
||||
},
|
||||
headers=self._v1_headers(api_key),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
self.print_info(f"Status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
self.print_error(f"Expected 200, got {response.status_code}")
|
||||
self.print_error(f"Response: {response.text[:300]}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Verify standard format
|
||||
checks = [
|
||||
("id" in data, "has id"),
|
||||
(data.get("object") == "chat.completion", "object is chat.completion"),
|
||||
("choices" in data, "has choices"),
|
||||
(len(data["choices"]) > 0, "choices not empty"),
|
||||
(data["choices"][0].get("message", {}).get("role") == "assistant", "role is assistant"),
|
||||
(data["choices"][0].get("message", {}).get("content") is not None, "has content"),
|
||||
(data["choices"][0].get("finish_reason") == "stop", "finish_reason is stop"),
|
||||
("usage" in data, "has usage"),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
for check, label in checks:
|
||||
if check:
|
||||
self.print_success(f" {label}")
|
||||
else:
|
||||
self.print_error(f" {label}")
|
||||
all_passed = False
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
self.print_info(f"Response: {content[:100]}")
|
||||
|
||||
# Check docsgpt extension
|
||||
if "docsgpt" in data:
|
||||
self.print_success(" has docsgpt extension")
|
||||
if "conversation_id" in data["docsgpt"]:
|
||||
self.print_success(f" conversation_id: {data['docsgpt']['conversation_id'][:8]}...")
|
||||
|
||||
self.record_result(test_name, all_passed, "All checks passed" if all_passed else "Some checks failed")
|
||||
return all_passed
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# /v1/chat/completions — Streaming
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_streaming_basic(self) -> bool:
|
||||
"""Test basic streaming chat completion."""
|
||||
test_name = "v1 chat completions - streaming"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json={
|
||||
"messages": [{"role": "user", "content": "Say hi briefly."}],
|
||||
"stream": True,
|
||||
},
|
||||
headers=self._v1_headers(api_key),
|
||||
stream=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
self.print_info(f"Status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
self.print_error(f"Expected 200, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
chunks = []
|
||||
content_pieces = []
|
||||
got_done = False
|
||||
got_stop = False
|
||||
got_id = False
|
||||
|
||||
for line in response.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line_str = line.decode("utf-8")
|
||||
if not line_str.startswith("data: "):
|
||||
continue
|
||||
|
||||
data_str = line_str[6:]
|
||||
if data_str.strip() == "[DONE]":
|
||||
got_done = True
|
||||
break
|
||||
|
||||
try:
|
||||
chunk = json_module.loads(data_str)
|
||||
chunks.append(chunk)
|
||||
|
||||
# Standard chunks
|
||||
if "choices" in chunk:
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
if "content" in delta:
|
||||
content_pieces.append(delta["content"])
|
||||
if chunk["choices"][0].get("finish_reason") == "stop":
|
||||
got_stop = True
|
||||
|
||||
# Extension chunks
|
||||
if "docsgpt" in chunk:
|
||||
ext = chunk["docsgpt"]
|
||||
if ext.get("type") == "id":
|
||||
got_id = True
|
||||
|
||||
except json_module.JSONDecodeError:
|
||||
pass
|
||||
|
||||
full_content = "".join(content_pieces)
|
||||
|
||||
checks = [
|
||||
(len(chunks) > 0, f"received {len(chunks)} chunks"),
|
||||
(len(content_pieces) > 0, f"got content: {full_content[:50]}..."),
|
||||
(got_stop, "got finish_reason=stop"),
|
||||
(got_done, "got [DONE] sentinel"),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
for check, label in checks:
|
||||
if check:
|
||||
self.print_success(f" {label}")
|
||||
else:
|
||||
self.print_error(f" {label}")
|
||||
all_passed = False
|
||||
|
||||
if got_id:
|
||||
self.print_success(" got conversation_id via docsgpt extension")
|
||||
|
||||
self.record_result(test_name, all_passed, "All checks passed" if all_passed else "Some checks failed")
|
||||
return all_passed
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# /v1/chat/completions — Multi-turn conversation
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_multi_turn_conversation(self) -> bool:
|
||||
"""Test multi-turn conversation with history in messages."""
|
||||
test_name = "v1 chat completions - multi-turn"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json={
|
||||
"messages": [
|
||||
{"role": "user", "content": "My name is TestBot."},
|
||||
{"role": "assistant", "content": "Hello TestBot!"},
|
||||
{"role": "user", "content": "What is my name?"},
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
headers=self._v1_headers(api_key),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self.print_error(f"Expected 200, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
self.print_info(f"Response: {content[:150]}")
|
||||
|
||||
# The response should reference "TestBot" from the history
|
||||
has_content = bool(content)
|
||||
self.print_success(f" Got response with {len(content)} chars")
|
||||
self.record_result(test_name, has_content, "Multi-turn works")
|
||||
return has_content
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# /v1/models
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_list_models(self) -> bool:
|
||||
"""Test GET /v1/models endpoint."""
|
||||
test_name = "v1 models - list"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}/v1/models",
|
||||
headers=self._v1_headers(api_key),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
self.print_info(f"Status: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
self.print_error(f"Expected 200, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
data = response.json()
|
||||
|
||||
checks = [
|
||||
(data.get("object") == "list", "object is list"),
|
||||
("data" in data, "has data array"),
|
||||
(len(data.get("data", [])) == 1, f"has exactly 1 model (got {len(data.get('data', []))})"),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
for check, label in checks:
|
||||
if check:
|
||||
self.print_success(f" {label}")
|
||||
else:
|
||||
self.print_error(f" {label}")
|
||||
all_passed = False
|
||||
|
||||
if data.get("data"):
|
||||
model = data["data"][0]
|
||||
model_checks = [
|
||||
("id" in model, "model has id"),
|
||||
(model.get("object") == "model", "model object is 'model'"),
|
||||
(model.get("owned_by") == "docsgpt", "owned_by is docsgpt"),
|
||||
]
|
||||
for check, label in model_checks:
|
||||
if check:
|
||||
self.print_success(f" {label}")
|
||||
else:
|
||||
self.print_error(f" {label}")
|
||||
all_passed = False
|
||||
|
||||
self.record_result(test_name, all_passed, "All checks passed" if all_passed else "Some checks failed")
|
||||
return all_passed
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_models_no_auth(self) -> bool:
|
||||
"""Test that /v1/models without auth returns 401."""
|
||||
test_name = "v1 models - no auth"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}/v1/models",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self.print_success("Correctly returned 401")
|
||||
self.record_result(test_name, True, "401 as expected")
|
||||
return True
|
||||
else:
|
||||
self.print_error(f"Expected 401, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Backward Compatibility — old endpoints still work
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_old_stream_endpoint_still_works(self) -> bool:
|
||||
"""Verify the old /stream endpoint still works after v1 changes."""
|
||||
test_name = "Backward compat - /stream"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
payload = {
|
||||
"question": "Say hello briefly.",
|
||||
"history": "[]",
|
||||
"isNoneDoc": True,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/stream",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
stream=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self.print_error(f"Expected 200, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
events = []
|
||||
got_end = False
|
||||
got_answer = False
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode("utf-8")
|
||||
if line_str.startswith("data: "):
|
||||
try:
|
||||
data = json_module.loads(line_str[6:])
|
||||
events.append(data)
|
||||
if data.get("type") == "answer":
|
||||
got_answer = True
|
||||
if data.get("type") == "end":
|
||||
got_end = True
|
||||
break
|
||||
except json_module.JSONDecodeError:
|
||||
pass
|
||||
|
||||
checks = [
|
||||
(len(events) > 0, f"received {len(events)} events"),
|
||||
(got_answer, "got answer event"),
|
||||
(got_end, "got end event"),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
for check, label in checks:
|
||||
if check:
|
||||
self.print_success(f" {label}")
|
||||
else:
|
||||
self.print_error(f" {label}")
|
||||
all_passed = False
|
||||
|
||||
self.record_result(test_name, all_passed, "Old endpoint works" if all_passed else "Regression")
|
||||
return all_passed
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_old_answer_endpoint_still_works(self) -> bool:
|
||||
"""Verify the old /api/answer endpoint still works."""
|
||||
test_name = "Backward compat - /api/answer"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
payload = {
|
||||
"question": "Say hi.",
|
||||
"history": "[]",
|
||||
"isNoneDoc": True,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/api/answer",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self.print_error(f"Expected 200, got {response.status_code}")
|
||||
self.record_result(test_name, False, f"Status {response.status_code}")
|
||||
return False
|
||||
|
||||
data = response.json()
|
||||
checks = [
|
||||
("answer" in data, "has answer"),
|
||||
("conversation_id" in data, "has conversation_id"),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
for check, label in checks:
|
||||
if check:
|
||||
self.print_success(f" {label}")
|
||||
else:
|
||||
self.print_error(f" {label}")
|
||||
all_passed = False
|
||||
|
||||
self.print_info(f"Answer: {data.get('answer', '')[:100]}")
|
||||
self.record_result(test_name, all_passed, "Old endpoint works" if all_passed else "Regression")
|
||||
return all_passed
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up test resources."""
|
||||
if hasattr(self, "_agent_id") and self._agent_id and self.is_authenticated:
|
||||
try:
|
||||
self.post(f"/api/delete_agent?id={self._agent_id}", json={})
|
||||
self.print_info(f"Cleaned up test agent {self._agent_id[:8]}...")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Run All
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_all(self) -> bool:
|
||||
"""Run all v1 API integration tests."""
|
||||
self.print_header("V1 Chat Completions API Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Authentication: {'Yes' if self.is_authenticated else 'No'}")
|
||||
|
||||
try:
|
||||
# Auth tests (no agent needed)
|
||||
self.test_no_auth_returns_401()
|
||||
time.sleep(0.5)
|
||||
|
||||
self.test_models_no_auth()
|
||||
time.sleep(0.5)
|
||||
|
||||
self.test_invalid_key_returns_error()
|
||||
time.sleep(0.5)
|
||||
|
||||
self.test_missing_messages_returns_400()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Non-streaming
|
||||
self.test_non_streaming_basic()
|
||||
time.sleep(1)
|
||||
|
||||
# Streaming
|
||||
self.test_streaming_basic()
|
||||
time.sleep(1)
|
||||
|
||||
# Multi-turn
|
||||
self.test_multi_turn_conversation()
|
||||
time.sleep(1)
|
||||
|
||||
# Models
|
||||
self.test_list_models()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Backward compatibility
|
||||
self.test_old_stream_endpoint_still_works()
|
||||
time.sleep(1)
|
||||
|
||||
self.test_old_answer_endpoint_still_works()
|
||||
time.sleep(1)
|
||||
|
||||
finally:
|
||||
self.cleanup()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
client = create_client_from_args(V1ApiTests, "DocsGPT V1 API Integration Tests")
|
||||
success = client.run_all()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,539 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""
|
||||
Integration tests for the /v1/ chat completions API — client tool-call flow.
|
||||
|
||||
Tests the full lifecycle:
|
||||
1. Send request with client tools → LLM triggers a tool call
|
||||
2. Verify response returns clean tool names (no internal _ct\d+ suffix)
|
||||
3. Send continuation with tool results + top-level conversation_id
|
||||
4. Verify the continuation completes successfully
|
||||
|
||||
Usage:
|
||||
python tests/integration/test_v1_tool_calls.py
|
||||
python tests/integration/test_v1_tool_calls.py --base-url http://localhost:7091
|
||||
"""
|
||||
|
||||
import json as json_module
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
_THIS_DIR = Path(__file__).parent
|
||||
_TESTS_DIR = _THIS_DIR.parent
|
||||
_ROOT_DIR = _TESTS_DIR.parent
|
||||
if str(_ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT_DIR))
|
||||
|
||||
from tests.integration.base import DocsGPTTestBase, create_client_from_args
|
||||
|
||||
# Internal suffix pattern that should NOT appear in client responses
|
||||
_CT_SUFFIX_RE = re.compile(r"_ct\d+$")
|
||||
|
||||
|
||||
class V1ToolCallTests(DocsGPTTestBase):
|
||||
"""Integration tests for /v1/ client tool-call flows."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_create_agent_key(self) -> Optional[str]:
|
||||
"""Get or create a test agent and return its API key."""
|
||||
if hasattr(self, "_agent_key") and self._agent_key:
|
||||
return self._agent_key
|
||||
|
||||
payload = {
|
||||
"name": f"V1 ToolCall Test {int(time.time())}",
|
||||
"description": "Integration test agent for tool-call flow",
|
||||
"prompt_id": "default",
|
||||
"chunks": 2,
|
||||
"retriever": "classic",
|
||||
"agent_type": "classic",
|
||||
"status": "published",
|
||||
"source": "default",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.post("/api/create_agent", json=payload, timeout=10)
|
||||
if response.status_code in [200, 201]:
|
||||
result = response.json()
|
||||
api_key = result.get("key")
|
||||
self._agent_id = result.get("id")
|
||||
if api_key:
|
||||
self._agent_key = api_key
|
||||
self.print_info(f"Created test agent with key: {api_key[:8]}...")
|
||||
return api_key
|
||||
except Exception as e:
|
||||
self.print_error(f"Failed to create agent: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _v1_headers(self, api_key: str) -> dict:
|
||||
return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
|
||||
# A simple client tool definition in OpenAI format
|
||||
_CLIENT_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create",
|
||||
"description": "Create a new todo item",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The title of the new todo item",
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def _send_streaming_request(
|
||||
self,
|
||||
api_key: str,
|
||||
messages: List[Dict],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
) -> Tuple[List[Dict], str, Optional[Dict]]:
|
||||
"""Send a streaming request and collect all events.
|
||||
|
||||
Returns:
|
||||
(all_chunks, full_content, tool_call_info)
|
||||
tool_call_info is a dict with 'name', 'arguments', 'call_id'
|
||||
if the response paused for a client tool call, else None.
|
||||
"""
|
||||
body: Dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json=body,
|
||||
headers=self._v1_headers(api_key),
|
||||
stream=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Expected 200, got {response.status_code}: {response.text[:300]}"
|
||||
)
|
||||
|
||||
chunks: List[Dict] = []
|
||||
content_pieces: List[str] = []
|
||||
tool_call_info: Optional[Dict] = None
|
||||
conversation_id_from_response: Optional[str] = None
|
||||
|
||||
for line in response.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line_str = line.decode("utf-8")
|
||||
if not line_str.startswith("data: "):
|
||||
continue
|
||||
|
||||
data_str = line_str[6:]
|
||||
if data_str.strip() == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
chunk = json_module.loads(data_str)
|
||||
chunks.append(chunk)
|
||||
|
||||
# Standard chunks
|
||||
if "choices" in chunk:
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
if "content" in delta:
|
||||
content_pieces.append(delta["content"])
|
||||
|
||||
# Tool call delta
|
||||
if "tool_calls" in delta:
|
||||
tc = delta["tool_calls"][0]
|
||||
tool_call_info = {
|
||||
"call_id": tc.get("id", ""),
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"].get("arguments", "{}"),
|
||||
}
|
||||
|
||||
# Extension chunks
|
||||
if "docsgpt" in chunk:
|
||||
ext = chunk["docsgpt"]
|
||||
if ext.get("type") == "id":
|
||||
conversation_id_from_response = ext.get("conversation_id")
|
||||
|
||||
except json_module.JSONDecodeError:
|
||||
pass
|
||||
|
||||
full_content = "".join(content_pieces)
|
||||
|
||||
# Attach conversation_id to tool_call_info for convenience
|
||||
if tool_call_info and conversation_id_from_response:
|
||||
tool_call_info["conversation_id"] = conversation_id_from_response
|
||||
|
||||
return chunks, full_content, tool_call_info
|
||||
|
||||
def _send_non_streaming_request(
|
||||
self,
|
||||
api_key: str,
|
||||
messages: List[Dict],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Send a non-streaming request and return parsed JSON."""
|
||||
body: Dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
|
||||
response = requests.post(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
json=body,
|
||||
headers=self._v1_headers(api_key),
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Expected 200, got {response.status_code}: {response.text[:300]}"
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def test_streaming_tool_call_clean_name(self) -> bool:
|
||||
"""Streaming: tool names returned to client must not have _ct suffixes."""
|
||||
test_name = "v1 streaming tool call - clean name"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
messages = [
|
||||
{"role": "user", "content": "Use the create tool to add a todo item titled 'Test integration'. Call the tool now."},
|
||||
]
|
||||
chunks, content, tool_call_info = self._send_streaming_request(
|
||||
api_key, messages, tools=self._CLIENT_TOOLS
|
||||
)
|
||||
|
||||
if not tool_call_info:
|
||||
# LLM didn't trigger the tool — could happen, not a failure of our code
|
||||
self.print_warning("LLM did not trigger a tool call (may need prompt tuning)")
|
||||
self.print_info(f"Got text response instead: {content[:100]}")
|
||||
self.record_result(test_name, True, "Skipped (LLM didn't call tool)")
|
||||
return True
|
||||
|
||||
tool_name = tool_call_info["name"]
|
||||
self.print_info(f"Tool call name: {tool_name}")
|
||||
|
||||
has_suffix = bool(_CT_SUFFIX_RE.search(tool_name))
|
||||
if has_suffix:
|
||||
self.print_error(f"Tool name has internal suffix: {tool_name}")
|
||||
self.record_result(test_name, False, f"Suffix leak: {tool_name}")
|
||||
return False
|
||||
|
||||
self.print_success(f"Tool name is clean: {tool_name}")
|
||||
self.record_result(test_name, True, f"Clean name: {tool_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_non_streaming_tool_call_clean_name(self) -> bool:
|
||||
"""Non-streaming: tool names returned to client must not have _ct suffixes."""
|
||||
test_name = "v1 non-streaming tool call - clean name"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
messages = [
|
||||
{"role": "user", "content": "Use the create tool to add a todo item titled 'Test non-stream'. Call the tool now."},
|
||||
]
|
||||
data = self._send_non_streaming_request(
|
||||
api_key, messages, tools=self._CLIENT_TOOLS
|
||||
)
|
||||
|
||||
message = data["choices"][0]["message"]
|
||||
tool_calls = message.get("tool_calls")
|
||||
|
||||
if not tool_calls:
|
||||
content = message.get("content", "")
|
||||
self.print_warning("LLM did not trigger a tool call")
|
||||
self.print_info(f"Got text response: {content[:100]}")
|
||||
self.record_result(test_name, True, "Skipped (LLM didn't call tool)")
|
||||
return True
|
||||
|
||||
tool_name = tool_calls[0]["function"]["name"]
|
||||
self.print_info(f"Tool call name: {tool_name}")
|
||||
|
||||
has_suffix = bool(_CT_SUFFIX_RE.search(tool_name))
|
||||
if has_suffix:
|
||||
self.print_error(f"Tool name has internal suffix: {tool_name}")
|
||||
self.record_result(test_name, False, f"Suffix leak: {tool_name}")
|
||||
return False
|
||||
|
||||
self.print_success(f"Tool name is clean: {tool_name}")
|
||||
self.record_result(test_name, True, f"Clean name: {tool_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_streaming_tool_continuation_with_top_level_conversation_id(self) -> bool:
|
||||
"""Full tool-call round-trip: trigger → get conversation_id → continue with top-level id."""
|
||||
test_name = "v1 streaming tool continuation - top-level conversation_id"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Step 1: trigger a tool call
|
||||
messages = [
|
||||
{"role": "user", "content": "Use the create tool to add a todo item titled 'Round trip test'. Call the tool now."},
|
||||
]
|
||||
chunks, content, tool_call_info = self._send_streaming_request(
|
||||
api_key, messages, tools=self._CLIENT_TOOLS
|
||||
)
|
||||
|
||||
if not tool_call_info:
|
||||
self.print_warning("LLM did not trigger a tool call")
|
||||
self.record_result(test_name, True, "Skipped (LLM didn't call tool)")
|
||||
return True
|
||||
|
||||
conversation_id = tool_call_info.get("conversation_id")
|
||||
if not conversation_id:
|
||||
self.print_error("No conversation_id returned in stream")
|
||||
self.record_result(test_name, False, "Missing conversation_id")
|
||||
return False
|
||||
|
||||
self.print_info(f"Got conversation_id: {conversation_id[:12]}...")
|
||||
self.print_info(f"Tool call: {tool_call_info['name']}({tool_call_info['arguments']})")
|
||||
|
||||
# Step 2: send continuation with tool result + top-level conversation_id
|
||||
# (standard OpenAI format — no docsgpt field in assistant message)
|
||||
continuation_messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tool_call_info["call_id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call_info["name"],
|
||||
"arguments": tool_call_info["arguments"],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_info["call_id"],
|
||||
"content": json_module.dumps({"id": 99, "title": "Round trip test", "status": "created"}),
|
||||
},
|
||||
]
|
||||
|
||||
chunks2, content2, tool_call_info2 = self._send_streaming_request(
|
||||
api_key,
|
||||
continuation_messages,
|
||||
tools=self._CLIENT_TOOLS,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
checks = [
|
||||
(len(chunks2) > 0, f"continuation returned {len(chunks2)} chunks"),
|
||||
(bool(content2) or tool_call_info2 is not None, "got content or another tool call"),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
for check, label in checks:
|
||||
if check:
|
||||
self.print_success(f" {label}")
|
||||
else:
|
||||
self.print_error(f" {label}")
|
||||
all_passed = False
|
||||
|
||||
if content2:
|
||||
self.print_info(f"Continuation response: {content2[:150]}")
|
||||
|
||||
self.record_result(
|
||||
test_name,
|
||||
all_passed,
|
||||
"Full round-trip works" if all_passed else "Continuation failed",
|
||||
)
|
||||
return all_passed
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
def test_non_streaming_tool_continuation_with_top_level_conversation_id(self) -> bool:
|
||||
"""Non-streaming full round-trip with top-level conversation_id."""
|
||||
test_name = "v1 non-streaming tool continuation - top-level conversation_id"
|
||||
self.print_header(f"Testing {test_name}")
|
||||
|
||||
api_key = self.get_or_create_agent_key()
|
||||
if not api_key:
|
||||
if not self.require_auth(test_name):
|
||||
return True
|
||||
self.record_result(test_name, True, "Skipped (no agent)")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Step 1: trigger a tool call
|
||||
messages = [
|
||||
{"role": "user", "content": "Use the create tool to add a todo item titled 'Non-stream round trip'. Call the tool now."},
|
||||
]
|
||||
data = self._send_non_streaming_request(
|
||||
api_key, messages, tools=self._CLIENT_TOOLS
|
||||
)
|
||||
|
||||
message = data["choices"][0]["message"]
|
||||
tool_calls = message.get("tool_calls")
|
||||
|
||||
if not tool_calls:
|
||||
self.print_warning("LLM did not trigger a tool call")
|
||||
self.record_result(test_name, True, "Skipped (LLM didn't call tool)")
|
||||
return True
|
||||
|
||||
conversation_id = data.get("docsgpt", {}).get("conversation_id")
|
||||
if not conversation_id:
|
||||
self.print_error("No conversation_id in response")
|
||||
self.record_result(test_name, False, "Missing conversation_id")
|
||||
return False
|
||||
|
||||
tc = tool_calls[0]
|
||||
self.print_info(f"Got tool call: {tc['function']['name']}")
|
||||
self.print_info(f"conversation_id: {conversation_id[:12]}...")
|
||||
|
||||
# Step 2: send continuation (standard format, top-level conversation_id)
|
||||
continuation_messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [tc],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": json_module.dumps({"id": 100, "title": "Non-stream round trip", "status": "created"}),
|
||||
},
|
||||
]
|
||||
|
||||
data2 = self._send_non_streaming_request(
|
||||
api_key,
|
||||
continuation_messages,
|
||||
tools=self._CLIENT_TOOLS,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
message2 = data2["choices"][0]["message"]
|
||||
has_response = bool(message2.get("content")) or bool(message2.get("tool_calls"))
|
||||
|
||||
if has_response:
|
||||
self.print_success("Continuation returned a response")
|
||||
content2 = message2.get("content", "")
|
||||
if content2:
|
||||
self.print_info(f"Response: {content2[:150]}")
|
||||
else:
|
||||
self.print_error("Continuation returned empty response")
|
||||
|
||||
self.record_result(
|
||||
test_name,
|
||||
has_response,
|
||||
"Round-trip works" if has_response else "Empty continuation response",
|
||||
)
|
||||
return has_response
|
||||
|
||||
except Exception as e:
|
||||
self.print_error(f"Error: {e}")
|
||||
self.record_result(test_name, False, str(e))
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Cleanup & Run All
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def cleanup(self):
|
||||
if hasattr(self, "_agent_id") and self._agent_id and self.is_authenticated:
|
||||
try:
|
||||
self.post(f"/api/delete_agent?id={self._agent_id}", json={})
|
||||
self.print_info(f"Cleaned up test agent {self._agent_id[:8]}...")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run_all(self) -> bool:
|
||||
self.print_header("V1 Tool-Call Flow Integration Tests")
|
||||
self.print_info(f"Base URL: {self.base_url}")
|
||||
self.print_info(f"Authentication: {'Yes' if self.is_authenticated else 'No'}")
|
||||
|
||||
try:
|
||||
# Streaming tests
|
||||
self.test_streaming_tool_call_clean_name()
|
||||
time.sleep(1)
|
||||
|
||||
self.test_non_streaming_tool_call_clean_name()
|
||||
time.sleep(1)
|
||||
|
||||
# Full round-trip tests
|
||||
self.test_streaming_tool_continuation_with_top_level_conversation_id()
|
||||
time.sleep(1)
|
||||
|
||||
self.test_non_streaming_tool_continuation_with_top_level_conversation_id()
|
||||
time.sleep(1)
|
||||
|
||||
finally:
|
||||
self.cleanup()
|
||||
|
||||
return self.print_summary()
|
||||
|
||||
|
||||
def main():
|
||||
client = create_client_from_args(V1ToolCallTests, "DocsGPT V1 Tool-Call Integration Tests")
|
||||
success = client.run_all()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""End-to-end regression tests for the gunicorn graceful-shutdown drain fix.
|
||||
|
||||
Boots real gunicorn against a minimal a2wsgi+Flask SSE app, holds an SSE
|
||||
connection open, trips a ``max_requests`` recycle, and checks the worker log:
|
||||
RED proves the stock worker is ``WORKER TIMEOUT``'d; GREEN proves the
|
||||
bounded-drain worker recycles cleanly. Opt-in (slow). Run with:
|
||||
python -m pytest tests/integration/test_worker_drain_e2e.py -o addopts=""
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow]
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_APP = "tests.integration._drain_harness_app:asgi_app"
|
||||
_GUNICORN = Path(sys.executable).with_name("gunicorn")
|
||||
|
||||
# Shrunk timing so a full recycle takes seconds, not the production minutes.
|
||||
_MAX_REQUESTS = "5"
|
||||
_TIMEOUT = "6" # gunicorn worker-timeout watchdog
|
||||
_GRACEFUL = "30" # gunicorn --graceful-timeout (not the lever; here for parity)
|
||||
_GUNICORN_CONF = _ROOT / "application" / "gunicorn_conf.py"
|
||||
|
||||
pytestmark.append(
|
||||
pytest.mark.skipif(not _GUNICORN.exists(), reason="gunicorn binary not found")
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
def _boot(worker_class: str, port: int, logpath: Path, extra_env: dict) -> tuple:
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
{
|
||||
"OTEL_SDK_DISABLED": "true",
|
||||
"AUTO_MIGRATE": "false",
|
||||
"AUTO_CREATE_DB": "false",
|
||||
"GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS": "3",
|
||||
**extra_env,
|
||||
}
|
||||
)
|
||||
cmd = [
|
||||
str(_GUNICORN),
|
||||
"-w", "1",
|
||||
"-k", worker_class,
|
||||
"--bind", f"127.0.0.1:{port}",
|
||||
"--timeout", _TIMEOUT,
|
||||
"--graceful-timeout", _GRACEFUL,
|
||||
"--keep-alive", "2",
|
||||
"--max-requests", _MAX_REQUESTS,
|
||||
"--max-requests-jitter", "0",
|
||||
"--pythonpath", str(_ROOT),
|
||||
"--config", str(_GUNICORN_CONF),
|
||||
_APP,
|
||||
]
|
||||
fh = open(logpath, "w")
|
||||
proc = subprocess.Popen(
|
||||
cmd, cwd=str(_ROOT), env=env, stdout=fh, stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
return proc, fh
|
||||
|
||||
|
||||
def _reap(proc, fh) -> None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
finally:
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get(url: str, timeout: float = 3.0) -> int:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
r.read()
|
||||
return r.status
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _wait_http(base: str, timeout: float = 30.0) -> bool:
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if _get(base + "/health", timeout=2) == 200:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def _hold_sse(url: str, hold: float = 25.0) -> threading.Thread:
|
||||
def _run():
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=hold).read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def _trip_recycle(base: str, n: int = 8) -> None:
|
||||
# Fire more than --max-requests completed responses so the recycle trips
|
||||
# deterministically (the held SSE response never completes, so it doesn't
|
||||
# count, and some requests race the drain once it starts).
|
||||
for _ in range(n):
|
||||
_get(base + "/health", timeout=3)
|
||||
|
||||
|
||||
def _wait_for(logpath: Path, needle: str, timeout: float) -> str:
|
||||
end = time.monotonic() + timeout
|
||||
text = ""
|
||||
while time.monotonic() < end:
|
||||
text = logpath.read_text()
|
||||
if needle in text:
|
||||
return text
|
||||
time.sleep(0.3)
|
||||
return text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.slow
|
||||
def test_stock_worker_is_force_killed_with_held_sse(tmp_path):
|
||||
"""RED: stock worker + non-cooperative SSE -> drain hangs -> WORKER TIMEOUT."""
|
||||
port = _free_port()
|
||||
log = tmp_path / "stock.log"
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
proc, fh = _boot("uvicorn_worker.UvicornWorker", port, log, {})
|
||||
try:
|
||||
assert _wait_http(base), f"app never came up:\n{log.read_text()}"
|
||||
_hold_sse(base + "/sse", hold=25)
|
||||
time.sleep(1.0) # let the SSE request register before tripping
|
||||
_trip_recycle(base)
|
||||
text = _wait_for(log, "WORKER TIMEOUT", timeout=15)
|
||||
finally:
|
||||
_reap(proc, fh)
|
||||
|
||||
assert "Maximum request limit" in text, text
|
||||
# The held SSE hangs the unbounded drain until the watchdog force-kills it.
|
||||
assert "WORKER TIMEOUT" in text, text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.slow
|
||||
def test_bounded_drain_worker_exits_cleanly_with_held_sse(tmp_path):
|
||||
"""GREEN: bounded-drain worker + cooperative SSE -> clean recycle, no kill."""
|
||||
port = _free_port()
|
||||
log = tmp_path / "fixed.log"
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
proc, fh = _boot(
|
||||
"application.gunicorn_worker.BoundedDrainUvicornWorker",
|
||||
port,
|
||||
log,
|
||||
{"DRAIN_HARNESS_COOPERATIVE": "1"},
|
||||
)
|
||||
try:
|
||||
assert _wait_http(base), f"app never came up:\n{log.read_text()}"
|
||||
_hold_sse(base + "/sse", hold=25)
|
||||
time.sleep(1.0)
|
||||
_trip_recycle(base)
|
||||
# Clean recycle => replacement worker reaches "Application startup complete" twice.
|
||||
end = time.monotonic() + 14
|
||||
text = ""
|
||||
while time.monotonic() < end:
|
||||
text = log.read_text()
|
||||
if text.count("Application startup complete") >= 2:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
_reap(proc, fh)
|
||||
|
||||
assert "Maximum request limit" in text, text
|
||||
assert text.count("Application startup complete") >= 2, (
|
||||
f"replacement worker did not boot cleanly:\n{text}"
|
||||
)
|
||||
for bad in ("WORKER TIMEOUT", "was sent SIGABRT", "was sent SIGKILL"):
|
||||
assert bad not in text, f"unexpected watchdog kill ({bad}):\n{text}"
|
||||
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for DocsGPT workflow management endpoints.
|
||||
|
||||
Uses Flask test client with a real Postgres instance (must be running).
|
||||
|
||||
Endpoints tested:
|
||||
- /api/workflows (POST) - Create workflow
|
||||
- /api/workflows/<id> (GET) - Get workflow
|
||||
- /api/workflows/<id> (PUT) - Update workflow
|
||||
- /api/workflows/<id> (DELETE) - Delete workflow
|
||||
|
||||
Run:
|
||||
pytest tests/integration/test_workflows.py -v
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Create the real Flask app (connects to real Postgres)."""
|
||||
from application.app import app as flask_app
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
"""Flask test client.
|
||||
|
||||
When AUTH_TYPE is set to simple_jwt/session_jwt a Bearer token is
|
||||
injected; otherwise the backend already returns {"sub": "local"}
|
||||
for every request so no token is needed.
|
||||
"""
|
||||
from application.core.settings import settings
|
||||
|
||||
c = app.test_client()
|
||||
if settings.AUTH_TYPE in ("simple_jwt", "session_jwt"):
|
||||
secret = settings.JWT_SECRET_KEY
|
||||
if not secret:
|
||||
pytest.skip("JWT_SECRET_KEY not configured")
|
||||
payload = {"sub": f"test_workflow_integration_{int(time.time())}"}
|
||||
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||
c.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {token}"
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def created_ids():
|
||||
"""Accumulator for workflow IDs to clean up after all tests."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def cleanup(client, created_ids):
|
||||
"""Delete all test-created workflows after the module finishes."""
|
||||
yield
|
||||
for wf_id in created_ids:
|
||||
try:
|
||||
client.delete(f"/api/workflows/{wf_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def simple_workflow(suffix=""):
|
||||
"""Start -> End."""
|
||||
return {
|
||||
"name": f"Simple WF {int(time.time())}{suffix}",
|
||||
"description": "integration test",
|
||||
"nodes": [
|
||||
{"id": "start_1", "type": "start", "title": "Start",
|
||||
"position": {"x": 0, "y": 0}, "data": {}},
|
||||
{"id": "end_1", "type": "end", "title": "End",
|
||||
"position": {"x": 400, "y": 0}, "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "edge_1", "source": "start_1", "target": "end_1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def linear_workflow(suffix=""):
|
||||
"""Start -> Agent -> End."""
|
||||
return {
|
||||
"name": f"Linear WF {int(time.time())}{suffix}",
|
||||
"description": "integration test",
|
||||
"nodes": [
|
||||
{"id": "start_1", "type": "start", "title": "Start",
|
||||
"position": {"x": 0, "y": 0}, "data": {}},
|
||||
{"id": "agent_1", "type": "agent", "title": "Agent",
|
||||
"position": {"x": 200, "y": 0}, "data": {
|
||||
"agent_type": "classic",
|
||||
"system_prompt": "You are helpful.",
|
||||
"prompt_template": "",
|
||||
"stream_to_user": False,
|
||||
}},
|
||||
{"id": "end_1", "type": "end", "title": "End",
|
||||
"position": {"x": 400, "y": 0}, "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "edge_1", "source": "start_1", "target": "agent_1"},
|
||||
{"id": "edge_2", "source": "agent_1", "target": "end_1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def multi_input_end_workflow(suffix=""):
|
||||
"""Condition branches into two agents, both converging on one end node.
|
||||
|
||||
Graph:
|
||||
start -> condition --(case_1)--> agent_a --\
|
||||
--(else)----> agent_b ---+--> end
|
||||
"""
|
||||
return {
|
||||
"name": f"Multi-Input End {int(time.time())}{suffix}",
|
||||
"description": "end node with multiple inputs",
|
||||
"nodes": [
|
||||
{"id": "start_1", "type": "start", "title": "Start",
|
||||
"position": {"x": 0, "y": 100}, "data": {}},
|
||||
{"id": "cond_1", "type": "condition", "title": "Branch",
|
||||
"position": {"x": 200, "y": 100}, "data": {
|
||||
"mode": "simple",
|
||||
"cases": [
|
||||
{"name": "Case 1", "expression": "true",
|
||||
"sourceHandle": "case_1"},
|
||||
],
|
||||
}},
|
||||
{"id": "agent_a", "type": "agent", "title": "Agent A",
|
||||
"position": {"x": 400, "y": 0}, "data": {
|
||||
"agent_type": "classic",
|
||||
"system_prompt": "Branch A",
|
||||
"prompt_template": "",
|
||||
"stream_to_user": False,
|
||||
}},
|
||||
{"id": "agent_b", "type": "agent", "title": "Agent B",
|
||||
"position": {"x": 400, "y": 200}, "data": {
|
||||
"agent_type": "classic",
|
||||
"system_prompt": "Branch B",
|
||||
"prompt_template": "",
|
||||
"stream_to_user": False,
|
||||
}},
|
||||
{"id": "end_1", "type": "end", "title": "End",
|
||||
"position": {"x": 600, "y": 100}, "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start_1", "target": "cond_1"},
|
||||
{"id": "e2", "source": "cond_1", "target": "agent_a",
|
||||
"sourceHandle": "case_1"},
|
||||
{"id": "e3", "source": "cond_1", "target": "agent_b",
|
||||
"sourceHandle": "else"},
|
||||
# Both agents feed into the SAME end node
|
||||
{"id": "e4", "source": "agent_a", "target": "end_1"},
|
||||
{"id": "e5", "source": "agent_b", "target": "end_1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_id(resp):
|
||||
"""Pull workflow id from create/update response."""
|
||||
body = resp.get_json()
|
||||
data = body.get("data") or body
|
||||
return data.get("id")
|
||||
|
||||
|
||||
def _get_graph(client, wf_id):
|
||||
"""Fetch workflow and return (nodes, edges)."""
|
||||
resp = client.get(f"/api/workflows/{wf_id}")
|
||||
assert resp.status_code == 200, resp.get_data(as_text=True)
|
||||
body = resp.get_json()
|
||||
data = body.get("data") or body
|
||||
return data.get("nodes", []), data.get("edges", [])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CRUD tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestWorkflowCRUD:
|
||||
|
||||
def test_create_simple_workflow(self, client, created_ids):
|
||||
resp = client.post("/api/workflows", json=simple_workflow())
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
assert wf_id
|
||||
created_ids.append(wf_id)
|
||||
|
||||
def test_create_linear_workflow(self, client, created_ids):
|
||||
resp = client.post("/api/workflows", json=linear_workflow())
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
assert wf_id
|
||||
created_ids.append(wf_id)
|
||||
|
||||
def test_get_workflow_returns_nodes_and_edges(self, client, created_ids):
|
||||
resp = client.post("/api/workflows", json=simple_workflow(" get"))
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, edges = _get_graph(client, wf_id)
|
||||
assert len(nodes) == 2
|
||||
assert len(edges) == 1
|
||||
|
||||
def test_update_workflow(self, client, created_ids):
|
||||
resp = client.post("/api/workflows", json=simple_workflow(" upd"))
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
update_resp = client.put(
|
||||
f"/api/workflows/{wf_id}", json=linear_workflow(" updated")
|
||||
)
|
||||
assert update_resp.status_code == 200, update_resp.get_data(as_text=True)
|
||||
|
||||
nodes, edges = _get_graph(client, wf_id)
|
||||
assert len(nodes) == 3 # start, agent, end
|
||||
assert len(edges) == 2
|
||||
|
||||
def test_delete_workflow(self, client):
|
||||
resp = client.post("/api/workflows", json=simple_workflow(" del"))
|
||||
wf_id = _extract_id(resp)
|
||||
|
||||
del_resp = client.delete(f"/api/workflows/{wf_id}")
|
||||
assert del_resp.status_code == 200
|
||||
|
||||
get_resp = client.get(f"/api/workflows/{wf_id}")
|
||||
assert get_resp.status_code in (400, 404)
|
||||
|
||||
def test_reject_workflow_without_end_node(self, client):
|
||||
payload = {
|
||||
"name": "No End",
|
||||
"nodes": [
|
||||
{"id": "s", "type": "start", "title": "Start",
|
||||
"position": {"x": 0, "y": 0}, "data": {}},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
assert resp.status_code == 400, resp.get_data(as_text=True)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Multi-input end node tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestMultiInputEndNode:
|
||||
"""Verify that an end node can receive edges from multiple source nodes."""
|
||||
|
||||
def test_create_multi_input_end_workflow_accepted(self, client, created_ids):
|
||||
"""Backend must accept a workflow where two edges target the same end node."""
|
||||
resp = client.post("/api/workflows", json=multi_input_end_workflow())
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
assert wf_id
|
||||
created_ids.append(wf_id)
|
||||
|
||||
def test_multi_input_end_all_edges_persisted(self, client, created_ids):
|
||||
"""After round-trip, both edges into the end node must still be present."""
|
||||
resp = client.post(
|
||||
"/api/workflows", json=multi_input_end_workflow(" persist")
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, edges = _get_graph(client, wf_id)
|
||||
|
||||
# Locate end node
|
||||
end_ids = {n["id"] for n in nodes if n["type"] == "end"}
|
||||
assert end_ids, "no end node in response"
|
||||
|
||||
# Count edges targeting any end node
|
||||
edges_to_end = [e for e in edges if e["target"] in end_ids]
|
||||
assert len(edges_to_end) >= 2, (
|
||||
f"Expected >=2 edges to end, got {len(edges_to_end)}: {edges_to_end}"
|
||||
)
|
||||
|
||||
def test_multi_input_end_total_edge_count(self, client, created_ids):
|
||||
"""All 5 edges of the multi-input graph must survive persistence."""
|
||||
resp = client.post(
|
||||
"/api/workflows", json=multi_input_end_workflow(" count")
|
||||
)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
_, edges = _get_graph(client, wf_id)
|
||||
assert len(edges) == 5, f"Expected 5 edges, got {len(edges)}"
|
||||
|
||||
def test_update_to_multi_input_end_preserves_edges(self, client, created_ids):
|
||||
"""Updating a simple workflow to multi-input end keeps all edges."""
|
||||
# Create simple
|
||||
resp = client.post("/api/workflows", json=simple_workflow(" pre"))
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
# Update to multi-input end
|
||||
update_resp = client.put(
|
||||
f"/api/workflows/{wf_id}",
|
||||
json=multi_input_end_workflow(" post"),
|
||||
)
|
||||
assert update_resp.status_code == 200, update_resp.get_data(as_text=True)
|
||||
|
||||
nodes, edges = _get_graph(client, wf_id)
|
||||
end_ids = {n["id"] for n in nodes if n["type"] == "end"}
|
||||
edges_to_end = [e for e in edges if e["target"] in end_ids]
|
||||
assert len(edges_to_end) >= 2, (
|
||||
f"Expected >=2 edges to end after update, got {len(edges_to_end)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-aware payload helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def workflow_with_sources(sources, suffix=""):
|
||||
"""Start -> Agent (with sources) -> End."""
|
||||
return {
|
||||
"name": f"Source WF {int(time.time())}{suffix}",
|
||||
"description": "integration test with sources",
|
||||
"nodes": [
|
||||
{"id": "start_1", "type": "start", "title": "Start",
|
||||
"position": {"x": 0, "y": 0}, "data": {}},
|
||||
{"id": "agent_1", "type": "agent", "title": "Agent",
|
||||
"position": {"x": 200, "y": 0}, "data": {
|
||||
"agent_type": "classic",
|
||||
"system_prompt": "You are helpful.",
|
||||
"prompt_template": "",
|
||||
"stream_to_user": False,
|
||||
"sources": sources,
|
||||
"tools": [],
|
||||
}},
|
||||
{"id": "end_1", "type": "end", "title": "End",
|
||||
"position": {"x": 400, "y": 0}, "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "edge_1", "source": "start_1", "target": "agent_1"},
|
||||
{"id": "edge_2", "source": "agent_1", "target": "end_1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def workflow_multi_agent_sources(suffix=""):
|
||||
"""Start -> Agent A (sources A) -> Agent B (sources B) -> End."""
|
||||
return {
|
||||
"name": f"Multi-Agent Sources {int(time.time())}{suffix}",
|
||||
"description": "two agents with different sources",
|
||||
"nodes": [
|
||||
{"id": "start_1", "type": "start", "title": "Start",
|
||||
"position": {"x": 0, "y": 0}, "data": {}},
|
||||
{"id": "agent_a", "type": "agent", "title": "Agent A",
|
||||
"position": {"x": 200, "y": 0}, "data": {
|
||||
"agent_type": "agentic",
|
||||
"system_prompt": "Agent A prompt",
|
||||
"prompt_template": "",
|
||||
"stream_to_user": False,
|
||||
"sources": ["src_alpha", "src_beta"],
|
||||
"tools": [],
|
||||
}},
|
||||
{"id": "agent_b", "type": "agent", "title": "Agent B",
|
||||
"position": {"x": 400, "y": 0}, "data": {
|
||||
"agent_type": "classic",
|
||||
"system_prompt": "Agent B prompt",
|
||||
"prompt_template": "",
|
||||
"stream_to_user": True,
|
||||
"sources": ["src_gamma"],
|
||||
"tools": [],
|
||||
}},
|
||||
{"id": "end_1", "type": "end", "title": "End",
|
||||
"position": {"x": 600, "y": 0}, "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start_1", "target": "agent_a"},
|
||||
{"id": "e2", "source": "agent_a", "target": "agent_b"},
|
||||
{"id": "e3", "source": "agent_b", "target": "end_1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _find_agent_node(nodes, node_id):
|
||||
"""Find a specific node by id."""
|
||||
return next((n for n in nodes if n["id"] == node_id), None)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Workflow integration tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestWorkflowIntegration:
|
||||
"""Verify end-to-end workflow create → get → update → get round-trips."""
|
||||
|
||||
def test_linear_workflow_round_trip(self, client, created_ids):
|
||||
"""Create a linear workflow and verify all nodes/edges survive the round-trip."""
|
||||
payload = linear_workflow(" round-trip")
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
assert wf_id
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, edges = _get_graph(client, wf_id)
|
||||
assert len(nodes) == 3
|
||||
assert len(edges) == 2
|
||||
|
||||
# Verify node types
|
||||
types = {n["id"]: n["type"] for n in nodes}
|
||||
assert types["start_1"] == "start"
|
||||
assert types["agent_1"] == "agent"
|
||||
assert types["end_1"] == "end"
|
||||
|
||||
def test_agent_config_persisted(self, client, created_ids):
|
||||
"""Agent node config (type, prompts, stream_to_user) round-trips correctly."""
|
||||
payload = linear_workflow(" config")
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent = _find_agent_node(nodes, "agent_1")
|
||||
assert agent is not None
|
||||
assert agent["data"]["agent_type"] == "classic"
|
||||
assert agent["data"]["system_prompt"] == "You are helpful."
|
||||
assert agent["data"]["stream_to_user"] is False
|
||||
|
||||
def test_update_workflow_replaces_graph(self, client, created_ids):
|
||||
"""Updating a workflow fully replaces nodes and edges."""
|
||||
resp = client.post("/api/workflows", json=simple_workflow(" replace"))
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, edges = _get_graph(client, wf_id)
|
||||
assert len(nodes) == 2
|
||||
|
||||
# Update to linear
|
||||
update_resp = client.put(
|
||||
f"/api/workflows/{wf_id}", json=linear_workflow(" replaced")
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
|
||||
nodes, edges = _get_graph(client, wf_id)
|
||||
assert len(nodes) == 3
|
||||
assert len(edges) == 2
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Source-specific integration tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestWorkflowSources:
|
||||
"""Verify that agent node sources are persisted and retrieved correctly."""
|
||||
|
||||
def test_create_workflow_with_single_source(self, client, created_ids):
|
||||
"""A workflow with one source on an agent node persists it."""
|
||||
payload = workflow_with_sources(["default"])
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
assert wf_id
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent = _find_agent_node(nodes, "agent_1")
|
||||
assert agent is not None, "Agent node not found"
|
||||
assert agent["data"].get("sources") == ["default"], (
|
||||
f"Expected sources=['default'], got {agent['data'].get('sources')}"
|
||||
)
|
||||
|
||||
def test_create_workflow_with_multiple_sources(self, client, created_ids):
|
||||
"""Multiple sources on an agent node are all persisted."""
|
||||
sources = ["src_1", "src_2", "src_3"]
|
||||
payload = workflow_with_sources(sources)
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent = _find_agent_node(nodes, "agent_1")
|
||||
assert agent is not None
|
||||
assert agent["data"].get("sources") == sources
|
||||
|
||||
def test_create_workflow_with_empty_sources(self, client, created_ids):
|
||||
"""An agent node with empty sources list is accepted and persisted."""
|
||||
payload = workflow_with_sources([])
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
assert wf_id
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent = _find_agent_node(nodes, "agent_1")
|
||||
assert agent is not None
|
||||
assert agent["data"].get("sources") == []
|
||||
|
||||
def test_update_workflow_sources(self, client, created_ids):
|
||||
"""Updating a workflow replaces agent sources."""
|
||||
# Create with original sources
|
||||
payload = workflow_with_sources(["old_src"])
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
# Update with new sources
|
||||
updated_payload = workflow_with_sources(["new_src_1", "new_src_2"], " upd")
|
||||
update_resp = client.put(f"/api/workflows/{wf_id}", json=updated_payload)
|
||||
assert update_resp.status_code == 200, update_resp.get_data(as_text=True)
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent = _find_agent_node(nodes, "agent_1")
|
||||
assert agent is not None
|
||||
assert agent["data"].get("sources") == ["new_src_1", "new_src_2"]
|
||||
|
||||
def test_multi_agent_independent_sources(self, client, created_ids):
|
||||
"""Each agent node keeps its own distinct sources list."""
|
||||
payload = workflow_multi_agent_sources()
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent_a = _find_agent_node(nodes, "agent_a")
|
||||
agent_b = _find_agent_node(nodes, "agent_b")
|
||||
|
||||
assert agent_a is not None, "Agent A not found"
|
||||
assert agent_b is not None, "Agent B not found"
|
||||
assert agent_a["data"].get("sources") == ["src_alpha", "src_beta"]
|
||||
assert agent_b["data"].get("sources") == ["src_gamma"]
|
||||
|
||||
def test_sources_survive_workflow_update(self, client, created_ids):
|
||||
"""Sources survive when a workflow is updated without changing sources."""
|
||||
payload = workflow_with_sources(["persistent_src"])
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
# Update keeping same sources
|
||||
update_resp = client.put(f"/api/workflows/{wf_id}", json=payload)
|
||||
assert update_resp.status_code == 200
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent = _find_agent_node(nodes, "agent_1")
|
||||
assert agent["data"].get("sources") == ["persistent_src"]
|
||||
|
||||
def test_remove_sources_on_update(self, client, created_ids):
|
||||
"""Clearing sources on update results in empty list."""
|
||||
payload = workflow_with_sources(["will_be_removed"])
|
||||
resp = client.post("/api/workflows", json=payload)
|
||||
wf_id = _extract_id(resp)
|
||||
created_ids.append(wf_id)
|
||||
|
||||
# Update with no sources
|
||||
cleared_payload = workflow_with_sources([], " cleared")
|
||||
update_resp = client.put(f"/api/workflows/{wf_id}", json=cleared_payload)
|
||||
assert update_resp.status_code == 200
|
||||
|
||||
nodes, _ = _get_graph(client, wf_id)
|
||||
agent = _find_agent_node(nodes, "agent_1")
|
||||
assert agent["data"].get("sources") == []
|
||||
Reference in New Issue
Block a user