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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+64
View File
@@ -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",
]
+51
View File
@@ -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))])
+485
View File
@@ -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)
+86
View File
@@ -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
+227
View File
@@ -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
+323
View File
@@ -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
+355
View File
@@ -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()
+495
View File
@@ -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()
+267
View File
@@ -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()
+317
View File
@@ -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()
+432
View File
@@ -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()
+183
View File
@@ -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}
+636
View File
@@ -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()
+519
View File
@@ -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()
+179
View File
@@ -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"
]
+681
View File
@@ -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()
+539
View File
@@ -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()
+206
View File
@@ -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}"
+577
View File
@@ -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") == []