Compare commits
18 Commits
main
...
test/real-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f26d0a83 | ||
|
|
868e403877 | ||
|
|
3b1186aafb | ||
|
|
22faa1e198 | ||
|
|
d3a13538a5 | ||
|
|
8109ebc62e | ||
|
|
e41753d3d5 | ||
|
|
82a0c03f2e | ||
|
|
3e60a9e30e | ||
|
|
b9c25d7201 | ||
|
|
0a445b5be7 | ||
|
|
7510452eac | ||
|
|
82d13532d5 | ||
|
|
72f2305839 | ||
|
|
e09ad12cc1 | ||
|
|
070cd79c39 | ||
|
|
1d912fee41 | ||
|
|
e92ffbdf50 |
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user