Compare commits

...

18 Commits

Author SHA1 Message Date
Asuka Minato
22f26d0a83 test: use real Notion ORM and provider fixtures 2026-10-07 00:10:55 +09:00
Asuka Minato
868e403877 test: use real endpoint HTTP responses 2026-10-07 00:10:55 +09:00
Asuka Minato
3b1186aafb test: use real plugin runtime HTTP and stream responses 2026-10-07 00:10:55 +09:00
Asuka Minato
22faa1e198 test: exercise real MCP POST transport 2026-10-07 00:10:55 +09:00
Asuka Minato
d3a13538a5 test: use real Notion HTTP responses 2026-10-07 00:10:55 +09:00
Asuka Minato
8109ebc62e test: decrypt real ciphertext in encryption batches 2026-10-07 00:10:55 +09:00
Asuka Minato
e41753d3d5 test: implement catalog gateway fixture protocols 2026-10-07 00:10:54 +09:00
Asuka Minato
82a0c03f2e test: implement web passport fixture protocols 2026-10-07 00:10:54 +09:00
Asuka Minato
3e60a9e30e test: exercise real bearer resolvers and rate limiters 2026-10-07 00:10:54 +09:00
Asuka Minato
b9c25d7201 test: exercise real code executor transport and transformer 2026-10-07 00:10:54 +09:00
Asuka Minato
0a445b5be7 test: implement recommended query fixture protocols 2026-10-07 00:10:54 +09:00
Asuka Minato
7510452eac test: use real token and permission fixtures 2026-10-07 00:10:54 +09:00
Asuka Minato
82d13532d5 test: use real recommended catalog responses 2026-10-07 00:10:53 +09:00
Asuka Minato
72f2305839 test: implement API key auth fixture protocols 2026-10-07 00:10:53 +09:00
Asuka Minato
e09ad12cc1 test: use real OAuth client responses 2026-10-07 00:10:53 +09:00
Asuka Minato
070cd79c39 test: use real plugin runtime objects 2026-10-07 00:10:53 +09:00
Codex
1d912fee41 test: specify marketplace response payload types 2026-10-07 00:07:16 +09:00
Asuka Minato
e92ffbdf50 test: use real marketplace and Nacos responses 2026-10-07 00:07:16 +09:00
20 changed files with 1111 additions and 1008 deletions

View File

@@ -1,17 +1,15 @@
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import httpx
from configs.remote_settings_sources.nacos.http_request import NacosHttpClient
def _ok_response(text: str = "ok", json_data: dict | None = None) -> MagicMock:
response = MagicMock()
response.text = text
response.raise_for_status.return_value = None
def _ok_response(text: str = "ok", json_data: dict | None = None) -> httpx.Response:
request = httpx.Request("GET", "http://nacos.test")
if json_data is not None:
response.json.return_value = json_data
return response
return httpx.Response(200, json=json_data, request=request)
return httpx.Response(200, text=text, request=request)
def test_http_request_passes_bounded_timeout():

View File

@@ -15,7 +15,6 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
import json
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from unittest.mock import Mock, patch
from uuid import uuid4
@@ -25,39 +24,38 @@ import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from core.datasource.entities.datasource_entities import DatasourceProviderType
from core.datasource.entities.datasource_entities import (
DatasourceEntity,
DatasourceIdentity,
DatasourceProviderEntityWithPlugin,
DatasourceProviderIdentity,
DatasourceProviderType,
)
from core.datasource.online_document.online_document_provider import (
OnlineDocumentDatasourcePluginProviderController,
)
from core.rag.extractor import notion_extractor as notion_extractor_module
from core.rag.extractor.notion_extractor import NotionExtractor
from core.rag.models.document import Document
from core.tools.entities.common_entities import I18nObject
from extensions.application_services.data_sources import build_data_source_credentials
from models.base import TypeBase
from models.dataset import Document as DocumentModel
from models.enums import DataSourceType, DocumentCreatedFrom
@dataclass(frozen=True)
class _Database:
"""Expose the real SQLite session used by the extractor update."""
session: Session
@pytest.fixture
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Database]:
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
"""Bind a real session for Notion document metadata persistence."""
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
with Session(sqlite_engine, expire_on_commit=False) as session:
database = _Database(session)
monkeypatch.setattr(notion_extractor_module, "db", database)
yield database
monkeypatch.setattr(notion_extractor_module.db, "session", session)
yield session
@pytest.fixture
def persisted_document(database: _Database) -> DocumentModel:
def persisted_document(database: Session) -> DocumentModel:
document = DocumentModel(
id=str(uuid4()),
tenant_id=str(uuid4()),
@@ -70,8 +68,8 @@ def persisted_document(database: _Database) -> DocumentModel:
created_from=DocumentCreatedFrom.WEB,
created_by=str(uuid4()),
)
database.session.add(document)
database.session.commit()
database.add(document)
database.commit()
return document
@@ -87,11 +85,11 @@ class TestNotionExtractorAuthentication:
@pytest.fixture
def mock_document_model(self):
"""Mock DocumentModel for testing."""
mock_doc = Mock()
mock_doc.id = "test-doc-id"
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
return mock_doc
"""Create a document with persisted-format Notion metadata."""
return DocumentModel(
id="test-doc-id",
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
)
def test_init_with_explicit_token(self, mock_document_model):
"""Test NotionExtractor initialization with explicit access token."""
@@ -112,11 +110,12 @@ class TestNotionExtractorAuthentication:
assert extractor._notion_obj_id == "page-456"
assert extractor._notion_page_type == "page"
def test_init_with_credential_id(self, mock_document_model):
@patch("services.data_source.provider_service.DatasourceProviderService.get_datasource_credentials")
def test_init_with_credential_id(self, get_credentials, mock_document_model, sqlite_session_factory):
"""Test NotionExtractor initialization with credential ID retrieval."""
# Arrange
mock_service = Mock()
mock_service.get_stored_notion_access_token.return_value = "credential-token-xyz"
providers = build_data_source_credentials(database_client=sqlite_session_factory).providers
get_credentials.return_value = {"integration_secret": "credential-token-xyz"}
# Act
extractor = NotionExtractor(
@@ -126,16 +125,18 @@ class TestNotionExtractorAuthentication:
tenant_id="tenant-789",
credential_id="cred-123",
document_model=mock_document_model,
notion_token_loader=lambda: mock_service.get_stored_notion_access_token(
notion_token_loader=lambda: providers.get_stored_notion_access_token(
tenant_id="tenant-789", credential_id="cred-123"
),
)
# Assert
assert extractor._notion_access_token == "credential-token-xyz"
mock_service.get_stored_notion_access_token.assert_called_once_with(
get_credentials.assert_called_once_with(
tenant_id="tenant-789",
credential_id="cred-123",
provider="notion_datasource",
plugin_id="langgenius/notion_datasource",
)
@patch("services.data_source.provider_service.DatasourceProviderService.get_datasource_credentials")
@@ -212,13 +213,9 @@ class TestNotionExtractorPageRetrieval:
notion_token_loader=lambda: "token",
)
def _create_mock_response(self, data: dict[str, Any], status_code: int = 200) -> Mock:
"""Helper to create mock HTTP response."""
response = Mock()
response.status_code = status_code
response.json.return_value = data
response.text = json.dumps(data)
return response
def _create_response(self, data: dict[str, Any], status_code: int = 200) -> httpx.Response:
"""Build an HTTP response with serialized JSON for the extractor to parse."""
return httpx.Response(status_code, json=data)
def _create_block(
self, block_id: str, block_type: str, text_content: str, has_children: bool = False
@@ -253,7 +250,7 @@ class TestNotionExtractorPageRetrieval:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = self._create_mock_response(mock_data)
mock_request.return_value = self._create_response(mock_data)
# Act
result = extractor._get_notion_block_data("page-456")
@@ -279,7 +276,7 @@ class TestNotionExtractorPageRetrieval:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = self._create_mock_response(mock_data)
mock_request.return_value = self._create_response(mock_data)
# Act
result = extractor._get_notion_block_data("page-456")
@@ -308,8 +305,8 @@ class TestNotionExtractorPageRetrieval:
"has_more": False,
}
mock_request.side_effect = [
self._create_mock_response(first_page),
self._create_mock_response(second_page),
self._create_response(first_page),
self._create_response(second_page),
]
# Act
@@ -344,8 +341,8 @@ class TestNotionExtractorPageRetrieval:
"has_more": False,
}
mock_request.side_effect = [
self._create_mock_response(parent_data),
self._create_mock_response(child_data),
self._create_response(parent_data),
self._create_response(child_data),
]
# Act
@@ -361,7 +358,7 @@ class TestNotionExtractorPageRetrieval:
def test_get_notion_block_data_error_handling(self, mock_request, extractor: NotionExtractor):
"""Test error handling for failed API requests."""
# Arrange
mock_request.return_value = self._create_mock_response({}, status_code=404)
mock_request.return_value = self._create_response({}, status_code=404)
# Act & Assert
with pytest.raises(ValueError) as exc_info:
@@ -372,7 +369,7 @@ class TestNotionExtractorPageRetrieval:
def test_get_notion_block_data_invalid_response(self, mock_request, extractor: NotionExtractor):
"""Test handling of invalid API response structure."""
# Arrange
mock_request.return_value = self._create_mock_response({"invalid": "structure"})
mock_request.return_value = self._create_response({"invalid": "structure"})
# Act & Assert
with pytest.raises(ValueError) as exc_info:
@@ -430,8 +427,7 @@ class TestNotionExtractorDatabaseRetrieval:
def test_get_notion_database_data_simple(self, mock_post, extractor: NotionExtractor):
"""Test retrieving simple database with basic properties."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "list",
"results": [
self._create_database_page(
@@ -452,6 +448,7 @@ class TestNotionExtractorDatabaseRetrieval:
"has_more": False,
"next_cursor": None,
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Act
@@ -469,8 +466,7 @@ class TestNotionExtractorDatabaseRetrieval:
def test_get_notion_database_data_with_pagination(self, mock_post, extractor: NotionExtractor):
"""Test retrieving database with paginated results."""
# Arrange
first_response = Mock()
first_response.json.return_value = {
first_response_data = {
"object": "list",
"results": [
self._create_database_page("page-1", {"Title": {"type": "title", "value": [{"plain_text": "Page 1"}]}}),
@@ -478,8 +474,8 @@ class TestNotionExtractorDatabaseRetrieval:
"has_more": True,
"next_cursor": "cursor-xyz",
}
second_response = Mock()
second_response.json.return_value = {
first_response = httpx.Response(200, json=first_response_data)
second_response_data = {
"object": "list",
"results": [
self._create_database_page("page-2", {"Title": {"type": "title", "value": [{"plain_text": "Page 2"}]}}),
@@ -487,6 +483,7 @@ class TestNotionExtractorDatabaseRetrieval:
"has_more": False,
"next_cursor": None,
}
second_response = httpx.Response(200, json=second_response_data)
mock_post.side_effect = [first_response, second_response]
# Act
@@ -503,8 +500,7 @@ class TestNotionExtractorDatabaseRetrieval:
def test_get_notion_database_data_multi_select(self, mock_post, extractor: NotionExtractor):
"""Test database with multi_select property type."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "list",
"results": [
self._create_database_page(
@@ -521,6 +517,7 @@ class TestNotionExtractorDatabaseRetrieval:
"has_more": False,
"next_cursor": None,
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Act
@@ -536,8 +533,7 @@ class TestNotionExtractorDatabaseRetrieval:
def test_get_notion_database_data_empty_properties(self, mock_post, extractor: NotionExtractor):
"""Test database with empty property values."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "list",
"results": [
self._create_database_page(
@@ -551,6 +547,7 @@ class TestNotionExtractorDatabaseRetrieval:
"has_more": False,
"next_cursor": None,
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Act
@@ -566,13 +563,13 @@ class TestNotionExtractorDatabaseRetrieval:
def test_get_notion_database_data_empty_results(self, mock_post, extractor: NotionExtractor):
"""Test handling of empty database."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "list",
"results": [],
"has_more": False,
"next_cursor": None,
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Act
@@ -585,8 +582,8 @@ class TestNotionExtractorDatabaseRetrieval:
def test_get_notion_database_data_missing_results(self, mock_post, extractor: NotionExtractor):
"""Test handling of malformed API response."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {"object": "list"}
mock_response_data = {"object": "list"}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Act
@@ -659,7 +656,7 @@ class TestNotionExtractorTableParsing:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._read_table_rows("table-block-123")
@@ -691,7 +688,7 @@ class TestNotionExtractorTableParsing:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._read_table_rows("table-block-123")
@@ -730,7 +727,7 @@ class TestNotionExtractorTableParsing:
"next_cursor": None,
"has_more": False,
}
mock_request.side_effect = [Mock(json=lambda: first_page), Mock(json=lambda: second_page)]
mock_request.side_effect = [httpx.Response(200, json=first_page), httpx.Response(200, json=second_page)]
# Act
result = extractor._read_table_rows("table-block-123")
@@ -751,11 +748,11 @@ class TestNotionExtractorLastEditedTime:
@pytest.fixture
def mock_document_model(self):
"""Mock DocumentModel for testing."""
mock_doc = Mock()
mock_doc.id = "test-doc-id"
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
return mock_doc
"""Create a document with persisted-format Notion metadata."""
return DocumentModel(
id="test-doc-id",
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
)
@pytest.fixture
def extractor_page(self, mock_document_model):
@@ -787,12 +784,12 @@ class TestNotionExtractorLastEditedTime:
def test_get_notion_last_edited_time_page(self, mock_request, extractor_page):
"""Test retrieving last edited time for a page."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "page",
"id": "page-456",
"last_edited_time": "2024-11-27T12:00:00.000Z",
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_request.return_value = mock_response
# Act
@@ -808,12 +805,12 @@ class TestNotionExtractorLastEditedTime:
def test_get_notion_last_edited_time_database(self, mock_request, extractor_database):
"""Test retrieving last edited time for a database."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "database",
"id": "database-789",
"last_edited_time": "2024-11-27T15:30:00.000Z",
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_request.return_value = mock_response
# Act
@@ -830,24 +827,24 @@ class TestNotionExtractorLastEditedTime:
self,
mock_request: Mock,
extractor_page: NotionExtractor,
database: _Database,
database: Session,
persisted_document: DocumentModel,
):
"""Test updating document model with last edited time."""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "page",
"id": "page-456",
"last_edited_time": "2024-11-27T18:00:00.000Z",
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_request.return_value = mock_response
# Act
extractor_page.update_last_edited_time(persisted_document)
# Assert
database.session.expire(persisted_document)
database.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
def test_update_last_edited_time_no_document(self, extractor_page):
@@ -868,15 +865,15 @@ class TestNotionExtractorIntegration:
@pytest.fixture
def mock_document_model(self):
"""Mock DocumentModel for testing."""
mock_doc = Mock()
mock_doc.id = "test-doc-id"
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
return mock_doc
"""Create a document with persisted-format Notion metadata."""
return DocumentModel(
id="test-doc-id",
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
)
@patch("httpx.request")
def test_extract_page_complete_workflow(
self, mock_request: Mock, database: _Database, persisted_document: DocumentModel
self, mock_request: Mock, database: Session, persisted_document: DocumentModel
):
"""Test complete page extraction workflow."""
# Arrange
@@ -891,16 +888,14 @@ class TestNotionExtractorIntegration:
)
# Mock last edited time request
last_edited_response = Mock()
last_edited_response.json.return_value = {
last_edited_response_data = {
"object": "page",
"last_edited_time": "2024-11-27T20:00:00.000Z",
}
last_edited_response = httpx.Response(200, json=last_edited_response_data)
# Mock block data request
block_response = Mock()
block_response.status_code = 200
block_response.json.return_value = {
block_response_data = {
"object": "list",
"results": [
{
@@ -927,6 +922,7 @@ class TestNotionExtractorIntegration:
"next_cursor": None,
"has_more": False,
}
block_response = httpx.Response(200, json=block_response_data)
mock_request.side_effect = [last_edited_response, block_response]
@@ -938,7 +934,7 @@ class TestNotionExtractorIntegration:
assert isinstance(documents[0], Document)
assert "# Test Page" in documents[0].page_content
assert "Test content" in documents[0].page_content
database.session.expire(persisted_document)
database.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
@patch("httpx.post")
@@ -947,7 +943,7 @@ class TestNotionExtractorIntegration:
self,
mock_request: Mock,
mock_post: Mock,
database: _Database,
database: Session,
persisted_document: DocumentModel,
):
"""Test complete database extraction workflow."""
@@ -963,16 +959,15 @@ class TestNotionExtractorIntegration:
)
# Mock last edited time request
last_edited_response = Mock()
last_edited_response.json.return_value = {
last_edited_response_data = {
"object": "database",
"last_edited_time": "2024-11-27T20:00:00.000Z",
}
last_edited_response = httpx.Response(200, json=last_edited_response_data)
mock_request.return_value = last_edited_response
# Mock database query request
database_response = Mock()
database_response.json.return_value = {
database_response_data = {
"object": "list",
"results": [
{
@@ -988,6 +983,7 @@ class TestNotionExtractorIntegration:
"has_more": False,
"next_cursor": None,
}
database_response = httpx.Response(200, json=database_response_data)
mock_post.return_value = database_response
# Act
@@ -998,7 +994,7 @@ class TestNotionExtractorIntegration:
assert isinstance(documents[0], Document)
assert "Name:Item 1" in documents[0].page_content
assert "Status:Active" in documents[0].page_content
database.session.expire(persisted_document)
database.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
def test_extract_invalid_page_type(self):
@@ -1062,7 +1058,7 @@ class TestNotionExtractorReadBlock:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._read_block("block-parent", num_tabs=2)
@@ -1088,7 +1084,7 @@ class TestNotionExtractorReadBlock:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._read_block("block-parent")
@@ -1109,13 +1105,17 @@ class TestNotionProviderController:
@pytest.fixture
def mock_entity(self):
"""Mock provider entity for testing."""
entity = Mock()
entity.identity.name = "notion_datasource"
entity.identity.icon = "notion-icon.png"
entity.credentials_schema = []
entity.datasources = []
return entity
"""Create a validated Notion provider entity."""
return DatasourceProviderEntityWithPlugin(
identity=DatasourceProviderIdentity(
author="langgenius",
name="notion_datasource",
icon="notion-icon.png",
description=I18nObject(en_US="Notion documents"),
label=I18nObject(en_US="Notion"),
),
provider_type=DatasourceProviderType.ONLINE_DOCUMENT,
)
def test_provider_controller_initialization(self, mock_entity):
"""Test OnlineDocumentDatasourcePluginProviderController initialization."""
@@ -1136,8 +1136,15 @@ class TestNotionProviderController:
def test_provider_controller_get_datasource(self, mock_entity):
"""Test retrieving datasource from controller."""
# Arrange
mock_datasource_entity = Mock()
mock_datasource_entity.identity.name = "notion_datasource"
mock_datasource_entity = DatasourceEntity(
identity=DatasourceIdentity(
author="langgenius",
name="notion_datasource",
label=I18nObject(en_US="Notion"),
provider="notion_datasource",
),
description=I18nObject(en_US="Notion documents"),
)
mock_entity.datasources = [mock_datasource_entity]
controller = OnlineDocumentDatasourcePluginProviderController(
@@ -1236,7 +1243,7 @@ class TestNotionExtractorAdvancedBlockTypes:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(status_code=200, json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._get_notion_block_data("page-456")
@@ -1263,7 +1270,7 @@ class TestNotionExtractorAdvancedBlockTypes:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(status_code=200, json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._get_notion_block_data("page-456")
@@ -1298,8 +1305,8 @@ class TestNotionExtractorAdvancedBlockTypes:
"has_more": False,
}
mock_request.side_effect = [
Mock(status_code=200, json=lambda: parent_data),
Mock(status_code=200, json=lambda: child_data),
httpx.Response(200, json=parent_data),
httpx.Response(200, json=child_data),
]
# Act
@@ -1330,7 +1337,7 @@ class TestNotionExtractorAdvancedBlockTypes:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(status_code=200, json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._get_notion_block_data("page-456")
@@ -1398,8 +1405,7 @@ class TestNotionExtractorDatabaseAdvanced:
All property types should be extracted correctly.
"""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "list",
"results": [
self._create_database_page_with_properties(
@@ -1419,6 +1425,7 @@ class TestNotionExtractorDatabaseAdvanced:
"has_more": False,
"next_cursor": None,
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Act
@@ -1444,8 +1451,7 @@ class TestNotionExtractorDatabaseAdvanced:
This tests that all pages are retrieved correctly.
"""
# Arrange - Create 3 pages of results
page1_response = Mock()
page1_response.json.return_value = {
page1_response_data = {
"object": "list",
"results": [
self._create_database_page_with_properties(
@@ -1456,9 +1462,9 @@ class TestNotionExtractorDatabaseAdvanced:
"has_more": True,
"next_cursor": "cursor-1",
}
page1_response = httpx.Response(200, json=page1_response_data)
page2_response = Mock()
page2_response.json.return_value = {
page2_response_data = {
"object": "list",
"results": [
self._create_database_page_with_properties(
@@ -1469,9 +1475,9 @@ class TestNotionExtractorDatabaseAdvanced:
"has_more": True,
"next_cursor": "cursor-2",
}
page2_response = httpx.Response(200, json=page2_response_data)
page3_response = Mock()
page3_response.json.return_value = {
page3_response_data = {
"object": "list",
"results": [
self._create_database_page_with_properties(
@@ -1482,6 +1488,7 @@ class TestNotionExtractorDatabaseAdvanced:
"has_more": False,
"next_cursor": None,
}
page3_response = httpx.Response(200, json=page3_response_data)
mock_post.side_effect = [page1_response, page2_response, page3_response]
@@ -1504,8 +1511,7 @@ class TestNotionExtractorDatabaseAdvanced:
Rich text properties can contain formatted text and should be extracted.
"""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "list",
"results": [
self._create_database_page_with_properties(
@@ -1522,6 +1528,7 @@ class TestNotionExtractorDatabaseAdvanced:
"has_more": False,
"next_cursor": None,
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Act
@@ -1603,9 +1610,7 @@ class TestNotionExtractorErrorScenarios:
Different HTTP error codes (401, 403, 404, 429) should be handled appropriately.
"""
# Arrange
mock_response = Mock()
mock_response.status_code = status_code
mock_response.text = description
mock_response = httpx.Response(status_code, text=description)
mock_request.return_value = mock_response
# Act & Assert
@@ -1630,9 +1635,7 @@ class TestNotionExtractorErrorScenarios:
Various malformed responses should be handled gracefully.
"""
# Arrange
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = response_data
mock_response = httpx.Response(200, json=response_data)
mock_request.return_value = mock_response
# Act & Assert
@@ -1647,8 +1650,7 @@ class TestNotionExtractorErrorScenarios:
Databases can be queried with filters to retrieve specific rows.
"""
# Arrange
mock_response = Mock()
mock_response.json.return_value = {
mock_response_data = {
"object": "list",
"results": [
{
@@ -1664,6 +1666,7 @@ class TestNotionExtractorErrorScenarios:
"has_more": False,
"next_cursor": None,
}
mock_response = httpx.Response(200, json=mock_response_data)
mock_post.return_value = mock_response
# Create a custom query filter
@@ -1735,7 +1738,7 @@ class TestNotionExtractorTableAdvanced:
"next_cursor": None,
"has_more": False,
}
mock_request.return_value = Mock(json=lambda: mock_data)
mock_request.return_value = httpx.Response(200, json=mock_data)
# Act
result = extractor._read_table_rows("table-block-123")

View File

@@ -1,12 +1,16 @@
from __future__ import annotations
import json
from collections.abc import Callable, Iterator
from contextlib import ExitStack
from typing import Any, cast
from unittest.mock import MagicMock
import httpx
import pytest
from pytest_mock import MockerFixture
from core.helper.code_executor import code_executor as code_executor_module
from core.helper.code_executor.python3.python3_transformer import Python3TemplateTransformer
def test_execute_workflow_code_template_raises_for_unsupported_language() -> None:
@@ -14,97 +18,131 @@ def test_execute_workflow_code_template_raises_for_unsupported_language() -> Non
code_executor_module.CodeExecutor.execute_workflow_code_template(cast(Any, "ruby"), "print(1)", {})
@pytest.fixture
def sandbox_client(mocker: MockerFixture) -> Iterator[Callable[[httpx.Response | Exception], list[httpx.Request]]]:
with ExitStack() as stack:
def install(result: httpx.Response | Exception) -> list[httpx.Request]:
requests: list[httpx.Request] = []
def send(request: httpx.Request) -> httpx.Response:
requests.append(request)
if isinstance(result, Exception):
raise result
return result
client = stack.enter_context(httpx.Client(transport=httpx.MockTransport(send)))
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
return requests
yield install
def test_execute_workflow_code_template_uses_transformer(mocker: MockerFixture) -> None:
transformer = MagicMock()
transformer.transform_caller.return_value = ("runner-script", "preload-script")
transformer.transform_response.return_value = {"result": "ok"}
transform = mocker.spy(Python3TemplateTransformer, "transform_caller")
execute_mock = mocker.patch.object(
code_executor_module.CodeExecutor,
"execute_code",
return_value='<<RESULT>>{"result":"ok"}<<RESULT>>',
)
mocker.patch.dict(code_executor_module.CodeExecutor.code_template_transformers, {"fake": transformer}, clear=False)
code = "def main(a): return {'result': a}"
language = code_executor_module.CodeLanguage.PYTHON3
result = code_executor_module.CodeExecutor.execute_workflow_code_template(cast(Any, "fake"), "code", {"a": 1})
result = code_executor_module.CodeExecutor.execute_workflow_code_template(language, code, {"a": 1})
assert result == {"result": "ok"}
transformer.transform_caller.assert_called_once_with("code", {"a": 1})
execute_mock.assert_called_once_with("fake", "preload-script", "runner-script")
transform.assert_called_once_with(code, {"a": 1})
runner, preload = transform.spy_return
assert code in runner
execute_mock.assert_called_once_with(language, preload, runner)
def test_execute_code_raises_service_unavailable_for_503(mocker: MockerFixture) -> None:
response = MagicMock()
response.status_code = 503
client = MagicMock()
client.post.return_value = response
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
def test_execute_code_raises_service_unavailable_for_503(
sandbox_client: Callable[[httpx.Response | Exception], list[httpx.Request]],
) -> None:
sandbox_client(httpx.Response(503))
with pytest.raises(code_executor_module.CodeExecutionError, match="service is unavailable"):
code_executor_module.CodeExecutor.execute_code(cast(Any, "python3"), preload="", code="print(1)")
code_executor_module.CodeExecutor.execute_code(
code_executor_module.CodeLanguage.PYTHON3, preload="", code="print(1)"
)
def test_execute_code_returns_stdout_on_success(mocker: MockerFixture) -> None:
response = MagicMock()
response.status_code = 200
response.json.return_value = {"code": 0, "message": "ok", "data": {"stdout": "done", "error": None}}
client = MagicMock()
client.post.return_value = response
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
def test_execute_code_returns_stdout_on_success(
sandbox_client: Callable[[httpx.Response | Exception], list[httpx.Request]],
) -> None:
response = httpx.Response(200, json={"code": 0, "message": "ok", "data": {"stdout": "done", "error": None}})
requests = sandbox_client(response)
assert code_executor_module.CodeExecutor.execute_code(cast(Any, "python3"), preload="", code="print(1)") == "done"
assert (
code_executor_module.CodeExecutor.execute_code(
code_executor_module.CodeLanguage.PYTHON3, preload="", code="print(1)"
)
== "done"
)
assert len(requests) == 1
assert requests[0].method == "POST"
assert requests[0].url.path == "/v1/sandbox/run"
assert json.loads(requests[0].content) == {
"language": "python3",
"code": "print(1)",
"preload": "",
"enable_network": True,
}
def test_execute_code_raises_for_non_200_status(mocker: MockerFixture) -> None:
response = MagicMock()
response.status_code = 500
client = MagicMock()
client.post.return_value = response
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
def test_execute_code_raises_for_non_200_status(
sandbox_client: Callable[[httpx.Response | Exception], list[httpx.Request]],
) -> None:
sandbox_client(httpx.Response(500))
with pytest.raises(code_executor_module.CodeExecutionError, match="likely a network issue"):
code_executor_module.CodeExecutor.execute_code(cast(Any, "python3"), preload="", code="print(1)")
code_executor_module.CodeExecutor.execute_code(
code_executor_module.CodeLanguage.PYTHON3, preload="", code="print(1)"
)
def test_execute_code_raises_when_client_post_fails(mocker: MockerFixture) -> None:
client = MagicMock()
client.post.side_effect = RuntimeError("timeout")
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
def test_execute_code_raises_when_client_post_fails(
sandbox_client: Callable[[httpx.Response | Exception], list[httpx.Request]],
) -> None:
sandbox_client(httpx.ConnectTimeout("timeout"))
with pytest.raises(code_executor_module.CodeExecutionError, match="likely a network issue"):
code_executor_module.CodeExecutor.execute_code(cast(Any, "python3"), preload="", code="print(1)")
code_executor_module.CodeExecutor.execute_code(
code_executor_module.CodeLanguage.PYTHON3, preload="", code="print(1)"
)
def test_execute_code_raises_when_response_json_is_invalid(mocker: MockerFixture) -> None:
response = MagicMock()
response.status_code = 200
response.json.side_effect = ValueError("bad json")
client = MagicMock()
client.post.return_value = response
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
def test_execute_code_raises_when_response_json_is_invalid(
sandbox_client: Callable[[httpx.Response | Exception], list[httpx.Request]],
) -> None:
sandbox_client(httpx.Response(200, content=b"bad json"))
with pytest.raises(code_executor_module.CodeExecutionError, match="Failed to parse response"):
code_executor_module.CodeExecutor.execute_code(cast(Any, "python3"), preload="", code="print(1)")
code_executor_module.CodeExecutor.execute_code(
code_executor_module.CodeLanguage.PYTHON3, preload="", code="print(1)"
)
def test_execute_code_raises_when_sandbox_returns_error_code(mocker: MockerFixture) -> None:
response = MagicMock()
response.status_code = 200
response.json.return_value = {"code": 1, "message": "boom", "data": {"stdout": "", "error": None}}
client = MagicMock()
client.post.return_value = response
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
def test_execute_code_raises_when_sandbox_returns_error_code(
sandbox_client: Callable[[httpx.Response | Exception], list[httpx.Request]],
) -> None:
response = httpx.Response(200, json={"code": 1, "message": "boom", "data": {"stdout": "", "error": None}})
sandbox_client(response)
with pytest.raises(code_executor_module.CodeExecutionError, match="Got error code: 1"):
code_executor_module.CodeExecutor.execute_code(cast(Any, "python3"), preload="", code="print(1)")
code_executor_module.CodeExecutor.execute_code(
code_executor_module.CodeLanguage.PYTHON3, preload="", code="print(1)"
)
def test_execute_code_raises_when_response_contains_runtime_error(mocker: MockerFixture) -> None:
response = MagicMock()
response.status_code = 200
response.json.return_value = {"code": 0, "message": "ok", "data": {"stdout": "", "error": "runtime failed"}}
client = MagicMock()
client.post.return_value = response
mocker.patch("core.helper.code_executor.code_executor.get_pooled_http_client", return_value=client)
def test_execute_code_raises_when_response_contains_runtime_error(
sandbox_client: Callable[[httpx.Response | Exception], list[httpx.Request]],
) -> None:
response = httpx.Response(200, json={"code": 0, "message": "ok", "data": {"stdout": "", "error": "runtime failed"}})
sandbox_client(response)
with pytest.raises(code_executor_module.CodeExecutionError, match="runtime failed"):
code_executor_module.CodeExecutor.execute_code(cast(Any, "python3"), preload="", code="print(1)")
code_executor_module.CodeExecutor.execute_code(
code_executor_module.CodeLanguage.PYTHON3, preload="", code="print(1)"
)

View File

@@ -1,8 +1,10 @@
import base64
import binascii
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from Crypto.PublicKey import RSA
from pytest_mock import MockerFixture
from sqlalchemy.orm import Session
import models.engine as model_engine
@@ -13,6 +15,7 @@ from core.helper.encrypter import (
get_decrypt_decoding,
obfuscated_token,
)
from libs import gmpy2_pkcs10aep_cipher, rsa
from libs.rsa import PrivkeyNotFoundError
from models.account import Tenant
@@ -22,18 +25,14 @@ pytestmark = [
]
class _DatabaseBinding:
"""Expose the real SQLite session used by the legacy encryption lookup."""
session: Session
def __init__(self, session: Session) -> None:
self.session = session
@pytest.fixture(autouse=True)
def bind_sqlite_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(model_engine, "db", _DatabaseBinding(sqlite_session))
monkeypatch.setattr(model_engine.db, "session", sqlite_session)
@pytest.fixture(scope="module")
def rsa_key() -> RSA.RsaKey:
return RSA.generate(2048)
def _persist_tenant(session: Session, *, public_key: str = "mock_public_key") -> Tenant:
@@ -107,24 +106,14 @@ class TestDecryptToken:
class TestBatchDecryptToken:
@patch("libs.rsa.get_decrypt_decoding")
@patch("libs.rsa.decrypt_token_with_decoding")
def test_batch_decryption(self, mock_decrypt_with_decoding, mock_get_decoding):
"""Test batch decryption functionality"""
mock_rsa_key = MagicMock()
mock_cipher_rsa = MagicMock()
mock_get_decoding.return_value = (mock_rsa_key, mock_cipher_rsa)
def test_batch_decryption(self, mock_get_decoding, sqlite_session: Session, rsa_key: RSA.RsaKey):
"""Decrypt actual tenant ciphertext while loading the key only once."""
_persist_tenant(sqlite_session, public_key=rsa_key.publickey().export_key().decode())
mock_get_decoding.return_value = (rsa_key, gmpy2_pkcs10aep_cipher.new(rsa_key))
plaintexts = ["token1", "token2", "token3"]
tokens = [encrypt_token("tenant-123", token) for token in plaintexts]
# Test multiple tokens
mock_decrypt_with_decoding.side_effect = ["token1", "token2", "token3"]
tokens = [
base64.b64encode(b"encrypted1").decode(),
base64.b64encode(b"encrypted2").decode(),
base64.b64encode(b"encrypted3").decode(),
]
result = batch_decrypt_token("tenant-123", tokens)
assert result == ["token1", "token2", "token3"]
# Key should only be loaded once
assert batch_decrypt_token("tenant-123", tokens) == plaintexts
mock_get_decoding.assert_called_once_with("tenant-123")
@@ -266,20 +255,19 @@ class TestEdgeCases:
encrypt_token("tenant-123", long_token)
@patch("libs.rsa.get_decrypt_decoding")
@patch("libs.rsa.decrypt_token_with_decoding")
def test_batch_decrypt_loads_key_only_once(self, mock_decrypt_with_decoding, mock_get_decoding):
"""Verify batch decryption optimization - loads key only once"""
mock_rsa_key = MagicMock()
mock_cipher_rsa = MagicMock()
mock_get_decoding.return_value = (mock_rsa_key, mock_cipher_rsa)
def test_batch_decrypt_loads_key_only_once(
self, mock_get_decoding, sqlite_session: Session, rsa_key: RSA.RsaKey, mocker: MockerFixture
):
"""Reuse one decoding context across every actual ciphertext in the batch."""
_persist_tenant(sqlite_session, public_key=rsa_key.publickey().export_key().decode())
cipher_rsa = gmpy2_pkcs10aep_cipher.new(rsa_key)
mock_get_decoding.return_value = (rsa_key, cipher_rsa)
decrypt = mocker.spy(rsa, "decrypt_token_with_decoding")
plaintexts = [f"token{i}" for i in range(1, 6)]
tokens = [encrypt_token("tenant-123", token) for token in plaintexts]
# Test with multiple tokens
mock_decrypt_with_decoding.side_effect = ["token1", "token2", "token3", "token4", "token5"]
tokens = [base64.b64encode(f"encrypted{i}".encode()).decode() for i in range(5)]
result = batch_decrypt_token("tenant-123", tokens)
assert result == ["token1", "token2", "token3", "token4", "token5"]
# Key should only be loaded once regardless of token count
assert batch_decrypt_token("tenant-123", tokens) == plaintexts
mock_get_decoding.assert_called_once_with("tenant-123")
assert mock_decrypt_with_decoding.call_count == 5
assert decrypt.call_count == len(tokens)
for invocation, token in zip(decrypt.call_args_list, tokens, strict=True):
assert invocation.args == (base64.b64decode(token), rsa_key, cipher_rsa)

View File

@@ -1,7 +1,6 @@
from collections.abc import Callable
from types import SimpleNamespace
from unittest.mock import MagicMock
import httpx
import pytest
from pytest_mock import MockerFixture
@@ -13,6 +12,11 @@ from core.helper.marketplace import (
get_plugin_pkg_url,
record_install_plugin_event,
)
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
def _response(payload: dict[str, object]) -> httpx.Response:
return httpx.Response(200, json=payload, request=httpx.Request("POST", "https://marketplace.test"))
def test_get_plugin_pkg_url_contains_unique_identifier() -> None:
@@ -45,22 +49,19 @@ def test_batch_fetch_plugin_by_ids_returns_empty_for_empty_input(mocker: MockerF
def test_batch_fetch_plugin_by_ids_returns_plugins_from_response(mocker: MockerFixture) -> None:
response = MagicMock()
response.json.return_value = {"data": {"plugins": [{"id": "p1"}]}}
response.raise_for_status.return_value = None
response = _response({"data": {"plugins": [{"id": "p1"}]}})
check_status = mocker.spy(response, "raise_for_status")
post_mock = mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
plugins = batch_fetch_plugin_by_ids(["p1"])
assert plugins == [{"id": "p1"}]
post_mock.assert_called_once()
response.raise_for_status.assert_called_once()
check_status.assert_called_once()
def test_batch_fetch_plugin_by_ids_rejects_invalid_plugins_response(mocker: MockerFixture) -> None:
response = MagicMock()
response.json.return_value = {"data": {"plugins": ["p1"]}}
response.raise_for_status.return_value = None
response = _response({"data": {"plugins": ["p1"]}})
mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
with pytest.raises(ValueError, match="plugins list"):
@@ -75,9 +76,8 @@ def test_batch_fetch_plugin_manifests_returns_empty_for_empty_input(mocker: Mock
def test_batch_fetch_plugin_manifests_validates_and_returns_plugins(mocker: MockerFixture) -> None:
response = MagicMock()
response.raise_for_status.return_value = None
response.json.return_value = {"data": {"plugins": [{"id": "p1"}, {"id": "p2"}]}}
response = _response({"data": {"plugins": [{"id": "p1"}, {"id": "p2"}]}})
check_status = mocker.spy(response, "raise_for_status")
post_mock = mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
validate_mock = mocker.patch(
"core.helper.marketplace.MarketplacePluginDeclaration.model_validate",
@@ -89,48 +89,47 @@ def test_batch_fetch_plugin_manifests_validates_and_returns_plugins(mocker: Mock
assert result == ["manifest-1", "manifest-2"]
post_mock.assert_called_once()
assert validate_mock.call_count == 2
response.raise_for_status.assert_called_once()
check_status.assert_called_once()
def test_record_install_plugin_event_posts_and_checks_status(mocker: MockerFixture) -> None:
response = MagicMock()
response.raise_for_status.return_value = None
response = _response({})
check_status = mocker.spy(response, "raise_for_status")
post_mock = mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
record_install_plugin_event("plugin.a")
post_mock.assert_called_once()
response.raise_for_status.assert_called_once()
check_status.assert_called_once()
def test_fetch_global_plugin_manifest_caches_each_plugin(mocker: MockerFixture) -> None:
response = MagicMock()
response.raise_for_status.return_value = None
response.json.return_value = {"plugins": [{"id": "a"}, {"id": "b"}]}
snapshots = [
MarketplacePluginSnapshot(
org="test",
name=name,
latest_version="1.0.0",
latest_package_identifier=f"test/{name}:1.0.0@checksum",
latest_package_url=f"https://marketplace.test/{name}",
)
for name in ("a", "b")
]
response = _response({"plugins": [snapshot.model_dump() for snapshot in snapshots]})
mocker.patch("core.helper.marketplace.httpx.get", return_value=response)
snapshot_a = SimpleNamespace(plugin_id="plugin-a", model_dump_json=lambda: '{"id":"a"}')
snapshot_b = SimpleNamespace(plugin_id="plugin-b", model_dump_json=lambda: '{"id":"b"}')
validate_mock = mocker.patch(
"core.helper.marketplace.MarketplacePluginSnapshot.model_validate",
side_effect=[snapshot_a, snapshot_b],
)
setex_mock = mocker.patch("core.helper.marketplace.redis_client.setex")
cached_count = fetch_global_plugin_manifest("prefix:", 60)
assert cached_count == 2
assert validate_mock.call_count == 2
setex_mock.assert_any_call(name="prefix:plugin-a", time=60, value='{"id":"a"}')
setex_mock.assert_any_call(name="prefix:plugin-b", time=60, value='{"id":"b"}')
for snapshot in snapshots:
setex_mock.assert_any_call(name=f"prefix:{snapshot.plugin_id}", time=60, value=snapshot.model_dump_json())
def test_fetch_global_plugin_manifest_reports_zero_for_empty_snapshot(mocker: MockerFixture) -> None:
"""An empty snapshot must be reported so callers can fall back instead of upgrading nothing."""
empty_plugins: list[dict[str, object]] = []
response = MagicMock()
response.raise_for_status.return_value = None
response.json.return_value = {"metadata": {"plugin_count": 0}, "plugins": empty_plugins}
response = _response({"metadata": {"plugin_count": 0}, "plugins": empty_plugins})
mocker.patch("core.helper.marketplace.httpx.get", return_value=response)
setex_mock = mocker.patch("core.helper.marketplace.redis_client.setex")
@@ -141,9 +140,7 @@ def test_fetch_global_plugin_manifest_reports_zero_for_empty_snapshot(mocker: Mo
def test_fetch_global_plugin_manifest_reports_zero_when_plugins_key_missing(mocker: MockerFixture) -> None:
response = MagicMock()
response.raise_for_status.return_value = None
response.json.return_value = {"metadata": {"plugin_count": 0}}
response = _response({"metadata": {"plugin_count": 0}})
mocker.patch("core.helper.marketplace.httpx.get", return_value=response)
setex_mock = mocker.patch("core.helper.marketplace.redis_client.setex")

View File

@@ -921,11 +921,11 @@ class TestHandleResumptionRequestNew:
class TestHandlePostRequestNew:
def _make_ctx(self, transport, q, message=None) -> RequestContext:
def _make_ctx(self, transport, q, response: httpx.Response, message=None) -> RequestContext:
if message is None:
message = _make_request_msg("tools/list", 1)
return RequestContext(
client=MagicMock(),
client=httpx.Client(transport=httpx.MockTransport(lambda _request: response)),
headers=transport.request_headers,
session_id=transport.session_id,
session_message=SessionMessage(message),
@@ -934,42 +934,32 @@ class TestHandlePostRequestNew:
sse_read_timeout=60,
)
def _stream_ctx(self, mock_response):
@contextmanager
def _stream[**P](*args: P.args, **kwargs: P.kwargs):
yield mock_response
return _stream
def test_202_returns_immediately_no_queue(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
ctx = self._make_ctx(t, q)
mock_resp = MagicMock()
mock_resp.status_code = 202
ctx.client.stream = self._stream_ctx(mock_resp)
t._handle_post_request(ctx)
response = httpx.Response(202)
ctx = self._make_ctx(t, q, response)
with ctx.client:
t._handle_post_request(ctx)
assert q.empty()
def test_204_returns_immediately_no_queue(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
ctx = self._make_ctx(t, q)
mock_resp = MagicMock()
mock_resp.status_code = 204
ctx.client.stream = self._stream_ctx(mock_resp)
t._handle_post_request(ctx)
response = httpx.Response(204)
ctx = self._make_ctx(t, q, response)
with ctx.client:
t._handle_post_request(ctx)
assert q.empty()
def test_404_sends_session_terminated_error_for_request(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
msg = _make_request_msg("tools/list", 77)
ctx = self._make_ctx(t, q, message=msg)
mock_resp = MagicMock()
mock_resp.status_code = 404
ctx.client.stream = self._stream_ctx(mock_resp)
t._handle_post_request(ctx)
response = httpx.Response(404)
ctx = self._make_ctx(t, q, response, message=msg)
with ctx.client:
t._handle_post_request(ctx)
item = q.get_nowait()
assert isinstance(item, SessionMessage)
assert isinstance(item.message.root, JSONRPCError)
@@ -980,11 +970,10 @@ class TestHandlePostRequestNew:
t = _new_transport(url="http://example.com/mcp/server/abc123/mcp")
q: queue.Queue = queue.Queue()
msg = _make_request_msg("initialize", 1)
ctx = self._make_ctx(t, q, message=msg)
mock_resp = MagicMock()
mock_resp.status_code = 404
ctx.client.stream = self._stream_ctx(mock_resp)
t._handle_post_request(ctx)
response = httpx.Response(404)
ctx = self._make_ctx(t, q, response, message=msg)
with ctx.client:
t._handle_post_request(ctx)
item = q.get_nowait()
assert isinstance(item, SessionMessage)
assert isinstance(item.message.root, JSONRPCError)
@@ -996,56 +985,44 @@ class TestHandlePostRequestNew:
t = _new_transport()
q: queue.Queue = queue.Queue()
msg = _make_notification_msg("some/notification")
ctx = self._make_ctx(t, q, message=msg)
mock_resp = MagicMock()
mock_resp.status_code = 404
ctx.client.stream = self._stream_ctx(mock_resp)
t._handle_post_request(ctx)
response = httpx.Response(404)
ctx = self._make_ctx(t, q, response, message=msg)
with ctx.client:
t._handle_post_request(ctx)
assert q.empty()
def test_json_response_puts_session_message(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
ctx = self._make_ctx(t, q)
response_data = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}).encode()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"content-type": "application/json"}
mock_resp.raise_for_status.return_value = None
mock_resp.read.return_value = response_data
ctx.client.stream = self._stream_ctx(mock_resp)
response = httpx.Response(200, headers={"content-type": "application/json"}, content=response_data)
ctx = self._make_ctx(t, q, response)
t._handle_post_request(ctx)
with ctx.client:
t._handle_post_request(ctx)
assert isinstance(q.get_nowait(), SessionMessage)
def test_json_response_invalid_json_puts_exception(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
ctx = self._make_ctx(t, q)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"content-type": "application/json"}
mock_resp.raise_for_status.return_value = None
mock_resp.read.return_value = b"{bad json!"
ctx.client.stream = self._stream_ctx(mock_resp)
response = httpx.Response(200, headers={"content-type": "application/json"}, content=b"{bad json!")
ctx = self._make_ctx(t, q, response)
t._handle_post_request(ctx)
with ctx.client:
t._handle_post_request(ctx)
assert isinstance(q.get_nowait(), Exception)
def test_unexpected_content_type_puts_value_error(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
ctx = self._make_ctx(t, q)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"content-type": "text/plain"}
mock_resp.raise_for_status.return_value = None
ctx.client.stream = self._stream_ctx(mock_resp)
response = httpx.Response(200, headers={"content-type": "text/plain"})
ctx = self._make_ctx(t, q, response)
t._handle_post_request(ctx)
with ctx.client:
t._handle_post_request(ctx)
item = q.get_nowait()
assert isinstance(item, ValueError)
assert "Unexpected content type" in str(item)
@@ -1054,57 +1031,43 @@ class TestHandlePostRequestNew:
t = _new_transport()
q: queue.Queue = queue.Queue()
msg = _make_request_msg("initialize", 1)
ctx = self._make_ctx(t, q, message=msg)
response_data = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}).encode()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = MagicMock()
headers_dict = {"content-type": "application/json", MCP_SESSION_ID: "new-sid"}
mock_resp.headers.__getitem__ = lambda self, k: headers_dict[k]
mock_resp.headers.get = lambda k, default=None: headers_dict.get(k, default)
mock_resp.raise_for_status.return_value = None
mock_resp.read.return_value = response_data
ctx.client.stream = self._stream_ctx(mock_resp)
response = httpx.Response(
200,
headers={"content-type": "application/json", MCP_SESSION_ID: "new-sid"},
content=response_data,
)
ctx = self._make_ctx(t, q, response, message=msg)
t._handle_post_request(ctx)
with ctx.client:
t._handle_post_request(ctx)
assert t.session_id == "new-sid"
def test_notification_skips_response_processing(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
msg = _make_notification_msg("notifications/something")
ctx = self._make_ctx(t, q, message=msg)
response_data = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}).encode()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"content-type": "application/json"}
mock_resp.raise_for_status.return_value = None
mock_resp.read.return_value = response_data
ctx.client.stream = self._stream_ctx(mock_resp)
response = httpx.Response(200, headers={"content-type": "application/json"}, content=response_data)
ctx = self._make_ctx(t, q, response, message=msg)
t._handle_post_request(ctx)
with ctx.client:
t._handle_post_request(ctx)
assert q.empty()
def test_sse_response_handles_stream(self):
t = _new_transport()
q: queue.Queue = queue.Queue()
ctx = self._make_ctx(t, q)
data = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}})
mock_sse_event = _make_sse_mock("message", data)
response = httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=f"event: message\ndata: {data}\n\n"
)
ctx = self._make_ctx(t, q, response)
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"content-type": "text/event-stream"}
mock_resp.raise_for_status.return_value = None
ctx.client.stream = self._stream_ctx(mock_resp)
with patch("core.mcp.client.streamable_client.EventSource") as MockEventSource:
mock_es_instance = MagicMock()
mock_es_instance.iter_sse.return_value = [mock_sse_event]
MockEventSource.return_value = mock_es_instance
with ctx.client:
t._handle_post_request(ctx)
assert isinstance(q.get_nowait(), SessionMessage)

View File

@@ -1,5 +1,4 @@
from unittest.mock import MagicMock
import httpx
import pytest
from pytest_mock import MockerFixture
@@ -9,7 +8,7 @@ from core.plugin.impl.asset import PluginAssetManager
class TestPluginAssetManager:
def test_fetch_asset_success(self, mocker: MockerFixture):
manager = PluginAssetManager()
response = MagicMock(status_code=200, content=b"asset-bytes")
response = httpx.Response(status_code=200, content=b"asset-bytes")
request_mock = mocker.patch.object(manager, "_request", return_value=response)
result = manager.fetch_asset("tenant-1", "asset-1")
@@ -19,14 +18,14 @@ class TestPluginAssetManager:
def test_fetch_asset_not_found_raises(self, mocker: MockerFixture):
manager = PluginAssetManager()
mocker.patch.object(manager, "_request", return_value=MagicMock(status_code=404, content=b""))
mocker.patch.object(manager, "_request", return_value=httpx.Response(status_code=404, content=b""))
with pytest.raises(ValueError, match="can not found asset asset-1"):
manager.fetch_asset("tenant-1", "asset-1")
def test_extract_asset_success(self, mocker: MockerFixture):
manager = PluginAssetManager()
response = MagicMock(status_code=200, content=b"file-content")
response = httpx.Response(status_code=200, content=b"file-content")
request_mock = mocker.patch.object(manager, "_request", return_value=response)
result = manager.extract_asset("tenant-1", "org/plugin:1", "README.md")
@@ -40,7 +39,7 @@ class TestPluginAssetManager:
def test_extract_asset_not_found_raises(self, mocker: MockerFixture):
manager = PluginAssetManager()
mocker.patch.object(manager, "_request", return_value=MagicMock(status_code=404, content=b""))
mocker.patch.object(manager, "_request", return_value=httpx.Response(status_code=404, content=b""))
with pytest.raises(ValueError, match="can not found asset org/plugin:1, 404"):
manager.extract_asset("tenant-1", "org/plugin:1", "README.md")

View File

@@ -1,14 +1,17 @@
from unittest.mock import Mock, patch
from unittest.mock import patch
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly
from core.model_manager import ModelManager
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, create_plugin_model_runtime
from core.plugin.plugin_service import PluginService
from core.provider_manager import ProviderManager
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
def test_plugin_model_assembly_reuses_single_runtime_across_views():
runtime = Mock(name="runtime")
provider_factory = Mock(name="provider_factory")
provider_manager = Mock(name="provider_manager")
model_manager = Mock(name="model_manager")
runtime = create_plugin_model_runtime(tenant_id="tenant-1", user_id="user-1")
provider_factory = ModelProviderFactory(runtime=runtime)
provider_manager = ProviderManager(model_runtime=runtime)
model_manager = ModelManager(provider_manager=provider_manager)
with (
patch(
@@ -38,8 +41,6 @@ def test_plugin_model_assembly_reuses_single_runtime_across_views():
def test_create_plugin_model_runtime_injects_plugin_service():
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
runtime = create_plugin_model_runtime(tenant_id="tenant-1", user_id="user-1")
assert runtime._plugin_service is PluginService

View File

@@ -9,7 +9,7 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
"""
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import httpx
import pytest
@@ -21,8 +21,17 @@ from core.plugin.impl.exc import PluginDaemonInternalServerError
@pytest.fixture(autouse=True)
def _patch_shared_httpx_client():
"""Patch module-level client methods to delegate to module httpx.request/stream."""
def request[**P](*args: P.args, **kwargs: P.kwargs) -> httpx.Response:
response = httpx.request(*args, **kwargs)
method, url = kwargs["method"], kwargs["url"]
assert isinstance(method, str)
assert isinstance(url, str)
response.request = httpx.Request(method, url)
return response
with (
patch("core.plugin.impl.base._httpx_client.request", side_effect=lambda **kw: httpx.request(**kw)),
patch("core.plugin.impl.base._httpx_client.request", side_effect=request),
patch("core.plugin.impl.base._httpx_client.stream", side_effect=lambda **kw: httpx.stream(**kw)),
):
yield
@@ -64,13 +73,14 @@ class TestPluginEndpointClientDelete:
user_id = "user-456"
endpoint_id = "endpoint-789"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 0,
"message": "success",
"data": True,
}
mock_response = httpx.Response(
200,
json={
"code": 0,
"message": "success",
"data": True,
},
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -100,15 +110,16 @@ class TestPluginEndpointClientDelete:
user_id = "user-456"
endpoint_id = "endpoint-789"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": -1,
"message": (
'{"error_type": "PluginDaemonInternalServerError", '
'"message": "failed to remove endpoint: record not found"}'
),
}
mock_response = httpx.Response(
200,
json={
"code": -1,
"message": (
'{"error_type": "PluginDaemonInternalServerError", '
'"message": "failed to remove endpoint: record not found"}'
),
},
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -137,15 +148,16 @@ class TestPluginEndpointClientDelete:
user_id = "user-456"
endpoint_id = "endpoint-789"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": -1,
"message": (
'{"error_type": "PluginDaemonInternalServerError", '
'"message": "failed to remove endpoint: internal server error"}'
),
}
mock_response = httpx.Response(
200,
json={
"code": -1,
"message": (
'{"error_type": "PluginDaemonInternalServerError", '
'"message": "failed to remove endpoint: internal server error"}'
),
},
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -175,12 +187,13 @@ class TestPluginEndpointClientDelete:
user_id = "user-456"
endpoint_id = "endpoint-789"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": -1,
"message": '{"error_type": "PluginDaemonInternalServerError", "message": "Record Not Found"}',
}
mock_response = httpx.Response(
200,
json={
"code": -1,
"message": '{"error_type": "PluginDaemonInternalServerError", "message": "Record Not Found"}',
},
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -211,24 +224,26 @@ class TestPluginEndpointClientDelete:
endpoint_id = "endpoint-789"
# First call - success
mock_response_success = MagicMock()
mock_response_success.status_code = 200
mock_response_success.json.return_value = {
"code": 0,
"message": "success",
"data": True,
}
mock_response_success = httpx.Response(
200,
json={
"code": 0,
"message": "success",
"data": True,
},
)
# Second call - record not found
mock_response_not_found = MagicMock()
mock_response_not_found.status_code = 200
mock_response_not_found.json.return_value = {
"code": -1,
"message": (
'{"error_type": "PluginDaemonInternalServerError", '
'"message": "failed to remove endpoint: record not found"}'
),
}
mock_response_not_found = httpx.Response(
200,
json={
"code": -1,
"message": (
'{"error_type": "PluginDaemonInternalServerError", '
'"message": "failed to remove endpoint: record not found"}'
),
},
)
with patch("httpx.request", autospec=True) as mock_request:
# Act - first call
@@ -267,12 +282,13 @@ class TestPluginEndpointClientDelete:
user_id = "user-456"
endpoint_id = "endpoint-789"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": -1,
"message": '{"error_type": "PluginDaemonUnauthorizedError", "message": "unauthorized access"}',
}
mock_response = httpx.Response(
200,
json={
"code": -1,
"message": '{"error_type": "PluginDaemonUnauthorizedError", "message": "unauthorized access"}',
},
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert

View File

@@ -13,7 +13,7 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
import json
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import httpx
import pytest
@@ -54,8 +54,17 @@ def _patch_shared_httpx_client():
After refactor, code uses core.plugin.impl.base._httpx_client directly.
Patch its request/stream to route through module-level httpx so existing mocks still apply.
"""
def request[**P](*args: P.args, **kwargs: P.kwargs) -> httpx.Response:
response = httpx.request(*args, **kwargs)
method, url = kwargs["method"], kwargs["url"]
assert isinstance(method, str)
assert isinstance(url, str)
response.request = httpx.Request(method, url)
return response
with (
patch("core.plugin.impl.base._httpx_client.request", side_effect=lambda **kw: httpx.request(**kw)),
patch("core.plugin.impl.base._httpx_client.request", side_effect=request),
patch("core.plugin.impl.base._httpx_client.stream", side_effect=lambda **kw: httpx.stream(**kw)),
):
yield
@@ -124,9 +133,7 @@ class TestPluginRuntimeExecution:
def test_successful_request_execution(self, plugin_client, mock_config):
"""Test successful HTTP request execution."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"result": "success"}
mock_response = httpx.Response(200, json={"result": "success"})
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -143,8 +150,7 @@ class TestPluginRuntimeExecution:
def test_request_with_timeout_configuration(self, plugin_client, mock_config):
"""Test that timeout configuration is properly applied."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -155,7 +161,7 @@ class TestPluginRuntimeExecution:
assert "timeout" in call_kwargs
def test_request_timeout_can_be_scoped_to_current_context(self, plugin_client, mock_config):
mock_response = MagicMock(status_code=200)
mock_response = httpx.Response(200)
with (
patch("core.plugin.impl.base.plugin_daemon_request_timeout", httpx.Timeout(600.0)),
@@ -215,9 +221,7 @@ class TestPluginRuntimeSandboxIsolation:
def test_api_key_authentication(self, plugin_client, mock_config):
"""Test that all requests include API key for authentication."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": True}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": True})
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -234,9 +238,7 @@ class TestPluginRuntimeSandboxIsolation:
class TestResponse(BaseModel):
result: str
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": {"result": "isolated_execution"}}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": {"result": "isolated_execution"}})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -250,10 +252,8 @@ class TestPluginRuntimeSandboxIsolation:
def test_plugin_daemon_unauthorized_error(self, plugin_client, mock_config):
"""Test handling of unauthorized access to plugin daemon."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps({"error_type": "PluginDaemonUnauthorizedError", "message": "Unauthorized access"})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -264,12 +264,10 @@ class TestPluginRuntimeSandboxIsolation:
def test_plugin_permission_denied(self, plugin_client, mock_config):
"""Test handling of permission denied errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps(
{"error_type": "PluginPermissionDeniedError", "message": "Permission denied for this operation"}
)
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -306,8 +304,7 @@ class TestPluginRuntimeResourceLimits:
def test_timeout_configuration_applied(self, plugin_client, mock_config):
"""Test that timeout configuration is properly applied to requests."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -338,12 +335,10 @@ class TestPluginRuntimeResourceLimits:
def test_resource_limit_error_from_daemon(self, plugin_client, mock_config):
"""Test handling of resource limit errors from plugin daemon."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps(
{"error_type": "PluginDaemonInternalServerError", "message": "Resource limit exceeded"}
)
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -378,15 +373,13 @@ class TestPluginRuntimeErrorHandling:
def test_plugin_invoke_rate_limit_error(self, plugin_client, mock_config):
"""Test handling of rate limit errors during plugin invocation."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeRateLimitError",
"message": "Rate limit exceeded",
"args": {"description": "Rate limit exceeded"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -397,15 +390,13 @@ class TestPluginRuntimeErrorHandling:
def test_plugin_invoke_authorization_error(self, plugin_client, mock_config):
"""Test handling of authorization errors during plugin invocation."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeAuthorizationError",
"message": "Invalid credentials",
"args": {"description": "Invalid credentials"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -416,15 +407,13 @@ class TestPluginRuntimeErrorHandling:
def test_plugin_invoke_bad_request_error(self, plugin_client, mock_config):
"""Test handling of bad request errors during plugin invocation."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeBadRequestError",
"message": "Invalid parameters",
"args": {"description": "Invalid parameters"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -435,15 +424,13 @@ class TestPluginRuntimeErrorHandling:
def test_plugin_invoke_connection_error(self, plugin_client, mock_config):
"""Test handling of connection errors during plugin invocation."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeConnectionError",
"message": "Connection to external service failed",
"args": {"description": "Connection to external service failed"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -454,15 +441,13 @@ class TestPluginRuntimeErrorHandling:
def test_plugin_invoke_server_unavailable_error(self, plugin_client, mock_config):
"""Test handling of server unavailable errors during plugin invocation."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeServerUnavailableError",
"message": "Service temporarily unavailable",
"args": {"description": "Service temporarily unavailable"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -473,14 +458,12 @@ class TestPluginRuntimeErrorHandling:
def test_credentials_validation_error(self, plugin_client, mock_config):
"""Test handling of credential validation errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
invoke_error = {
"error_type": "CredentialsValidateFailedError",
"message": "Invalid API key format",
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -491,12 +474,10 @@ class TestPluginRuntimeErrorHandling:
def test_plugin_not_found_error(self, plugin_client, mock_config):
"""Test handling of plugin not found errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps(
{"error_type": "PluginNotFoundError", "message": "Plugin with ID 'test-plugin' not found"}
)
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -507,12 +488,10 @@ class TestPluginRuntimeErrorHandling:
def test_plugin_unique_identifier_error(self, plugin_client, mock_config):
"""Test handling of unique identifier errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps(
{"error_type": "PluginUniqueIdentifierError", "message": "Invalid plugin identifier format"}
)
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -523,12 +502,10 @@ class TestPluginRuntimeErrorHandling:
def test_daemon_bad_request_error(self, plugin_client, mock_config):
"""Test handling of daemon bad request errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps(
{"error_type": "PluginDaemonBadRequestError", "message": "Missing required parameter"}
)
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -539,10 +516,8 @@ class TestPluginRuntimeErrorHandling:
def test_daemon_not_found_error(self, plugin_client, mock_config):
"""Test handling of daemon not found errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps({"error_type": "PluginDaemonNotFoundError", "message": "Resource not found"})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -553,14 +528,12 @@ class TestPluginRuntimeErrorHandling:
def test_generic_plugin_invoke_error(self, plugin_client, mock_config):
"""Test handling of generic plugin invoke errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
# Create a proper nested JSON structure for PluginInvokeError
invoke_error_message = json.dumps(
{"error_type": "UnknownInvokeError", "message": "Generic plugin execution error"}
)
error_message = json.dumps({"error_type": "PluginInvokeError", "message": invoke_error_message})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -571,10 +544,8 @@ class TestPluginRuntimeErrorHandling:
def test_unknown_error_type(self, plugin_client, mock_config):
"""Test handling of unknown error types."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps({"error_type": "UnknownErrorType", "message": "Unknown error occurred"})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -585,10 +556,8 @@ class TestPluginRuntimeErrorHandling:
def test_http_status_error_handling(self, plugin_client, mock_config):
"""Test handling of HTTP status errors."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=mock_response
mock_response = httpx.Response(
500, request=httpx.Request("GET", "http://127.0.0.1:5002/plugin/test-tenant/test")
)
with patch("httpx.request", return_value=mock_response, autospec=True):
@@ -599,9 +568,7 @@ class TestPluginRuntimeErrorHandling:
def test_empty_data_response_error(self, plugin_client, mock_config):
"""Test handling of empty data in successful response."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": None}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -641,9 +608,7 @@ class TestPluginRuntimeCommunication:
value: str
count: int
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": {"value": "test", "count": 42}}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": {"value": "test", "count": 42}})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -669,8 +634,7 @@ class TestPluginRuntimeCommunication:
'data: {"code": 0, "message": "", "data": {"chunk": "third"}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -699,8 +663,7 @@ class TestPluginRuntimeCommunication:
f'data: {{"code": -500, "message": {json.dumps(error_obj)}, "data": null}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -738,9 +701,7 @@ class TestPluginRuntimeCommunication:
status: str
data: dict[str, Any]
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"status": "success", "data": {"key": "value"}}
mock_response = httpx.Response(200, json={"status": "success", "data": {"key": "value"}})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -764,8 +725,7 @@ class TestPluginRuntimeCommunication:
'{"id": 2, "text": "second"}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -796,8 +756,7 @@ class TestPluginRuntimeCommunication:
"",
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -845,8 +804,7 @@ class TestPluginToolManagerIntegration:
'data: {"code": 0, "message": "", "data": {"type": "text", "message": {"text": "Result"}}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -875,8 +833,7 @@ class TestPluginToolManagerIntegration:
'data: {"code": 0, "message": "", "data": {"result": true}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -899,8 +856,7 @@ class TestPluginToolManagerIntegration:
'data: {"code": 0, "message": "", "data": {"result": false}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -923,8 +879,7 @@ class TestPluginToolManagerIntegration:
'data: {"code": 0, "message": "", "data": {"result": true}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -967,16 +922,17 @@ class TestPluginInstallerIntegration:
def test_list_plugins_success(self, installer, mock_config):
"""Test successful plugin listing."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 0,
"message": "",
"data": {
"list": [],
"total": 0,
mock_response = httpx.Response(
200,
json={
"code": 0,
"message": "",
"data": {
"list": [],
"total": 0,
},
},
}
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -988,9 +944,7 @@ class TestPluginInstallerIntegration:
def test_uninstall_plugin_success(self, installer, mock_config):
"""Test successful plugin uninstallation."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": True}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": True})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -1002,9 +956,7 @@ class TestPluginInstallerIntegration:
def test_fetch_plugin_by_identifier_success(self, installer, mock_config):
"""Test successful plugin fetch by identifier."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": True}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": True})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -1040,9 +992,7 @@ class TestPluginRuntimeEdgeCases:
def test_malformed_json_response(self, plugin_client, mock_config):
"""Test handling of malformed JSON responses."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
mock_response = httpx.Response(200, content=b"invalid JSON")
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -1052,10 +1002,8 @@ class TestPluginRuntimeEdgeCases:
def test_invalid_response_structure(self, plugin_client, mock_config):
"""Test handling of invalid response structure."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
# Missing required fields in response
mock_response.json.return_value = {"invalid": "structure"}
mock_response = httpx.Response(200, json={"invalid": "structure"})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -1070,8 +1018,7 @@ class TestPluginRuntimeEdgeCases:
"data: {invalid json}",
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1094,8 +1041,7 @@ class TestPluginRuntimeEdgeCases:
def test_request_with_bytes_data(self, plugin_client, mock_config):
"""Test request with bytes data."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -1108,8 +1054,7 @@ class TestPluginRuntimeEdgeCases:
def test_request_with_files(self, plugin_client, mock_config):
"""Test request with file upload."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
files = {"file": ("test.txt", b"file content", "text/plain")}
@@ -1124,8 +1069,7 @@ class TestPluginRuntimeEdgeCases:
def test_streaming_empty_response(self, plugin_client, mock_config):
"""Test streaming with empty response."""
# Arrange
mock_response = MagicMock()
mock_response.iter_lines.return_value = []
mock_response = httpx.Response(200, content=b"")
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1144,8 +1088,7 @@ class TestPluginRuntimeEdgeCases:
f'data: {{"code": -500, "message": {json.dumps(error_obj)}, "data": null}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1164,9 +1107,7 @@ class TestPluginRuntimeEdgeCases:
def test_non_json_error_message(self, plugin_client, mock_config):
"""Test handling of non-JSON error message."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": -1, "message": "Plain text error message", "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": "Plain text error message", "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -1201,9 +1142,7 @@ class TestPluginRuntimeAdvancedScenarios:
def test_multiple_sequential_requests(self, plugin_client, mock_config):
"""Test multiple sequential requests to the same endpoint."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": True}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": True})
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -1230,9 +1169,7 @@ class TestPluginRuntimeAdvancedScenarios:
],
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": complex_data}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": complex_data})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -1259,8 +1196,7 @@ class TestPluginRuntimeAdvancedScenarios:
'{"code": 0, "message": "", "data": {"type": "complete", "data": {"result": "success"}}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1289,8 +1225,7 @@ class TestPluginRuntimeAdvancedScenarios:
call_count += 1
if call_count < 3:
raise httpx.RequestError("Temporary failure")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
return mock_response
with patch("httpx.request", side_effect=side_effect, autospec=True):
@@ -1314,8 +1249,7 @@ class TestPluginRuntimeAdvancedScenarios:
"X-Tenant-ID": "tenant-456",
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -1340,8 +1274,7 @@ class TestPluginRuntimeAdvancedScenarios:
f'{{"code": 0, "message": "", "data": {{"chunk_id": {i}, "data": "{large_data}"}}}}' for i in range(10)
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1386,8 +1319,7 @@ class TestPluginRuntimeSecurityAndValidation:
def test_api_key_header_always_present(self, plugin_client, mock_config):
"""Test that API key header is always included in requests."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -1407,9 +1339,7 @@ class TestPluginRuntimeSecurityAndValidation:
"credentials": {"token": "secret-token"},
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": True}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": True})
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -1428,10 +1358,8 @@ class TestPluginRuntimeSecurityAndValidation:
def test_unauthorized_access_with_invalid_key(self, plugin_client, mock_config):
"""Test handling of unauthorized access with invalid API key."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps({"error_type": "PluginDaemonUnauthorizedError", "message": "Invalid API key"})
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -1447,12 +1375,10 @@ class TestPluginRuntimeSecurityAndValidation:
"limit": 0, # Invalid zero limit
}
mock_response = MagicMock()
mock_response.status_code = 200
error_message = json.dumps(
{"error_type": "PluginDaemonBadRequestError", "message": "Invalid parameters: page must be positive"}
)
mock_response.json.return_value = {"code": -1, "message": error_message, "data": None}
mock_response = httpx.Response(200, json={"code": -1, "message": error_message, "data": None})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert
@@ -1465,8 +1391,7 @@ class TestPluginRuntimeSecurityAndValidation:
def test_content_type_header_validation(self, plugin_client, mock_config):
"""Test that Content-Type header is properly set for JSON requests."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response = httpx.Response(200)
with patch("httpx.request", return_value=mock_response, autospec=True) as mock_request:
# Act
@@ -1515,8 +1440,7 @@ class TestPluginRuntimePerformanceScenarios:
f'{{"code": 0, "message": "", "data": {{"index": {i}, "value": "chunk_{i}"}}}}' for i in range(100)
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1550,8 +1474,7 @@ class TestPluginRuntimePerformanceScenarios:
stream_data = [f'{{"code": 0, "message": "", "data": {{"data": "chunk_{i}"}}}}' for i in range(10)]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1577,9 +1500,7 @@ class TestPluginRuntimePerformanceScenarios:
def test_concurrent_request_simulation(self, plugin_client, mock_config):
"""Test simulation of concurrent requests (sequential execution in test)."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": True}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": True})
request_results = []
@@ -1637,8 +1558,7 @@ class TestPluginToolManagerAdvanced:
),
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1666,8 +1586,7 @@ class TestPluginToolManagerAdvanced:
'data: {"code": 0, "message": "", "data": {"type": "text", "message": {"text": "Context-aware result"}}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1698,8 +1617,7 @@ class TestPluginToolManagerAdvanced:
'data: {"code": 0, "message": "", "data": {"parameters": []}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1729,8 +1647,7 @@ class TestPluginToolManagerAdvanced:
'data: {"code": 0, "message": "", "data": {"result": true}}',
]
mock_response = MagicMock()
mock_response.iter_lines.return_value = [line.encode("utf-8") for line in stream_data]
mock_response = httpx.Response(200, text="\n".join(stream_data))
with patch("httpx.stream", autospec=True) as mock_stream:
mock_stream.return_value.__enter__.return_value = mock_response
@@ -1774,28 +1691,29 @@ class TestPluginInstallerAdvanced:
"""Test successful plugin package upload."""
# Arrange
plugin_package = b"fake-plugin-package-data"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 0,
"message": "",
"data": {
"unique_identifier": "test-org/test-plugin",
"manifest": {
"version": "1.0.0",
"author": "test-org",
"name": "test-plugin",
"description": {"en_US": "Test plugin"},
"icon": "icon.png",
"label": {"en_US": "Test Plugin"},
"created_at": "2024-01-01T00:00:00Z",
"resource": {"memory": 256},
"plugins": {},
"meta": {},
mock_response = httpx.Response(
200,
json={
"code": 0,
"message": "",
"data": {
"unique_identifier": "test-org/test-plugin",
"manifest": {
"version": "1.0.0",
"author": "test-org",
"name": "test-plugin",
"description": {"en_US": "Test plugin"},
"icon": "icon.png",
"label": {"en_US": "Test Plugin"},
"created_at": "2024-01-01T00:00:00Z",
"resource": {"memory": 256},
"plugins": {},
"meta": {},
},
"verification": None,
},
"verification": None,
},
}
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -1807,13 +1725,14 @@ class TestPluginInstallerAdvanced:
def test_fetch_plugin_readme_success(self, installer, mock_config):
"""Test successful plugin readme fetch."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 0,
"message": "",
"data": {"content": "# Plugin README\n\nThis is a test plugin.", "language": "en"},
}
mock_response = httpx.Response(
200,
json={
"code": 0,
"message": "",
"data": {"content": "# Plugin README\n\nThis is a test plugin.", "language": "en"},
},
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -1826,13 +1745,7 @@ class TestPluginInstallerAdvanced:
def test_fetch_plugin_readme_not_found(self, installer, mock_config):
"""Test plugin readme fetch when readme doesn't exist."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 404
def raise_for_status():
raise httpx.HTTPStatusError("Not Found", request=MagicMock(), response=mock_response)
mock_response.raise_for_status = raise_for_status
mock_response = httpx.Response(404)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act & Assert - Should raise PluginDaemonClientSideError for 404
@@ -1842,16 +1755,17 @@ class TestPluginInstallerAdvanced:
def test_list_plugins_with_pagination(self, installer, mock_config):
"""Test plugin listing with pagination."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"code": 0,
"message": "",
"data": {
"list": [],
"total": 50,
mock_response = httpx.Response(
200,
json={
"code": 0,
"message": "",
"data": {
"list": [],
"total": 50,
},
},
}
)
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act
@@ -1871,9 +1785,7 @@ class TestPluginInstallerAdvanced:
GenericProviderID("langgenius/plugin2/provider2"),
]
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"code": 0, "message": "", "data": [True, False]}
mock_response = httpx.Response(200, json={"code": 0, "message": "", "data": [True, False]})
with patch("httpx.request", return_value=mock_response, autospec=True):
# Act

View File

@@ -4,8 +4,10 @@ Unit tests for API Token Cache module.
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from models.enums import ApiTokenType
from models.model import ApiToken
from services.api_token_service import (
CACHE_KEY_PREFIX,
CACHE_NULL_TTL_SECONDS,
@@ -20,11 +22,11 @@ class TestApiTokenCache:
def setup_method(self):
"""Setup test fixtures."""
self.mock_token = MagicMock()
self.mock_token = ApiToken()
self.mock_token.id = "test-token-id-123"
self.mock_token.app_id = "test-app-id-456"
self.mock_token.tenant_id = "test-tenant-id-789"
self.mock_token.type = "app"
self.mock_token.type = ApiTokenType.APP
self.mock_token.token = "test-token-value-abc"
self.mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
self.mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
@@ -54,11 +56,11 @@ class TestApiTokenCache:
def test_serialize_token_with_nulls(self):
"""Test token serialization with None values."""
mock_token = MagicMock()
mock_token = ApiToken()
mock_token.id = "test-id"
mock_token.app_id = None
mock_token.tenant_id = None
mock_token.type = "dataset"
mock_token.type = ApiTokenType.DATASET
mock_token.token = "test-token"
mock_token.last_used_at = None
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
@@ -213,12 +215,12 @@ class TestApiTokenCacheIntegration:
@patch("services.api_token_service.redis_client")
def test_full_cache_lifecycle(self, mock_redis):
"""Test complete cache lifecycle: set -> get -> delete."""
# Setup mock token
mock_token = MagicMock()
# Use the persisted model's fields for the cache lifecycle.
mock_token = ApiToken()
mock_token.id = "id-123"
mock_token.app_id = "app-456"
mock_token.tenant_id = "tenant-789"
mock_token.type = "app"
mock_token.type = ApiTokenType.APP
mock_token.token = "token-abc"
mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)

View File

@@ -4,12 +4,13 @@ from __future__ import annotations
import uuid
from datetime import UTC, datetime
from unittest.mock import MagicMock
import pytest
from redis import Redis
from sqlalchemy.orm import sessionmaker
from constants.oauth_bearer import Scope, SubjectType, TokenType
from libs.oauth_bearer import ResolvedRow, _TokenTypeResolver
from libs.oauth_bearer import OAuthAccessTokenResolver, ResolvedRow, _TokenTypeResolver
def _row(account_id: uuid.UUID | None) -> ResolvedRow:
@@ -26,7 +27,7 @@ def _row(account_id: uuid.UUID | None) -> ResolvedRow:
@pytest.mark.parametrize("token_type", list(TokenType), ids=lambda t: t.value)
def test_a_row_matches_its_subject_only_when_its_account_binding_agrees(token_type: TokenType) -> None:
"""Every token type, present or future, is held to the binding its subject declares."""
resolver = _TokenTypeResolver(MagicMock(), token_type)
resolver = _TokenTypeResolver(OAuthAccessTokenResolver(sessionmaker(), Redis()), token_type)
bound = token_type.subject.bound_to_account
assert resolver._matches_subject(_row(uuid.uuid4())) is bound
assert resolver._matches_subject(_row(None)) is not bound

View File

@@ -1,24 +1,26 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from redis import Redis
from sqlalchemy.orm import sessionmaker
from constants.oauth_bearer import TokenType
from libs.oauth_bearer import BearerAuthenticator, InvalidBearerError
from libs.oauth_bearer import BearerAuthenticator, InvalidBearerError, OAuthAccessTokenResolver, Resolver
def _authenticator_with(resolver) -> BearerAuthenticator:
def _authenticator_with(resolver: Resolver) -> BearerAuthenticator:
return BearerAuthenticator({TokenType.OAUTH_ACCOUNT: resolver})
@patch("libs.oauth_bearer.enforce_bearer_rate_limit")
def test_rate_limit_called_before_resolve(rl):
def test_rate_limit_called_before_resolve(rl, monkeypatch: pytest.MonkeyPatch):
call_order: list[str] = []
rl.side_effect = lambda _h: call_order.append("rl")
resolver = MagicMock()
resolver.resolve.side_effect = lambda _h: call_order.append("resolve") or None
auth = _authenticator_with(resolver)
resolver = OAuthAccessTokenResolver(sessionmaker(), Redis())
monkeypatch.setattr(resolver, "cache_get", lambda _h: call_order.append("resolve") or "invalid")
auth = _authenticator_with(resolver.for_token_type(TokenType.OAUTH_ACCOUNT))
with pytest.raises(InvalidBearerError):
auth.authenticate("dfoa_xyz")
@@ -28,10 +30,10 @@ def test_rate_limit_called_before_resolve(rl):
@pytest.mark.parametrize("token", ["zzz_xyz", "dfoa_revoked"], ids=["unknown prefix", "revoked"])
@patch("libs.oauth_bearer.enforce_bearer_rate_limit")
def test_every_refusal_raises_the_generic_invalid_bearer(rl, token: str):
resolver = MagicMock()
resolver.resolve.return_value = None
auth = _authenticator_with(resolver)
def test_every_refusal_raises_the_generic_invalid_bearer(rl, token: str, monkeypatch: pytest.MonkeyPatch):
resolver = OAuthAccessTokenResolver(sessionmaker(), Redis())
monkeypatch.setattr(resolver, "cache_get", lambda _h: "invalid")
auth = _authenticator_with(resolver.for_token_type(TokenType.OAUTH_ACCOUNT))
with pytest.raises(InvalidBearerError) as exc:
auth.authenticate(token)
assert str(exc.value) == "invalid_bearer"

View File

@@ -1,5 +1,5 @@
import urllib.parse
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import httpx
import pytest
@@ -18,12 +18,6 @@ class BaseOAuthTest:
"redirect_uri": "http://localhost/callback",
}
@pytest.fixture
def mock_response(self):
response = MagicMock()
response.json.return_value = {}
return response
def parse_auth_url(self, url):
"""Helper to parse authorization URL"""
parsed = urllib.parse.urlparse(url)
@@ -87,11 +81,8 @@ class TestGitHubOAuth(BaseOAuthTest):
],
)
@patch("libs.oauth._http_client.post", autospec=True)
def test_should_retrieve_access_token(
self, mock_post, oauth, mock_response, response_data, expected_token, should_raise
):
mock_response.json.return_value = response_data
mock_post.return_value = mock_response
def test_should_retrieve_access_token(self, mock_post, oauth, response_data, expected_token, should_raise):
mock_post.return_value = httpx.Response(200, json=response_data)
if should_raise:
with pytest.raises(ValueError) as exc_info:
@@ -129,11 +120,8 @@ class TestGitHubOAuth(BaseOAuthTest):
)
@patch("libs.oauth._http_client.get", autospec=True)
def test_should_retrieve_user_info_correctly(self, mock_get, oauth, user_data, email_data, expected_email):
user_response = MagicMock()
user_response.json.return_value = user_data
email_response = MagicMock()
email_response.json.return_value = email_data
user_response = httpx.Response(200, json=user_data, request=httpx.Request("GET", oauth._USER_INFO_URL))
email_response = httpx.Response(200, json=email_data, request=httpx.Request("GET", oauth._EMAIL_INFO_URL))
mock_get.side_effect = [user_response, email_response]
@@ -148,13 +136,11 @@ class TestGitHubOAuth(BaseOAuthTest):
@patch("libs.oauth._http_client.get", autospec=True)
def test_should_skip_email_endpoint_when_profile_email_present(self, mock_get, oauth):
"""When the /user profile already contains an email, do not call /user/emails."""
user_response = MagicMock()
user_response.json.return_value = {
"id": 12345,
"login": "testuser",
"name": "Test User",
"email": "profile@example.com",
}
user_response = httpx.Response(
200,
json={"id": 12345, "login": "testuser", "name": "Test User", "email": "profile@example.com"},
request=httpx.Request("GET", oauth._USER_INFO_URL),
)
mock_get.return_value = user_response
user_info = oauth.get_user_info("test_token")
@@ -182,11 +168,8 @@ class TestGitHubOAuth(BaseOAuthTest):
)
@patch("libs.oauth._http_client.get", autospec=True)
def test_should_use_noreply_email_when_no_usable_email(self, mock_get, oauth, user_data, email_data):
user_response = MagicMock()
user_response.json.return_value = user_data
email_response = MagicMock()
email_response.json.return_value = email_data
user_response = httpx.Response(200, json=user_data, request=httpx.Request("GET", oauth._USER_INFO_URL))
email_response = httpx.Response(200, json=email_data, request=httpx.Request("GET", oauth._EMAIL_INFO_URL))
mock_get.side_effect = [user_response, email_response]
@@ -197,13 +180,12 @@ class TestGitHubOAuth(BaseOAuthTest):
@patch("libs.oauth._http_client.get", autospec=True)
def test_should_use_noreply_email_when_email_endpoint_fails(self, mock_get, oauth):
user_response = MagicMock()
user_response.json.return_value = {"id": 12345, "login": "testuser", "name": "Test User"}
email_response = MagicMock()
email_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Forbidden", request=MagicMock(), response=MagicMock()
user_response = httpx.Response(
200,
json={"id": 12345, "login": "testuser", "name": "Test User"},
request=httpx.Request("GET", oauth._USER_INFO_URL),
)
email_response = httpx.Response(403, request=httpx.Request("GET", oauth._EMAIL_INFO_URL))
mock_get.side_effect = [user_response, email_response]
@@ -278,10 +260,9 @@ class TestGoogleOAuth(BaseOAuthTest):
)
@patch("libs.oauth._http_client.post", autospec=True)
def test_should_retrieve_access_token(
self, mock_post, oauth, oauth_config, mock_response, response_data, expected_token, should_raise
self, mock_post, oauth, oauth_config, response_data, expected_token, should_raise
):
mock_response.json.return_value = response_data
mock_post.return_value = mock_response
mock_post.return_value = httpx.Response(200, json=response_data)
if should_raise:
with pytest.raises(ValueError) as exc_info:
@@ -311,9 +292,8 @@ class TestGoogleOAuth(BaseOAuthTest):
],
)
@patch("libs.oauth._http_client.get", autospec=True)
def test_should_retrieve_user_info_correctly(self, mock_get, oauth, mock_response, user_data, expected_name):
mock_response.json.return_value = user_data
mock_get.return_value = mock_response
def test_should_retrieve_user_info_correctly(self, mock_get, oauth, user_data, expected_name):
mock_get.return_value = httpx.Response(200, json=user_data, request=httpx.Request("GET", oauth._USER_INFO_URL))
user_info = oauth.get_user_info("test_token")
@@ -333,13 +313,21 @@ class TestGoogleOAuth(BaseOAuthTest):
)
@patch("libs.oauth._http_client.get", autospec=True)
def test_should_handle_http_errors(self, mock_get, oauth, exception_type):
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = exception_type("Error")
mock_get.return_value = mock_response
mock_get.side_effect = exception_type("Error")
with pytest.raises(exception_type):
oauth.get_raw_user_info("invalid_token")
@patch("libs.oauth._http_client.get", autospec=True)
def test_should_handle_http_status_errors(self, mock_get, oauth):
response = httpx.Response(401, request=httpx.Request("GET", oauth._USER_INFO_URL))
mock_get.return_value = response
with pytest.raises(httpx.HTTPStatusError) as exc_info:
oauth.get_raw_user_info("invalid_token")
assert exc_info.value.response is response
class TestOAuthUserInfo:
@pytest.mark.parametrize(

View File

@@ -3,9 +3,10 @@
from __future__ import annotations
from datetime import timedelta
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from redis import Redis
from werkzeug.exceptions import TooManyRequests
from libs.helper import RateLimiter
@@ -18,8 +19,8 @@ from libs.rate_limit import (
@pytest.fixture
def mock_redis():
return MagicMock()
def redis_client():
return Redis()
def test_limit_bearer_per_token_uses_60_per_minute_default():
@@ -27,48 +28,72 @@ def test_limit_bearer_per_token_uses_60_per_minute_default():
assert LIMIT_BEARER_PER_TOKEN.window == timedelta(minutes=1)
def test_seconds_until_available_returns_remaining_window(mock_redis):
def test_seconds_until_available_returns_remaining_window(redis_client, monkeypatch: pytest.MonkeyPatch):
"""ZSET oldest entry score = 100; window = 60s; now = 130s → remaining = 30s."""
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=mock_redis)
mock_redis.zrange.return_value = [(b"member-1", 100.0)]
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
monkeypatch.setattr(redis_client, "execute_command", lambda *_args, **_kwargs: [(b"member-1", 100.0)])
with patch("libs.helper.time.time", return_value=130):
assert rl.seconds_until_available("k1") == 30
def test_seconds_until_available_floor_one_second(mock_redis):
def test_seconds_until_available_floor_one_second(redis_client, monkeypatch: pytest.MonkeyPatch):
"""Even when math says <1s remaining, return at least 1 so client backs off measurably."""
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=mock_redis)
mock_redis.zrange.return_value = [(b"member-1", 119.5)]
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
monkeypatch.setattr(redis_client, "execute_command", lambda *_args, **_kwargs: [(b"member-1", 119.5)])
with patch("libs.helper.time.time", return_value=180):
# window expired (180 > 119.5+60=179.5 by 0.5s) — bucket is actually free now
# but this method only called when is_rate_limited() == True; defensive floor.
assert rl.seconds_until_available("k1") >= 1
def test_seconds_until_available_empty_bucket(mock_redis):
def test_seconds_until_available_empty_bucket(redis_client, monkeypatch: pytest.MonkeyPatch):
"""No entries → 1s sentinel (defensive; should not be reached when limited)."""
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=mock_redis)
mock_redis.zrange.return_value = []
rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
monkeypatch.setattr(redis_client, "execute_command", lambda *_args, **_kwargs: [])
assert rl.seconds_until_available("k1") == 1
@patch("libs.rate_limit._build_limiter")
def test_enforce_bearer_rate_limit_passes_under_limit(mock_build):
limiter = MagicMock()
limiter.is_rate_limited.return_value = False
def test_enforce_bearer_rate_limit_passes_under_limit(mock_build, redis_client, monkeypatch: pytest.MonkeyPatch):
commands: list[tuple] = []
def execute_command(*args, **_kwargs):
commands.append(args)
return 0
monkeypatch.setattr(redis_client, "execute_command", execute_command)
limiter = RateLimiter(
"rl:bearer:token", max_attempts=60, time_window=60, member_factory=lambda: "member-1", redis_client=redis_client
)
mock_build.return_value = limiter
enforce_bearer_rate_limit("hash-1")
limiter.increment_rate_limit.assert_called_once_with("token:hash-1")
with patch("libs.helper.time.time", return_value=100):
enforce_bearer_rate_limit("hash-1")
assert commands == [
("ZREMRANGEBYSCORE", "rl:bearer:token:token:hash-1", "-inf", 40),
("ZCARD", "rl:bearer:token:token:hash-1"),
("ZADD", "rl:bearer:token:token:hash-1", 100, "member-1"),
("EXPIRE", "rl:bearer:token:token:hash-1", 120),
]
@patch("libs.rate_limit._build_limiter")
def test_enforce_bearer_rate_limit_raises_429_with_retry_after(mock_build):
limiter = MagicMock()
limiter.is_rate_limited.return_value = True
limiter.seconds_until_available.return_value = 23
def test_enforce_bearer_rate_limit_raises_429_with_retry_after(
mock_build, redis_client, monkeypatch: pytest.MonkeyPatch
):
commands: list[tuple] = []
def execute_command(*args, **_kwargs):
commands.append(args)
if args[0] == "ZRANGE":
return [(b"member-1", 63.0)]
return 60 if args[0] == "ZCARD" else 0
monkeypatch.setattr(redis_client, "execute_command", execute_command)
limiter = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=redis_client)
mock_build.return_value = limiter
with pytest.raises(TooManyRequests) as exc:
with patch("libs.helper.time.time", return_value=100), pytest.raises(TooManyRequests) as exc:
enforce_bearer_rate_limit("hash-1")
assert [command[0] for command in commands] == ["ZREMRANGEBYSCORE", "ZCARD", "ZRANGE"]
# Header-only TooManyRequests: the canonical ErrorBody (code "too_many_requests") is built
# later by the openapi formatter; here we only assert the advisory header rides along.
assert dict(exc.value.headers).get("Retry-After") == "23"

View File

@@ -1,5 +1,5 @@
import logging
from unittest.mock import Mock, patch
from unittest.mock import patch
import pytest
from werkzeug.exceptions import Forbidden
@@ -9,6 +9,7 @@ from libs.workspace_permission import (
check_workspace_member_invite_permission,
check_workspace_owner_transfer_permission,
)
from services.enterprise.enterprise_service import WorkspacePermission
class TestWorkspacePermissionHelper:
@@ -37,8 +38,7 @@ class TestWorkspacePermissionHelper:
"""Enterprise edition should block invitations when workspace policy is False."""
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
mock_permission = Mock()
mock_permission.allow_member_invite = False
mock_permission = WorkspacePermission(workspaceId="test-workspace-id", allowMemberInvite=False)
mock_enterprise_service.WorkspacePermissionService.get_permission.return_value = mock_permission
with pytest.raises(Forbidden, match="Workspace policy prohibits member invitations"):
@@ -51,8 +51,7 @@ class TestWorkspacePermissionHelper:
"""Enterprise edition should allow invitations when workspace policy is True."""
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
mock_permission = Mock()
mock_permission.allow_member_invite = True
mock_permission = WorkspacePermission(workspaceId="test-workspace-id", allowMemberInvite=True)
mock_enterprise_service.WorkspacePermissionService.get_permission.return_value = mock_permission
# Should not raise
@@ -74,8 +73,7 @@ class TestWorkspacePermissionHelper:
def test_enterprise_blocks_transfer_when_disabled(self, mock_enterprise_service, config_overrides):
"""Enterprise edition should block transfer when workspace policy is False."""
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
mock_permission = Mock()
mock_permission.allow_owner_transfer = False # Workspace policy blocks
mock_permission = WorkspacePermission(workspaceId="test-workspace-id", allowOwnerTransfer=False)
mock_enterprise_service.WorkspacePermissionService.get_permission.return_value = mock_permission
with pytest.raises(Forbidden, match="Workspace policy prohibits ownership transfer"):
@@ -87,8 +85,7 @@ class TestWorkspacePermissionHelper:
def test_enterprise_allows_transfer_when_both_enabled(self, mock_enterprise_service, config_overrides):
"""Enterprise edition should allow transfer when both billing and workspace policy allow."""
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
mock_permission = Mock()
mock_permission.allow_owner_transfer = True # Workspace policy allows
mock_permission = WorkspacePermission(workspaceId="test-workspace-id", allowOwnerTransfer=True)
mock_enterprise_service.WorkspacePermissionService.get_permission.return_value = mock_permission
# Should not raise

View File

@@ -1,10 +1,14 @@
"""Unit tests for the data-source API-key auth application service and ports."""
from datetime import datetime
from unittest.mock import MagicMock
from machinery.context import RequestContext
from services.data_source.auth.api_key_service import DataSourceApiKeyAuthService
from services.data_source.auth.api_key_service import (
ApiKeyAuthCredentialEncryptor,
ApiKeyAuthCredentialValidator,
DataSourceApiKeyAuthBindingRepository,
DataSourceApiKeyAuthService,
)
from services.data_source.entities.api_key_auth import (
DataSourceApiKeyAuthBindingCreate,
DataSourceApiKeyAuthBindingRecord,
@@ -12,6 +16,51 @@ from services.data_source.entities.api_key_auth import (
)
class RecordingBindings(DataSourceApiKeyAuthBindingRepository):
def __init__(self, events: list[str]) -> None:
self.events = events
self.records: list[DataSourceApiKeyAuthBindingRecord] = []
self.list_calls: list[str] = []
self.create_calls: list[tuple[str, str, str, DataSourceApiKeyAuthCredentials]] = []
self.delete_calls: list[tuple[str, str]] = []
def list_enabled(self, workspace_id: str) -> list[DataSourceApiKeyAuthBindingRecord]:
self.list_calls.append(workspace_id)
return self.records
def create(
self, workspace_id: str, category: str, provider: str, credentials: DataSourceApiKeyAuthCredentials
) -> None:
self.events.append("create")
self.create_calls.append((workspace_id, category, provider, credentials))
def delete(self, workspace_id: str, binding_id: str) -> None:
self.delete_calls.append((workspace_id, binding_id))
class RecordingValidator(ApiKeyAuthCredentialValidator):
def __init__(self, events: list[str]) -> None:
self.events = events
self.accepted = True
self.calls: list[tuple[str, DataSourceApiKeyAuthCredentials]] = []
def validate(self, provider: str, credentials: DataSourceApiKeyAuthCredentials) -> bool:
self.events.append("validate")
self.calls.append((provider, credentials))
return self.accepted
class RecordingEncryptor(ApiKeyAuthCredentialEncryptor):
def __init__(self, events: list[str]) -> None:
self.events = events
self.calls: list[tuple[str, str]] = []
def encrypt(self, workspace_id: str, token: str) -> str:
self.events.append("encrypt")
self.calls.append((workspace_id, token))
return "encrypted-secret"
def _context() -> RequestContext:
return RequestContext(
request_id="request-1",
@@ -33,10 +82,11 @@ def _command() -> DataSourceApiKeyAuthBindingCreate:
)
def _service() -> tuple[DataSourceApiKeyAuthService, MagicMock, MagicMock, MagicMock]:
bindings = MagicMock()
validator = MagicMock()
encryptor = MagicMock()
def _service() -> tuple[DataSourceApiKeyAuthService, RecordingBindings, RecordingValidator, RecordingEncryptor]:
events: list[str] = []
bindings = RecordingBindings(events)
validator = RecordingValidator(events)
encryptor = RecordingEncryptor(events)
return (
DataSourceApiKeyAuthService(bindings=bindings, validator=validator, encryptor=encryptor),
bindings,
@@ -55,43 +105,45 @@ def test_list_bindings_uses_active_workspace() -> None:
created_at=datetime(2026, 1, 1),
updated_at=datetime(2026, 1, 2),
)
bindings.list_enabled.return_value = [record]
bindings.records = [record]
assert service.list_bindings(_context()) == (record,)
bindings.list_enabled.assert_called_once_with("workspace-1")
assert bindings.list_calls == ["workspace-1"]
def test_create_binding_validates_before_encrypting_and_persisting() -> None:
service, bindings, validator, encryptor = _service()
command = _command()
validator.validate.return_value = True
encryptor.encrypt.return_value = "encrypted-secret"
service.create_binding(_context(), command)
validator.validate.assert_called_once_with("firecrawl", command.credentials)
encryptor.encrypt.assert_called_once_with("workspace-1", "secret")
bindings.create.assert_called_once_with(
"workspace-1",
"search",
"firecrawl",
DataSourceApiKeyAuthCredentials(
auth_type="bearer",
api_key="encrypted-secret",
options={"base_url": "https://example.com"},
),
)
assert validator.calls == [("firecrawl", command.credentials)]
assert encryptor.calls == [("workspace-1", "secret")]
assert bindings.events == ["validate", "encrypt", "create"]
assert bindings.create_calls == [
(
"workspace-1",
"search",
"firecrawl",
DataSourceApiKeyAuthCredentials(
auth_type="bearer",
api_key="encrypted-secret",
options={"base_url": "https://example.com"},
),
)
]
assert command.credentials.api_key == "secret"
def test_create_binding_does_not_persist_rejected_credentials() -> None:
service, bindings, validator, encryptor = _service()
validator.validate.return_value = False
validator.accepted = False
service.create_binding(_context(), _command())
encryptor.encrypt.assert_not_called()
bindings.create.assert_not_called()
assert bindings.events == ["validate"]
assert encryptor.calls == []
assert bindings.create_calls == []
def test_delete_binding_scopes_to_active_workspace() -> None:
@@ -99,4 +151,4 @@ def test_delete_binding_scopes_to_active_workspace() -> None:
service.delete_binding(_context(), "binding-1")
bindings.delete.assert_called_once_with("workspace-1", "binding-1")
assert bindings.delete_calls == [("workspace-1", "binding-1")]

View File

@@ -1,8 +1,11 @@
import json
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from typing import Never
from unittest.mock import patch
import httpx
import pytest
import yaml
@@ -14,6 +17,7 @@ from services.recommended_app_catalog_gateway import (
)
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
RecommendedAppDetailRecord,
RecommendedAppInfoRecord,
RecommendedAppRecord,
@@ -101,6 +105,48 @@ def _expected_page(*, categories: tuple[str, ...] = ("Workflow",)) -> Recommende
)
@dataclass
class RecordingCatalog(RecommendedAppCatalogQuery):
recommended_pages: list[RecommendedAppCatalogPage] = field(default_factory=lambda: [_expected_page()])
learn_dify_page: RecommendedAppCatalogPage = field(default_factory=_expected_page)
detail: RecommendedAppDetailRecord | None = None
member: bool = False
calls: list[tuple[str, str]] = field(default_factory=list)
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
self.calls.append(("recommended", language))
return self.recommended_pages.pop(0) if len(self.recommended_pages) > 1 else self.recommended_pages[0]
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
self.calls.append(("learn_dify", language))
return self.learn_dify_page
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
self.calls.append(("detail", app_id))
return self.detail
def contains(self, app_id: str) -> bool:
self.calls.append(("contains", app_id))
return self.member
def _fetch_timeout(_key: str) -> Never:
raise ConnectionError("timeout")
def _install_http_get(
monkeypatch: pytest.MonkeyPatch, response: httpx.Response
) -> list[tuple[str, dict[str, str], httpx.Timeout]]:
requests: list[tuple[str, dict[str, str], httpx.Timeout]] = []
def get(url: str, *, headers: dict[str, str], timeout: httpx.Timeout) -> httpx.Response:
requests.append((url, headers, timeout))
return response
monkeypatch.setattr(gateway_module.httpx, "get", get)
return requests
class TestBuiltinRecommendedAppCatalogGateway:
def test_maps_bundled_catalog(self) -> None:
gateway = BuiltinRecommendedAppCatalogGateway()
@@ -248,8 +294,8 @@ class TestRemoteRecommendedAppCatalogGateway:
gateway = RemoteRecommendedAppCatalogGateway()
payload = _page_payload("app-2", "app-1")
payload["categories"] = ["Writing", "Agent"]
monkeypatch.setattr(gateway, "_fetch_page", MagicMock(return_value=payload))
monkeypatch.setattr(gateway, "_fetch_learn_dify_page", MagicMock(return_value=payload))
monkeypatch.setattr(gateway, "_fetch_page", lambda _language: payload)
monkeypatch.setattr(gateway, "_fetch_learn_dify_page", lambda _language: payload)
recommended = gateway.list_recommended("en-US")
learn_dify = gateway.list_learn_dify("en-US")
@@ -259,58 +305,57 @@ class TestRemoteRecommendedAppCatalogGateway:
assert learn_dify.categories == ()
def test_list_fetch_error_falls_back_through_builtin_en_us(self, monkeypatch: pytest.MonkeyPatch) -> None:
fallback = MagicMock()
fallback = RecordingCatalog()
empty_page = RecommendedAppCatalogPage(recommended_apps=(), categories=())
fallback_page = _expected_page(categories=("builtin",))
fallback.list_recommended.side_effect = [empty_page, fallback_page]
fallback.recommended_pages = [empty_page, fallback_page]
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
monkeypatch.setattr(remote, "_fetch_page", MagicMock(side_effect=ConnectionError("timeout")))
monkeypatch.setattr(remote, "_fetch_page", _fetch_timeout)
assert router.list_recommended("fr-FR") == fallback_page
assert fallback.list_recommended.call_args_list == [call("fr-FR"), call("en-US")]
assert fallback.calls == [("recommended", "fr-FR"), ("recommended", "en-US")]
def test_json_decode_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None:
fallback = MagicMock()
fallback = RecordingCatalog()
expected_page = _expected_page()
fallback.list_recommended.return_value = expected_page
fallback.recommended_pages = [expected_page]
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
response = MagicMock(status_code=200)
response.json.side_effect = ValueError("invalid JSON")
monkeypatch.setattr(gateway_module.httpx, "get", MagicMock(return_value=response))
response = httpx.Response(status_code=200, content=b"invalid JSON")
_install_http_get(monkeypatch, response)
assert router.list_recommended("en-US") == expected_page
fallback.list_recommended.assert_called_once_with("en-US")
assert fallback.calls == [("recommended", "en-US")]
def test_payload_mapping_error_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None:
fallback = MagicMock()
fallback = RecordingCatalog()
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
monkeypatch.setattr(remote, "_fetch_page", MagicMock(return_value=object()))
monkeypatch.setattr(remote, "_fetch_page", lambda _key: object())
with pytest.raises(TypeError, match="recommended app page must be a mapping"):
router.list_recommended("en-US")
fallback.list_recommended.assert_not_called()
assert fallback.calls == []
def test_learn_dify_fetch_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None:
builtin = MagicMock()
database = MagicMock()
builtin = RecordingCatalog()
database = RecordingCatalog()
page = RecommendedAppCatalogPage(recommended_apps=(), categories=())
builtin.list_learn_dify.return_value = page
builtin.learn_dify_page = page
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
@@ -318,31 +363,31 @@ class TestRemoteRecommendedAppCatalogGateway:
builtin=builtin,
)
monkeypatch.setattr(remote, "_fetch_learn_dify_page", MagicMock(side_effect=ConnectionError("timeout")))
monkeypatch.setattr(remote, "_fetch_learn_dify_page", _fetch_timeout)
assert router.list_learn_dify("ja-JP") == page
builtin.list_learn_dify.assert_called_once_with("ja-JP")
database.list_learn_dify.assert_not_called()
assert builtin.calls == [("learn_dify", "ja-JP")]
assert database.calls == []
def test_empty_remote_learn_dify_page_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None:
database = MagicMock()
database = RecordingCatalog()
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=database,
builtin=MagicMock(),
builtin=RecordingCatalog(),
)
monkeypatch.setattr(
remote,
"_fetch_learn_dify_page",
MagicMock(return_value={"recommended_apps": [], "categories": []}),
lambda _language: {"recommended_apps": [], "categories": []},
)
assert router.list_learn_dify("en-US") == RecommendedAppCatalogPage(
recommended_apps=(),
categories=(),
)
database.list_learn_dify.assert_not_called()
assert database.calls == []
@pytest.mark.parametrize("status_code", [404, 500])
def test_detail_non_200_returns_none_without_builtin_fallback(
@@ -350,21 +395,21 @@ class TestRemoteRecommendedAppCatalogGateway:
monkeypatch: pytest.MonkeyPatch,
status_code: int,
) -> None:
fallback = MagicMock()
fallback = RecordingCatalog()
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
response = MagicMock(status_code=status_code)
monkeypatch.setattr("services.recommended_app_catalog_gateway.httpx.get", MagicMock(return_value=response))
response = httpx.Response(status_code=status_code)
_install_http_get(monkeypatch, response)
assert router.get_detail("missing") is None
fallback.get_detail.assert_not_called()
assert fallback.calls == []
def test_detail_fetch_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None:
fallback = MagicMock()
fallback = RecordingCatalog()
fallback_detail = RecommendedAppDetailRecord(
id="fallback",
name="Fallback",
@@ -373,50 +418,50 @@ class TestRemoteRecommendedAppCatalogGateway:
mode="chat",
export_data="{}",
)
fallback.get_detail.return_value = fallback_detail
fallback.detail = fallback_detail
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
monkeypatch.setattr(remote, "_fetch_detail", MagicMock(side_effect=ConnectionError("timeout")))
monkeypatch.setattr(remote, "_fetch_detail", _fetch_timeout)
assert router.get_detail("app-1") == fallback_detail
fallback.get_detail.assert_called_once_with("app-1")
assert fallback.calls == [("detail", "app-1")]
def test_detail_mapping_error_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None:
fallback = MagicMock()
fallback = RecordingCatalog()
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
monkeypatch.setattr(remote, "_fetch_detail", MagicMock(return_value=object()))
monkeypatch.setattr(remote, "_fetch_detail", lambda _key: object())
with pytest.raises(TypeError, match="recommended app detail must be a mapping"):
router.get_detail("app-1")
fallback.get_detail.assert_not_called()
assert fallback.calls == []
def test_learn_dify_mapping_error_does_not_fall_back(self, monkeypatch: pytest.MonkeyPatch) -> None:
database = MagicMock()
database = RecordingCatalog()
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=database,
builtin=MagicMock(),
builtin=RecordingCatalog(),
)
monkeypatch.setattr(remote, "_fetch_learn_dify_page", MagicMock(return_value=object()))
monkeypatch.setattr(remote, "_fetch_learn_dify_page", lambda _key: object())
with pytest.raises(TypeError, match="Learn Dify app page must be a mapping"):
router.list_learn_dify("en-US")
database.list_learn_dify.assert_not_called()
assert database.calls == []
def test_membership_accepts_raw_non_none_payload(self, monkeypatch: pytest.MonkeyPatch) -> None:
gateway = RemoteRecommendedAppCatalogGateway()
monkeypatch.setattr(gateway, "_fetch_detail", MagicMock(return_value=object()))
monkeypatch.setattr(gateway, "_fetch_detail", lambda _key: object())
assert gateway.contains("app-1") is True
@@ -426,41 +471,39 @@ class TestRemoteRecommendedAppCatalogGateway:
monkeypatch: pytest.MonkeyPatch,
status_code: int,
) -> None:
fallback = MagicMock()
fallback = RecordingCatalog()
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
response = MagicMock(status_code=status_code)
monkeypatch.setattr(gateway_module.httpx, "get", MagicMock(return_value=response))
response = httpx.Response(status_code=status_code)
_install_http_get(monkeypatch, response)
assert router.contains("missing") is False
fallback.contains.assert_not_called()
assert fallback.calls == []
def test_membership_fetch_error_falls_back_to_builtin(self, monkeypatch: pytest.MonkeyPatch) -> None:
fallback = MagicMock()
fallback.contains.return_value = True
fallback = RecordingCatalog()
fallback.member = True
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
monkeypatch.setattr(remote, "_fetch_detail", MagicMock(side_effect=ConnectionError("timeout")))
monkeypatch.setattr(remote, "_fetch_detail", _fetch_timeout)
assert router.contains("app-1") is True
fallback.contains.assert_called_once_with("app-1")
assert fallback.calls == [("contains", "app-1")]
def test_remote_request_uses_configured_origin_and_timeouts(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = MagicMock(status_code=200)
response.json.return_value = _detail_payload()
http_get = MagicMock(return_value=response)
monkeypatch.setattr(gateway_module.httpx, "get", http_get)
response = httpx.Response(status_code=200, json=_detail_payload())
requests = _install_http_get(monkeypatch, response)
apply_config_overrides(
monkeypatch,
HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com",
@@ -469,18 +512,16 @@ class TestRemoteRecommendedAppCatalogGateway:
gateway = RemoteRecommendedAppCatalogGateway()
gateway.get_detail("app-1")
http_get.assert_called_once()
call = http_get.call_args
assert call.args == ("https://catalog.example.com/apps/app-1",)
assert call.kwargs["headers"] == {"Origin": "https://console.example.com"}
assert call.kwargs["timeout"].connect == 3.0
assert call.kwargs["timeout"].read == 10.0
assert len(requests) == 1
url, headers, timeout = requests[0]
assert url == "https://catalog.example.com/apps/app-1"
assert headers == {"Origin": "https://console.example.com"}
assert timeout.connect == 3.0
assert timeout.read == 10.0
def test_remote_request_uses_cache(self, monkeypatch: pytest.MonkeyPatch) -> None:
response = MagicMock(status_code=200)
response.json.return_value = _page_payload()
http_get = MagicMock(return_value=response)
monkeypatch.setattr(gateway_module.httpx, "get", http_get)
response = httpx.Response(status_code=200, json=_page_payload())
requests = _install_http_get(monkeypatch, response)
apply_config_overrides(
monkeypatch,
HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com",
@@ -490,46 +531,41 @@ class TestRemoteRecommendedAppCatalogGateway:
assert gateway.list_recommended("en-US") == _expected_page()
assert gateway.list_recommended("en-US") == _expected_page()
http_get.assert_called_once()
assert len(requests) == 1
def test_remote_request_does_not_cache_failed_responses(self, monkeypatch: pytest.MonkeyPatch) -> None:
response = MagicMock(status_code=500)
http_get = MagicMock(return_value=response)
monkeypatch.setattr(gateway_module.httpx, "get", http_get)
response = httpx.Response(status_code=500)
requests = _install_http_get(monkeypatch, response)
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600)
expected_page = _expected_page()
fallback = MagicMock()
fallback.list_recommended.return_value = expected_page
fallback = RecordingCatalog()
fallback.recommended_pages = [expected_page]
router = RecommendedAppCatalogRouter(
remote=RemoteRecommendedAppCatalogGateway(),
database=MagicMock(),
database=RecordingCatalog(),
builtin=fallback,
)
assert router.list_recommended("en-US") == expected_page
assert router.list_recommended("en-US") == expected_page
assert http_get.call_count == 2
assert len(requests) == 2
def test_remote_request_skips_cache_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
response = MagicMock(status_code=200)
response.json.return_value = _page_payload()
http_get = MagicMock(return_value=response)
monkeypatch.setattr(gateway_module.httpx, "get", http_get)
response = httpx.Response(status_code=200, json=_page_payload())
requests = _install_http_get(monkeypatch, response)
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=0)
gateway = RemoteRecommendedAppCatalogGateway()
gateway.list_recommended("en-US")
gateway.list_recommended("en-US")
assert http_get.call_count == 2
assert len(requests) == 2
def test_remote_request_cache_isolated_by_configured_origin(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = MagicMock(status_code=200)
response.json.return_value = _page_payload()
http_get = MagicMock(return_value=response)
monkeypatch.setattr(gateway_module.httpx, "get", http_get)
response = httpx.Response(status_code=200, json=_page_payload())
requests = _install_http_get(monkeypatch, response)
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL=600)
gateway = RemoteRecommendedAppCatalogGateway()
apply_config_overrides(monkeypatch, CONSOLE_WEB_URL="https://cloud-a.example.com")
@@ -537,7 +573,7 @@ class TestRemoteRecommendedAppCatalogGateway:
apply_config_overrides(monkeypatch, CONSOLE_WEB_URL="https://cloud-b.example.com")
gateway.list_recommended("en-US")
assert http_get.call_count == 2
assert len(requests) == 2
@pytest.mark.parametrize(
("console_web_url", "expected_headers"),
@@ -553,15 +589,13 @@ class TestRemoteRecommendedAppCatalogGateway:
console_web_url: str,
expected_headers: dict[str, str],
) -> None:
response = MagicMock(status_code=200)
response.json.return_value = _detail_payload()
http_get = MagicMock(return_value=response)
monkeypatch.setattr(gateway_module.httpx, "get", http_get)
response = httpx.Response(status_code=200, json=_detail_payload())
requests = _install_http_get(monkeypatch, response)
apply_config_overrides(monkeypatch, CONSOLE_WEB_URL=console_web_url)
gateway = RemoteRecommendedAppCatalogGateway()
gateway.get_detail("app-1")
assert http_get.call_args.kwargs["headers"] == expected_headers
assert requests[-1][1] == expected_headers
@pytest.mark.parametrize(
("operation", "expected_url"),
@@ -576,18 +610,17 @@ class TestRemoteRecommendedAppCatalogGateway:
operation: str,
expected_url: str,
) -> None:
response = MagicMock(status_code=500)
http_get = MagicMock(return_value=response)
monkeypatch.setattr(gateway_module.httpx, "get", http_get)
response = httpx.Response(status_code=500)
requests = _install_http_get(monkeypatch, response)
apply_config_overrides(
monkeypatch,
HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN="https://catalog.example.com",
)
fallback = MagicMock()
database = MagicMock()
fallback = RecordingCatalog()
database = RecordingCatalog()
expected_page = _expected_page()
fallback.list_recommended.return_value = expected_page
fallback.list_learn_dify.return_value = expected_page
fallback.recommended_pages = [expected_page]
fallback.learn_dify_page = expected_page
remote = RemoteRecommendedAppCatalogGateway()
router = RecommendedAppCatalogRouter(
remote=remote,
@@ -596,36 +629,35 @@ class TestRemoteRecommendedAppCatalogGateway:
)
result = router.list_recommended("ja-JP") if operation == "recommended" else router.list_learn_dify("ja-JP")
fallback_call = fallback.list_recommended if operation == "recommended" else fallback.list_learn_dify
assert result == expected_page
assert http_get.call_args.args == (expected_url,)
fallback_call.assert_called_once_with("ja-JP")
database.list_learn_dify.assert_not_called()
assert requests[-1][0] == expected_url
assert fallback.calls == [(operation, "ja-JP")]
assert database.calls == []
class TestRecommendedAppCatalogRouter:
def test_empty_page_falls_back_to_builtin_en_us(self, monkeypatch: pytest.MonkeyPatch) -> None:
remote = MagicMock()
builtin = MagicMock()
remote.list_recommended.return_value = RecommendedAppCatalogPage(recommended_apps=(), categories=())
remote = RecordingCatalog()
builtin = RecordingCatalog()
remote.recommended_pages = [RecommendedAppCatalogPage(recommended_apps=(), categories=())]
expected_page = _expected_page(categories=("builtin",))
builtin.list_recommended.return_value = expected_page
builtin.recommended_pages = [expected_page]
gateway = RecommendedAppCatalogRouter(
remote=remote,
database=MagicMock(),
database=RecordingCatalog(),
builtin=builtin,
)
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote")
assert gateway.list_recommended("ja-JP") == expected_page
remote.list_recommended.assert_called_once_with("ja-JP")
builtin.list_recommended.assert_called_once_with("en-US")
assert remote.calls == [("recommended", "ja-JP")]
assert builtin.calls == [("recommended", "en-US")]
def test_resolves_mode_for_every_operation(self, monkeypatch: pytest.MonkeyPatch) -> None:
remote = MagicMock()
database = MagicMock()
builtin = MagicMock()
remote = RecordingCatalog()
database = RecordingCatalog()
builtin = RecordingCatalog()
gateway = RecommendedAppCatalogRouter(
remote=remote,
database=database,
@@ -641,32 +673,31 @@ class TestRecommendedAppCatalogRouter:
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="remote")
gateway.contains("app-1")
remote.list_recommended.assert_called_once_with("en-US")
database.list_learn_dify.assert_called_once_with("en-US")
builtin.get_detail.assert_called_once_with("app-1")
remote.contains.assert_called_once_with("app-1")
assert remote.calls == [("recommended", "en-US"), ("contains", "app-1")]
assert database.calls == [("learn_dify", "en-US")]
assert builtin.calls == [("detail", "app-1")]
def test_builtin_mode_reads_builtin_learn_dify(self, monkeypatch: pytest.MonkeyPatch) -> None:
builtin = MagicMock()
database = MagicMock()
builtin = RecordingCatalog()
database = RecordingCatalog()
expected_page = RecommendedAppCatalogPage(recommended_apps=(), categories=())
builtin.list_learn_dify.return_value = expected_page
builtin.learn_dify_page = expected_page
gateway = RecommendedAppCatalogRouter(
remote=MagicMock(),
remote=RecordingCatalog(),
database=database,
builtin=builtin,
)
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="builtin")
assert gateway.list_learn_dify("en-US") == expected_page
builtin.list_learn_dify.assert_called_once_with("en-US")
database.list_learn_dify.assert_not_called()
assert builtin.calls == [("learn_dify", "en-US")]
assert database.calls == []
def test_rejects_invalid_mode(self, monkeypatch: pytest.MonkeyPatch) -> None:
gateway = RecommendedAppCatalogRouter(
remote=MagicMock(),
database=MagicMock(),
builtin=MagicMock(),
remote=RecordingCatalog(),
database=RecordingCatalog(),
builtin=RecordingCatalog(),
)
apply_config_overrides(monkeypatch, HOSTED_FETCH_APP_TEMPLATES_MODE="invalid")

View File

@@ -1,17 +1,56 @@
from unittest.mock import MagicMock
from collections.abc import Sequence
import pytest
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
RecommendedAppDetailRecord,
RecommendedAppInfoRecord,
RecommendedAppNotFoundError,
RecommendedAppQueryService,
RecommendedAppRecord,
TrialAppQuery,
)
class Catalog(RecommendedAppCatalogQuery):
def __init__(self) -> None:
self.page = RecommendedAppCatalogPage((), ())
self.detail: RecommendedAppDetailRecord | None = None
self.app_ids: set[str] = set()
self.contains_calls: list[str] = []
self.detail_calls: list[str] = []
self.recommended_calls: list[str] = []
self.learn_calls: list[str] = []
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
self.recommended_calls.append(language)
return self.page
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
self.learn_calls.append(language)
return self.page
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
self.detail_calls.append(app_id)
return self.detail
def contains(self, app_id: str) -> bool:
self.contains_calls.append(app_id)
return app_id in self.app_ids
class TrialApps(TrialAppQuery):
def __init__(self) -> None:
self.app_ids: frozenset[str] = frozenset()
self.calls: list[Sequence[str]] = []
def existing_ids(self, app_ids: Sequence[str]) -> frozenset[str]:
self.calls.append(app_ids)
return self.app_ids.intersection(app_ids)
def _app(app_id: str) -> RecommendedAppRecord:
return RecommendedAppRecord(
app=RecommendedAppInfoRecord(
@@ -42,11 +81,11 @@ def _page(*app_ids: str, categories: tuple[str, ...] = ("Workflow",)) -> Recomme
def _service(
*,
catalog: MagicMock,
trial_apps: MagicMock | None = None,
catalog: Catalog,
trial_apps: TrialApps | None = None,
trial_enabled: bool = False,
) -> tuple[RecommendedAppQueryService, MagicMock]:
trial_apps = trial_apps or MagicMock()
) -> tuple[RecommendedAppQueryService, TrialApps]:
trial_apps = trial_apps if trial_apps is not None else TrialApps()
return (
RecommendedAppQueryService(
catalog=catalog,
@@ -58,27 +97,26 @@ def _service(
def test_is_previewable_accepts_trial_registration_without_querying_catalog() -> None:
catalog = MagicMock()
trial_apps = MagicMock()
trial_apps.existing_ids.return_value = frozenset({"app-1"})
catalog = Catalog()
trial_apps = TrialApps()
trial_apps.app_ids = frozenset({"app-1"})
service, _ = _service(catalog=catalog, trial_apps=trial_apps)
assert service.is_previewable("app-1") is True
trial_apps.existing_ids.assert_called_once_with(("app-1",))
catalog.contains.assert_not_called()
assert trial_apps.calls == [("app-1",)]
assert catalog.contains_calls == []
@pytest.mark.parametrize("expected", [True, False])
def test_is_previewable_falls_back_to_catalog(expected: bool) -> None:
catalog = MagicMock()
catalog.contains.return_value = expected
trial_apps = MagicMock()
trial_apps.existing_ids.return_value = frozenset()
catalog = Catalog()
catalog.app_ids = {"app-1"} if expected else set()
trial_apps = TrialApps()
service, _ = _service(catalog=catalog, trial_apps=trial_apps)
assert service.is_previewable("app-1") is expected
trial_apps.existing_ids.assert_called_once_with(("app-1",))
catalog.contains.assert_called_once_with("app-1")
assert trial_apps.calls == [("app-1",)]
assert catalog.contains_calls == ["app-1"]
@pytest.mark.parametrize(
@@ -92,33 +130,33 @@ def test_list_recommended_resolves_language(
language: str,
expected: str,
) -> None:
catalog = MagicMock()
catalog.list_recommended.return_value = _page("app-1")
catalog = Catalog()
catalog.page = _page("app-1")
service, _ = _service(catalog=catalog)
service.list_recommended(
language=language,
)
catalog.list_recommended.assert_called_once_with(expected)
assert catalog.recommended_calls == [expected]
def test_list_recommended_disables_upstream_trial_without_querying_trial_apps() -> None:
catalog = MagicMock()
catalog.list_recommended.return_value = _page("app-1")
catalog = Catalog()
catalog.page = _page("app-1")
service, trial_apps = _service(catalog=catalog)
result = service.list_recommended(language="en-US")
assert result.recommended_apps[0].can_trial is False
trial_apps.existing_ids.assert_not_called()
assert trial_apps.calls == []
def test_list_recommended_enriches_trial_status_in_one_bulk_query() -> None:
catalog = MagicMock()
catalog.list_recommended.return_value = _page("app-1", "app-2")
trial_apps = MagicMock()
trial_apps.existing_ids.return_value = frozenset({"app-1"})
catalog = Catalog()
catalog.page = _page("app-1", "app-2")
trial_apps = TrialApps()
trial_apps.app_ids = frozenset({"app-1"})
service, _ = _service(
catalog=catalog,
trial_apps=trial_apps,
@@ -128,34 +166,33 @@ def test_list_recommended_enriches_trial_status_in_one_bulk_query() -> None:
result = service.list_recommended(language="en-US")
assert [app.can_trial for app in result.recommended_apps] == [True, False]
trial_apps.existing_ids.assert_called_once_with(["app-1", "app-2"])
assert trial_apps.calls == [["app-1", "app-2"]]
def test_list_learn_dify_does_not_return_categories() -> None:
catalog = MagicMock()
catalog.list_learn_dify.return_value = _page(categories=("ignored",))
catalog = Catalog()
catalog.page = _page(categories=("ignored",))
service, _ = _service(catalog=catalog)
result = service.list_learn_dify(language="invalid")
catalog.list_learn_dify.assert_called_once_with("en-US")
assert catalog.learn_calls == ["en-US"]
assert result.recommended_apps == ()
assert not hasattr(result, "categories")
def test_get_detail_raises_not_found_without_querying_trial_apps() -> None:
catalog = MagicMock()
catalog.get_detail.return_value = None
catalog = Catalog()
service, trial_apps = _service(catalog=catalog, trial_enabled=True)
with pytest.raises(RecommendedAppNotFoundError):
service.get_detail("missing")
trial_apps.existing_ids.assert_not_called()
assert trial_apps.calls == []
def test_get_detail_does_not_query_trial_apps_when_disabled() -> None:
catalog = MagicMock()
catalog.get_detail.return_value = RecommendedAppDetailRecord(
catalog = Catalog()
catalog.detail = RecommendedAppDetailRecord(
id="catalog-app-id",
name="App",
icon=None,
@@ -168,13 +205,13 @@ def test_get_detail_does_not_query_trial_apps_when_disabled() -> None:
result = service.get_detail("route-app-id")
assert result.can_trial is False
trial_apps.existing_ids.assert_not_called()
assert trial_apps.calls == []
@pytest.mark.parametrize(("existing_ids", "expected"), [(frozenset({"catalog-app-id"}), True), (frozenset(), False)])
def test_get_detail_uses_catalog_result_id_for_trial_status(existing_ids: frozenset[str], expected: bool) -> None:
catalog = MagicMock()
catalog.get_detail.return_value = RecommendedAppDetailRecord(
catalog = Catalog()
catalog.detail = RecommendedAppDetailRecord(
id="catalog-app-id",
name="App",
icon=None,
@@ -184,8 +221,8 @@ def test_get_detail_uses_catalog_result_id_for_trial_status(existing_ids: frozen
package_url="https://templates.example/agent.ifpkg",
version_id="published-version",
)
trial_apps = MagicMock()
trial_apps.existing_ids.return_value = existing_ids
trial_apps = TrialApps()
trial_apps.app_ids = existing_ids
service, _ = _service(
catalog=catalog,
trial_apps=trial_apps,
@@ -197,4 +234,4 @@ def test_get_detail_uses_catalog_result_id_for_trial_status(existing_ids: frozen
assert result.can_trial is expected
assert result.package_url == "https://templates.example/agent.ifpkg"
assert result.version_id == "published-version"
trial_apps.existing_ids.assert_called_once_with(("catalog-app-id",))
assert trial_apps.calls == [("catalog-app-id",)]

View File

@@ -1,5 +1,5 @@
from collections.abc import Mapping
from datetime import UTC, datetime
from unittest.mock import MagicMock
import pytest
@@ -12,8 +12,11 @@ from services.entities.passport_entities import (
from services.web_passport_service import (
WebAppAuthType,
WebPassportAuthenticationRequiredError,
WebPassportAuthGateway,
WebPassportNotFoundError,
WebPassportRepository,
WebPassportService,
WebPassportTokenGateway,
WebPassportUnauthorizedError,
)
@@ -21,31 +24,86 @@ APP = WebAppRecord(site_id="site-1", app_id="app-1", tenant_id="tenant-1", app_c
NOW = datetime(2026, 8, 13, 12, 0, tzinfo=UTC)
class PassportRepository(WebPassportRepository):
def __init__(self) -> None:
self.app: WebAppRecord | None = APP
self.active = True
self.resolution = WebPassportEndUserResolution(app_active=True, end_user=EndUserRecord(id="end-user-1"))
self.standard_calls: list[tuple[WebAppRecord, str | None]] = []
self.authenticated_calls: list[tuple[WebAppRecord, str | None, str | None]] = []
self.events: list[str] = []
def get_active_web_app(self, app_code: str) -> WebAppRecord | None:
assert app_code == "app-code"
return self.app
def is_web_app_active(self, app: WebAppRecord) -> bool:
assert app == APP
self.events.append("active")
return self.active
def resolve_standard_end_user(self, app: WebAppRecord, session_id: str | None) -> WebPassportEndUserResolution:
self.standard_calls.append((app, session_id))
return self.resolution
def resolve_authenticated_end_user(
self, app: WebAppRecord, *, end_user_id: str | None, session_id: str | None
) -> WebPassportEndUserResolution:
self.authenticated_calls.append((app, end_user_id, session_id))
return self.resolution
class PassportAuth(WebPassportAuthGateway):
def __init__(self) -> None:
self.enabled = False
self.auth_type = WebAppAuthType.INTERNAL
self.enabled_calls = 0
self.auth_type_calls: list[str] = []
self.events: list[str] = []
def is_webapp_auth_enabled(self) -> bool:
self.enabled_calls += 1
return self.enabled
def get_app_auth_type(self, app_id: str) -> WebAppAuthType:
self.auth_type_calls.append(app_id)
self.events.append("auth_type")
return self.auth_type
class PassportTokens(WebPassportTokenGateway):
def __init__(self) -> None:
self.claims: dict[str, object] = {}
self.token = "issued-token"
self.verify_calls: list[str] = []
self.issue_calls: list[dict[str, object]] = []
def verify(self, token: str) -> Mapping[str, object]:
self.verify_calls.append(token)
return self.claims
def issue(self, payload: Mapping[str, object]) -> str:
self.issue_calls.append(dict(payload))
return self.token
def _service(
*,
repository: MagicMock | None = None,
auth: MagicMock | None = None,
tokens: MagicMock | None = None,
) -> tuple[WebPassportService, MagicMock, MagicMock, MagicMock]:
repository: PassportRepository | None = None,
auth: PassportAuth | None = None,
tokens: PassportTokens | None = None,
) -> tuple[WebPassportService, PassportRepository, PassportAuth, PassportTokens]:
if repository is None:
repository = MagicMock()
repository.get_active_web_app.return_value = APP
repository.is_web_app_active.return_value = True
resolution = WebPassportEndUserResolution(app_active=True, end_user=EndUserRecord(id="end-user-1"))
repository.resolve_standard_end_user.return_value = resolution
repository.resolve_authenticated_end_user.return_value = resolution
repository = PassportRepository()
if auth is None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = False
auth = PassportAuth()
if tokens is None:
tokens = MagicMock()
tokens.issue.return_value = "issued-token"
now = MagicMock(return_value=NOW)
tokens = PassportTokens()
service = WebPassportService(
passports=repository,
auth=auth,
tokens=tokens,
now=now,
now=lambda: NOW,
access_token_expire_minutes=60,
)
return service, repository, auth, tokens
@@ -61,8 +119,8 @@ def test_issue_creates_anonymous_user_and_standard_passport() -> None:
result = service.issue(_request())
assert result.access_token == "issued-token"
repository.resolve_standard_end_user.assert_called_once_with(APP, None)
tokens.issue.assert_called_once_with(
assert repository.standard_calls == [(APP, None)]
assert tokens.issue_calls == [
{
"iss": "app-1",
"sub": "Web API Passport",
@@ -70,7 +128,7 @@ def test_issue_creates_anonymous_user_and_standard_passport() -> None:
"app_code": "app-code",
"end_user_id": "end-user-1",
}
)
]
def test_issue_reuses_requested_session_user() -> None:
@@ -78,48 +136,47 @@ def test_issue_reuses_requested_session_user() -> None:
service.issue(_request(user_session_id="existing-session"))
repository.resolve_standard_end_user.assert_called_once_with(APP, "existing-session")
assert repository.standard_calls == [(APP, "existing-session")]
def test_issue_returns_not_found_for_inactive_app() -> None:
repository = MagicMock()
repository.get_active_web_app.return_value = None
repository = PassportRepository()
repository.app = None
service, _repository, auth, _tokens = _service(repository=repository)
with pytest.raises(WebPassportNotFoundError):
service.issue(_request())
auth.is_webapp_auth_enabled.assert_not_called()
assert auth.enabled_calls == 0
def test_issue_revalidates_app_after_enterprise_io() -> None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = True
auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL
tokens = MagicMock()
tokens.verify.return_value = {
auth = PassportAuth()
auth.enabled = True
auth.auth_type = WebAppAuthType.INTERNAL
tokens = PassportTokens()
tokens.claims = {
"token_source": "webapp_login_token",
"auth_type": "internal",
"session_id": "session-1",
}
service, repository, _auth, _tokens = _service(auth=auth, tokens=tokens)
def app_is_active(_app: WebAppRecord) -> bool:
auth.get_app_auth_type.assert_called_once_with(APP.app_id)
return False
repository.is_web_app_active.side_effect = app_is_active
repository.active = False
repository.events = auth.events
with pytest.raises(WebPassportNotFoundError):
service.issue(_request(access_token="login-token"))
repository.resolve_authenticated_end_user.assert_not_called()
tokens.issue.assert_not_called()
assert auth.auth_type_calls == [APP.app_id]
assert auth.events == ["auth_type", "active"]
assert repository.authenticated_calls == []
assert tokens.issue_calls == []
def test_issue_returns_not_found_when_app_becomes_inactive_before_user_creation() -> None:
service, repository, _auth, tokens = _service()
repository.resolve_standard_end_user.return_value = WebPassportEndUserResolution(
repository.resolution = WebPassportEndUserResolution(
app_active=False,
end_user=None,
)
@@ -127,13 +184,13 @@ def test_issue_returns_not_found_when_app_becomes_inactive_before_user_creation(
with pytest.raises(WebPassportNotFoundError):
service.issue(_request())
tokens.issue.assert_not_called()
assert tokens.issue_calls == []
def test_issue_requires_login_for_private_webapp() -> None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = True
auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL
auth = PassportAuth()
auth.enabled = True
auth.auth_type = WebAppAuthType.INTERNAL
service, _repository, _auth, _tokens = _service(auth=auth)
with pytest.raises(WebPassportAuthenticationRequiredError):
@@ -141,11 +198,11 @@ def test_issue_requires_login_for_private_webapp() -> None:
def test_issue_rejects_wrong_login_token_source() -> None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = True
auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL
tokens = MagicMock()
tokens.verify.return_value = {"token_source": "other"}
auth = PassportAuth()
auth.enabled = True
auth.auth_type = WebAppAuthType.INTERNAL
tokens = PassportTokens()
tokens.claims = {"token_source": "other"}
service, _repository, _auth, _tokens = _service(auth=auth, tokens=tokens)
with pytest.raises(WebPassportUnauthorizedError, match="token source"):
@@ -153,11 +210,11 @@ def test_issue_rejects_wrong_login_token_source() -> None:
def test_issue_rejects_auth_type_mismatch() -> None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = True
auth.get_app_auth_type.return_value = WebAppAuthType.EXTERNAL
tokens = MagicMock()
tokens.verify.return_value = {
auth = PassportAuth()
auth.enabled = True
auth.auth_type = WebAppAuthType.EXTERNAL
tokens = PassportTokens()
tokens.claims = {
"token_source": "webapp_login_token",
"auth_type": "internal",
"session_id": "session-1",
@@ -169,11 +226,11 @@ def test_issue_rejects_auth_type_mismatch() -> None:
def test_issue_exchanges_enterprise_token_after_user_resolution() -> None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = True
auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL
tokens = MagicMock()
tokens.verify.return_value = {
auth = PassportAuth()
auth.enabled = True
auth.auth_type = WebAppAuthType.INTERNAL
tokens = PassportTokens()
tokens.claims = {
"token_source": "webapp_login_token",
"user_id": "account-1",
"end_user_id": "stale-end-user",
@@ -181,9 +238,9 @@ def test_issue_exchanges_enterprise_token_after_user_resolution() -> None:
"auth_type": "internal",
"exp": 2_000_000_000,
}
tokens.issue.return_value = "enterprise-token"
tokens.token = "enterprise-token"
service, repository, _auth, _tokens = _service(auth=auth, tokens=tokens)
repository.resolve_authenticated_end_user.return_value = WebPassportEndUserResolution(
repository.resolution = WebPassportEndUserResolution(
app_active=True,
end_user=EndUserRecord(id="end-user-by-session"),
)
@@ -191,12 +248,8 @@ def test_issue_exchanges_enterprise_token_after_user_resolution() -> None:
result = service.issue(_request(access_token="login-token"))
assert result.access_token == "enterprise-token"
repository.resolve_authenticated_end_user.assert_called_once_with(
APP,
end_user_id="stale-end-user",
session_id="session-1",
)
tokens.issue.assert_called_once_with(
assert repository.authenticated_calls == [(APP, "stale-end-user", "session-1")]
assert tokens.issue_calls == [
{
"iss": "site-1",
"sub": "Web API Passport",
@@ -209,17 +262,17 @@ def test_issue_exchanges_enterprise_token_after_user_resolution() -> None:
"token_source": "webapp",
"exp": 2_000_000_000,
}
)
]
def test_issue_requires_session_id_when_enterprise_user_is_missing() -> None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = True
auth.get_app_auth_type.return_value = WebAppAuthType.INTERNAL
tokens = MagicMock()
tokens.verify.return_value = {"token_source": "webapp_login_token", "auth_type": "internal"}
auth = PassportAuth()
auth.enabled = True
auth.auth_type = WebAppAuthType.INTERNAL
tokens = PassportTokens()
tokens.claims = {"token_source": "webapp_login_token", "auth_type": "internal"}
service, repository, _auth, _tokens = _service(auth=auth, tokens=tokens)
repository.resolve_authenticated_end_user.return_value = WebPassportEndUserResolution(
repository.resolution = WebPassportEndUserResolution(
app_active=True,
end_user=None,
)
@@ -229,15 +282,15 @@ def test_issue_requires_session_id_when_enterprise_user_is_missing() -> None:
def test_public_webapp_verifies_optional_login_token_then_uses_standard_flow() -> None:
auth = MagicMock()
auth.is_webapp_auth_enabled.return_value = True
auth.get_app_auth_type.return_value = WebAppAuthType.PUBLIC
tokens = MagicMock()
tokens.verify.return_value = {"token_source": "webapp_login_token"}
tokens.issue.return_value = "public-token"
auth = PassportAuth()
auth.enabled = True
auth.auth_type = WebAppAuthType.PUBLIC
tokens = PassportTokens()
tokens.claims = {"token_source": "webapp_login_token"}
tokens.token = "public-token"
service, _repository, _auth, _tokens = _service(auth=auth, tokens=tokens)
service.issue(_request(access_token="login-token"))
tokens.verify.assert_called_once_with("login-token")
assert tokens.issue.call_args.args[0]["iss"] == "app-1"
assert tokens.verify_calls == ["login-token"]
assert tokens.issue_calls[0]["iss"] == "app-1"