chore(merge): sync master and preserve monitoring test coverage

This commit is contained in:
BiFangKNT
2026-10-18 12:43:44 +08:00
174 changed files with 7645 additions and 3218 deletions
@@ -580,3 +580,4 @@ class TestChatHandlerAsyncBehavior:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1
assert query.resp_messages[0].content == 'Reply from plugin'
@@ -303,6 +303,10 @@ class TestRunnerContextProtocolV1:
assert ctx.event is not None
assert ctx.event.event_type == 'message.received'
from pydantic import ValidationError
with pytest.raises(ValidationError, match='event'):
RunnerContext.model_validate(ctx.model_dump(exclude={'event'}))
def test_sdk_context_has_no_history_message_fields(self):
"""RunnerContext should not expose inline history message fields."""
@@ -332,16 +336,6 @@ class TestRunnerContextProtocolV1:
assert not hasattr(ctx, 'bootstrap')
class TestHostManagedHistoryNotInProtocol:
"""Test that Host-managed history payloads are not in Protocol v1."""
def test_messages_not_in_sdk_context_top_level(self):
"""RunnerContext should not expose top-level history messages."""
ctx_fields = RunnerContext.model_fields.keys()
assert 'messages' not in ctx_fields
class TestSDKResultProtocolV1:
"""Test SDK RunnerResult for Protocol v1."""
@@ -355,6 +349,8 @@ class TestSDKResultProtocolV1:
)
assert result.run_id == 'run_1'
with pytest.raises(TypeError, match='run_id'):
RunnerResult.message_completed(message=Message(role='assistant', content='Hello'))
# Fixtures
@@ -139,30 +139,31 @@ class TestTranscriptStore:
"""Test TranscriptStore operations."""
@pytest.mark.asyncio
async def test_append_transcript(self, mock_db_engine):
async def test_append_transcript(self):
"""Test appending a transcript item."""
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from uuid import UUID
store = TranscriptStore(mock_db_engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
# Mock _get_next_seq
with patch.object(store, '_get_next_seq', return_value=1):
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate
event_id='evt_1',
conversation_id='conv_1',
role='user',
content='Hello',
)
assert transcript_id is not None
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
store = TranscriptStore(engine)
transcript_id = await store.append_transcript(
transcript_id=None,
event_id='evt_1',
conversation_id='conv_1',
role='user',
content='Hello',
)
assert UUID(transcript_id).version == 4
items, _, _, _ = await store.page_transcript('conv_1')
assert [(item['transcript_id'], item['event_id'], item['role'], item['content']) for item in items] == [
(transcript_id, 'evt_1', 'user', 'Hello')
]
finally:
await engine.dispose()
class TestEventLogStoreRealSQLite:
@@ -258,6 +259,7 @@ class TestEventLogStoreRealSQLite:
async def test_get_latest_cursor(self, db_engine):
"""Test get_latest_cursor with real DB."""
store = EventLogStore(db_engine)
assert await store.get_latest_cursor('conv_cursor') is None
# Append events
for i in range(3):
@@ -270,8 +272,12 @@ class TestEventLogStoreRealSQLite:
# Get latest cursor
cursor = await store.get_latest_cursor('conv_cursor')
assert cursor is not None
assert int(cursor) > 0
assert cursor == '3'
await store.append_event(
event_id='other', event_type='message.received', source='platform', conversation_id='other'
)
assert await store.get_latest_cursor('conv_cursor') == '3'
assert await store.get_latest_cursor('other') == '4'
@pytest.mark.asyncio
async def test_cleanup_events_older_than(self, db_engine):
@@ -205,14 +205,43 @@ def official_plugin_root():
@pytest.mark.parametrize(
'runner,folder,method,error_name,local_identity',
[
('DifyAgent', 'dify-agent', '_get_user_tag', 'DifyConfigError', 'person_Session_007'),
('N8nAgent', 'n8n-agent', '_get_user_tag', 'N8nConfigError', 'person_Session_007'),
('CozeAgent', 'coze-agent', '_get_user_id', 'CozeConfigError', 'person_launcher-123'),
('TboxAgent', 'tbox-agent', '_get_user_id', 'TboxConfigError', 'bot-uuid-123'),
pytest.param(
'DifyAgent',
'dify-agent',
'_get_user_tag',
'DifyConfigError',
'person_Session_007',
marks=pytest.mark.contract('dify_identity'),
),
pytest.param(
'N8nAgent',
'n8n-agent',
'_get_user_tag',
'N8nConfigError',
'person_Session_007',
marks=pytest.mark.contract('n8n_identity'),
),
pytest.param(
'CozeAgent',
'coze-agent',
'_get_user_id',
'CozeConfigError',
'person_launcher-123',
marks=pytest.mark.contract('coze_identity'),
),
pytest.param(
'TboxAgent',
'tbox-agent',
'_get_user_id',
'TboxConfigError',
'bot-uuid-123',
marks=pytest.mark.contract('tbox_identity'),
),
],
)
@pytest.mark.parametrize('missing', [False, True], ids=['trusted-identity', 'missing-identity'])
@pytest.mark.parametrize('profile', ['dedicated', 'shared'])
@pytest.mark.plugin_identity
async def test_real_plugin_identity_helper_with_host_payload(
mock_query, monkeypatch, official_plugin_root, runner, folder, method, error_name, local_identity, missing, profile
):
@@ -789,6 +789,13 @@ async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event(
assert [message.content for message in messages] == ['state saved']
# State is persisted to the database via PersistentStateStore.
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
event = QueryEntryAdapter.query_to_event(query)
config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
binding = AgentBindingResolver().resolve_one(event, [config])
snapshot = await get_persistent_state_store(db_engine).build_snapshot_from_event(event, binding, descriptor)
assert snapshot['conversation'].get('external.conversation_id') == 'external_conv_123'
@pytest.mark.asyncio
+6
View File
@@ -124,6 +124,7 @@ class TestRegistryDiscovery:
async def test_discover_caches_results(self):
"""Discovery should cache results."""
ap = FakeApplication()
ap.plugin_connector.list_runners = AsyncMock(wraps=ap.plugin_connector.list_runners)
registry = RunnerRegistry(ap)
# First discovery
@@ -134,6 +135,11 @@ class TestRegistryDiscovery:
assert registry._cache is not None
assert len(runners1) == len(runners2)
ap.plugin_connector.list_runners.assert_awaited_once()
assert [runner.id for runner in runners2] == [
'plugin:langbot-team/LocalAgent/default',
'plugin:alice/my-agent/custom',
]
@pytest.mark.asyncio
async def test_discover_handles_plugin_disabled(self):
@@ -156,6 +156,7 @@ class TestNormalizeRunCompleted:
assert result is not None
assert isinstance(result, provider_message.Message)
assert (result.role, result.content) == ('assistant', 'Final response')
@pytest.mark.asyncio
async def test_normalize_run_completed_without_message(self):
@@ -175,6 +175,22 @@ async def _create_run(
)
@pytest.fixture
async def ledger_snapshot(db_engine):
await _create_run(
db_engine, run_id='workspace_guard', workspace_id='workspace_guard', plugin_identity='guard/runner'
)
async def snapshot():
async with AsyncSession(db_engine) as session:
return [
(await session.execute(sqlalchemy.select(*model.__table__.c).order_by(model.id))).mappings().all()
for model in (agent_run_model.AgentRun, agent_run_model.AgentRunEvent)
]
return snapshot
@pytest.mark.asyncio
async def test_run_get_returns_current_run(session_registry, db_engine):
await _register_session(session_registry, available_apis={'run_get': True})
@@ -264,6 +280,7 @@ async def test_run_list_filters_same_scope_different_runner_owner(session_regist
async def test_non_admin_target_run_actions_reject_same_scope_different_runner_owner(
session_registry,
db_engine,
ledger_snapshot,
):
await _register_session(
session_registry,
@@ -351,10 +368,12 @@ async def test_non_admin_target_run_actions_reject_same_scope_different_runner_o
),
]
before = await ledger_snapshot()
for action, payload in calls:
result = await handler.actions[action](payload)
assert result.code != 0, action
assert 'not accessible' in result.message.lower()
assert await ledger_snapshot() == before
@pytest.mark.asyncio
@@ -1092,11 +1111,12 @@ async def test_run_append_result_uses_cas_and_redacts_metadata(session_registry,
@pytest.mark.asyncio
async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine):
async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine, ledger_snapshot):
await _register_session(session_registry, available_apis={'run_append_result': True})
await _create_run(db_engine)
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
before = await ledger_snapshot()
result = await run_append_result(
{
@@ -1112,14 +1132,16 @@ async def test_run_append_result_rejects_side_effecting_result_type(session_regi
assert result.code != 0
assert 'canonical runner result path' in result.message
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine):
async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine, ledger_snapshot):
await _register_session(session_registry, available_apis={'run_append_result': True})
await _create_run(db_engine)
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
before = await ledger_snapshot()
result = await run_append_result(
{
@@ -1135,14 +1157,16 @@ async def test_run_append_result_rejects_unknown_result_type(session_registry, d
assert result.code != 0
assert 'unknown result type' in result.message
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_run_append_result_validates_known_payloads(session_registry, db_engine):
async def test_run_append_result_validates_known_payloads(session_registry, db_engine, ledger_snapshot):
await _register_session(session_registry, available_apis={'run_append_result': True})
await _create_run(db_engine)
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
before = await ledger_snapshot()
result = await run_append_result(
{
@@ -1158,10 +1182,13 @@ async def test_run_append_result_validates_known_payloads(session_registry, db_e
assert result.code != 0
assert 'invalid action.requested payload' in result.message
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_run_append_and_finalize_claimed_target_require_active_claim(session_registry, db_engine):
async def test_run_append_and_finalize_claimed_target_require_active_claim(
session_registry, db_engine, ledger_snapshot
):
await _register_session(
session_registry,
available_apis={'run_append_result': True, 'run_finalize': True},
@@ -1185,6 +1212,7 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value]
before = await ledger_snapshot()
missing_claim = await run_append_result(
{
@@ -1198,6 +1226,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
},
}
)
assert missing_claim.code != 0
assert 'active claim ownership' in missing_claim.message
assert await ledger_snapshot() == before
wrong_claim = await run_finalize(
{
'run_id': 'run_1',
@@ -1208,6 +1239,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
'status': 'completed',
}
)
assert wrong_claim.code != 0
assert 'claim ownership is not active' in wrong_claim.message
assert await ledger_snapshot() == before
appended = await run_append_result(
{
'run_id': 'run_1',
@@ -1233,10 +1267,6 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
}
)
assert missing_claim.code != 0
assert 'active claim ownership' in missing_claim.message
assert wrong_claim.code != 0
assert 'claim ownership is not active' in wrong_claim.message
assert appended.code == 0
assert finalized.code == 0
assert finalized.data['status'] == 'completed'
@@ -1471,7 +1501,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation(
@pytest.mark.asyncio
async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine):
async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine, ledger_snapshot):
await _register_session(
session_registry,
available_apis={'run_renew_claim': True, 'run_release_claim': True},
@@ -1493,6 +1523,7 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
handler = _handler(db_engine)
run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value]
run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value]
before = await ledger_snapshot()
renewed = await run_renew_claim(
{
@@ -1503,6 +1534,9 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
'claim_token': claim['claim_token'],
}
)
assert renewed.code != 0
assert 'not accessible' in renewed.message.lower()
assert await ledger_snapshot() == before
released = await run_release_claim(
{
'run_id': 'run_1',
@@ -1513,14 +1547,13 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
}
)
assert renewed.code != 0
assert 'not accessible' in renewed.message.lower()
assert released.code != 0
assert 'not accessible' in released.message.lower()
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine):
async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine, ledger_snapshot):
await _register_session(
session_registry,
available_apis={'run_claim': True, 'run_release_claim': True},
@@ -1549,6 +1582,7 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_
}
)
assert claimed.code == 0
before = await ledger_snapshot()
released = await run_release_claim(
{
@@ -1563,3 +1597,4 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_
assert released.code != 0
assert 'use run_finalize' in released.message
assert await ledger_snapshot() == before
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import os
import tempfile
from copy import deepcopy
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
@@ -132,6 +133,24 @@ class TestStateScopeHelpers:
assert keys['subject'].startswith('subject:v2:')
assert keys['runner'].startswith('runner:v2:')
assert len(set(keys.values())) == len(keys)
for dimension, value in [
('workspace_id', 'ws_other'),
('bot_id', 'bot_other'),
]:
other = deepcopy(event)
setattr(other, dimension, value)
for scope in VALID_STATE_SCOPES:
assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope]
for scope, dimension, value in [
('conversation', 'conversation_id', 'conv_other'),
('conversation', 'thread_id', 'thread_other'),
('actor', 'actor_id', 'user_other'),
('subject', 'subject_id', 'msg_other'),
]:
other = deepcopy(event)
owner = other.actor if scope == 'actor' else other.subject if scope == 'subject' else other
setattr(owner, dimension, value)
assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope]
def test_scope_key_missing_identity_returns_none(self):
descriptor = make_descriptor()
@@ -868,6 +868,8 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters(
service = AgentService(app)
with pytest.raises(ValueError, match='Select an installed'):
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'invalid:a/b/c'})
app.persistence_mgr.execute_async.assert_not_awaited()
app.runner_registry.get.assert_not_awaited()
app.runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
@@ -882,6 +884,8 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters(
await service.create_agent(
WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c', 'parameters': {}}
)
app.persistence_mgr.execute_async.assert_not_awaited()
app.runner_registry.get.assert_awaited_once_with(WORKSPACE_UUID, 'plugin:a/b/c')
async def test_unavailable_event_processor_can_still_be_renamed():
@@ -347,11 +347,6 @@ class TestApiKeyServiceVerifyApiKey:
assert await service.verify_api_key('lbk_invalid_key') is False
async def test_verify_api_key_empty_string(self, api_key_context):
_application, service, _context, _engine = api_key_context
assert await service.verify_api_key('') is False
async def test_verify_api_key_unknown_key(self, api_key_context):
_application, service, _context, _engine = api_key_context
@@ -11,12 +11,15 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace
import hashlib
import hmac
import json
import sqlalchemy
import uuid
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.entity.persistence.bot import Bot
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
@@ -182,6 +185,11 @@ class TestBotServiceGetBot:
assert result is not None
assert result['uuid'] == 'test-uuid'
assert result['name'] == 'Found Bot'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(
sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID)
)
ap.persistence_mgr.serialize_model.assert_called_once_with(Bot, bot, ['adapter_config'])
async def test_get_bot_by_uuid_not_found(self):
"""Returns None when bot not found."""
@@ -361,6 +369,8 @@ class TestBotServiceCreateBot:
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of bots'):
await service.create_bot(WORKSPACE_UUID, {'name': 'New Bot'})
assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list)
ap.platform_mgr.load_bot.assert_not_awaited()
async def test_create_bot_no_limit(self):
"""Creates bot without limit check when max_bots=-1."""
@@ -408,7 +418,22 @@ class TestBotServiceCreateBot:
# Verify
assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format
assert uuid.UUID(bot_uuid).version == 4
statement = ap.persistence_mgr.execute_async.await_args_list[0].args[0]
assert statement.is_insert
assert statement.table.name == Bot.__tablename__
values = statement.compile().params
expected = {
'uuid': bot_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Bot',
'adapter': 'telegram',
'adapter_config': {},
'event_bindings': [],
'plugin_processors': [],
}
assert {key: values.get(key) for key in expected} == expected
ap.platform_mgr.load_bot.assert_not_awaited()
async def test_create_bot_scopes_insert_without_legacy_pipeline_fields(self):
"""Creates a Workspace-owned Bot without restoring removed pipeline columns."""
@@ -647,26 +672,28 @@ class TestBotServiceDeleteBot:
await service.delete_bot(WORKSPACE_UUID, 'test-uuid')
# Verify
ap.platform_mgr.remove_bot.assert_called_once_with(WORKSPACE_UUID, 'test-uuid')
ap.persistence_mgr.execute_async.assert_called_once()
ap.platform_mgr.remove_bot.assert_awaited_once_with(WORKSPACE_UUID, 'test-uuid')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_delete
assert statement.table.name == Bot.__tablename__
assert statement.whereclause.compare(
sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID)
)
async def test_delete_bot_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
"""A missing persisted Bot is rejected before removing its runtime."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0))
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
# Execute - should not raise
await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify - both called regardless
ap.platform_mgr.remove_bot.assert_called_once()
with pytest.raises(WorkspaceNotFoundError, match='Bot not found'):
await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid')
ap.platform_mgr.remove_bot.assert_not_awaited()
class TestBotServiceListEventLogs:
@@ -811,7 +838,9 @@ class TestBotServiceHttpBotInboundTest:
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
headers = request.kwargs['headers']
assert headers['X-LB-Timestamp']
assert headers['X-LB-Signature'].startswith('sha256=')
signing_input = headers['X-LB-Timestamp'].encode() + b'.' + request.kwargs['data']
digest = hmac.new(b'test-secret', signing_input, hashlib.sha256).hexdigest()
assert headers['X-LB-Signature'] == f'sha256={digest}'
async def test_rejects_non_http_bot(self):
service = BotService(SimpleNamespace())
@@ -236,6 +236,8 @@ async def test_extension_discovery_preserves_empty_bindings_and_redacts_plugins(
ap.plugin_connector.require_workspace_context.assert_awaited_once_with(request_context())
@pytest.mark.compiled_cli
@pytest.mark.contract('cli_http')
async def test_compiled_cli_against_core_http(service, tmp_path): # noqa: F811
"""Optional cross-repo check: real HTTP/SQLite/Pipeline, synthetic runtime providers."""
import json
@@ -73,7 +73,7 @@ async def test_all_model_types_reject_creation_under_managed_provider(
with pytest.raises(ValueError, match='managed by Cloud'):
await getattr(service, create_method)(WORKSPACE, model_data)
guard.assert_awaited_once()
guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER)
@pytest.mark.parametrize(
@@ -109,4 +109,4 @@ async def test_all_model_types_reject_update_and_delete_for_managed_provider(
with pytest.raises(ValueError, match='managed by Cloud'):
await getattr(service, write_method)(*args)
guard.assert_awaited_once()
guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER)
@@ -390,6 +390,7 @@ class TestGetKnowledgeBase:
result = await KnowledgeService(app).get_knowledge_base(CONTEXT, 'kb1')
assert result == {'uuid': 'kb1', 'name': 'KB1'}
app.rag_mgr.get_knowledge_base_details.assert_awaited_once_with(CONTEXT, 'kb1')
@pytest.mark.asyncio
async def test_returns_none_when_not_found(self):
@@ -555,7 +556,11 @@ class TestListParsers:
result = await KnowledgeService(app).list_parsers(CONTEXT)
assert len(result) == 2
assert result == [
{'id': 'parser1', 'supported_mime_types': ['text/plain']},
{'id': 'parser2', 'supported_mime_types': ['application/pdf']},
]
app.plugin_connector.list_parsers.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_filters_by_mime_type(self):
@@ -589,7 +594,8 @@ class TestGetEngineSchemas:
'author/engine',
)
assert 'properties' in result
assert result == {'properties': {'name': {'type': 'string'}}}
app.plugin_connector.get_rag_creation_schema.assert_awaited_once_with('author/engine')
@pytest.mark.asyncio
async def test_returns_retrieval_schema(self):
@@ -601,7 +607,8 @@ class TestGetEngineSchemas:
'author/engine',
)
assert 'properties' in result
assert result == {'properties': {'top_k': {'type': 'integer'}}}
app.plugin_connector.get_rag_retrieval_schema.assert_awaited_once_with('author/engine')
@pytest.mark.asyncio
async def test_reports_schema_runtime_exception(self):
@@ -14,8 +14,9 @@ Source: src/langbot/pkg/api/http/service/maintenance.py
from __future__ import annotations
import contextlib
import os
import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from unittest.mock import AsyncMock, Mock, patch, MagicMock, call
from types import SimpleNamespace
import datetime
from pathlib import Path
@@ -91,6 +92,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Execute
result = await service.cleanup_expired_files(TEST_CONTEXT)
service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7)
service._cleanup_expired_log_files.assert_called_once_with(3)
# Verify - returns counts
assert 'uploaded_files' in result
@@ -128,6 +131,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Execute
result = await service.cleanup_expired_files(TEST_CONTEXT)
service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 14)
service._cleanup_expired_log_files.assert_called_once_with(7)
# Verify
assert result['uploaded_files'] == 2
@@ -152,7 +157,10 @@ class TestMaintenanceServiceCleanupExpiredFiles:
service = MaintenanceService(ap)
# Mock the internal cleanup methods
service._cleanup_expired_uploaded_files = AsyncMock(return_value=1)
service._expired_s3_upload_candidates = AsyncMock(return_value=[{'key': 'scoped/expired-upload'}])
protected = Mock()
protected.scalars.return_value.all.return_value = []
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=protected))
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
# Execute
@@ -161,6 +169,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Verify
assert result['uploaded_files'] == 1
assert result['log_files'] == 0
service._expired_s3_upload_candidates.assert_awaited_once_with(TEST_CONTEXT, 7)
s3_provider.delete.assert_awaited_once_with('scoped/expired-upload')
async def test_cleanup_expired_files_invalid_retention(self):
"""Uses default for invalid retention config."""
@@ -192,6 +202,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Execute
result = await service.cleanup_expired_files(TEST_CONTEXT)
service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7)
service._cleanup_expired_log_files.assert_called_once_with(3)
# Verify - warning logged, defaults used
assert ap.logger.warning.called
@@ -280,7 +292,25 @@ class TestMaintenanceServiceGetStorageAnalysis:
assert 'cleanup_policy' in result
assert 'sections' in result
assert 'database' in result
assert 'cleanup_candidates' in result
assert result['cleanup_policy'] == {'uploaded_file_retention_days': 7, 'log_retention_days': 3}
assert {
section['key']: (Path(section['path']).as_posix(), section['size_bytes'], section['file_count'])
for section in result['sections']
} == {
'database': ('data/langbot.db', 1000, 5),
'logs': ('data/logs', 1000, 5),
'storage': ('data/storage', 1000, 5),
'vector_store': ('data/chroma', 1000, 5),
'temp': ('data/temp', 1000, 5),
}
assert result['total_size_bytes'] == 5000
assert result['database'] == {
'type': 'sqlite',
'monitoring_counts': {'messages': 10, 'errors': 0},
'binary_storage': {'count': 5, 'size_bytes': 500},
}
assert result['cleanup_candidates'] == {'uploaded_files': [], 'log_files': []}
assert result['tasks'] == {'running': 0}
async def test_get_storage_analysis_sections(self):
"""Returns all storage sections."""
@@ -462,8 +492,12 @@ class TestMaintenanceServiceGetStorageAnalysis:
result = await service.get_storage_analysis(TEST_CONTEXT)
# Verify
assert len(result['cleanup_candidates']['uploaded_files']) == 1
assert len(result['cleanup_candidates']['log_files']) == 1
assert result['cleanup_candidates'] == {
'uploaded_files': [{'key': 'old_file', 'size_bytes': 100}],
'log_files': [{'name': 'old_log', 'size_bytes': 50}],
}
service._expired_uploaded_candidates.assert_awaited_once_with(TEST_CONTEXT, 7)
service._expired_log_candidates.assert_called_once_with(3)
class TestMaintenanceServiceMonitoringCounts:
@@ -484,12 +518,9 @@ class TestMaintenanceServiceMonitoringCounts:
result = await service._monitoring_counts(TEST_CONTEXT)
# Verify - all table keys present
assert 'messages' in result
assert 'llm_calls' in result
assert 'embedding_calls' in result
assert 'errors' in result
assert 'sessions' in result
assert 'feedback' in result
assert result == dict.fromkeys(
['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 42
)
async def test_monitoring_counts_zero_results(self):
"""Returns zero counts when tables empty."""
@@ -506,7 +537,9 @@ class TestMaintenanceServiceMonitoringCounts:
result = await service._monitoring_counts(TEST_CONTEXT)
# Verify - all zero
assert all(v == 0 for v in result.values())
assert result == dict.fromkeys(
['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 0
)
class TestMaintenanceServiceBinaryStorageStats:
@@ -573,7 +606,7 @@ class TestMaintenanceServiceBinaryStorageStats:
# Verify - warning logged, size_bytes None or 0
assert ap.logger.warning.called
assert result['count'] == 5
assert result == {'count': 5, 'size_bytes': None}
class TestMaintenanceServicePathSize:
@@ -788,11 +821,17 @@ class TestMaintenanceServiceIsUploadedFileKey:
service = MaintenanceService(ap)
# Execute - simple filename without path
key = f'{ap.storage_mgr.scoped_prefix(TEST_CONTEXT, owner_type="upload")}uploaded_file.txt'
ap.storage_mgr.scoped_prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{owner_type}/')
ap.storage_mgr.is_scoped_object_key = Mock(return_value=True)
key = 'scoped/upload/uploaded_file.txt'
result = service._is_uploaded_file_key(TEST_CONTEXT, key)
# Verify
assert result is True
assert ap.storage_mgr.scoped_prefix.call_args_list == [
call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload')
]
ap.storage_mgr.is_scoped_object_key.assert_called_once_with(key, expected_owner_type='upload')
def test_is_uploaded_file_key_with_path(self):
"""Returns False for key with path separator."""
@@ -907,7 +946,7 @@ class TestMaintenanceServiceExpiredLogCandidates:
result = service._expired_log_candidates(3, include_paths=True)
# Verify - path included
assert 'path' in result[0]
assert result[0]['path'] == '/data/logs/' + old_log_name
class TestMaintenanceServiceExpiredLocalUploadCandidates:
@@ -928,40 +967,38 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
# Verify
assert result == []
def test_expired_local_upload_candidates_filters_uploaded(self):
"""Only returns uploaded files matching pattern."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.storage_mgr = _scoped_storage_manager()
def test_expired_local_upload_candidates_filters_uploaded(self, tmp_path, monkeypatch):
"""Only expired files below this Workspace's upload roots are candidates."""
monkeypatch.chdir(tmp_path)
prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{context.workspace_uuid}/{owner_type}/')
ap = SimpleNamespace(logger=SimpleNamespace(warning=Mock()), storage_mgr=SimpleNamespace(scoped_prefix=prefix))
storage_root = Path('data/storage')
upload_root = storage_root / 'scoped/test-workspace/upload'
upload_root.mkdir(parents=True)
old = upload_root / 'valid_upload.txt'
old.write_bytes(b'x' * 100)
os.utime(old, (0, 0))
recent = upload_root / 'recent.txt'
recent.write_text('keep')
(upload_root / 'directory').mkdir()
foreign = storage_root / 'scoped/other-workspace/upload/foreign.txt'
foreign.parent.mkdir(parents=True)
foreign.write_text('keep')
os.utime(foreign, (0, 0))
service = MaintenanceService(ap)
# Create one file and one non-file entry under the scoped upload root.
mock_entry_valid = Mock(spec=Path)
mock_entry_valid.is_file = Mock(return_value=True)
mock_entry_valid.name = 'valid_upload.txt'
mock_stat = Mock()
mock_stat.st_size = 100
mock_stat.st_mtime = 0 # Very old
mock_entry_valid.stat = Mock(return_value=mock_stat)
mock_entry_valid.relative_to = Mock(return_value=Path('scoped/valid_upload.txt'))
result = MaintenanceService(ap)._expired_local_upload_candidates(TEST_CONTEXT, 7)
mock_entry_plugin = Mock(spec=Path)
mock_entry_plugin.is_file = Mock(return_value=False)
mock_entry_plugin.name = 'plugin_config_test.json'
mock_stat2 = Mock()
mock_stat2.st_size = 200
mock_stat2.st_mtime = 0
mock_entry_plugin.stat = Mock(return_value=mock_stat2)
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'rglob') as mock_rglob:
mock_rglob.return_value = [mock_entry_valid, mock_entry_plugin]
result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
# Verify - only valid upload included
assert len(result) == 1
assert result[0]['key'] == 'scoped/valid_upload.txt'
assert result == [
{
'key': 'scoped/test-workspace/upload/valid_upload.txt',
'size_bytes': 100,
'modified_at': '1970-01-01T00:00:00+00:00',
}
]
assert prefix.call_args_list == [
call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload')
]
assert recent.read_text() == foreign.read_text() == 'keep'
def test_expired_local_upload_candidates_includes_path(self):
"""Includes path when include_paths=True."""
@@ -991,7 +1028,7 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
)
# Verify - path included
assert 'path' in result[0]
assert result[0]['path'] == '/data/storage/old_file.txt'
def test_expired_local_upload_candidates_respects_run_limit(self):
ap = SimpleNamespace(
+101 -21
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio
import copy
import sqlalchemy
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock
from types import SimpleNamespace
@@ -97,6 +98,22 @@ def _create_mock_result(items: list = None, first_item=None, *, scalar_value=0,
return result
def _write_statement(ap, operation):
statements = [
call.args[0]
for call in ap.persistence_mgr.execute_async.await_args_list
if getattr(call.args[0], f'is_{operation}')
]
assert len(statements) == 1
statement = statements[0]
assert statement.table.name == MCPServer.__tablename__
if operation != 'insert':
assert statement.whereclause.compare(
sqlalchemy.and_(MCPServer.uuid == 'test-uuid', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid)
)
return statement
class TestMCPServiceGetRuntimeInfo:
"""Tests for get_runtime_info method."""
@@ -118,7 +135,8 @@ class TestMCPServiceGetRuntimeInfo:
# Verify
assert result is not None
assert result['status'] == 'running'
assert result == {'status': 'running', 'tools': 5}
ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'test-server')
async def test_get_runtime_info_session_not_exists(self):
"""Returns None when session not exists."""
@@ -400,6 +418,8 @@ class TestMCPServiceCreateMCPServer:
# Execute & Verify - 2 plugins + new server would exceed limit
with pytest.raises(ValueError, match='Maximum number of extensions'):
await service.create_mcp_server(_CONTEXT, {'name': 'New Server'})
assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list)
ap.tool_mgr.mcp_tool_loader.get_session.assert_not_called()
async def test_create_mcp_server_no_limit(self):
"""Creates MCP server without limit when max_extensions=-1."""
@@ -427,7 +447,11 @@ class TestMCPServiceCreateMCPServer:
# Verify
assert server_uuid is not None
assert len(server_uuid) == 36 # UUID format
assert uuid.UUID(server_uuid).version == 4
values = _write_statement(ap, 'insert').compile().params
assert values['uuid'] == server_uuid
assert values['workspace_uuid'] == _CONTEXT.workspace_uuid
assert values['name'] == 'New Server'
async def test_create_mcp_server_duplicate_name_raises(self):
"""Rejects duplicate MCP server names."""
@@ -485,7 +509,14 @@ class TestMCPServiceCreateMCPServer:
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
# Verify - host_mcp_server was called
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
values = _write_statement(ap, 'insert').compile().params
assert values['workspace_uuid'] == _CONTEXT.workspace_uuid
assert values['name'] == 'New Server'
assert values['enable'] is True
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': True}
)
async def test_create_mcp_server_does_not_start_host_until_transaction_commits(self):
"""The Runtime must not observe a server row that can still roll back."""
@@ -536,25 +567,36 @@ class TestMCPServiceCreateMCPServer:
]
async def test_create_mcp_server_disabled_no_load(self):
"""Does not load server when disabled."""
"""Persists and passes disabled configuration to the runtime loader."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = None
ap.tool_mgr = SimpleNamespace(
mcp_tool_loader=SimpleNamespace(host_mcp_server=AsyncMock(), _hosted_mcp_tasks=[])
)
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'})
server = _create_mock_mcp_server(server_uuid='new-uuid', name='New Server', enable=False)
ap.persistence_mgr.execute_async = AsyncMock(
side_effect=[_create_mock_result(), _create_mock_result(), _create_mock_result(first_item=server)]
)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': False}
)
service = _service(ap)
# Execute with enable=False
server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': False})
# Verify - no tool_mgr load attempt
assert server_uuid is not None
# Verify the persisted flag and the configuration passed to the loader
assert uuid.UUID(server_uuid).version == 4
assert _write_statement(ap, 'insert').compile().params['enable'] is False
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': False}
)
class TestMCPServiceGetMCPServerByName:
@@ -585,6 +627,12 @@ class TestMCPServiceGetMCPServerByName:
# Verify
assert result is not None
assert result['name'] == 'Found Server'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(
sqlalchemy.and_(MCPServer.name == 'Found Server', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid)
)
ap.persistence_mgr.serialize_model.assert_called_once_with(MCPServer, server)
ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'Found Server')
async def test_get_mcp_server_by_name_not_found(self):
"""Returns None when MCP server not found."""
@@ -647,7 +695,9 @@ class TestMCPServiceUpdateMCPServer:
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False})
# Verify - server was removed
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once()
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is False
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_awaited_once_with(_CONTEXT, 'Old Server')
async def test_update_mcp_server_enable_disabled_server(self):
"""Loads server when enabling previously disabled server."""
@@ -687,7 +737,12 @@ class TestMCPServiceUpdateMCPServer:
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': True})
# Verify - server was loaded
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is True
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
async def test_update_mcp_server_update_enabled_server(self):
"""Removes and reloads server when updating enabled server."""
@@ -725,7 +780,13 @@ class TestMCPServiceUpdateMCPServer:
# Verify - remove and reload
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Old Server')
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is True
assert values.get('extra_args') == {'new': 'args'}
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
async def test_update_mcp_server_no_tool_mgr(self):
"""Updates persistence without tool_mgr operations."""
@@ -763,7 +824,8 @@ class TestMCPServiceUpdateMCPServer:
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False})
# Verify - persistence was called
assert ap.persistence_mgr.execute_async.call_count >= 2
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is False
async def test_update_restores_existing_masked_secrets_and_preserves_explicit_changes(self):
ap = SimpleNamespace()
@@ -884,7 +946,7 @@ class TestMCPServiceDeleteMCPServer:
# Verify
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Server to Delete')
ap.persistence_mgr.execute_async.assert_called()
_write_statement(ap, 'delete')
async def test_delete_mcp_server_not_in_sessions(self):
"""Does not attempt remove if server not in sessions."""
@@ -923,7 +985,8 @@ class TestMCPServiceDeleteMCPServer:
await service.delete_mcp_server(_CONTEXT, 'test-uuid')
# Verify - remove not called (server not in sessions)
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_called()
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_awaited()
_write_statement(ap, 'delete')
async def test_delete_mcp_server_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
@@ -989,6 +1052,7 @@ class TestMCPServiceTestMCPServer:
mock_session = MagicMock()
mock_session.status = MCPSessionStatus.ERROR
mock_session.start = AsyncMock()
mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3}
mock_session.refresh = AsyncMock()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session)
@@ -997,8 +1061,10 @@ class TestMCPServiceTestMCPServer:
service = _service(ap)
service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'}))
def create_user_task(coroutine, **_kwargs):
coroutine.close()
captured = {}
def create_user_task(coroutine, **kwargs):
captured.update(coroutine=coroutine, context=kwargs['context'])
return SimpleNamespace(id=123)
ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
@@ -1007,7 +1073,11 @@ class TestMCPServiceTestMCPServer:
task_id = await service.test_mcp_server(_CONTEXT, 'existing-server', {})
# Verify - returns task ID
ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'existing-server')
assert task_id == 123
await captured['coroutine']
mock_session.start.assert_awaited_once_with()
assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3}
@pytest.mark.parametrize('refresh_first', [False, True])
async def test_persisted_test_preserves_failure_details(self, refresh_first):
@@ -1064,14 +1134,18 @@ class TestMCPServiceTestMCPServer:
mock_session = MagicMock()
mock_session.start = AsyncMock()
mock_session.shutdown = AsyncMock()
mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3}
ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session)
ap.task_mgr = SimpleNamespace()
service = _service(ap)
def create_user_task(coroutine, **_kwargs):
coroutine.close()
captured = {}
def create_user_task(coroutine, **kwargs):
captured.update(coroutine=coroutine, context=kwargs['context'])
return SimpleNamespace(id=456)
ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
@@ -1080,8 +1154,14 @@ class TestMCPServiceTestMCPServer:
task_id = await service.test_mcp_server(_CONTEXT, '_', {'name': 'New Server'})
# Verify - load_mcp_server called
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_awaited_once_with(
_CONTEXT, {'name': 'New Server', 'workspace_uuid': _CONTEXT.workspace_uuid}
)
assert task_id == 456
await captured['coroutine']
mock_session.start.assert_awaited_once_with()
mock_session.shutdown.assert_awaited_once_with()
assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3}
async def test_transient_test_preserves_runtime_info_after_connection_failure(self):
runtime_info = {
@@ -14,6 +14,7 @@ Source: src/langbot/pkg/api/http/service/model.py
from __future__ import annotations
import pytest
import sqlalchemy
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
@@ -182,6 +183,42 @@ def _create_reasoning_runtime_provider(capabilities: dict) -> SimpleNamespace:
)
def _assert_created_model(ap, model_class, expected):
statements = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_insert]
assert len(statements) == 1
statement = statements[0]
assert statement.table.name == model_class.__tablename__
values = statement.compile().params
assert {key: values.get(key) for key in expected} == expected
kind = {LLMModel: 'llm', EmbeddingModel: 'embedding', RerankModel: 'rerank'}[model_class]
load = getattr(ap.model_mgr, f'load_{kind}_model_with_provider')
load.assert_awaited_once()
context, entity, provider = load.await_args.args
assert context == WORKSPACE_UUID
assert entity.uuid == expected['uuid']
assert provider is ap.model_mgr.provider_dict[expected['provider_uuid']]
getattr(ap.model_mgr, f'cache_{kind}_model').assert_awaited_once_with(WORKSPACE_UUID, load.return_value)
def _assert_provider_query(ap, model_class, provider_uuid):
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_select
assert statement.whereclause.compare(
sqlalchemy.and_(model_class.provider_uuid == provider_uuid, model_class.workspace_uuid == WORKSPACE_UUID)
)
def _assert_model_delete(ap, model_class, model_uuid):
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_delete
assert statement.table.name == model_class.__tablename__
assert statement.whereclause.compare(
sqlalchemy.and_(model_class.uuid == model_uuid, model_class.workspace_uuid == WORKSPACE_UUID)
)
class TestParseProviderApiKeys:
"""Tests for _parse_provider_api_keys helper function."""
@@ -348,6 +385,8 @@ class TestLLMModelsServiceGetLLMModels:
assert len(result) == 1
assert result[0]['name'] == 'Test LLM'
assert result[0]['context_length'] == 128000
assert result[0].get('provider', {}).get('uuid') == 'provider-uuid'
assert result[0].get('provider', {}).get('name') == 'Test Provider'
async def test_get_llm_models_hide_secret_keys(self):
"""Hides secret API keys when include_secret=False."""
@@ -462,7 +501,9 @@ class TestLLMModelsServiceGetLLMModelsByProvider:
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'model-1', 'name': 'Model 1'})
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = LLMModelsService(ap)
@@ -470,7 +511,8 @@ class TestLLMModelsServiceGetLLMModelsByProvider:
result = await service.get_llm_models_by_provider(WORKSPACE_UUID, 'target-provider')
# Verify
assert len(result) == 2
assert [item['uuid'] for item in result] == ['model-1', 'model-2']
_assert_provider_query(ap, LLMModel, 'target-provider')
class TestLLMModelsServiceCreateLLMModel:
@@ -507,6 +549,11 @@ class TestLLMModelsServiceCreateLLMModel:
# Verify
assert model_uuid is not None
assert len(model_uuid) == 36 # UUID format
_assert_created_model(
ap,
LLMModel,
{'uuid': model_uuid, 'workspace_uuid': WORKSPACE_UUID, 'name': 'New LLM', 'provider_uuid': 'provider-uuid'},
)
async def test_create_llm_model_preserve_uuid(self):
"""Creates LLM model preserving provided UUID."""
@@ -540,6 +587,16 @@ class TestLLMModelsServiceCreateLLMModel:
# Verify
assert model_uuid == 'preserved-uuid'
_assert_created_model(
ap,
LLMModel,
{
'uuid': model_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'Preserved UUID Model',
'provider_uuid': 'provider-uuid',
},
)
async def test_create_llm_model_persists_context_length_as_column(self):
"""Creates LLM model with context_length outside extra_args."""
@@ -574,6 +631,17 @@ class TestLLMModelsServiceCreateLLMModel:
assert runtime_entity.context_length == 128000
assert runtime_entity.extra_args == {'temperature': 0.2}
assert 'context_length' not in runtime_entity.extra_args
_assert_created_model(
ap,
LLMModel,
{
'uuid': 'model-with-context',
'workspace_uuid': WORKSPACE_UUID,
'provider_uuid': 'provider-uuid',
'context_length': 128000,
'extra_args': {'temperature': 0.2},
},
)
async def test_create_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found in runtime."""
@@ -639,7 +707,9 @@ class TestLLMModelsServiceCreateLLMModel:
)
# Verify - provider_service was called and UUID generated
ap.provider_service.find_or_create_provider.assert_called_once()
ap.provider_service.find_or_create_provider.assert_awaited_once_with(
WORKSPACE_UUID, requester='openai', base_url='https://api.openai.com', api_keys=['key']
)
assert result_uuid is not None
async def test_create_llm_model_validates_explicit_reasoning_level(self):
@@ -738,6 +808,11 @@ class TestLLMModelsServiceUpdateLLMModel:
# Verify - remove and load called
ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert 'uuid' not in statement.compile().params
assert statement.compile().params.get('name') == 'Updated Name'
async def test_update_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found after update."""
@@ -795,6 +870,11 @@ class TestLLMModelsServiceUpdateLLMModel:
assert runtime_entity.context_length == 64000
assert runtime_entity.extra_args == {'temperature': 0.4}
assert 'context_length' not in runtime_entity.extra_args
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert statement.compile().params.get('context_length') == 64000
assert statement.compile().params.get('extra_args') == {'temperature': 0.4}
class TestLLMModelsServiceDeleteLLMModel:
@@ -817,7 +897,8 @@ class TestLLMModelsServiceDeleteLLMModel:
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'delete-uuid')
ap.model_mgr.remove_llm_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-uuid')
_assert_model_delete(ap, LLMModel, 'delete-uuid')
class TestEmbeddingModelsServiceGetEmbeddingModels:
@@ -880,7 +961,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModels:
)
# Verify
assert len(result) == 1
assert [(item['uuid'], item['name']) for item in result] == [('embedding-uuid', 'Test Embedding')]
assert result[0].get('provider', {}).get('uuid') == 'provider-uuid'
assert result[0].get('provider', {}).get('name') == 'Test Provider'
class TestEmbeddingModelsServiceGetEmbeddingModel:
@@ -907,11 +990,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel:
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-embedding',
'name': 'Found Embedding',
'provider': {'uuid': 'provider-uuid'},
}
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = EmbeddingModelsService(ap)
@@ -920,7 +999,11 @@ class TestEmbeddingModelsServiceGetEmbeddingModel:
result = await service.get_embedding_model(WORKSPACE_UUID, 'found-embedding')
# Verify
assert result is not None
assert result == {
'uuid': 'found-embedding',
'name': 'Test Embedding',
'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'},
}
async def test_get_embedding_model_not_found(self):
"""Returns None when model not found."""
@@ -971,6 +1054,16 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
# Verify
assert model_uuid is not None
assert len(model_uuid) == 36
_assert_created_model(
ap,
EmbeddingModel,
{
'uuid': model_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Embedding',
'provider_uuid': 'provider-uuid',
},
)
async def test_create_embedding_model_provider_not_found_raises(self):
"""Raises Exception when provider not found."""
@@ -1016,7 +1109,8 @@ class TestEmbeddingModelsServiceDeleteEmbeddingModel:
await service.delete_embedding_model(WORKSPACE_UUID, 'delete-embedding-uuid')
# Verify
ap.model_mgr.remove_embedding_model.assert_called_once()
ap.model_mgr.remove_embedding_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-embedding-uuid')
_assert_model_delete(ap, EmbeddingModel, 'delete-embedding-uuid')
class TestRerankModelsServiceGetRerankModels:
@@ -1078,7 +1172,9 @@ class TestRerankModelsServiceGetRerankModels:
)
# Verify
assert len(result) == 1
assert [(item['uuid'], item['name']) for item in result] == [('rerank-uuid', 'Test Rerank')]
assert result[0].get('provider', {}).get('uuid') == 'provider-uuid'
assert result[0].get('provider', {}).get('name') == 'Test Provider'
class TestRerankModelsServiceGetRerankModel:
@@ -1105,11 +1201,7 @@ class TestRerankModelsServiceGetRerankModel:
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-rerank',
'name': 'Found Rerank',
'provider': {'uuid': 'provider-uuid'},
}
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = RerankModelsService(ap)
@@ -1118,7 +1210,11 @@ class TestRerankModelsServiceGetRerankModel:
result = await service.get_rerank_model(WORKSPACE_UUID, 'found-rerank')
# Verify
assert result is not None
assert result == {
'uuid': 'found-rerank',
'name': 'Test Rerank',
'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'},
}
async def test_get_rerank_model_not_found(self):
"""Returns None when model not found."""
@@ -1168,6 +1264,16 @@ class TestRerankModelsServiceCreateRerankModel:
# Verify
assert model_uuid is not None
_assert_created_model(
ap,
RerankModel,
{
'uuid': model_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Rerank',
'provider_uuid': 'provider-uuid',
},
)
async def test_create_rerank_model_provider_not_found_raises(self):
"""Raises Exception when provider not found."""
@@ -1213,7 +1319,8 @@ class TestRerankModelsServiceDeleteRerankModel:
await service.delete_rerank_model(WORKSPACE_UUID, 'delete-rerank-uuid')
# Verify
ap.model_mgr.remove_rerank_model.assert_called_once()
ap.model_mgr.remove_rerank_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-rerank-uuid')
_assert_model_delete(ap, RerankModel, 'delete-rerank-uuid')
class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
@@ -1230,7 +1337,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'emb-1', 'name': 'Embedding 1'})
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = EmbeddingModelsService(ap)
@@ -1238,7 +1347,8 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
result = await service.get_embedding_models_by_provider(WORKSPACE_UUID, 'provider-uuid')
# Verify
assert len(result) == 2
assert [item['uuid'] for item in result] == ['emb-1', 'emb-2']
_assert_provider_query(ap, EmbeddingModel, 'provider-uuid')
class TestRerankModelsServiceGetRerankModelsByProvider:
@@ -1255,7 +1365,9 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'})
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = RerankModelsService(ap)
@@ -1263,7 +1375,8 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
result = await service.get_rerank_models_by_provider(WORKSPACE_UUID, 'provider-uuid')
# Verify
assert len(result) == 2
assert [item['uuid'] for item in result] == ['rerank-1', 'rerank-2']
_assert_provider_query(ap, RerankModel, 'provider-uuid')
class TestValidateProviderSupports:
@@ -21,6 +21,8 @@ pytestmark = pytest.mark.asyncio
@pytest.mark.asyncio(loop_scope='module')
@pytest.mark.postgres
@pytest.mark.contract('monitoring_sessions')
async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811
import asyncio
import importlib
@@ -138,7 +138,8 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
assert (await service.get_message_details(context_a, message_b))['found'] is False
async def test_session_search_matches_user_id_or_name_within_workspace(service):
@pytest.mark.parametrize('bot_ids', [None, ['same-bot']])
async def test_session_search_matches_user_id_or_name_within_workspace(service, bot_ids):
context_a = _context(WORKSPACE_A)
context_b = _context(WORKSPACE_B)
fixtures = [
@@ -159,8 +160,8 @@ async def test_session_search_matches_user_id_or_name_within_workspace(service):
user_name=user_name,
)
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
by_id, id_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='customer-42')
by_name, name_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='alice')
assert id_total == 1
assert [session['session_id'] for session in by_id] == ['session-id-match']
@@ -367,6 +367,9 @@ async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env
await service.update_pipeline(context(), 'one', {'name': 'renamed'})
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
async with env.engine.connect() as conn:
name = (await conn.execute(sa.select(LegacyPipeline.name).where(LegacyPipeline.uuid == 'one'))).scalar_one()
assert name == 'renamed'
@pytest.mark.asyncio
@@ -777,7 +780,8 @@ async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env):
await conn.execute(sa.delete(PluginSetting))
async def install(source, info, task_context):
assert info['plugin_version'] == '1.0'
assert source == env.m.PluginInstallSource.MARKETPLACE
assert info == {'plugin_author': 'langbot-team', 'plugin_name': 'TestAgent', 'plugin_version': '1.0'}
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(PluginSetting).values(
@@ -848,7 +852,14 @@ async def test_all_mode_has_no_fifty_pipeline_limit(env):
)
response, metadata = await execute_all(env, install_plugins=False)
assert len(response['pipeline_uuids']) == 53
expected = {'one', 'two', *(f'extra-{i}' for i in range(51))}
assert {r['pipeline_uuid'] for r in metadata['results']} == expected
assert len(metadata['results']) == 53
assert all(r['state'] == 'migrated' for r in metadata['results'])
configs, backups = await rows(env)
assert len(backups) == 53
assert all(configs[pipeline_uuid]['ai']['runner']['id'] == RID for pipeline_uuid in expected)
assert configs['foreign'] == SOURCE
@pytest.mark.asyncio
@@ -23,12 +23,6 @@ env = existing.env
NATIVE = 'langbot.pkg.provider.runners.difysvapi'
@pytest.fixture(autouse=True)
async def interaction_schema(env):
async with env.engine.begin() as conn:
await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True))
async def add_request(env, status='pending', workspace=WS, pipeline='one'):
async with env.engine.begin() as conn:
await conn.execute(
@@ -224,11 +218,3 @@ async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch):
configs, snapshots = await rows(env)
assert configs['one'] == SOURCE and not snapshots
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
# Also exercise the unchanged regression assertions with this module's
# interaction-schema fixture. The original tests remain in their own module.
for _name, _test in vars(existing).items():
if _name.startswith('test_') and callable(_test):
globals()['test_existing_' + _name[5:]] = _test
@@ -17,6 +17,7 @@ import pytest
from unittest.mock import AsyncMock, Mock, patch, mock_open
from types import SimpleNamespace
import uuid
import sqlalchemy
import json
from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order
@@ -145,8 +146,12 @@ class TestPipelineServiceGetPipelines:
)
# Verify
assert len(result) == 2
ap.persistence_mgr.execute_async.assert_called_once()
assert result == [{'uuid': 'uuid-1', 'name': 'Pipeline 1'}, {'uuid': 'uuid-2', 'name': 'Pipeline 2'}]
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert len(statement._order_by_clauses) == 1
assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.created_at.desc())
assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
async def test_get_pipelines_sort_by_updated_at_asc(self):
"""Returns pipelines sorted by updated_at ascending."""
@@ -164,7 +169,11 @@ class TestPipelineServiceGetPipelines:
await service.get_pipelines(WORKSPACE_UUID, sort_by='updated_at', sort_order='ASC')
# Verify - execute was called with sort parameters
ap.persistence_mgr.execute_async.assert_called_once()
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert len(statement._order_by_clauses) == 1
assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.updated_at.asc())
assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
class TestPipelineServiceGetPipeline:
@@ -271,7 +280,19 @@ class TestPipelineServiceCreatePipeline:
# Verify
assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format
assert uuid.UUID(bot_uuid).version == 4
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_insert
assert statement.table.name == LegacyPipeline.__tablename__
values = statement.compile().params
assert values['uuid'] == bot_uuid
assert values['workspace_uuid'] == WORKSPACE_UUID
assert values['name'] == 'New Pipeline'
assert values['config'] == default_config
assert values['for_version'] == '1.0.0'
assert values['is_default'] is False
service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, bot_uuid, include_secret=True)
ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value)
async def test_create_pipeline_as_default(self):
"""Creates pipeline with is_default=True."""
@@ -305,7 +326,10 @@ class TestPipelineServiceCreatePipeline:
await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True)
# Verify - execute was called
ap.persistence_mgr.execute_async.assert_called()
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_insert
assert statement.compile().params['is_default'] is True
async def test_create_pipeline_sets_default_extensions_preferences(self):
"""Sets default extensions_preferences when not provided."""
@@ -515,21 +539,19 @@ class TestPipelineServiceDeletePipeline:
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_pipeline_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
"""Missing persisted Pipelines do not remove runtime instances."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0))
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
service = PipelineService(ap)
# Execute - should not raise
await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify
ap.pipeline_mgr.remove_pipeline.assert_called_once()
with pytest.raises(WorkspaceNotFoundError, match='Pipeline not found'):
await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
ap.pipeline_mgr.remove_pipeline.assert_not_awaited()
class TestPipelineServiceCopyPipeline:
@@ -627,7 +649,23 @@ class TestPipelineServiceCopyPipeline:
# Verify
assert new_uuid is not None
assert len(new_uuid) == 36 # UUID format
assert uuid.UUID(new_uuid).version == 4
insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
assert insert.is_insert
values = insert.compile().params
assert values['uuid'] == new_uuid
assert values['workspace_uuid'] == WORKSPACE_UUID
assert values['name'] == 'Original Pipeline (Copy)'
assert values['description'] == 'Original description'
assert values['stages'] == ['Stage1', 'Stage2']
assert values['config'] == {'key': 'value'}
assert values['extensions_preferences']['enable_all_plugins'] is False
assert values['extensions_preferences']['plugins'] == []
query = ap.persistence_mgr.execute_async.await_args_list[0].args[0]
assert query.whereclause.compare(
sqlalchemy.and_(LegacyPipeline.uuid == 'original-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
)
service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, new_uuid, include_secret=True)
async def test_copy_pipeline_is_not_default(self):
"""Copy is never set as default."""
@@ -659,7 +697,20 @@ class TestPipelineServiceCopyPipeline:
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
# Verify - pipeline_mgr.load_pipeline called (copy created)
ap.pipeline_mgr.load_pipeline.assert_called_once()
ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value)
insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
assert insert.is_insert
assert insert.compile().params['is_default'] is False
def _persisted_extensions(ap):
updates = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_update]
assert len(updates) == 1
statement = updates[0]
assert statement.whereclause.compare(
sqlalchemy.and_(LegacyPipeline.uuid == 'test-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
)
return statement.compile().params['extensions_preferences']
class TestPipelineServiceUpdatePipelineExtensions:
@@ -731,7 +782,9 @@ class TestPipelineServiceUpdatePipelineExtensions:
)
# Verify
ap.persistence_mgr.execute_async.assert_called()
persisted = _persisted_extensions(ap)
assert persisted['enable_all_plugins'] is False
assert persisted['plugins'] == [{'author': 'test', 'name': 'plugin-1'}]
async def test_update_extensions_sets_mcp_servers(self):
"""Updates MCP servers in extensions_preferences."""
@@ -782,7 +835,9 @@ class TestPipelineServiceUpdatePipelineExtensions:
)
# Verify
ap.persistence_mgr.execute_async.assert_called()
persisted = _persisted_extensions(ap)
assert persisted['enable_all_mcp_servers'] is False
assert persisted['mcp_servers'] == ['mcp-server-1']
async def test_update_extensions_none_mcp_servers_keeps_existing(self):
"""Does not modify mcp_servers when bound_mcp_servers is None."""
@@ -825,7 +880,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
# Verify - persistence was called
ap.persistence_mgr.execute_async.assert_called()
assert _persisted_extensions(ap)['mcp_servers'] == ['existing-server']
async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
"""Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
@@ -863,10 +918,9 @@ class TestPipelineServiceUpdatePipelineExtensions:
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
assert original_pipeline.extensions_preferences['mcp_resources'] == [
{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
]
persisted = _persisted_extensions(ap)
assert persisted['mcp_resource_agent_read_enabled'] is False
assert persisted['mcp_resources'] == [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}]
class TestPipelineSecretRoundtrip:
@@ -14,12 +14,14 @@ Source: src/langbot/pkg/api/http/service/provider.py
from __future__ import annotations
import pytest
import sqlalchemy
from uuid import UUID
from contextlib import nullcontext
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
@@ -300,7 +302,23 @@ class TestModelProviderServiceCreateProvider:
# Verify - UUID is generated
assert provider_uuid is not None
assert len(provider_uuid) == 36 # UUID format
assert UUID(provider_uuid).version == 4
expected = {
'uuid': provider_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
}
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_insert
assert statement.table.name == ModelProvider.__tablename__
values = statement.compile().params
assert {key: values.get(key) for key in expected} == expected
ap.model_mgr.load_provider.assert_awaited_once_with(WORKSPACE_UUID, expected)
ap.model_mgr.cache_provider.assert_awaited_once_with(WORKSPACE_UUID, runtime_provider)
async def test_create_provider_loads_to_runtime(self):
"""Loads provider to runtime model_mgr."""
@@ -362,7 +380,15 @@ class TestModelProviderServiceUpdateProvider:
)
# Verify - reload called
ap.model_mgr.reload_provider.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid')
ap.model_mgr.reload_provider.assert_awaited_once_with(WORKSPACE_UUID, 'existing-uuid')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert 'uuid' not in statement.compile().params
assert statement.compile().params.get('name') == 'Updated Name'
assert statement.whereclause.compare(
sqlalchemy.and_(ModelProvider.uuid == 'existing-uuid', ModelProvider.workspace_uuid == WORKSPACE_UUID)
)
async def test_update_provider_reloads_runtime(self):
"""Reloads provider in runtime after update."""
@@ -432,16 +458,20 @@ class TestModelProviderServiceGetProviderModelCounts:
rerank_result = Mock()
rerank_result.scalar = Mock(return_value=1)
call_count = 0
results_by_table = {
LLMModel.__tablename__: (LLMModel, llm_result),
EmbeddingModel.__tablename__: (EmbeddingModel, embedding_result),
RerankModel.__tablename__: (RerankModel, rerank_result),
}
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return llm_result
elif call_count == 2:
return embedding_result
return rerank_result
model_type, result = results_by_table[query.get_final_froms()[0].name]
assert query.whereclause.compare(
sqlalchemy.and_(
model_type.provider_uuid == 'provider-uuid', model_type.workspace_uuid == WORKSPACE_UUID
)
)
return result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
@@ -602,7 +632,12 @@ class TestModelProviderServiceFindOrCreateProvider:
)
# Verify - name should be parsed from URL (api.example.com)
ap.model_mgr.load_provider.assert_called_once()
ap.model_mgr.load_provider.assert_awaited_once()
assert ap.model_mgr.load_provider.await_args.args[0] == WORKSPACE_UUID
assert ap.model_mgr.load_provider.await_args.args[1]['name'] == 'api.example.com'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_insert
assert statement.compile().params.get('name') == 'api.example.com'
assert result_uuid is not None
@@ -624,6 +659,16 @@ class TestModelProviderServiceUpdateSpaceModelProviderApiKeys:
# Execute
await service.update_space_model_provider_api_keys(WORKSPACE_UUID, 'space-api-key')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert statement.compile().params.get('api_keys') == ['space-api-key']
assert statement.whereclause.compare(
sqlalchemy.and_(
ModelProvider.uuid == '00000000-0000-0000-0000-000000000000',
ModelProvider.workspace_uuid == WORKSPACE_UUID,
)
)
# Verify - update and reload called for Space provider UUID
ap.model_mgr.reload_provider.assert_called_once_with(
WORKSPACE_UUID,
@@ -86,11 +86,19 @@ async def test_reset_preserves_logs_and_isolates_context(context_service):
async with engine.connect() as conn:
assert (await conn.execute(sa.select(sa.func.count()).select_from(Transcript))).scalar() == 2
assert (await conn.execute(sa.select(sa.func.count()).select_from(MonitoringSession))).scalar() == 1
assert (
remaining = (
await conn.execute(
sa.select(sa.func.count()).select_from(RunnerState).where(RunnerState.runner_id == 'runner')
sa.select(
RunnerState.workspace_id, RunnerState.bot_id, RunnerState.conversation_id, RunnerState.scope
).where(RunnerState.runner_id == 'runner')
)
).scalar() == 4
).all()
assert set(remaining) == {
('w', 'b2', 'person_u', 'conversation'),
('w2', 'b', 'person_u', 'conversation'),
('w', 'b', 'person_other', 'conversation'),
('w', 'b', 'person_u', 'actor'),
}
page = await store.page_transcript('person_u', bot_id='b', workspace_id='w')
assert not page[0]
assert (await store.page_transcript('person_u', bot_id='b2', workspace_id='w'))[0]
@@ -143,6 +151,6 @@ async def test_reset_starts_new_external_runner_conversation(context_service):
] == 'remote-old'
await service.reset_session_context('w', 'b', 'person_u')
snapshot = await store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot['conversation']['external.conversation_id'] == await get_reset_generation(
engine, 'w', 'b', 'person_u'
)
generation = await get_reset_generation(engine, 'w', 'b', 'person_u')
assert isinstance(generation, str) and generation and generation not in {'remote-old', 'person_u'}
assert snapshot['conversation']['external.conversation_id'] == generation
@@ -15,6 +15,7 @@ from __future__ import annotations
from collections import OrderedDict
import json
import sqlalchemy
from urllib.parse import parse_qs, urlsplit
import pytest
@@ -25,6 +26,7 @@ import time
from langbot.pkg.api.http.service.space import SpaceService
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
from langbot.pkg.utils import constants
@@ -157,6 +159,8 @@ class TestSpaceServiceGetUserByEmail:
# Verify
assert result is not None
assert result.user == '[email protected]'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(User.user == '[email protected]')
async def test_get_user_by_email_not_found(self):
"""Returns None when user not found."""
@@ -478,6 +482,9 @@ class TestSpaceServiceRefreshToken:
# Verify
assert result['access_token'] == 'new_access_token'
mock_session_obj.post.assert_called_once_with(
'https://space.langbot.app/api/v1/accounts/token/refresh', json={'refresh_token': 'old_refresh_token'}
)
async def test_refresh_token_api_error(self):
"""Raises ValueError on API error."""
@@ -662,6 +669,9 @@ class TestSpaceServiceGetUserInfoRaw:
# Verify
assert result['email'] == '[email protected]'
assert result['credits'] == 100
mock_session_obj.get.assert_called_once_with(
'https://space.langbot.app/api/v1/accounts/me', headers={'Authorization': 'Bearer access_token'}
)
async def test_get_user_info_raw_api_error(self):
"""Raises ValueError on API error."""
@@ -737,7 +747,8 @@ class TestSpaceServiceGetUserInfo:
result = await service.get_user_info('[email protected]')
# Verify
assert result['email'] == '[email protected]'
assert result == {'email': '[email protected]', 'credits': 100}
service.get_user_info_raw.assert_awaited_once_with('valid_token')
class TestSpaceServiceGetModels:
@@ -980,6 +991,20 @@ class TestSpaceServiceGetModelSelection:
service.get_model_selection.assert_awaited_once_with('chat')
model_mgr.sync_new_models_from_space.assert_awaited_once()
assert persistence.execute_async.await_count == 2
execution = model_mgr.sync_new_models_from_space.await_args.args[0]
assert (execution.instance_uuid, execution.workspace_uuid, execution.placement_generation) == (
'instance',
'workspace',
1,
)
for call in persistence.execute_async.await_args_list:
assert call.args[0].whereclause.compare(
sqlalchemy.and_(
LLMModel.workspace_uuid == 'workspace',
ModelProvider.requester == 'space-chat-completions',
sqlalchemy.or_(LLMModel.uuid == 'best-upstream-uuid', LLMModel.name == 'best-chat-model'),
)
)
class TestSpaceServiceCreditsCache:
@@ -13,6 +13,7 @@ Source: src/langbot/pkg/api/http/service/user.py
from __future__ import annotations
import asyncio
import pytest
import jwt
import datetime
@@ -28,6 +29,7 @@ from langbot.pkg.api.http.service.user import (
UserService,
)
from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User
from langbot.pkg.entity.persistence.workspace import WorkspaceMembership, MembershipRole, MembershipStatus
from langbot.pkg.entity.errors.account import (
AccountEmailMismatchError,
SpaceAccountBindingRequiredError,
@@ -269,6 +271,8 @@ class TestUserServiceGetUserByEmail:
# Verify
assert result is not None
assert result.user == '[email protected]'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(User.normalized_email == '[email protected]')
async def test_get_user_by_email_not_found(self):
"""Returns None when user not found."""
@@ -327,6 +331,8 @@ class TestUserServiceGetUserBySpaceAccountUuid:
# Verify
assert result is not None
assert result.space_account_uuid == 'space-uuid-123'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(User.space_account_uuid == 'space-uuid-123')
async def test_get_user_by_space_uuid_not_found(self):
"""Returns None when Space UUID not found."""
@@ -621,6 +627,16 @@ class TestUserServiceSetPassword:
await service.set_password('[email protected]', 'new_password')
def _space_user_write(persistence, operation, credentials):
persistence.execute_async.assert_awaited_once()
statement = persistence.execute_async.await_args.args[0]
assert getattr(statement, f'is_{operation}')
assert statement.table.name == User.__tablename__
values = statement.compile().params
assert {key: values.get(key) for key in credentials} == credentials
return statement, values
class TestUserServiceCreateOrUpdateSpaceUser:
"""Tests for create_or_update_space_user method."""
@@ -657,7 +673,17 @@ class TestUserServiceCreateOrUpdateSpaceUser:
)
# Verify - update was called and user returned
ap.persistence_mgr.execute_async.assert_called()
statement, values = _space_user_write(
ap.persistence_mgr,
'update',
{
'space_access_token': 'new_access_token',
'space_refresh_token': 'new_refresh_token',
'space_api_key': 'new_api_key',
},
)
assert statement.whereclause.compare(User.space_account_uuid == 'existing-space-uuid')
assert isinstance(values['space_access_token_expires_at'], datetime.datetime)
assert updated_user.space_account_uuid == 'existing-space-uuid'
async def test_cloud_login_updates_only_the_projected_space_account(self):
@@ -690,7 +716,23 @@ class TestUserServiceCreateOrUpdateSpaceUser:
)
assert result is projected
persistence.execute_async.assert_awaited_once()
statement, values = _space_user_write(
persistence,
'update',
{
'space_access_token': 'access-token',
'space_refresh_token': 'refresh-token',
'space_api_key': 'api-key',
},
)
assert statement.whereclause.compare(
sqlalchemy.and_(
User.uuid == 'projected-space-uuid',
User.space_account_uuid == 'projected-space-uuid',
User.source == AccountSource.CLOUD_PROJECTION.value,
)
)
assert isinstance(values['space_access_token_expires_at'], datetime.datetime)
async def test_cloud_login_never_creates_an_unprojected_account(self):
persistence = SimpleNamespace(execute_async=AsyncMock())
@@ -770,6 +812,20 @@ class TestUserServiceCreateOrUpdateSpaceUser:
# Verify
assert result.space_account_uuid == 'new-space-uuid'
_space_user_write(
ap.persistence_mgr,
'insert',
{
'user': '[email protected]',
'normalized_email': '[email protected]',
'password': '',
'account_type': 'space',
'space_account_uuid': 'new-space-uuid',
'space_access_token': 'access_token',
'space_refresh_token': 'refresh_token',
'space_api_key': 'api_key',
},
)
async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self):
"""Unknown Space email is distinct from an existing local Account collision."""
@@ -914,6 +970,17 @@ class TestUserServiceCreateOrUpdateSpaceUser:
# Verify
assert result is not None
assert result.space_account_uuid == 'noexpiry-uuid'
_space_user_write(
ap.persistence_mgr,
'insert',
{
'space_account_uuid': 'noexpiry-uuid',
'space_access_token': 'token',
'space_refresh_token': 'refresh',
'space_api_key': 'key',
'space_access_token_expires_at': None,
},
)
async def test_bind_space_account_rejects_different_email(self):
service = UserService(SimpleNamespace())
@@ -941,13 +1008,23 @@ class TestUserServiceCreateOrUpdateSpaceUser:
async def test_get_workspace_owner_returns_user_object_from_core_connection_result(self):
service = UserService(SimpleNamespace())
owner = _create_mock_user('[email protected]', password='pw')
service.ap.persistence_mgr = SimpleNamespace(
current_session=lambda: SimpleNamespace(scalar=AsyncMock(return_value=owner)),
)
session = SimpleNamespace(scalar=AsyncMock(return_value=owner))
service.ap.persistence_mgr = SimpleNamespace(current_session=lambda: session)
resolved = await service.get_workspace_owner('workspace-1')
assert resolved is owner
session.scalar.assert_awaited_once()
statement = session.scalar.await_args.args[0]
assert statement.whereclause.compare(
sqlalchemy.and_(
WorkspaceMembership.workspace_uuid == 'workspace-1',
WorkspaceMembership.role == MembershipRole.OWNER.value,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
User.status == AccountStatus.ACTIVE.value,
)
)
assert statement.get_final_froms()[0].onclause.compare(WorkspaceMembership.account_uuid == User.uuid)
class TestUserServiceLoginCapabilities:
@@ -980,4 +1057,5 @@ class TestUserServiceCreateUserLock:
# Verify lock exists
assert hasattr(service, '_create_user_lock')
assert service._create_user_lock is not None
assert isinstance(service._create_user_lock, asyncio.Lock)
assert not service._create_user_lock.locked()
@@ -125,6 +125,24 @@ class TestWebhookServiceGetWebhooks:
assert result[1]['name'] == 'Webhook 2'
def _assert_webhook_write(ap, operation, expected):
statements = [
call.args[0]
for call in ap.persistence_mgr.execute_async.await_args_list
if getattr(call.args[0], f'is_{operation}')
]
assert len(statements) == 1
statement = statements[0]
assert statement.table.name == Webhook.__tablename__
if operation == 'insert':
values = statement.compile().params
assert {key: values.get(key) for key in expected} == expected
else:
assert statement.whereclause.compare(
sqlalchemy.and_(Webhook.id == expected, Webhook.workspace_uuid == WORKSPACE_UUID)
)
class TestWebhookServiceCreateWebhook:
"""Tests for create_webhook method."""
@@ -187,6 +205,17 @@ class TestWebhookServiceCreateWebhook:
assert result['url'] == 'http://new.example.com/webhook'
assert result['description'] == 'New Description'
assert result['enabled'] is True
_assert_webhook_write(
ap,
'insert',
{
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Webhook',
'url': 'http://new.example.com/webhook',
'description': 'New Description',
'enabled': True,
},
)
async def test_create_webhook_defaults(self):
"""Creates webhook with default description and enabled."""
@@ -236,6 +265,17 @@ class TestWebhookServiceCreateWebhook:
# Verify defaults
assert result['description'] == ''
assert result['enabled'] is True
_assert_webhook_write(
ap,
'insert',
{
'workspace_uuid': WORKSPACE_UUID,
'name': 'Minimal Webhook',
'url': 'http://minimal.example.com',
'description': '',
'enabled': True,
},
)
async def test_create_webhook_disabled(self):
"""Creates webhook with enabled=False."""
@@ -271,6 +311,7 @@ class TestWebhookServiceCreateWebhook:
# Verify
assert result['enabled'] is False
_assert_webhook_write(ap, 'insert', {'enabled': False, 'workspace_uuid': WORKSPACE_UUID})
async def test_create_webhook_rejects_workspace_at_capacity(self):
ap = SimpleNamespace(
@@ -448,7 +489,8 @@ class TestWebhookServiceDeleteWebhook:
await service.delete_webhook(WORKSPACE_UUID, 1)
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
ap.persistence_mgr.execute_async.assert_awaited_once()
_assert_webhook_write(ap, 'delete', 1)
async def test_delete_webhook_nonexistent_id(self):
"""Delete operation completes even for nonexistent ID."""
@@ -463,7 +505,8 @@ class TestWebhookServiceDeleteWebhook:
await service.delete_webhook(WORKSPACE_UUID, 999)
# Verify - still called
ap.persistence_mgr.execute_async.assert_called_once()
ap.persistence_mgr.execute_async.assert_awaited_once()
_assert_webhook_write(ap, 'delete', 999)
class TestWebhookServiceGetEnabledWebhooks:
@@ -190,10 +190,17 @@ async def test_session_capacity_evicts_oldest_session_in_same_workspace():
_bind_session_scope(session, owner_context)
sessions[f'session-{index}'] = session
foreign_task = SimpleNamespace(done=Mock(return_value=False), cancel=Mock())
foreign = {'created_at': -1.0, 'task': foreign_task}
_bind_session_scope(foreign, _request_context(workspace_uuid='workspace-b'))
sessions['foreign'] = foreign
_make_room_for_session(sessions, owner_context)
assert 'session-0' not in sessions
assert len(sessions) == 9
assert len(sessions) == 10
assert sessions['foreign'] is foreign
foreign_task.cancel.assert_not_called()
tasks[0].cancel.assert_called_once_with()
+24 -1
View File
@@ -3,10 +3,12 @@ from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import hashlib
import sqlalchemy
import pytest
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.entity.persistence.apikey import ApiKeyStatus
from langbot.pkg.entity.persistence.apikey import ApiKey, ApiKeyStatus
@pytest.mark.asyncio
@@ -76,6 +78,27 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists):
result = await service.verify_api_key('lbk_valid_format')
assert result is key_exists
digest = hashlib.sha256(b'lbk_valid_format').hexdigest()
statements = [call.args[0] for call in persistence_mgr.execute_async.await_args_list]
assert statements[0].whereclause.compare(ApiKey.key_hash == digest)
if key_exists:
assert statements[1].whereclause.compare(
sqlalchemy.and_(
ApiKey.id == 1,
ApiKey.workspace_uuid == 'workspace-a',
ApiKey.key_hash == digest,
)
)
assert statements[2].is_update
assert statements[2].whereclause.compare(
sqlalchemy.and_(
ApiKey.id == 1,
ApiKey.workspace_uuid == 'workspace-a',
ApiKey.key_hash == digest,
ApiKey.status == ApiKeyStatus.ACTIVE.value,
)
)
assert statements[2].compile().params['last_used_at'] is not None
assert persistence_mgr.execute_async.await_count == (3 if key_exists else 1)
if key_exists:
workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a')
@@ -9,6 +9,7 @@ import quart
from quart.datastructures import FileStorage
from langbot.pkg.api.http.controller.groups.files import FilesRouterGroup
from langbot.pkg.api.http.context import RequestContext
pytestmark = pytest.mark.asyncio
@@ -52,6 +53,13 @@ async def test_document_upload_uses_dedicated_scoped_owner_type():
assert response.status_code == 200
assert (await response.get_json())['data']['file_id'] == 'scoped-document-key'
storage_mgr.save_scoped.assert_awaited_once()
context = storage_mgr.save_scoped.await_args.args[0]
assert isinstance(context, RequestContext)
assert context.instance_uuid == 'instance-test'
assert context.workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert context.placement_generation == 3
assert context.account_uuid == 'account-test'
kwargs = storage_mgr.save_scoped.await_args.kwargs
assert kwargs['owner_type'] == 'upload_document'
assert kwargs['owner'] == 'account:account-test'
@@ -62,9 +62,7 @@ async def _create_client(role: str):
application.provider_service = SimpleNamespace(
get_providers=AsyncMock(side_effect=get_providers),
get_provider=AsyncMock(side_effect=get_provider),
get_provider_model_counts=AsyncMock(
return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}
),
get_provider_model_counts=AsyncMock(return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}),
)
def model_service(list_name: str, get_name: str):
@@ -136,8 +134,15 @@ async def test_default_preserves_secrets_and_explicit_false_redacts_high_permiss
false_data = await false_response.get_json()
default_value = default_data['data'].get('providers', default_data['data'].get('models'))[0]
false_value = false_data['data'].get('providers', false_data['data'].get('models'))[0]
assert '***' not in str(default_value)
expected = (
{**RAW_PROVIDER, 'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}
if endpoint.endswith('/providers')
else RAW_MODEL
)
assert default_value == expected
assert '***' in str(false_value)
assert 'provider-secret' not in str(false_data)
assert 'model-secret' not in str(false_data)
@pytest.mark.parametrize(
@@ -158,6 +163,8 @@ async def test_explicit_true_does_not_grant_low_permission_reads(endpoint):
data = await response.get_json()
value = data['data'].get('providers', data['data'].get('models'))[0]
assert '***' in str(value)
assert 'provider-secret' not in str(data)
assert 'model-secret' not in str(data)
@pytest.mark.parametrize(
@@ -16,7 +16,7 @@ import asyncio
import logging
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock, call
import pytest
import quart
@@ -65,13 +65,17 @@ def _make_genkeys_ap(existing_key: str) -> SimpleNamespace:
)
async def test_recovery_key_generation_is_short_and_unambiguous():
async def test_recovery_key_generation_is_short_and_unambiguous(monkeypatch):
"""Eight random base32 characters balance manual entry and online throttling."""
ap = _make_genkeys_ap(existing_key='')
choice = Mock(side_effect=list('ZYXW9876'))
monkeypatch.setattr('langbot.pkg.core.stages.genkeys.secrets.choice', choice)
await GenKeysStage().run(ap)
key = ap.instance_config.data['system']['recovery_key']
assert key == 'ZYXW9876'
assert choice.call_args_list == [call('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')] * 8
assert len(key) == 8
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
assert ap.instance_config.dump_config.called
@@ -198,7 +202,7 @@ async def test_non_string_recovery_key_does_not_crash():
@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff'])
async def test_non_ascii_recovery_key_does_not_crash(key):
"""Non-ASCII keys must compare safely (encode-based constant-time compare)."""
client, _, _ = await _create_client()
client, reset_password, _ = await _create_client()
resp = await client.post(
'/api/v1/user/reset-password',
@@ -206,6 +210,7 @@ async def test_non_ascii_recovery_key_does_not_crash(key):
)
assert resp.status_code == 403
reset_password.assert_not_awaited()
async def test_quota_exhausted_after_max_attempts():
+72 -55
View File
@@ -434,7 +434,7 @@ async def test_box_service_get_sessions_delegates_to_client():
sessions = await service.get_sessions(_CONTEXT)
assert sessions == [{'session_id': 'test-session'}]
client.get_sessions.assert_awaited_once()
client.get_sessions.assert_awaited_once_with(action_context=_ACTION_CONTEXT)
@pytest.mark.asyncio
@@ -599,12 +599,14 @@ async def test_cloud_box_service_reconnect_restores_runtime_only(
connector.reconnect = AsyncMock()
service._ensure_default_workspace = Mock()
service._verify_cloud_runtime = AsyncMock()
service._purge_attachment_dirs = AsyncMock()
monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
await service._reconnect_loop(connector)
connector.reconnect.assert_awaited_once()
service._verify_cloud_runtime.assert_awaited_once()
service._purge_attachment_dirs.assert_not_awaited()
assert service.available is True
@@ -674,6 +676,8 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path):
assert result['ok'] is True
assert backend.start_calls == ['person_test_user']
assert backend.start_specs[0].host_path == os.path.realpath(host_dir)
assert backend.start_specs[0].host_path_mode == BoxHostMountMode.READ_WRITE
@pytest.mark.asyncio
@@ -744,16 +748,20 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path):
service = BoxService(make_app(logger, [str(allowed_root)]), client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
with pytest.raises(BoxValidationError):
with pytest.raises(BoxValidationError, match='outside allowed_mount_roots'):
await service.execute_spec_payload(
{
'cmd': 'pwd',
'host_path': str(disallowed_root),
'session_id': '12',
'session_id': 'person_test_user',
},
make_query(12),
)
assert backend.start_calls == []
assert backend.exec_calls == []
assert list(disallowed_root.iterdir()) == []
@pytest.mark.asyncio
async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path):
@@ -1064,8 +1072,18 @@ def test_builtin_profiles_are_consistent():
@pytest.mark.asyncio
async def test_profile_default_applies_resource_limits():
async def test_profile_default_applies_resource_limits(monkeypatch):
"""Default profile resource limits are applied to BoxSpec."""
profile = BUILTIN_PROFILES['default'].model_copy(
update={
'cpus': 0.75,
'memory_mb': 320,
'pids_limit': 37,
'read_only_rootfs': False,
'workspace_quota_mb': 19,
}
)
monkeypatch.setitem(BUILTIN_PROFILES, 'default', profile)
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
@@ -1473,8 +1491,14 @@ async def test_rpc_client_get_status():
try:
status = await client.get_status(action_context=_ACTION_CONTEXT)
assert 'backend' in status
assert 'active_sessions' in status
assert status['backend']['name'] == 'fake'
assert status['backend']['available'] is True
assert status['active_sessions'] == 0
await client.execute(BoxSpec(cmd='true', session_id='status-session'), action_context=_ACTION_CONTEXT)
status = await client.get_status(action_context=_ACTION_CONTEXT)
assert status['active_sessions'] == 1
assert status['managed_processes'] == 0
assert status['session_ttl_sec'] == 300
finally:
server_task.cancel()
client_task.cancel()
@@ -1515,8 +1539,10 @@ async def test_rpc_client_delete_session():
spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-del-1'})
await client.execute(spec, action_context=_ACTION_CONTEXT)
physical_session_id = backend.start_calls[0]
await client.delete_session('r-del-1', action_context=_ACTION_CONTEXT)
assert backend.stop_calls == [physical_session_id]
sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
assert len(sessions) == 0
finally:
@@ -1884,21 +1910,6 @@ class TestBuildSkillExecutionMounts:
with pytest.raises(ValueError, match='no immutable package root'):
skill_loader.build_execution_mounts(app, query)
def test_empty_package_root_skipped_even_when_not_shared(self):
"""An empty package_root is always invalid regardless of topology."""
logger = Mock()
skills = {
'no_root': {
'name': 'no_root',
'package_root': '',
'manifest_path': '',
'revision': 'sha256:' + '1' * 64,
}
}
app = self._make_app(logger, skills)
assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_returns_empty_when_no_skill_manager(self):
logger = Mock()
app = make_app(logger)
@@ -1973,13 +1984,26 @@ class TestAttachmentHelpers:
class TestInboundOutboundRoundTrip:
@staticmethod
async def _execute_command(command, workspace):
process = await asyncio.create_subprocess_exec(
'/bin/sh',
'-c',
command.replace('/workspace', str(workspace)),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
assert process.returncode == 0, stderr.decode()
return stdout.decode().replace(str(workspace), '/workspace')
def _service(self) -> BoxService:
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
service._available = True
return service
@pytest.mark.asyncio
async def test_materialize_inbound_writes_and_describes(self):
async def test_materialize_inbound_writes_and_describes(self, tmp_path):
import base64
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -1997,14 +2021,9 @@ class TestInboundOutboundRoundTrip:
]
)
# Mock the sandbox write path: echo back the written paths.
async def fake_execute_tool(parameters, q):
assert '/workspace/inbox/' in parameters['command']
return {
'ok': True,
'stdout': '["/workspace/inbox/query-42/image_1.png"]',
'stderr': '',
}
assert q is query
return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''}
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
@@ -2014,6 +2033,7 @@ class TestInboundOutboundRoundTrip:
assert d['type'] == 'Image'
assert d['path'] == '/workspace/inbox/query-42/image_1.png'
assert d['size'] == len(img_bytes)
assert (tmp_path / 'inbox/query-42/image_1.png').read_bytes() == img_bytes
@pytest.mark.asyncio
async def test_materialize_inbound_noop_without_attachments(self):
@@ -2027,45 +2047,40 @@ class TestInboundOutboundRoundTrip:
service.execute_tool.assert_not_called()
@pytest.mark.asyncio
async def test_collect_outbound_reads_and_clears(self):
async def test_collect_outbound_reads_and_clears(self, tmp_path):
service = self._service()
query = make_query()
calls = []
outbox = tmp_path / 'outbox/query-42'
outbox.mkdir(parents=True)
(outbox / 'out.png').write_bytes(b'ABC')
guard = tmp_path / 'outbox/query-other/protected.txt'
guard.parent.mkdir()
guard.write_bytes(b'other query')
async def fake_client_execute(spec, *, action_context=None):
assert action_context.workspace_uuid == query.workspace_uuid
cmd = spec.cmd
calls.append(cmd)
if 'os.scandir' in cmd:
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[{"name": "out.png", "b64": "QUJD"}]',
duration_ms=10,
)
# the rm -rf cleanup call
assert action_context == _ACTION_CONTEXT
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
stdout=await self._execute_command(spec.cmd, tmp_path),
duration_ms=10,
)
async def fake_execute_tool(parameters, q):
assert q is query
return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''}
service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
attachments = await service.collect_outbound_attachments(query)
assert len(attachments) == 1
assert attachments[0]['type'] == 'Image'
assert attachments[0]['name'] == 'out.png'
# cleanup (rm -rf) must have been issued after a successful collection
service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
assert attachments == [{'type': 'Image', 'name': 'out.png', 'base64': 'data:image/png;base64,QUJD'}]
assert not (outbox / 'out.png').exists()
assert guard.read_bytes() == b'other query'
assert await service.collect_outbound_attachments(query) == []
@pytest.mark.asyncio
async def test_collect_outbound_empty_still_clears(self):
@@ -2102,9 +2117,9 @@ class TestInboundOutboundRoundTrip:
service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
assert await service.collect_outbound_attachments(query) == []
# cleanup (rm -rf) is issued unconditionally now
# Cleanup is issued even when the outbox is empty.
service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
assert 'clear_directory' in service.execute_tool.await_args.args[0]['command']
@pytest.mark.asyncio
async def test_passthrough_noop_when_unavailable(self):
@@ -2172,6 +2187,7 @@ class TestAttachmentHostPath:
stale_dir = os.path.join(ws, 'inbox', 'query-42')
os.makedirs(stale_dir, exist_ok=True)
pathlib.Path(stale_dir, 'image_1.png').write_bytes(b'STALE-OLD-IMAGE')
pathlib.Path(stale_dir, 'leftover-secret.txt').write_bytes(b'STALE-OTHER-FILE')
new = b'\x89PNG\r\n\x1a\n NEW'
b64 = 'data:image/png;base64,' + base64.b64encode(new).decode()
@@ -2185,6 +2201,7 @@ class TestAttachmentHostPath:
assert host_bytes == new
# No leftover content from the stale image.
assert b'STALE-OLD-IMAGE' not in host_bytes
assert sorted(os.listdir(stale_dir)) == ['image_1.png']
@pytest.mark.asyncio
async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path):
@@ -0,0 +1,319 @@
"""Permission recovery must keep sandbox exports inside the bound run."""
import asyncio
import errno
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus
from langbot.pkg.box import secure_fs
from tests.unit_tests.box import test_box_service as fixtures
@pytest.mark.parametrize('denied_component', ['root', 'outbox', 'run', 'nested', 'answer.txt'])
def test_unreadable_host_file_is_not_silently_dropped(tmp_path, monkeypatch, denied_component):
outbox = tmp_path / 'outbox' / 'run' / 'nested'
outbox.mkdir(parents=True)
(outbox / 'answer.txt').write_bytes(b'private output')
original_open = os.open
def denied_open(path, flags, *args, **kwargs):
target = str(tmp_path) if denied_component == 'root' else denied_component
if path == target:
raise PermissionError(errno.EACCES, 'container-owned output', path)
return original_open(path, flags, *args, **kwargs)
monkeypatch.setattr(secure_fs.os, 'open', denied_open)
with pytest.raises(PermissionError):
secure_fs.read_regular_files(
str(tmp_path), 'outbox', 'run', max_file_bytes=1024, max_files=20, max_total_bytes=4096
)
def test_host_outbox_skips_fifo_without_waiting_for_a_writer(tmp_path):
outbox = tmp_path / 'outbox' / 'run'
outbox.mkdir(parents=True)
os.mkfifo(outbox / 'pipe')
script = (
'from langbot.pkg.box import secure_fs; '
f'assert secure_fs.read_regular_files({str(tmp_path)!r}, "outbox", "run", '
'max_file_bytes=1024, max_files=20, max_total_bytes=4096) == []'
)
subprocess.run([sys.executable, '-c', script], check=True, timeout=3)
@pytest.mark.asyncio
async def test_unreadable_host_output_uses_the_bound_sandbox(tmp_path):
service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path)
query = fixtures.make_query()
outbox = Path(workspace) / 'outbox'
current = outbox / 'query-42'
current.mkdir(parents=True)
(current / 'answer.txt').write_bytes(b'private output')
(current / ' answer.txt ').write_bytes(b'spaced output')
other = outbox / 'other-run'
other.mkdir()
(other / 'secret.txt').write_bytes(b'other run secret')
(current / 'leak.txt').symlink_to(other / 'secret.txt')
service.OUTBOX_MOUNT_DIR = str(outbox)
service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output'))
service._clear_outbox = AsyncMock()
observed = []
async def execute(spec, *, action_context):
assert action_context == fixtures._ACTION_CONTEXT
assert spec.session_id == 'person_test_user'
assert spec.host_path == workspace
process = await asyncio.create_subprocess_shell(
spec.cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
observed.append(spec)
return BoxExecutionResult(
session_id=spec.session_id,
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=process.returncode,
stdout=stdout.decode(),
stderr=stderr.decode(),
duration_ms=0,
)
service.client.execute = AsyncMock(side_effect=execute)
attachments = await service.collect_outbound_attachments(query)
assert {item['name']: item['base64'] for item in attachments} == {
'answer.txt': 'cHJpdmF0ZSBvdXRwdXQ=',
' answer.txt ': 'c3BhY2VkIG91dHB1dA==',
}
assert len(observed) == 1
assert (other / 'secret.txt').read_bytes() == b'other run secret'
service._clear_outbox.assert_awaited_once_with(query, str(current))
def _script(root, operation='read', **limits):
from langbot.pkg.box.sandbox_files import outbox_script
return outbox_script(
str(root / 'workspace' / 'outbox'),
'run',
operation=operation,
max_file_bytes=limits.get('max_file_bytes', 1024),
max_files=limits.get('max_files', 20),
max_total_bytes=limits.get('max_total_bytes', 4096),
)
def _run_script(script):
return subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, timeout=3)
@pytest.mark.parametrize('operation', ['read', 'clear'])
@pytest.mark.parametrize('component', ['workspace', 'outbox', 'run'])
def test_sandbox_outbox_never_follows_ancestor_links(tmp_path, operation, component):
workspace = tmp_path / 'workspace'
current = workspace / 'outbox' / 'run'
current.mkdir(parents=True)
other = tmp_path / 'other'
other.mkdir()
target = {
'workspace': other / 'outbox' / 'run',
'outbox': other / 'run',
'run': other,
}[component]
target.mkdir(parents=True, exist_ok=True)
protected = target / 'secret.txt'
protected.write_bytes(b'other run secret')
import shutil
link = {'workspace': workspace, 'outbox': current.parent, 'run': current}[component]
shutil.rmtree(link)
link.symlink_to(other, target_is_directory=True)
result = _run_script(_script(tmp_path, operation))
assert result.returncode != 0
assert protected.read_bytes() == b'other run secret'
@pytest.mark.parametrize('replacement', ['symlink', 'fifo', 'directory'])
def test_sandbox_file_open_resists_type_changes(tmp_path, replacement):
import json
current = tmp_path / 'workspace' / 'outbox' / 'run'
current.mkdir(parents=True)
(current / 'answer.txt').write_bytes(b'expected')
secret = tmp_path / 'secret.txt'
secret.write_bytes(b'outside secret')
# Change the real filesystem after classification, immediately before open.
prelude = f"""
import os
original_open = os.open
replacement = {replacement!r}
def racing_open(path, flags, *args, **kwargs):
fd = kwargs.get('dir_fd')
if path == 'answer.txt' and fd is not None:
os.unlink(path, dir_fd=fd)
if replacement == 'symlink':
os.symlink({str(secret)!r}, path, dir_fd=fd)
elif replacement == 'fifo':
os.mkfifo(path, dir_fd=fd)
else:
os.mkdir(path, dir_fd=fd)
child = original_open(path, os.O_RDONLY | os.O_DIRECTORY, dir_fd=fd)
try:
output = original_open('secret.txt', os.O_WRONLY | os.O_CREAT, 0o600, dir_fd=child)
os.write(output, b'unexpected directory')
os.close(output)
finally:
os.close(child)
return original_open(path, flags, *args, **kwargs)
os.open = racing_open
"""
result = _run_script(prelude + _script(tmp_path))
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == []
assert secret.read_bytes() == b'outside secret'
@pytest.mark.parametrize('limit', ['file_bytes', 'total_bytes', 'total_bytes_exact', 'entries', 'directories'])
def test_sandbox_output_limits_fail_without_removing_files(tmp_path, limit):
current = tmp_path / 'workspace' / 'outbox' / 'run'
current.mkdir(parents=True)
options = {}
if limit == 'file_bytes':
(current / 'answer.txt').write_bytes(b'1234')
options['max_file_bytes'] = 3
elif limit in {'total_bytes', 'total_bytes_exact'}:
(current / 'a.txt').write_bytes(b'12')
(current / 'b.txt').write_bytes(b'34')
options['max_total_bytes'] = 3
if limit == 'total_bytes_exact':
(current / 'c.txt').write_bytes(b'56')
options['max_total_bytes'] = 4
elif limit == 'entries':
for index in range(513):
(current / str(index)).symlink_to(tmp_path / 'missing')
else:
for index in range(65):
(current / str(index)).mkdir()
before = set(current.iterdir())
result = _run_script(_script(tmp_path, **options))
assert result.returncode != 0
assert 'limit' in result.stderr
assert set(current.iterdir()) == before
def test_sandbox_output_count_and_depth_are_bounded(tmp_path):
import json
current = tmp_path / 'workspace' / 'outbox' / 'run'
current.mkdir(parents=True)
(current / 'a.txt').write_bytes(b'a')
(current / 'b.txt').write_bytes(b'b')
result = _run_script(_script(tmp_path, max_files=1))
assert result.returncode == 0, result.stderr
assert len(json.loads(result.stdout)) == 1
(current / 'a.txt').unlink()
(current / 'b.txt').unlink()
deep = current.joinpath(*('nested' for _ in range(9)))
deep.mkdir(parents=True)
(deep / 'too-deep.txt').write_bytes(b'not exported')
result = _run_script(_script(tmp_path))
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == []
@pytest.mark.parametrize('operation', ['read', 'clear'])
def test_sandbox_nested_links_do_not_touch_their_targets(tmp_path, operation):
import json
current = tmp_path / 'workspace' / 'outbox' / 'run'
current.mkdir(parents=True)
other = tmp_path / 'other'
other.mkdir()
protected = other / 'secret.txt'
protected.write_bytes(b'protected')
(current / 'linked-dir').symlink_to(other, target_is_directory=True)
(current / 'linked-file').symlink_to(protected)
result = _run_script(_script(tmp_path, operation))
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == []
assert protected.read_bytes() == b'protected'
if operation == 'clear':
assert list(current.iterdir()) == []
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', ['permission', 'truncated-json'])
async def test_permission_recovery_failure_does_not_clear_output(tmp_path, failure):
from langbot_plugin.box.errors import BoxValidationError
service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path)
query = fixtures.make_query()
current = Path(workspace) / 'outbox' / 'query-42'
current.mkdir(parents=True)
output = current / 'answer.txt'
output.write_bytes(b'keep on failure')
service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output'))
service._clear_outbox = AsyncMock()
service.client.execute = AsyncMock(
return_value=BoxExecutionResult(
session_id='person_test_user',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=1 if failure == 'permission' else 0,
stdout='' if failure == 'permission' else '[{"name": "answer.txt", "b64": "truncated',
stderr='Permission denied' if failure == 'permission' else '',
duration_ms=0,
)
)
error = 'Failed to read sandbox outbox' if failure == 'permission' else 'invalid attachment data'
with pytest.raises(BoxValidationError, match=error):
await service.collect_outbound_attachments(query)
service._clear_outbox.assert_not_awaited()
assert output.read_bytes() == b'keep on failure'
@pytest.mark.asyncio
async def test_changed_outbox_ancestor_never_uses_cleanup_exec(tmp_path):
service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path)
query = fixtures.make_query()
outbox = Path(workspace) / 'outbox'
other = tmp_path / 'other'
protected = other / 'query-42' / 'secret.txt'
protected.parent.mkdir(parents=True)
protected.write_bytes(b'keep other run')
outbox.symlink_to(other, target_is_directory=True)
service.execute_tool = AsyncMock(side_effect=AssertionError('unsafe host ancestor must not use exec'))
await service._clear_outbox(query, str(outbox / 'query-42'))
service.execute_tool.assert_not_awaited()
assert protected.read_bytes() == b'keep other run'
assert any('unsafe sandbox outbox' in str(call.args[0]) for call in service.ap.logger.warning.call_args_list)
def test_sandbox_cleanup_has_a_bounded_inode_budget(tmp_path):
current = tmp_path / 'workspace' / 'outbox' / 'run'
current.mkdir(parents=True)
for index in range(4097):
(current / str(index)).write_bytes(b'')
result = _run_script(_script(tmp_path, 'clear'))
assert result.returncode != 0
assert 'inode limit' in result.stderr
assert list(current.iterdir())
def test_host_reset_preserves_permission_error_for_private_run_directory(tmp_path, monkeypatch):
current = tmp_path / 'outbox' / 'run'
current.mkdir(parents=True)
original_open = os.open
def denied_open(path, flags, *args, **kwargs):
if path == 'run' and kwargs.get('dir_fd') is not None:
raise PermissionError(errno.EACCES, 'private container directory', path)
return original_open(path, flags, *args, **kwargs)
monkeypatch.setattr(secure_fs.os, 'open', denied_open)
with pytest.raises(PermissionError):
secure_fs.reset_directory(str(tmp_path), 'outbox', 'run')
@@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio
import datetime
import logging
import sqlite3
from contextlib import closing
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -184,7 +186,16 @@ def _delta(
async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context):
application, _session_factory = projection_context
request_sync = Mock()
observed_revisions = []
def observe_committed_revision():
with closing(sqlite3.connect(application.persistence_mgr.engine.url.database)) as connection:
row = connection.execute(
'SELECT projection_revision FROM workspaces WHERE uuid = ?', (WORKSPACE_UUID,)
).fetchone()
observed_revisions.append(row[0])
request_sync = Mock(side_effect=observe_committed_revision)
application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync)
event = DirectoryEvent(
cursor=2,
@@ -209,10 +220,12 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
)
await service.initialize()
request_sync.reset_mock()
observed_revisions.clear()
await service.sync_once()
request_sync.assert_called_once_with()
assert observed_revisions == [2]
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
@@ -247,11 +260,7 @@ async def test_targeted_reconciliation_projects_new_workspace_without_advancing_
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
application, session_factory = projection_context
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
update={
'members': [
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
]
}
update={'members': [_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})]}
)
provider = _Provider(
[_snapshot(7)],
@@ -572,7 +581,13 @@ async def test_account_projection_reads_large_directory_in_bounded_batches(proje
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(User)) == 501
async def test_archived_and_absent_workspaces_are_execution_fenced(projection_context):
@pytest.mark.parametrize(
('status', 'execution_state', 'write_fenced', 'membership_status'),
[('archived', 'inactive', True, 'removed'), ('active', 'active', False, 'active')],
)
async def test_archived_and_absent_workspaces_are_execution_fenced(
projection_context, status, execution_state, write_fenced, membership_status
):
application, session_factory = projection_context
service = DirectoryProjectionService(
application,
@@ -584,17 +599,17 @@ async def test_archived_and_absent_workspaces_are_execution_fenced(projection_co
await service.apply_snapshot(
_snapshot(
2,
workspaces=[_workspace(revision=2, status='archived')],
workspaces=[_workspace(revision=2, status=status)],
)
)
async with session_factory() as session:
workspace = await session.get(Workspace, WORKSPACE_UUID)
execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
assert workspace.status == 'archived'
assert execution.state == 'inactive'
assert execution.write_fenced is True
assert membership.status == 'removed'
assert workspace.status == status
assert execution.state == execution_state
assert execution.write_fenced is write_fenced
assert membership.status == membership_status
await service.apply_snapshot(_snapshot(3, workspaces=[]))
async with session_factory() as session:
@@ -746,7 +761,15 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec
high_water_cursor=2,
events=[event],
)
provider = _Provider([_snapshot(1)], [batch], [_delta(workspaces=[])])
second_workspace = _workspace().model_copy(
update={
'uuid': SECOND_WORKSPACE_UUID,
'slug': 'second-workspace',
'type': 'team',
'members': [_member().model_copy(update={'membership_uuid': SECOND_MEMBERSHIP_UUID})],
}
)
provider = _Provider([_snapshot(1, workspaces=[_workspace(), second_workspace])], [batch], [_delta(workspaces=[])])
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
@@ -755,7 +778,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec
async with session_factory() as session:
workspace = await session.get(Workspace, WORKSPACE_UUID)
execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
membership = await session.scalar(
sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.workspace_uuid == WORKSPACE_UUID)
)
untouched_workspace = await session.get(Workspace, SECOND_WORKSPACE_UUID)
untouched_execution = await session.get(WorkspaceExecutionState, SECOND_WORKSPACE_UUID)
untouched_membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
assert workspace.status == 'archived'
assert workspace.projection_revision == 2
assert execution.state == 'inactive'
@@ -763,6 +791,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec
assert execution.desired_state_revision == 2
assert membership.status == 'removed'
assert membership.projection_revision == 2
assert untouched_workspace.status == 'active'
assert untouched_workspace.projection_revision == 1
assert untouched_execution.state == 'active'
assert untouched_execution.write_fenced is False
assert untouched_membership.status == 'active'
assert untouched_membership.projection_revision == 1
stale_same_cursor_snapshot = _snapshot(
2,
+4 -2
View File
@@ -437,8 +437,10 @@ class TestEmptyAndEdgeInputs:
async for ret in mgr.execute(' ', '/ ', query, session):
results.append(ret)
# Should yield error
assert len(results) >= 1
assert len(results) == 1
assert results[0].error is not None
assert results[0].error.message == '未知命令: '
assert results[0].text is None
@pytest.mark.asyncio
async def test_initialize_with_deep_nesting(self):
@@ -13,6 +13,7 @@ from __future__ import annotations
import pytest
import json
import yaml
from langbot.pkg.config.impls.yaml import YAMLConfigFile
from langbot.pkg.config.impls.json import JSONConfigFile
@@ -126,8 +127,7 @@ name: custom_name
assert config_file.exists()
content = config_file.read_text()
assert 'saved_app' in content
assert 'new_key' in content
assert yaml.safe_load(content) == {'name': 'saved_app', 'new_key': 'new_value'}
def test_yaml_save_sync(self, tmp_path):
"""YAML config can be saved synchronously."""
@@ -142,7 +142,7 @@ name: custom_name
assert config_file.exists()
content = config_file.read_text()
assert 'sync_saved' in content
assert yaml.safe_load(content) == {'name': 'sync_saved'}
class TestJSONConfigFile:
@@ -260,7 +260,7 @@ class TestConfigManager:
await manager.dump_config()
content = config_file.read_text()
assert 'dumped' in content
assert yaml.safe_load(content) == {'name': 'dumped', 'new_field': 'value'}
def test_config_manager_dump_sync(self, tmp_path):
"""ConfigManager can dump config synchronously."""
@@ -277,6 +277,7 @@ class TestConfigManager:
manager.dump_config_sync()
assert config_file.exists()
assert yaml.safe_load(config_file.read_text()) == {'name': 'sync_dumped'}
class TestConfigExists:
+4
View File
@@ -36,6 +36,8 @@ async def test_make_app_shuts_down_partially_built_application(monkeypatch):
@pytest.mark.asyncio
async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch):
captured_handler = {}
print_exc = Mock()
monkeypatch.setattr(boot.traceback, 'print_exc', print_exc)
def fake_signal(sig, handler):
captured_handler[sig] = handler
@@ -48,6 +50,8 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch
await boot.main(SimpleNamespace())
print_exc.assert_not_called()
@pytest.mark.asyncio
async def test_main_signal_handler_disposes_created_app(monkeypatch):
+2 -4
View File
@@ -50,8 +50,7 @@ class TestCheckDeps:
result = asyncio.run(check_deps())
assert 'requests' in result
assert 'openai' in result
assert result == ['requests', 'openai']
def test_check_deps_all_missing(self):
"""check_deps returns all deps when none present."""
@@ -66,8 +65,7 @@ class TestCheckDeps:
result = asyncio.run(check_deps())
# Should include all required_deps keys
assert len(result) == len(required_deps)
assert result == list(required_deps)
def test_required_deps_dict_exists(self):
"""required_deps dictionary is defined."""
+3 -5
View File
@@ -10,14 +10,10 @@ from __future__ import annotations
import logging
import os
import re
import langbot.pkg.core.bootutils.log as logmod
from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler
# Mirror of the cleanup pattern in api/http/service/maintenance.py.
MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
def _listing(directory):
return sorted(os.listdir(directory))
@@ -105,6 +101,8 @@ class TestDailyGroupedRotatingFileHandler:
assert f'langbot-{day}.log' in files
def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch):
from langbot.pkg.api.http.service.maintenance import LOG_FILE_PATTERN
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
@@ -117,4 +115,4 @@ class TestDailyGroupedRotatingFileHandler:
handler.close()
for name in _listing(tmp_path):
assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name
assert LOG_FILE_PATTERN.match(name), name
+5 -6
View File
@@ -119,12 +119,11 @@ class TestApplyEnvOverridesToConfig:
"""Test that '1', 'yes', 'on' are treated as true."""
load_config = get_load_config_module()
cfg = {'system': {'flag': False}}
for true_val in ['1', 'yes', 'on', 'TRUE']:
cfg = {'system': {'flag': False}}
env = {'SYSTEM__FLAG': true_val}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg.copy())
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['flag'] is True
def test_override_float_value(self):
@@ -237,7 +236,7 @@ class TestApplyEnvOverridesToConfig:
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'default'
assert result == {'system': {'name': 'default'}}
def test_skip_env_vars_with_empty_path_segments(self, capsys):
"""Platform variables such as __CF_USER_TEXT_ENCODING are not config."""
@@ -249,7 +248,7 @@ class TestApplyEnvOverridesToConfig:
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result == cfg
assert result == {'system': {'name': 'default'}}
assert capsys.readouterr().out == ''
def test_nested_config_path(self):
@@ -299,7 +298,7 @@ class TestApplyEnvOverridesToConfig:
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result == cfg
assert result == {'system': {'name': 'default'}}
def test_multiple_env_vars_override(self):
"""Test multiple env vars applied in order."""
+7 -32
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import inspect
from unittest.mock import MagicMock
import pytest
@@ -41,11 +42,11 @@ class TestStageClassDecorator:
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class
@stage_class('TestStage')
class TestStage:
value = 42
# Class attributes should be preserved
decorated = stage_class('TestStage')(TestStage)
assert decorated is TestStage
assert TestStage.value == 42
def test_stage_class_multiple_stages(self):
@@ -125,25 +126,15 @@ class TestBootingStageAbstract:
# Abstract class has name attribute defined as None
assert hasattr(BootingStage, 'name')
@pytest.mark.asyncio
async def test_booting_stage_run_signature(self):
"""run method receives Application parameter."""
def test_booting_stage_run_signature(self):
"""The production abstract stage accepts an Application asynchronously."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class TestStage(BootingStage):
name = 'TestStage'
async def run(self, ap):
self.ap_received = ap
stage = TestStage()
mock_ap = MagicMock()
await stage.run(mock_ap)
assert stage.ap_received == mock_ap
assert list(inspect.signature(BootingStage.run).parameters) == ['self', 'ap']
assert inspect.iscoroutinefunction(BootingStage.run)
class TestPreregisteredStages:
@@ -160,19 +151,3 @@ class TestPreregisteredStages:
from langbot.pkg.core.stage import preregistered_stages
assert isinstance(preregistered_stages, dict)
def test_preregistered_stages_key_is_string(self):
"""Registry keys are stage names (strings)."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
preregistered_stages.clear()
@stage_class('MyStage')
class MyStage:
pass
for key in preregistered_stages:
assert isinstance(key, str)
+31 -15
View File
@@ -15,7 +15,7 @@ import asyncio
import contextvars
import inspect
import sys
from unittest.mock import Mock, MagicMock
from unittest.mock import Mock, MagicMock, patch
from contextlib import contextmanager
from typing import Generator
@@ -181,12 +181,14 @@ class TestTaskContext:
ctx.set_current_action('test_action')
ctx.trace('Test message')
ctx.metadata['key'] = 'value'
ctx.metadata['count'] = 5
ctx.metadata['items'] = ['a', 'b', 'c']
result = ctx.to_dict()
assert result['current_action'] == 'test_action'
assert 'Test message' in result['log']
assert result['metadata'] == {'key': 'value'}
assert result['metadata'] == {'key': 'value', 'count': 5, 'items': ['a', 'b', 'c']}
def test_static_new_factory(self):
"""Test TaskContext.new() factory method."""
@@ -209,19 +211,9 @@ class TestTaskContext:
ctx1 = TaskContext.placeholder()
ctx2 = TaskContext.placeholder()
assert isinstance(ctx1, TaskContext)
assert ctx1 is ctx2
def test_metadata_is_mutable_dict(self):
"""Test that metadata is a mutable dict."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.metadata['count'] = 5
ctx.metadata['items'] = ['a', 'b', 'c']
assert ctx.metadata['count'] == 5
assert len(ctx.metadata['items']) == 3
class TestTaskWrapper:
"""Tests for TaskWrapper class."""
@@ -346,7 +338,8 @@ class TestTaskWrapper:
async def successful_coro():
return {'file_id': 'file-a'}
wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store')
with patch('time.time', return_value=1720000000.25):
wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store')
await wrapper.task
result = wrapper.to_public_dict()
@@ -358,7 +351,7 @@ class TestTaskWrapper:
'status': 'succeeded',
'error': None,
'result': {'file_id': 'file-a'},
'created_at': result['created_at'],
'created_at': 1720000000.25,
}
assert 'runtime' not in result
assert 'traceback' not in str(result).lower()
@@ -429,6 +422,29 @@ class TestTaskWrapper:
class TestAsyncTaskManager:
"""Tests for AsyncTaskManager class."""
@pytest.mark.asyncio
async def test_completed_retention_keeps_newest_tasks_and_running_task(self):
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
mock_app.instance_config.data['system']['task_retention']['completed_limit'] = 2
manager = AsyncTaskManager(mock_app)
running = manager.create_task(asyncio.Event().wait())
async def immediate_coro():
return 'done'
try:
completed = [manager.create_task(immediate_coro()) for _ in range(2)]
await asyncio.gather(*(wrapper.task for wrapper in completed))
assert manager.tasks == [running, *completed]
newest = manager.create_task(immediate_coro())
await newest.task
assert manager.tasks == [running, completed[1], newest]
finally:
running.cancel()
await asyncio.gather(running.task, return_exceptions=True)
@pytest.mark.asyncio
async def test_create_task_adds_to_list(self):
"""Test that create_task adds task to tasks list."""
@@ -46,4 +46,29 @@ def test_migrate_duplicate_routes_and_preserve_primary_and_disabled(column_exist
assert conn.execute(sa.select(migrated)).mappings().one() == row
migration.downgrade()
assert 'plugin_processors' not in {c['name'] for c in sa.inspect(conn).get_columns('bots')}
restored = sa.Table('bots', sa.MetaData(), autoload_with=conn)
bindings = conn.execute(sa.select(restored.c.event_bindings)).scalar_one()
assert bindings == [
primary,
{
'id': 'plugin_processor:one',
'event_pattern': '*',
'target_type': 'event_processor',
'target_uuid': 'one',
'enabled': True,
'filters': [],
'priority': 0,
'order': 1,
},
{
'id': 'plugin_processor:two',
'event_pattern': '*',
'target_type': 'event_processor',
'target_uuid': 'two',
'enabled': False,
'filters': [],
'priority': 0,
'order': 2,
},
]
engine.dispose()
@@ -13,7 +13,7 @@ from __future__ import annotations
import sys
from contextlib import contextmanager
from typing import Generator
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock
import pytest
@@ -174,40 +174,3 @@ class TestManagerClassDecorator:
# BaseDatabaseManager has name annotation (type hint)
# Check __annotations__ for the type hint
assert 'name' in database.BaseDatabaseManager.__annotations__
def test_decorated_class_inherits_from_base(self):
"""Test that decorated class properly inherits BaseDatabaseManager."""
database = get_database_module()
database.preregistered_managers.clear()
@database.manager_class('test_inherit')
class TestChild(database.BaseDatabaseManager):
async def initialize(self):
pass
assert issubclass(TestChild, database.BaseDatabaseManager)
# Has abstract method requirement satisfied
assert hasattr(TestChild, 'initialize')
def test_decorator_preserves_class_methods(self):
"""Test that decorator preserves existing class methods."""
database = get_database_module()
database.preregistered_managers.clear()
@database.manager_class('preserve_test')
class ManagerWithMethods(database.BaseDatabaseManager):
custom_attr = 'test_value'
async def initialize(self):
pass
def custom_method(self):
return self.custom_attr
assert ManagerWithMethods.custom_attr == 'test_value'
# Create instance to test method (with mock app)
mock_app = Mock()
instance = ManagerWithMethods(mock_app)
assert instance.custom_method() == 'test_value'
@@ -50,8 +50,8 @@ class TestExecuteAsync:
# Execute a simple select
await mgr.execute_async(sqlalchemy.select(1))
mock_conn.execute.assert_called_once()
mock_conn.commit.assert_called_once()
mock_conn.execute.assert_awaited_once()
mock_conn.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_execute_async_returns_result(self):
@@ -39,4 +39,6 @@ def test_two_populated_tables_fail_without_discarding_either():
with pytest.raises(RuntimeError, match='Both .* contain state'):
migration.upgrade()
assert set(sa.inspect(conn).get_table_names()) == {'agent_runner_state', 'runner_state'}
assert conn.execute(sa.text('SELECT id FROM agent_runner_state')).all() == [(1,)]
assert conn.execute(sa.text('SELECT id FROM runner_state')).all() == [(1,)]
engine.dispose()
@@ -81,7 +81,7 @@ class TestSerializeModel:
result = mgr.serialize_model(TestModel, instance)
assert '2024-01-15' in result['created_at']
assert result['created_at'] == '2024-01-15T10:30:45+00:00'
assert isinstance(result['created_at'], str)
def test_serialize_none_datetime(self):
+69 -19
View File
@@ -465,8 +465,19 @@ class TestMessageAggregatorAddMessage:
pipeline_uuid=None, # None -> disabled
)
# Should have called query_pool.add_query
assert app.query_pool.add_query.called
app.query_pool.add_query.assert_awaited_once_with(
execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
routed_by_rule=False,
variables=None,
)
@pytest.mark.asyncio
async def test_enabled_buffers_message(self):
@@ -505,8 +516,27 @@ class TestMessageAggregatorAddMessage:
pipeline_uuid='test-pipeline',
)
# Should have buffered the message
assert len(agg.buffers) == 1
context = execution_context(pipeline_uuid='test-pipeline')
key = aggregation_key(context, pipeline_uuid='test-pipeline')
assert list(agg.buffers) == [key]
assert agg.buffers[key].execution_context == context
assert len(agg.buffers[key].messages) == 1
pending = agg.buffers[key].messages[0]
assert {key: value for key, value in vars(pending).items() if key != 'timestamp'} == {
'execution_context': context,
'bot_uuid': 'test-bot',
'launcher_type': provider_session.LauncherTypes.PERSON,
'launcher_id': 12345,
'sender_id': 12345,
'message_event': event,
'message_chain': chain,
'adapter': adapter,
'pipeline_uuid': 'test-pipeline',
'routed_by_rule': False,
'variables': None,
}
app.query_pool.add_query.assert_not_awaited()
await agg.flush_all()
@pytest.mark.asyncio
async def test_control_variables_bypass_aggregation(self):
@@ -560,19 +590,18 @@ class TestMessageAggregatorAddMessage:
agg = aggregator.MessageAggregator(app)
chain = text_chain('hello')
event = friend_message_event(chain)
adapter = mock_adapter()
# Add messages up to MAX_BUFFER_MESSAGES
for i in range(aggregator.MAX_BUFFER_MESSAGES):
chain = text_chain(f'message {i}')
await agg.add_message(
execution_context=execution_context(pipeline_uuid='test-pipeline'),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_event=friend_message_event(chain),
message_chain=chain,
adapter=adapter,
pipeline_uuid='test-pipeline',
@@ -588,6 +617,14 @@ class TestMessageAggregatorAddMessage:
'test-pipeline',
)
assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0
app.query_pool.add_query.assert_awaited_once()
forwarded = app.query_pool.add_query.call_args.kwargs
assert str(forwarded['message_chain']) == '\n'.join(
f'message {i}' for i in range(aggregator.MAX_BUFFER_MESSAGES)
)
assert forwarded['execution_context'] == context
assert forwarded['bot_uuid'] == 'test-bot'
assert forwarded['pipeline_uuid'] == 'test-pipeline'
class TestMessageAggregatorMerge:
@@ -658,10 +695,7 @@ class TestMessageAggregatorMerge:
merged = agg._merge_messages([pending1, pending2])
# Should contain both messages with separator
merged_str = str(merged.message_chain)
assert 'hello' in merged_str
assert 'world' in merged_str
assert str(merged.message_chain) == 'hello\nworld'
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
"""Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
@@ -760,7 +794,19 @@ class TestMessageAggregatorFlush:
await agg._flush_buffer(key, context)
assert app.query_pool.add_query.called
app.query_pool.add_query.assert_awaited_once_with(
execution_context=context,
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
routed_by_rule=False,
variables=None,
)
assert key not in agg.buffers
@pytest.mark.asyncio
@@ -825,8 +871,8 @@ class TestMessageAggregatorFlushAll:
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain = text_chain('hello')
event = friend_message_event(chain)
first_chain = text_chain('first')
second_chain = text_chain('second')
adapter = mock_adapter()
# Create two buffers
@@ -836,8 +882,8 @@ class TestMessageAggregatorFlushAll:
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
message_event=friend_message_event(first_chain),
message_chain=first_chain,
adapter=adapter,
pipeline_uuid=None,
)
@@ -848,8 +894,8 @@ class TestMessageAggregatorFlushAll:
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=67890,
sender_id=67890,
message_event=event,
message_chain=chain,
message_event=friend_message_event(second_chain),
message_chain=second_chain,
adapter=adapter,
pipeline_uuid=None,
)
@@ -875,7 +921,11 @@ class TestMessageAggregatorFlushAll:
# Both buffers should be flushed
assert len(agg.buffers) == 0
assert app.query_pool.add_query.call_count == 2
assert app.query_pool.add_query.await_count == 2
assert [
(sent.kwargs['launcher_id'], str(sent.kwargs['message_chain']), sent.kwargs['execution_context'])
for sent in app.query_pool.add_query.await_args_list
] == [(12345, 'first', context), (67890, 'second', context)]
class TestMessageAggregatorWorkspaceIsolation:
+45 -9
View File
@@ -89,6 +89,25 @@ def set_runner(fake_app):
return _set_runner
@pytest.fixture
def chat_session():
from langbot_plugin.api.entities.builtin.provider.session import Session, Conversation, LauncherTypes
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
return Session(
launcher_type=LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
bot_uuid='test-bot-uuid',
using_conversation=Conversation(
prompt=Prompt(name='default', messages=[]),
messages=[],
pipeline_uuid='test-pipeline-uuid',
bot_uuid='test-bot-uuid',
),
)
# ============== CACHED LAZY IMPORTS ==============
_chat_handler_module = None
@@ -189,7 +208,7 @@ class TestChatMessageHandlerReal:
async def test_user_message_alter_string(self, fake_app, mock_event_ctx, set_runner):
"""user_message_alter as string updates query.user_message."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement
chat = get_chat_handler()
@@ -200,7 +219,8 @@ class TestChatMessageHandlerReal:
query = text_query('original')
query.adapter = Mock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.user_message = Message(role='user', content=[ContentElement.from_text('original')])
received_contents = []
class QuickRunner:
name = 'local-agent'
@@ -210,6 +230,7 @@ class TestChatMessageHandlerReal:
self.config = config
async def run(self, query):
received_contents.append(query.user_message.content)
yield Message(role='assistant', content='ok')
set_runner(QuickRunner)
@@ -220,10 +241,14 @@ class TestChatMessageHandlerReal:
async for result in handler.handle(query):
results.append(result)
assert query.user_message.content is not None
expected_content = [ContentElement.from_text('altered text')]
assert query.user_message.content == expected_content
assert received_contents == [expected_content]
@pytest.mark.asyncio
async def test_adapter_without_stream_method_defaults_non_stream(self, fake_app, mock_event_ctx, set_runner):
async def test_adapter_without_stream_method_defaults_non_stream(
self, fake_app, mock_event_ctx, set_runner, chat_session
):
"""Adapter without is_stream_output_supported defaults to non-stream."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement
@@ -237,6 +262,7 @@ class TestChatMessageHandlerReal:
query = text_query('test')
query.adapter = Mock(spec=[])
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
query.session = chat_session
class SingleRunner:
name = 'local-agent'
@@ -256,7 +282,9 @@ class TestChatMessageHandlerReal:
async for result in handler.handle(query):
results.append(result)
assert len(results) >= 1
assert len(results) == 1
assert results[0].result_type == get_entities().ResultType.CONTINUE
assert [(message.role, message.content) for message in query.resp_messages] == [('assistant', 'response')]
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -315,7 +343,7 @@ class TestChatHandlerStreaming:
query.adapter.create_message_card.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner):
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner, chat_session):
"""Streaming produces multiple results."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement, MessageChunk
@@ -330,6 +358,7 @@ class TestChatHandlerStreaming:
query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
query.adapter.create_message_card = AsyncMock()
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
query.session = chat_session
class StreamRunner:
name = 'local-agent'
@@ -340,17 +369,24 @@ class TestChatHandlerStreaming:
async def run(self, query):
yield MessageChunk(role='assistant', content='Hello', is_final=False)
yield MessageChunk(role='assistant', content=' World', is_final=True)
yield MessageChunk(role='assistant', content='Hello World', is_final=True)
set_runner(StreamRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
delivered = []
async for result in handler.handle(query):
results.append(result)
delivered.append(query.resp_messages[-1])
assert len(results) >= 1
assert len(results) == 2
assert all(result.result_type == get_entities().ResultType.CONTINUE for result in results)
assert [chunk.content for chunk in delivered] == ['Hello', 'Hello World']
assert [chunk.is_final for chunk in delivered] == [False, True]
assert len({chunk.resp_message_id for chunk in delivered}) == 1
query.adapter.create_message_card.assert_awaited_once()
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -536,7 +572,7 @@ class TestChatHandlerHelper:
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
assert result == 'first line...'
def test_response_size_limit_uses_instance_config(self, fake_app):
from langbot_plugin.api.entities.builtin.provider.message import Message
@@ -367,6 +367,8 @@ class TestCommandHandlerReal:
assert len(msg.content) == 2
assert msg.content[0].type == 'text'
assert msg.content[1].type == 'image_url'
assert msg.content[0].text == 'Here is the image:'
assert msg.content[1].image_url.url == 'https://example.com/image.png'
@pytest.mark.asyncio
async def test_command_result_empty_interrupts(self, fake_app, mock_event_ctx, mock_execute_factory):
@@ -410,4 +412,4 @@ class TestCommandHandlerHelper:
command = get_command_handler()
handler = command.CommandHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
assert result == 'first line...'
+8 -8
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
from types import SimpleNamespace
from tests.factories import (
FakeApp,
@@ -73,7 +74,7 @@ class TestLongTextProcessStageInit:
async def test_initialize_forward_strategy(self):
"""Initialize with strategy='forward' should use ForwardComponentStrategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward')
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
@@ -82,8 +83,7 @@ class TestLongTextProcessStageInit:
await stage.initialize(pipeline_config)
assert stage.strategy_impl is not None
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy)
@pytest.mark.asyncio
async def test_initialize_unknown_strategy_raises(self):
@@ -340,10 +340,11 @@ class TestLongTextProcessStageImageStrategy:
"""Tests for image strategy handling (requires PIL/font)."""
@pytest.mark.asyncio
async def test_image_strategy_missing_font_fallback(self):
async def test_image_strategy_missing_font_fallback(self, monkeypatch):
"""Missing font should fallback to forward strategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward')
monkeypatch.setattr(longtext, 'os', SimpleNamespace(name='posix', path=SimpleNamespace(exists=lambda _: False)))
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
@@ -354,6 +355,5 @@ class TestLongTextProcessStageImageStrategy:
# On non-Windows without font, should fallback to forward
await stage.initialize(pipeline_config)
# Should have initialized (possibly with fallback strategy)
if stage.strategy_impl is not None:
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
assert pipeline_config['output']['long-text-processing']['strategy'] == 'forward'
assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy)
+33 -12
View File
@@ -190,6 +190,7 @@ class TestPreProcessorNormalText:
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [('text', 'test message')]
class TestPreProcessorEmptyMessage:
@@ -254,6 +255,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_runner_descriptor(app)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
@@ -261,9 +263,8 @@ class TestPreProcessorImageSegment:
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
# Image query with base64
# Exercise both supported image transports.
query = image_query(text='look at this', url=None)
# Set base64 on the image component
import langbot_plugin.api.entities.builtin.platform.message as platform_message
chain = platform_message.MessageChain(
@@ -283,17 +284,23 @@ class TestPreProcessorImageSegment:
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
# User message should have content
assert result.new_query.user_message.content is not None
assert result.new_query.use_llm_model_uuid == 'vision-model'
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
expected_image = (
ContentElement.from_image_base64('data:image/png;base64,abc123')
if image_source == 'base64'
else ContentElement.from_image_url('https://cdn.example/image.png')
)
assert result.new_query.user_message.content == [ContentElement.from_text('look at this'), expected_image]
contents = [part.model_dump() for part in result.new_query.user_message.content]
attachments = QueryEntryAdapter._build_attachments(result.new_query, contents)
assert len(attachments) == 1
if image_source == 'url':
assert attachments[0]['url'] == 'https://cdn.example/image.png'
else:
assert attachments[0].get('content') or attachments[0].get('url')
assert attachments[0]['content'] == 'data:image/png;base64,abc123'
@pytest.mark.asyncio
async def test_image_without_vision_model(self):
@@ -315,6 +322,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_runner_descriptor(app)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
@@ -327,6 +335,12 @@ class TestPreProcessorImageSegment:
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
assert result.new_query.use_llm_model_uuid == 'text-only-model'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [
('text', 'describe this'),
('text', '[Image]'),
]
assert result.new_query.variables['user_message_text'] == 'describe this[Image]'
class TestPreProcessorModelSelection:
@@ -471,11 +485,18 @@ class TestPreProcessorVariables:
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'launcher_type' in variables
assert 'launcher_id' in variables
assert 'sender_id' in variables
assert variables['sender_id'] == 67890
assert 'user_message_text' in variables
expected = {
'launcher_type': 'person',
'launcher_id': 12345,
'sender_id': 67890,
'session_id': 'person_12345',
'conversation_id': 'conv-123',
'msg_create_time': 1609459200,
'group_name': '',
'sender_name': 'TestUser',
'user_message_text': 'hello',
}
assert {key: variables[key] for key in expected} == expected
@pytest.mark.asyncio
async def test_group_variables_include_group_name(self):
@@ -509,8 +530,8 @@ class TestPreProcessorVariables:
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'group_name' in variables
assert 'sender_name' in variables
assert variables['group_name'] == 'TestGroup'
assert variables['sender_name'] == 'TestUser'
class TestPreProcessorToolSelection:
+6 -3
View File
@@ -272,11 +272,14 @@ class TestFixedWindowAlgo:
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# release_access is empty in current implementation
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
containers = dict(algo.containers)
records = {key: dict(container.records) for key, container in containers.items()}
await algo.release_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
# Should not raise or change state
assert 'person_12345' not in algo.containers
assert algo.containers == containers
assert {key: dict(container.records) for key, container in algo.containers.items()} == records
# Original mock-based tests for RateLimit stage integration
+23 -12
View File
@@ -22,6 +22,7 @@ from tests.factories import (
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot_plugin.api.entities.builtin.provider.message import Message
def get_wrapper_module():
@@ -109,7 +110,7 @@ class TestResponseWrapperMessageChain:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(results[0].new_query.resp_message_chain) == 1
assert [str(chain) for chain in results[0].new_query.resp_message_chain] == ['response']
@pytest.mark.asyncio
async def test_message_chain_direct_append_consumes_pending_plugin_source(self):
@@ -171,12 +172,7 @@ class TestResponseWrapperCommand:
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create a command response message
command_resp = Mock()
command_resp.role = 'command'
command_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text='Help info')])
)
command_resp = Message(role='command', content='Help info')
query.resp_messages = [command_resp]
results = []
@@ -185,8 +181,7 @@ class TestResponseWrapperCommand:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Check that prefix was added (via get_content_platform_message_chain)
command_resp.get_content_platform_message_chain.assert_called_once()
assert [str(chain) for chain in query.resp_message_chain] == ['[bot] Help info']
class TestResponseWrapperPlugin:
@@ -223,6 +218,7 @@ class TestResponseWrapperPlugin:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert [str(chain) for chain in query.resp_message_chain] == ['Plugin response']
class TestResponseWrapperAssistant:
@@ -316,8 +312,11 @@ class TestResponseWrapperAssistant:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Event should have been emitted
app.plugin_connector.emit_event.assert_called()
assert [str(chain) for chain in query.resp_message_chain] == ['Hello back!']
app.plugin_connector.emit_event.assert_awaited_once()
event = app.plugin_connector.emit_event.await_args.args[0]
assert event.response_text == 'Hello back!'
assert event.funcs_called == []
@pytest.mark.asyncio
async def test_assistant_empty_content(self):
@@ -386,24 +385,36 @@ class TestResponseWrapperAssistant:
mock_tool_call = Mock()
mock_tool_call.function = Mock()
mock_tool_call.function.name = 'test_function'
other_tool_call = Mock()
other_tool_call.function.name = 'other_function'
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = 'Processing...'
assistant_resp.tool_calls = [mock_tool_call]
assistant_resp.tool_calls = [mock_tool_call, other_tool_call]
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text='Processing...')])
)
query.resp_messages = [assistant_resp]
results = []
delivered = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
delivered.append(str(query.resp_message_chain[-1]))
assert len(results) == 2
for result in results:
assert result.result_type == entities.ResultType.CONTINUE
assert app.plugin_connector.emit_event.await_count == 2
expected = ['Processing...', 'Calling test_function, other_function...']
assert delivered == expected
events = [sent.args[0] for sent in app.plugin_connector.emit_event.await_args_list]
assert [event.response_text for event in events] == expected
assert [event.funcs_called for event in events] == [
['test_function', 'other_function'],
['test_function', 'other_function'],
]
class TestResponseWrapperInterrupt:
@@ -613,6 +613,7 @@ def test_agent_event_keeps_image_file_and_voice_attachments():
)
agent_input = RuntimeBot._build_agent_input(event)
assert [attachment.type for attachment in agent_input.attachments] == ['image', 'file', 'voice']
assert agent_input.attachments[0].url == 'https://cdn.example.test/image.png'
assert agent_input.attachments[1].name == 'input.txt'
assert agent_input.attachments[1].url == 'https://cdn.example.test/input.txt'
assert agent_input.attachments[2].content == 'dGVzdA=='
@@ -3,7 +3,7 @@ from __future__ import annotations
import pathlib
import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
import yaml
@@ -401,8 +401,7 @@ async def test_discord_stream_keeps_tool_round_and_delivers_terminal_attachment(
assert 'stream' in adapter._stream_buffer
adapter.reply_message.assert_not_awaited()
await adapter.reply_message_chunk(source, {'resp_message_id': 'stream'}, message, is_final=True)
adapter.reply_message.assert_awaited_once()
assert adapter.reply_message.await_args.args[1].root == [attachment]
adapter.reply_message.assert_awaited_once_with(source, message, False)
channel.send.assert_not_awaited()
assert not adapter._stream_buffer
@@ -471,26 +470,34 @@ async def test_discord_uncached_edit_fetches_message_without_duplicate_or_previe
@pytest.mark.asyncio
async def test_guild_event_reply_uses_system_channel(monkeypatch):
@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)])
async def test_guild_event_reply_uses_system_channel(monkeypatch, guild_id, expected_guild_id):
adapter = make_adapter()
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=456)))
monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=channel))
get_guild = Mock(return_value=SimpleNamespace(system_channel=channel))
monkeypatch.setattr(adapter.bot, 'get_guild', get_guild)
adapter._get_channel = AsyncMock()
result = await adapter.send_message(
'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')])
'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')])
)
assert result.message_id == 456
get_guild.assert_called_once_with(expected_guild_id)
channel.send.assert_awaited_once_with(content='Welcome')
adapter._get_channel.assert_not_awaited()
@pytest.mark.asyncio
async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel(monkeypatch):
@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)])
async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel(
monkeypatch, guild_id, expected_guild_id
):
adapter = make_adapter()
monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=None))
get_guild = Mock(return_value=SimpleNamespace(system_channel=None))
monkeypatch.setattr(adapter.bot, 'get_guild', get_guild)
adapter._get_channel = AsyncMock()
with pytest.raises(ValueError, match='no system channel'):
await adapter.send_message(
'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')])
'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')])
)
get_guild.assert_called_once_with(expected_guild_id)
adapter._get_channel.assert_not_awaited()
@@ -137,10 +137,7 @@ async def test_officialaccount_message_converter_maps_components_to_passive_text
)
)
assert 'hi' in content
assert '[Image]' in content
assert '[File: a.txt]' in content
assert 'quoted' in content
assert content == 'hi\n[Image]\n[File: a.txt]\nquoted'
@pytest.mark.asyncio
@@ -11,12 +11,20 @@ from langbot.libs.openclaw_weixin_api.client import (
from langbot.libs.openclaw_weixin_api.types import ApiError
def test_cdn_crypto_helpers_round_trip():
original = b'tenant-media' * 128
def test_cdn_crypto_helpers_round_trip(monkeypatch):
# AES-128-ECB with PKCS7 padding, independently generated using OpenSSL.
key = bytes.fromhex('2b7e151628aed2a6abf7158809cf4f3c')
original = bytes.fromhex('6bc1bee22e409f96e93d7e117393172a')
expected_ciphertext = bytes.fromhex('3ad77bb40d7a3660a89ecaf32466ef97a254be88e037ddd9d79fb6411c3f9df8')
monkeypatch.setattr('langbot.libs.openclaw_weixin_api.client.os.urandom', lambda size: key)
aes_key_hex, _encoded_key, encrypted, _raw_md5 = _encrypt_cdn_payload(original)
aes_key_hex, encoded_key, encrypted, raw_md5 = _encrypt_cdn_payload(original)
assert _decrypt_cdn_payload(encrypted, bytes.fromhex(aes_key_hex)) == original
assert aes_key_hex == '2b7e151628aed2a6abf7158809cf4f3c'
assert encoded_key == 'MmI3ZTE1MTYyOGFlZDJhNmFiZjcxNTg4MDljZjRmM2M='
assert encrypted == expected_ciphertext
assert raw_md5 == 'f2ecfb66e72f88f217e659ac75b1075f'
assert _decrypt_cdn_payload(expected_ciphertext, key) == original
@pytest.mark.asyncio
@@ -164,12 +164,7 @@ async def test_slack_message_converter_maps_common_components_to_text():
)
)
assert 'hi' in text
assert '<@U-2>' in text
assert '<!channel>' in text
assert 'https://example.test/a.png' in text
assert 'a.txt' in text
assert 'quoted' in text
assert text == 'hi\n<@U-2>\n<!channel>\nhttps://example.test/a.png\na.txt\nquoted'
@pytest.mark.asyncio
@@ -604,6 +604,17 @@ async def test_telegram_platform_apis_call_underlying_bot_methods():
assert invite['invite_link'] == 'https://t.me/+abc'
assert await PLATFORM_API_MAP['answer_callback_query'](bot, {'callback_query_id': 'cb'}) == {'ok': True}
bot.pin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2, disable_notification=False)
bot.unpin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2)
bot.unpin_all_chat_messages.assert_awaited_once_with(chat_id=1)
bot.get_chat_administrators.assert_awaited_once_with(chat_id=1)
bot.set_chat_title.assert_awaited_once_with(chat_id=1, title='New')
bot.set_chat_description.assert_awaited_once_with(chat_id=1, description='Desc')
bot.get_chat_member_count.assert_awaited_once_with(chat_id=1)
bot.send_chat_action.assert_awaited_once_with(chat_id=1, action='typing')
bot.create_chat_invite_link.assert_awaited_once_with(chat_id=1, name='invite', expire_date=None, member_limit=None)
bot.answer_callback_query.assert_awaited_once_with(callback_query_id='cb', text=None, show_alert=False, url=None)
@pytest.mark.asyncio
async def test_telegram_unmute_member_uses_current_chat_permissions_fields():
@@ -90,17 +90,23 @@ async def test_image_defaults_to_png():
adapter, _, _ = _make_adapter()
chain = [{'type': 'Image', 'path': f'{_UPLOAD_PREFIX}blob'}]
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:image/png;base64,')
assert chain[0]['base64'] == 'data:image/png;base64,aGVsbG8='
@pytest.mark.asyncio
async def test_voice_uses_guessed_or_wav_mimetype():
async def test_voice_uses_guessed_or_wav_mimetype(monkeypatch):
adapter, storage_mgr, _ = _make_adapter()
chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
path = f'{_UPLOAD_PREFIX}clip.unknown'
chain = [{'type': 'Voice', 'path': path}]
guess_type = Mock(return_value=(None, None))
monkeypatch.setattr('mimetypes.guess_type', guess_type)
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:audio/')
guess_type.assert_called_once_with(path)
assert chain[0]['base64'] == 'data:audio/wav;base64,aGVsbG8='
assert chain[0]['path'] == ''
storage_mgr.delete_scoped_object_key.assert_awaited_once()
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(_CONTEXT, path, expected_owner_type='upload_image')
@pytest.mark.asyncio
+38
View File
@@ -0,0 +1,38 @@
"""Database fixture for plugin persistence behavior tests."""
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import Workspace
@pytest.fixture
async def plugin_db():
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
try:
async with engine.begin() as connection:
await connection.execute(text('PRAGMA foreign_keys=ON'))
await connection.run_sync(
Base.metadata.create_all,
tables=[User.__table__, Workspace.__table__, PluginSetting.__table__, BinaryStorage.__table__],
)
async with AsyncSession(engine, expire_on_commit=False) as session:
session.add_all(
Workspace(
uuid=workspace,
instance_uuid='instance-a',
name=workspace,
slug=workspace,
source='cloud_projection',
)
for workspace in ('workspace-a', 'workspace-b')
)
await session.flush()
yield session
finally:
await engine.dispose()
@@ -123,6 +123,7 @@ class TestExtractDepsMetadata:
# Should find requirements.txt in subdirectory
assert task_context.metadata['deps_total'] == 2
assert task_context.metadata['deps_list'] == ['pytest', 'black']
def test_archive_preview_rejects_extreme_compression_ratio(self):
from langbot.pkg.plugin.connector import inspect_plugin_archive_metadata
@@ -147,13 +148,6 @@ class TestParsePluginId:
assert author == 'myauthor'
assert name == 'myplugin'
def test_parse_plugin_id_empty(self):
"""Empty plugin ID is invalid."""
from langbot.pkg.plugin.connector import PluginRuntimeConnector
with pytest.raises(ValueError):
PluginRuntimeConnector._parse_plugin_id('')
@pytest.mark.asyncio
async def test_marketplace_response_reader_is_bounded():
@@ -583,11 +583,15 @@ async def test_local_install_cleans_untracked_legacy_plugin_before_runtime_apply
@pytest.mark.asyncio
@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)])
@pytest.mark.parametrize('remaining_references', [1, 0])
async def test_artifact_cleanup_is_reference_counted_within_workspace(
remaining_references: int,
statement_count: int,
plugin_db,
):
import sqlalchemy
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
@@ -598,19 +602,38 @@ async def test_artifact_cleanup_is_reference_counted_within_workspace(
workspace_uuid='workspace-a',
placement_generation=1,
)
statements = []
async def execute(statement):
statements.append(statement)
return SimpleNamespace(scalar_one=lambda: remaining_references)
digest = 'a' * 64
settings = [('workspace-b', digest), ('workspace-a', 'b' * 64)]
if remaining_references:
settings.append(('workspace-a', digest))
plugin_db.add_all(
PluginSetting(workspace_uuid=workspace, plugin_author='author', plugin_name=artifact, artifact_digest=artifact)
for workspace, artifact in settings
)
plugin_db.add_all(
BinaryStorage(
workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'pkg'
)
for workspace, key, owner_type, owner in [
('workspace-a', 'target', 'plugin_artifact', digest),
('workspace-b', 'other-workspace', 'plugin_artifact', digest),
('workspace-a', 'other-artifact', 'plugin_artifact', 'b' * 64),
('workspace-a', 'other-owner-type', 'plugin', digest),
]
)
await plugin_db.flush()
await connector._delete_artifact_if_unreferenced(
execution_context,
'a' * 64,
execute=execute,
digest,
execute=plugin_db.execute,
)
assert len(statements) == statement_count
remaining = (await plugin_db.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all()
expected = {'other-workspace', 'other-artifact', 'other-owner-type'}
if remaining_references:
expected.add('target')
assert set(remaining) == expected
@pytest.mark.asyncio
@@ -113,6 +113,7 @@ numpy"""
# Should find nested requirements.txt (ends with 'requirements.txt')
assert task_context.metadata.get('deps_total') == 2
assert task_context.metadata['deps_list'] == ['requests', 'flask']
def test_no_requirements_in_zip(self):
"""Test handling ZIP without requirements.txt."""
@@ -236,10 +236,16 @@ class TestInitializePluginSettings:
app.persistence_mgr.execute_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_existing_desired_setting_remains_core_owned(self, app):
async def test_existing_desired_setting_remains_core_owned(self, app, plugin_db):
"""Runtime initialization validates identity without rewriting Core state."""
import sqlalchemy
from langbot.pkg.entity.persistence.plugin import PluginSetting
runtime_handler = make_handler(app)
existing_setting = SimpleNamespace(
existing_setting = PluginSetting(
workspace_uuid='workspace-a',
plugin_author='test-author',
plugin_name='test-plugin',
enabled=False,
priority=5,
config={'key': 'value'},
@@ -247,7 +253,29 @@ class TestInitializePluginSettings:
runtime_revision=1,
artifact_digest='a' * 64,
)
app.persistence_mgr.execute_async.return_value = make_result(existing_setting)
other_setting = PluginSetting(
workspace_uuid='workspace-b',
plugin_author='test-author',
plugin_name='test-plugin',
enabled=True,
)
plugin_db.add_all([other_setting, existing_setting])
await plugin_db.flush()
async def execute(statement):
result = await plugin_db.execute(statement)
return result.scalars() if statement.is_select or statement.is_update else result
app.persistence_mgr.execute_async.side_effect = execute
before = (
(
await plugin_db.execute(
sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid)
)
)
.mappings()
.all()
)
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -260,6 +288,17 @@ class TestInitializePluginSettings:
assert response.code == 0
app.persistence_mgr.execute_async.assert_awaited_once()
plugin_db.expire_all()
after = (
(
await plugin_db.execute(
sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid)
)
)
.mappings()
.all()
)
assert after == before
class TestSetBinaryStorage:
+60 -32
View File
@@ -107,41 +107,69 @@ class TestMakeRagErrorResponse:
class TestCleanupPluginData:
"""Tests for cleanup_plugin_data method."""
@pytest.mark.asyncio
async def test_deletes_plugin_settings(self):
"""Test that plugin settings are deleted."""
handler_module = get_handler_module()
@pytest.fixture
async def cleanup(self, plugin_db):
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
from langbot_plugin.entities.io.context import InstallationBinding
mock_app = Mock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
# Mock the handler without connection (we only need ap)
handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler)
handler_instance.ap = mock_app
# Call cleanup_plugin_data
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(
handler_instance, 'test-author', 'test-plugin'
plugin_db.add_all(
PluginSetting(workspace_uuid=workspace, plugin_author=author, plugin_name=name)
for workspace, author, name in [
('workspace-a', 'test-author', 'test-plugin'),
('workspace-b', 'test-author', 'test-plugin'),
('workspace-a', 'other-author', 'test-plugin'),
('workspace-a', 'test-author', 'other-plugin'),
]
)
# Verify plugin settings delete was called
calls = mock_app.persistence_mgr.execute_async.call_args_list
assert len(calls) >= 1
plugin_db.add_all(
BinaryStorage(
workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'data'
)
for workspace, key, owner_type, owner in [
('workspace-a', 'target', 'plugin', 'test-author/test-plugin'),
('workspace-b', 'other-workspace', 'plugin', 'test-author/test-plugin'),
('workspace-a', 'other-owner', 'plugin', 'other-author/test-plugin'),
('workspace-a', 'other-type', 'skill', 'test-author/test-plugin'),
]
)
await plugin_db.flush()
app = Mock()
app.persistence_mgr.execute_async = plugin_db.execute
handler = get_handler_module().RuntimeConnectionHandler(Mock(), AsyncMock(), app)
binding = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
with handler.installation_scope(binding):
await handler.cleanup_plugin_data('test-author', 'test-plugin')
return plugin_db
@pytest.mark.asyncio
async def test_deletes_binary_storage(self):
async def test_deletes_plugin_settings(self, cleanup):
import sqlalchemy
from langbot.pkg.entity.persistence.plugin import PluginSetting
rows = (
await cleanup.execute(
sqlalchemy.select(PluginSetting.workspace_uuid, PluginSetting.plugin_author, PluginSetting.plugin_name)
)
).all()
assert set(rows) == {
('workspace-b', 'test-author', 'test-plugin'),
('workspace-a', 'other-author', 'test-plugin'),
('workspace-a', 'test-author', 'other-plugin'),
}
@pytest.mark.asyncio
async def test_deletes_binary_storage(self, cleanup):
"""Test that binary storage is deleted."""
handler_module = get_handler_module()
import sqlalchemy
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
mock_app = Mock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler)
handler_instance.ap = mock_app
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(handler_instance, 'author', 'plugin-name')
# Should have at least 2 calls: one for settings, one for binary storage
assert mock_app.persistence_mgr.execute_async.call_count >= 2
keys = (await cleanup.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all()
assert set(keys) == {'other-workspace', 'other-owner', 'other-type'}
@@ -5,10 +5,6 @@ import pytest
from langbot.pkg.plugin.connector import PluginRuntimeConnector
def test_parse_plugin_id_accepts_author_name():
assert PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine') == ('langbot', 'rag-engine')
@pytest.mark.parametrize(
'plugin_id',
[
@@ -1048,10 +1048,8 @@ class TestInvokeRerank:
documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'],
)
assert len(results) == 3
# Scores should be normalized
assert results[0]['index'] == 0
assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1
assert [result['index'] for result in results] == [0, 1, 2]
assert [result['relevance_score'] for result in results] == pytest.approx([1.0, 0.0, 0.76923076923])
@pytest.mark.asyncio
async def test_invoke_rerank_normalization(self):
@@ -1297,8 +1295,8 @@ class TestConvertMessages:
result = requester._convert_messages(messages)
assert len(result) == 1
# LiteLLM handles multiple text parts, we pass them through
assert isinstance(result[0]['content'], list)
assert result[0]['role'] == 'user'
assert result[0]['content'] == [{'type': 'text', 'text': 'Hello'}, {'type': 'text', 'text': 'World'}]
class TestScanModels:
@@ -56,11 +56,8 @@ def test_requester_base_class_is_abstract():
mock_app = SimpleNamespace()
mock_app.logger = Mock()
# ProviderAPIRequester has abstract methods, but ABCMeta allows instantiation
# if you don't call the abstract methods. Test that it has abstract methods.
assert hasattr(requester.ProviderAPIRequester, 'invoke_llm')
# Check that invoke_llm is abstract
assert hasattr(requester.ProviderAPIRequester.invoke_llm, '__isabstractmethod__')
with pytest.raises(TypeError, match='abstract'):
requester.ProviderAPIRequester(mock_app, {})
def test_requester_default_config_merged():
@@ -250,16 +247,6 @@ def test_runtime_provider_initialization(runtime_provider, fake_persistence_data
assert isinstance(provider.requester, requester.ProviderAPIRequester)
def test_runtime_provider_has_invoke_methods(runtime_provider):
"""Test RuntimeProvider has invoke methods that delegate to requester."""
provider = runtime_provider
assert hasattr(provider, 'invoke_llm')
assert hasattr(provider, 'invoke_llm_stream')
assert hasattr(provider, 'invoke_embedding')
assert hasattr(provider, 'invoke_rerank')
@pytest.mark.asyncio
async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_llm_model):
"""Test RuntimeProvider.invoke_llm delegates to requester."""
@@ -404,6 +391,7 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider
assert len(chunks) == 1
assert chunks[0].role == 'assistant'
assert [(part.type, part.text) for part in chunks[0].content] == [('text', 'Fake stream chunk')]
@pytest.mark.asyncio
@@ -513,13 +501,11 @@ def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_da
assert model.reasoning_config_override is None
def test_runtime_llm_model_provider_ref(runtime_llm_model):
def test_runtime_llm_model_provider_ref(runtime_llm_model, runtime_provider):
"""Test RuntimeLLMModel has correct provider reference."""
model = runtime_llm_model
assert model.provider.provider_entity is not None
assert model.provider.token_mgr is not None
assert model.provider.requester is not None
assert model.provider is runtime_provider
# ============================================================================
@@ -365,7 +365,10 @@ class TestSessionManagerGetConversation:
)
assert conversation.prompt.name == 'default'
assert len(conversation.prompt.messages) == 2
assert [(message.role, message.content) for message in conversation.prompt.messages] == [
('system', 'System message'),
('user', 'User message'),
]
class TestSessionManagerWorkspaceIsolation:
@@ -1,72 +1,128 @@
"""The optional-dependency CI guard must reject a false-green JUnit report."""
"""Exercise the required-suite policy through real pytest collection and execution."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import os
import subprocess
import sys
import pytest
SCRIPT = Path(__file__).resolve().parents[3] / 'scripts' / 'assert-required-tests.py'
SPEC = importlib.util.spec_from_file_location('required_contract_report', SCRIPT)
assert SPEC is not None and SPEC.loader is not None
REPORT = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(REPORT)
ROOT = Path(__file__).resolve().parents[3]
PASSING = """
import pytest
@pytest.mark.security
@pytest.mark.contract('debug_exec_absent')
@pytest.mark.parametrize('value', [1, 2, 3])
def test_renamed_behavior(value):
assert value > 0
@pytest.mark.security
@pytest.mark.contract('debug_route_absent')
def test_another_name():
pass
def test_unrelated():
assert False
"""
def write_report(tmp_path, cases):
path = tmp_path / 'report.xml'
path.write_text(f'<testsuites><testsuite>{cases}</testsuite></testsuites>')
return path
def test_accepts_exact_passing_cases(tmp_path):
path = write_report(
tmp_path, '<testcase classname="contracts" name="first"/><testcase classname="contracts" name="second"/>'
def run_suite(tmp_path, source, *args):
(tmp_path / 'moved').mkdir(exist_ok=True)
(tmp_path / 'moved/test_any_name.py').write_text(source)
(tmp_path / 'pytest.ini').write_text('[pytest]\nmarkers =\n security: suite\n contract(name): behavior\n')
return subprocess.run(
[
sys.executable,
'-m',
'pytest',
'-p',
'tests.utils.required_suites',
'--required-suite',
'security',
'-q',
*args,
],
cwd=tmp_path,
env={**os.environ, 'PYTHONPATH': str(ROOT)},
capture_output=True,
text=True,
timeout=30,
)
assert REPORT.verify_report(path, 'contracts', 2) == 2
@pytest.mark.parametrize('outcome', ['skipped', 'failure', 'error'])
def test_rejects_every_nonpassing_outcome(tmp_path, outcome):
path = write_report(tmp_path, f'<testcase classname="contracts" name="required"><{outcome}/></testcase>')
with pytest.raises(ValueError, match=outcome):
REPORT.verify_report(path, 'contracts', 1)
def test_renames_moves_and_parameter_growth_need_no_ci_edit(tmp_path):
result = run_suite(tmp_path, PASSING)
assert result.returncode == 0, result.stdout + result.stderr
assert '4 passed, 1 deselected' in result.stdout
result = run_suite(tmp_path, PASSING.replace('[1, 2, 3]', '[1, 2, 3, 4]'))
assert result.returncode == 0, result.stdout + result.stderr
assert '5 passed, 1 deselected' in result.stdout
@pytest.mark.parametrize('count', [0, 2])
def test_rejects_changed_case_count(tmp_path, count):
path = write_report(tmp_path, '<testcase classname="contracts" name="case"/>' * count)
with pytest.raises(ValueError, match='Expected 1 tests'):
REPORT.verify_report(path, 'contracts', 1)
@pytest.mark.parametrize(
'statement',
[
"pytest.skip('dependency unavailable')",
"pytest.xfail('known bug')",
'assert False',
"raise RuntimeError('broken')",
],
)
def test_required_cases_must_actually_pass(tmp_path, statement):
result = run_suite(tmp_path, PASSING.replace(' pass', f' {statement}'))
assert result.returncode != 0
assert 'all selected cases must pass' in result.stdout
def test_rejects_wrong_module(tmp_path):
path = write_report(tmp_path, '<testcase classname="unrelated" name="case"/>')
with pytest.raises(ValueError, match='Unexpected test module'):
REPORT.verify_report(path, 'contracts', 1)
def test_unexpected_xpass_is_not_a_required_pass(tmp_path):
source = PASSING.replace('def test_another_name():', '@pytest.mark.xfail(strict=False)\ndef test_another_name():')
result = run_suite(tmp_path, source)
assert result.returncode != 0
assert 'all selected cases must pass' in result.stdout
def test_rejects_missing_report(tmp_path):
with pytest.raises(FileNotFoundError):
REPORT.verify_report(tmp_path / 'missing.xml', 'contracts', 1)
def test_missing_behavior_cannot_be_replaced_by_more_unrelated_cases(tmp_path):
source = PASSING.replace("@pytest.mark.contract('debug_route_absent')", '')
result = run_suite(tmp_path, source)
assert result.returncode != 0
assert "missing contracts: ['debug_route_absent']" in result.stderr
def test_command_line_success_and_failure(tmp_path):
path = write_report(tmp_path, '<testcase classname="contracts" name="case"/>')
command = [sys.executable, str(SCRIPT), str(path), '--module', 'contracts', '--expected-tests']
good = subprocess.run([*command, '1'], capture_output=True, text=True)
assert good.returncode == 0
assert '1 required tests passed, 0 skipped' in good.stdout
bad = subprocess.run([*command, '0'], capture_output=True, text=True)
assert bad.returncode != 0
assert 'must be positive' in bad.stderr
def test_empty_suite_fails(tmp_path):
result = run_suite(tmp_path, 'def test_unrelated(): pass')
assert result.returncode != 0
assert 'missing contracts' in result.stderr
def test_rejects_wrong_function(tmp_path):
path = write_report(tmp_path, '<testcase classname="contracts" name="unrelated"/>')
with pytest.raises(ValueError, match='Unexpected test name'):
REPORT.verify_report(path, 'contracts', 1, 'test_required')
@pytest.mark.parametrize('args', [('-k', 'renamed'), ('-m', 'security'), ('--deselect', 'moved/test_any_name.py')])
def test_required_suite_rejects_partial_filters(tmp_path, args):
result = run_suite(tmp_path, PASSING, *args)
assert result.returncode != 0
assert 'cannot be combined' in result.stderr
@pytest.mark.parametrize('phase', ['setup', 'teardown'])
def test_fixture_skips_also_fail(tmp_path, phase):
fixture = '\n@pytest.fixture(autouse=True)\ndef dependency():\n'
if phase == 'teardown':
fixture += ' yield\n'
fixture += " pytest.skip('dependency unavailable')\n"
result = run_suite(tmp_path, PASSING + fixture)
assert result.returncode != 0
assert 'all selected cases must pass' in result.stdout
def test_optional_local_execution_still_allows_skips(tmp_path):
(tmp_path / 'test_optional.py').write_text("import pytest\ndef test_optional(): pytest.skip('no database')")
result = subprocess.run(
[sys.executable, '-m', 'pytest', '-p', 'tests.utils.required_suites', '-q'],
cwd=tmp_path,
env={**os.environ, 'PYTHONPATH': str(ROOT)},
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
assert '1 skipped' in result.stdout
+6 -2
View File
@@ -300,7 +300,9 @@ class TestStoreFileTask:
task_context.set_current_action.assert_called_once_with('Processing file')
kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with(object_key)
kb._ingest_document.assert_awaited_once()
assert kb.ap.persistence_mgr.execute_async.await_count == 2
statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list]
assert [params['status'] for params in statements] == ['processing', 'completed']
assert statements[-1]['engine_document_id'] == 'file-uuid'
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key)
@pytest.mark.asyncio
@@ -316,7 +318,9 @@ class TestStoreFileTask:
with pytest.raises(Exception, match='parser failed'):
await kb._store_file_task(CONTEXT, file_obj, task_context)
assert kb.ap.persistence_mgr.execute_async.await_count == 2
statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list]
assert [params['status'] for params in statements] == ['processing', 'failed']
assert statements[-1]['engine_document_id'] == 'file-uuid'
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key)
+3 -1
View File
@@ -662,7 +662,9 @@ class TestRuntimeKnowledgeBaseRetrieve:
assert len(results) == 1
assert results[0].id == 'doc1'
assert hasattr(results[0], 'content')
assert results[0].content[0].text == 'test content'
assert results[0].metadata == {'source': 'file.pdf'}
assert results[0].distance == 0.15
class TestRuntimeKnowledgeBaseDispose:
+18 -18
View File
@@ -12,8 +12,9 @@ Uses moto library to mock AWS S3 service.
from __future__ import annotations
import pytest
from unittest.mock import Mock
from unittest.mock import Mock, patch
from importlib import import_module
from botocore.exceptions import ClientError
def get_s3storage_module():
@@ -57,14 +58,6 @@ def s3_mock():
class TestS3StorageProviderInit:
"""Tests for S3StorageProvider initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores the Application reference."""
s3storage = get_s3storage_module()
mock_app = Mock()
provider = s3storage.S3StorageProvider(mock_app)
assert provider.ap is mock_app
def test_init_s3_client_none(self):
"""Test that s3_client starts as None."""
s3storage = get_s3storage_module()
@@ -102,6 +95,7 @@ class TestS3StorageProviderWithMoto:
assert provider.s3_client is not None
assert provider.bucket_name == 'test-langbot-storage'
mock_app_with_s3_config.logger.info.assert_called()
assert [bucket['Name'] for bucket in s3_mock.list_buckets()['Buckets']] == ['test-langbot-storage']
@pytest.mark.asyncio
async def test_initialize_uses_existing_bucket(self, mock_app_with_s3_config, s3_mock):
@@ -112,11 +106,16 @@ class TestS3StorageProviderWithMoto:
s3_mock.create_bucket(Bucket='test-langbot-storage')
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
with (
patch.object(s3storage.boto3.session.Session, 'client', return_value=s3_mock),
patch.object(s3_mock, 'head_bucket', wraps=s3_mock.head_bucket) as head_bucket,
patch.object(s3_mock, 'create_bucket', wraps=s3_mock.create_bucket) as create_bucket,
):
await provider.initialize()
assert provider.s3_client is not None
# Bucket creation log should not be called since bucket exists
# Note: moto may still call head_bucket successfully
assert provider.s3_client is s3_mock
head_bucket.assert_called_once_with(Bucket='test-langbot-storage')
create_bucket.assert_not_called()
@pytest.mark.asyncio
async def test_save_and_load_bytes(self, mock_app_with_s3_config, s3_mock):
@@ -232,7 +231,7 @@ class TestS3StorageProviderWithMoto:
@pytest.mark.asyncio
async def test_delete_dir_recursive_handles_trailing_slash(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive handles path without trailing slash."""
"""Test that delete_dir_recursive handles a path with a trailing slash."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
@@ -241,8 +240,7 @@ class TestS3StorageProviderWithMoto:
# Save object
await provider.save('mydir/file.txt', b'data')
# Delete without trailing slash
await provider.delete_dir_recursive('mydir')
await provider.delete_dir_recursive('mydir/')
# Verify deleted
assert await provider.exists('mydir/file.txt') is False
@@ -325,8 +323,9 @@ class TestS3StorageProviderErrorHandling:
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
with pytest.raises(ClientError) as exc_info:
await provider.load('nonexistent.txt')
assert exc_info.value.response['Error']['Code'] == 'NoSuchKey'
@pytest.mark.asyncio
async def test_size_nonexistent_raises_error(self, s3_mock):
@@ -350,5 +349,6 @@ class TestS3StorageProviderErrorHandling:
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
with pytest.raises(ClientError) as exc_info:
await provider.size('nonexistent.txt')
assert exc_info.value.response['Error']['Code'] == '404'
@@ -9,7 +9,6 @@ from unittest.mock import Mock, AsyncMock, patch
from langbot.pkg.storage.mgr import StorageMgr
from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
from langbot.pkg.storage.providers.s3storage import S3StorageProvider
class TestStorageMgr:
@@ -21,77 +20,6 @@ class TestStorageMgr:
storage_mgr = StorageMgr(mock_app)
assert storage_mgr.ap == mock_app
@pytest.mark.asyncio
async def test_initialize_default_local(self):
"""Should use local storage by default."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
mock_app.logger.info.assert_called()
@pytest.mark.asyncio
async def test_initialize_with_explicit_local(self):
"""Should use local storage when explicitly configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'storage': {'use': 'local'}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_with_s3(self):
"""Should use S3 storage when configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'storage': {'use': 's3', 's3': {'endpoint_url': 'https://s3.amazonaws.com'}}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, S3StorageProvider)
@pytest.mark.asyncio
async def test_initialize_invalid_type_defaults_to_local(self):
"""Should default to local storage for invalid storage type."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'storage': {'use': 'invalid_type'}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_calls_provider_initialize(self):
"""Should call the provider's initialize method."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:
await storage_mgr.initialize()
mock_init.assert_called_once()
@pytest.mark.asyncio
async def test_shutdown_delegates_to_active_provider(self):
storage_mgr = StorageMgr(Mock())
@@ -234,6 +234,12 @@ class TestTriggerEvent:
await manager.trigger_event('new_event')
assert 'new_event' in manager._triggered_events
statements = mock_app.persistence_mgr.execute_async.await_args_list
assert len(statements) == 2
assert statements[-1].args[0].compile().params == {
'key': 'survey_triggered_events',
'value': '["new_event"]',
}
class TestRecordBotResponseSuccess:
@@ -255,8 +261,9 @@ class TestRecordBotResponseSuccess:
await manager.record_bot_response_success()
assert manager._bot_response_count == 1
# select + insert for the count key
assert mock_app.persistence_mgr.execute_async.call_count >= 2
statements = mock_app.persistence_mgr.execute_async.await_args_list
assert len(statements) == 2
assert statements[-1].args[0].compile().params == {'key': 'survey_bot_response_count', 'value': '1'}
@pytest.mark.asyncio
async def test_fires_milestone_event_at_threshold(self):
+9 -4
View File
@@ -83,7 +83,9 @@ class TestTelemetrySendEarlyReturn:
manager.telemetry_config = {}
# Should return without making HTTP calls
await manager.send({'query_id': 'test'})
with patch.object(httpx, 'AsyncClient') as client:
await manager.send({'query_id': 'test'})
client.assert_not_called()
# No HTTP client should be created, no logs should be written
mock_app.logger.debug.assert_not_called()
@@ -99,7 +101,9 @@ class TestTelemetrySendEarlyReturn:
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'disable_telemetry': True, 'url': 'https://example.com'}
await manager.send({'query_id': 'test'})
with patch.object(httpx, 'AsyncClient') as client:
await manager.send({'query_id': 'test'})
client.assert_not_called()
mock_app.logger.debug.assert_not_called()
@@ -113,7 +117,9 @@ class TestTelemetrySendEarlyReturn:
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': ''}
await manager.send({'query_id': 'test'})
with patch.object(httpx, 'AsyncClient') as client:
await manager.send({'query_id': 'test'})
client.assert_not_called()
mock_app.logger.debug.assert_not_called()
@@ -627,4 +633,3 @@ class TestAuthenticatedWorkspaceReporter:
assert mock_client.post.call_args.kwargs['headers'] == {
'Authorization': 'Bearer refreshed-workspace-owner-token'
}
+72 -44
View File
@@ -1,61 +1,89 @@
"""Regression tests for the production Matrix ``!relogin`` command."""
from __future__ import annotations
"""Regression test for the Matrix ``!relogin`` command (``_handle_relogin_command``).
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, call
The old code placed ``logout_cmd = ...`` on a line after an unconditional
``continue`` inside the ``if not bridge.login_command or not bridge.dm_room_id``
branch. Because the ``continue`` always fired, ``logout_cmd`` was never assigned
on the configured path, so any bridge with both ``login_command`` and
``dm_room_id`` raised ``UnboundLocalError``. The fix moves the assignment above
the ``if`` so it runs for configured bridges.
import pytest
These tests replicate the loop's control flow with a minimal fake bridge so they
run without the Matrix SDK / langbot_plugin dependency.
"""
from langbot.pkg.platform.sources import matrix
class FakeBridge:
def __init__(self, user_id: str, login_command: str, logout_command: str = '', dm_room_id: str | None = None):
self.user_id = user_id
self.login_command = login_command
self.logout_command = logout_command
self.dm_room_id = dm_room_id
@pytest.fixture
def bridge():
bridge = matrix.BridgeState('@u:example.org', 'login', '', '', '')
bridge.dm_room_id = '!bridge:example.org'
bridge.logged_in = True
return bridge
def _relogin_commands(bridges: list[FakeBridge]) -> list[str]:
"""Return the logout commands the fixed loop would send for each bridge."""
commands: list[str] = []
for bridge in bridges:
if not bridge.login_command or not bridge.dm_room_id:
continue
# Use configured logout command, fallback to deriving from login command.
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
commands.append(logout_cmd)
return commands
@pytest.fixture
def adapter(monkeypatch):
adapter = matrix.MatrixAdapter.model_construct(
client=SimpleNamespace(room_send=AsyncMock()),
logger=None,
)
monkeypatch.setattr(matrix.asyncio, 'sleep', AsyncMock())
monkeypatch.setattr(matrix.MatrixAdapter, '_restart_bridge_login', Mock())
return adapter
def test_configured_bridge_produces_logout_command_without_error() -> None:
bridges = [FakeBridge('@u:example.org', 'login', dm_room_id='!room:example.org')]
# Old code raised UnboundLocalError here; the fix must return the derived
# logout command (no configured logout_command -> derive from login).
assert _relogin_commands(bridges) == ['logout']
async def test_configured_bridge_produces_logout_command_without_error(adapter, bridge) -> None:
adapter._bridges = [bridge]
await adapter._handle_relogin_command('!admin:example.org')
assert adapter.client.room_send.await_count == 2
assert adapter.client.room_send.await_args_list[0] == call(
room_id='!bridge:example.org',
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': 'logout'},
)
adapter._restart_bridge_login.assert_called_once_with(bridge)
assert bridge.logged_in is False
def test_configured_logout_command_is_used_verbatim() -> None:
bridges = [FakeBridge('@u:example.org', 'login', logout_command='leave', dm_room_id='!room:example.org')]
assert _relogin_commands(bridges) == ['leave']
async def test_configured_logout_command_is_used_verbatim(adapter, bridge) -> None:
bridge.logout_command = 'leave'
adapter._bridges = [bridge]
await adapter._handle_relogin_command('!admin:example.org')
assert adapter.client.room_send.await_count == 2
assert adapter.client.room_send.await_args_list[0] == call(
room_id='!bridge:example.org',
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': 'leave'},
)
adapter._restart_bridge_login.assert_called_once_with(bridge)
assert bridge.logged_in is False
def test_skipped_bridge_is_ignored() -> None:
# Missing dm_room_id -> skipped, no command emitted.
bridges = [FakeBridge('@u:example.org', 'login', dm_room_id=None)]
assert _relogin_commands(bridges) == []
async def test_skipped_bridge_is_ignored(adapter, bridge) -> None:
bridge.dm_room_id = None
adapter._bridges = [bridge]
await adapter._handle_relogin_command('!admin:example.org')
adapter.client.room_send.assert_awaited_once()
assert adapter.client.room_send.await_args.kwargs['room_id'] == '!admin:example.org'
adapter._restart_bridge_login.assert_not_called()
assert bridge.logged_in is True
def test_relogin_never_raises_for_mixed_configurations() -> None:
bridges = [
FakeBridge('@skip:example.org', '', dm_room_id='!room:example.org'), # no login_command
FakeBridge('@ok:example.org', 'login', dm_room_id='!room:example.org'),
FakeBridge('@skip2:example.org', 'login', dm_room_id=None), # no dm_room_id
async def test_relogin_never_raises_for_mixed_configurations(adapter, bridge) -> None:
missing_login = matrix.BridgeState('@skip:example.org', '', '', '', '')
missing_login.dm_room_id = '!skip:example.org'
missing_room = matrix.BridgeState('@skip2:example.org', 'login', '', '', '')
adapter._bridges = [missing_login, bridge, missing_room]
await adapter._handle_relogin_command('!admin:example.org')
assert [sent.kwargs['room_id'] for sent in adapter.client.room_send.await_args_list] == [
'!bridge:example.org',
'!admin:example.org',
]
assert _relogin_commands(bridges) == ['logout']
assert adapter.client.room_send.await_args_list[0].kwargs['content']['body'] == 'logout'
adapter._restart_bridge_login.assert_called_once_with(bridge)
assert bridge.logged_in is False
+2 -2
View File
@@ -70,8 +70,8 @@ class TestGetQQImageDownloadableUrl:
url = 'http://example.com/image.jpg#fragment'
result_url, query = get_qq_image_downloadable_url(url)
# Fragment is not included in query string parsing
assert 'http://example.com/image.jpg' in result_url
assert result_url == 'http://example.com/image.jpg'
assert query == {}
def test_https_url(self):
"""Parse HTTPS URL and preserve its scheme."""
+16 -16
View File
@@ -15,13 +15,14 @@ Uses mocking for import operations to avoid actual module imports.
import pytest
import importlib
from unittest.mock import patch, MagicMock
import os
from unittest.mock import patch, MagicMock, call
class TestImportDir:
"""Test import_dir function."""
def test_calls_importlib_for_each_python_file(self, tmp_path):
def test_calls_importlib_for_each_python_file(self, tmp_path, monkeypatch):
"""Should call importlib.import_module for each .py file."""
module_dir = tmp_path / 'test_modules'
module_dir.mkdir()
@@ -33,12 +34,17 @@ class TestImportDir:
from langbot.pkg.utils import importutil
monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py'))
with patch.object(importlib, 'import_module') as mock_import:
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
# Should call import_module for each .py file (excluding __init__.py)
assert mock_import.call_count == 2
mock_import.assert_has_calls(
[call('test_prefix.test_modules.module_a'), call('test_prefix.test_modules.module_b')],
any_order=True,
)
def test_skips_init_py(self, tmp_path):
def test_skips_init_py(self, tmp_path, monkeypatch):
"""Should skip __init__.py when importing."""
module_dir = tmp_path / 'test_modules'
module_dir.mkdir()
@@ -48,15 +54,12 @@ class TestImportDir:
from langbot.pkg.utils import importutil
monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py'))
with patch.object(importlib, 'import_module') as mock_import:
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
# __init__.py should be skipped
mock_import.assert_called_once()
# The call should not include __init__
call_args = mock_import.call_args[0][0]
assert '__init__' not in call_args
mock_import.assert_called_once_with('test_prefix.test_modules.regular')
def test_ignores_non_py_files(self, tmp_path):
def test_ignores_non_py_files(self, tmp_path, monkeypatch):
"""Should ignore non-.py files."""
module_dir = tmp_path / 'test_modules'
module_dir.mkdir()
@@ -67,10 +70,10 @@ class TestImportDir:
from langbot.pkg.utils import importutil
monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py'))
with patch.object(importlib, 'import_module') as mock_import:
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
# Only .py files should be imported
assert mock_import.call_count == 1
mock_import.assert_called_once_with('test_prefix.test_modules.module')
class TestImportModulesInPkg:
@@ -107,7 +110,7 @@ class TestImportModulesInPkgs:
with patch.object(importutil, 'import_modules_in_pkg') as mock_import:
importutil.import_modules_in_pkgs([mock_pkg1, mock_pkg2])
assert mock_import.call_count == 2
assert mock_import.call_args_list == [call(mock_pkg1), call(mock_pkg2)]
class TestImportDotStyleDir:
@@ -124,10 +127,7 @@ class TestImportDotStyleDir:
with patch.object(importutil, 'import_dir') as mock_import_dir:
importutil.import_dot_style_dir('my.pkg.test')
# The path should be converted using os.path.join
call_path = mock_import_dir.call_args[0][0]
# Should contain the path components joined
assert 'my' in call_path
mock_import_dir.assert_called_once_with(os.path.join('my', 'pkg', 'test'))
class TestReadResourceFile:
+7 -9
View File
@@ -9,7 +9,6 @@ Tests path utility functions:
Uses tmp_path for file system isolation where applicable.
"""
import os
import pytest
@@ -45,17 +44,16 @@ class TestCheckIfSourceInstall:
class TestGetFrontendPath:
"""Test get_frontend_path function."""
def test_returns_web_dist_by_default(self):
"""Should return a path containing web/dist as default."""
def test_returns_web_dist_by_default(self, monkeypatch):
"""Should return web/dist when no frontend build exists."""
from langbot.pkg.utils import paths
paths._is_source_install = None
monkeypatch.setattr(paths, '_is_source_install', False)
monkeypatch.setattr(paths.os.path, 'exists', lambda _: False)
monkeypatch.setattr(paths.Path, 'exists', lambda _: False)
result = paths.get_frontend_path()
# The result should contain web/dist or be an absolute path to it
assert 'web/dist' in result or result.endswith('dist')
paths._is_source_install = None
assert result == 'web/dist'
def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch):
"""Should find web/dist when running from source mode."""
@@ -130,7 +128,7 @@ class TestGetResourcePath:
paths._is_source_install = None
result = paths.get_resource_path('templates/config.yaml')
assert os.path.exists(result)
assert result == 'templates/config.yaml'
paths._is_source_install = None
@@ -0,0 +1,78 @@
"""Exercise generated shell/Python syntax without installing dependencies."""
import hashlib
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
import unittest
from langbot.pkg.utils.python_workspace import wrap_python_command_with_env
class TestPythonWorkspaceCommand(unittest.TestCase):
def test_generated_command_contains_no_null_bytes(self):
command = wrap_python_command_with_env('python main.py')
self.assertNotIn('\0', command)
@unittest.skipUnless(shutil.which('sh'), 'POSIX shell required')
def test_generated_command_has_valid_shell_syntax(self):
command = wrap_python_command_with_env('python main.py')
result = subprocess.run(['sh', '-n', '-c', command], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
@unittest.skipUnless(shutil.which('sh'), 'POSIX shell required')
def test_cached_environment_executes_command_and_hashes_manifest(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
mount = root / 'project'
state = root / 'state'
bin_dir = root / 'bin'
venv_bin = state / '.venv' / 'bin'
meta_dir = state / '.langbot'
for path in (mount, bin_dir, venv_bin, meta_dir):
path.mkdir(parents=True)
manifest = b'# local fixture\nexample==1.0\n'
(mount / 'requirements.txt').write_bytes(manifest)
# Run the real metadata interpreter, but fail closed if bootstrap is attempted.
system_python = bin_dir / 'python3'
system_python.write_text(
'#!/bin/sh\n'
'[ "$#" -eq 1 ] && [ "$1" = "-" ] || exit 99\n'
f'exec {shlex.quote(sys.executable)} "$@"\n',
encoding='utf-8',
)
system_python.chmod(0o755)
venv_python = venv_bin / 'python'
venv_python.write_text('#!/bin/sh\nprintf "%s\\n" "$VIRTUAL_ENV"\n', encoding='utf-8')
venv_python.chmod(0o755)
metadata = json.dumps(
{
'python_executable': sys.executable,
'python_version': list(sys.version_info[:3]),
'manifest_files': ['requirements.txt'],
'manifest_sha256': hashlib.sha256(b'requirements.txt\0' + manifest + b'\0').hexdigest(),
},
sort_keys=True,
)
meta_file = meta_dir / 'python-env.json'
meta_file.write_text(metadata, encoding='utf-8')
command = wrap_python_command_with_env('python', mount_path=str(mount), state_path=str(state))
result = subprocess.run(
['sh', '-c', command],
env={**os.environ, 'PATH': f'{bin_dir}{os.pathsep}{os.environ.get("PATH", "")}'},
capture_output=True,
text=True,
timeout=10,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, f'{state / ".venv"}\n')
self.assertEqual(meta_file.read_text(encoding='utf-8'), metadata)
self.assertFalse((meta_dir / 'python-env.lock').exists())
+1 -3
View File
@@ -198,6 +198,4 @@ class TestSupportedOpsConstant:
def test_supported_ops_is_frozenset(self):
"""SUPPORTED_OPS is a frozenset for immutability."""
from collections.abc import Set
assert isinstance(SUPPORTED_OPS, Set)
assert isinstance(SUPPORTED_OPS, frozenset)
@@ -10,6 +10,9 @@ from __future__ import annotations
from importlib import import_module
import pytest
from sqlalchemy import create_engine, select, text
def get_qdrant_module():
"""Lazy import qdrant module."""
@@ -261,6 +264,37 @@ class TestPgVectorFilterConversion:
Tests use only these supported fields.
"""
@pytest.fixture
def matching_file_ids(self):
"""Execute generated predicates against fixed rows, including the range boundary."""
pgvector_module = get_pgvector_module()
engine = create_engine('sqlite://')
try:
with engine.begin() as connection:
connection.execute(text('CREATE TABLE langbot_vectors (file_id TEXT, chunk_uuid TEXT, text TEXT)'))
connection.execute(
text('INSERT INTO langbot_vectors VALUES (:file_id, :chunk_uuid, :text)'),
[
{'file_id': 'abc', 'chunk_uuid': 'uuid_099', 'text': 'keep'},
{'file_id': 'deleted', 'chunk_uuid': 'uuid_100', 'text': ''},
{'file_id': 'def', 'chunk_uuid': 'uuid_101', 'text': 'keep'},
{'file_id': 'outside', 'chunk_uuid': 'uuid_102', 'text': 'keep'},
],
)
def matching(filter_dict):
entry = pgvector_module.PgVectorEntry
statement = (
select(entry.file_id)
.where(*pgvector_module._build_pg_conditions(filter_dict))
.order_by(entry.chunk_uuid)
)
return connection.execute(statement).scalars().all()
yield matching
finally:
engine.dispose()
def test_empty_filter_returns_empty_list(self):
"""Empty filter dict returns empty list."""
pgvector_module = get_pgvector_module()
@@ -268,73 +302,40 @@ class TestPgVectorFilterConversion:
result = pgvector_module._build_pg_conditions({})
assert result == []
def test_eq_operator_creates_equality_condition(self):
def test_eq_operator_creates_equality_condition(self, matching_file_ids):
"""$eq operator creates SQLAlchemy == condition."""
pgvector_module = get_pgvector_module()
assert matching_file_ids({'file_id': 'abc'}) == ['abc']
result = pgvector_module._build_pg_conditions({'file_id': 'abc'})
assert len(result) == 1
# Verify it's a SQLAlchemy BinaryExpression
from sqlalchemy.sql.expression import BinaryExpression
assert isinstance(result[0], BinaryExpression)
def test_ne_operator_creates_inequality_condition(self):
def test_ne_operator_creates_inequality_condition(self, matching_file_ids):
"""$ne operator creates SQLAlchemy != condition."""
pgvector_module = get_pgvector_module()
assert matching_file_ids({'file_id': {'$ne': 'deleted'}}) == ['abc', 'def', 'outside']
result = pgvector_module._build_pg_conditions({'file_id': {'$ne': 'deleted'}})
assert len(result) == 1
# Operator should be ne (not equals)
assert '!=' in str(result[0]) or 'ne' in str(result[0].operator)
def test_comparison_operators(self):
def test_comparison_operators(self, matching_file_ids):
"""$gt, $gte, $lt, $lte create comparison conditions."""
pgvector_module = get_pgvector_module()
# Test all comparison operators with supported field
for op, expected_op in [
('$gt', '>'),
('$gte', '>='),
('$lt', '<'),
('$lte', '<='),
for op, expected_ids in [
('$gt', ['def', 'outside']),
('$gte', ['deleted', 'def', 'outside']),
('$lt', ['abc']),
('$lte', ['abc', 'deleted']),
]:
result = pgvector_module._build_pg_conditions({'chunk_uuid': {op: 'uuid_100'}})
assert len(result) == 1
assert expected_op in str(result[0])
assert matching_file_ids({'chunk_uuid': {op: 'uuid_100'}}) == expected_ids
def test_in_operator_creates_in_condition(self):
def test_in_operator_creates_in_condition(self, matching_file_ids):
"""$in operator creates SQLAlchemy in_ condition."""
pgvector_module = get_pgvector_module()
assert matching_file_ids({'file_id': {'$in': ['abc', 'deleted']}}) == ['abc', 'deleted']
result = pgvector_module._build_pg_conditions({'file_id': {'$in': ['a', 'b', 'c']}})
assert len(result) == 1
assert 'IN' in str(result[0]).upper()
def test_nin_operator_creates_notin_condition(self):
def test_nin_operator_creates_notin_condition(self, matching_file_ids):
"""$nin operator creates SQLAlchemy notin_ condition."""
pgvector_module = get_pgvector_module()
assert matching_file_ids({'file_id': {'$nin': ['abc', 'deleted']}}) == ['def', 'outside']
result = pgvector_module._build_pg_conditions({'file_id': {'$nin': ['a', 'b']}})
assert len(result) == 1
assert 'NOT IN' in str(result[0]).upper()
def test_multiple_conditions_list(self):
def test_multiple_conditions_list(self, matching_file_ids):
"""Multiple conditions return list of conditions."""
pgvector_module = get_pgvector_module()
result = pgvector_module._build_pg_conditions(
assert matching_file_ids(
{
'file_id': 'abc',
'chunk_uuid': {'$ne': 'def'},
'file_id': {'$in': ['abc', 'deleted']},
'chunk_uuid': {'$ne': 'uuid_100'},
}
)
assert len(result) == 2
) == ['abc']
def test_unsupported_field_dropped(self):
"""Unsupported fields are dropped (not in _PG_SUPPORTED_FIELDS)."""
@@ -343,27 +344,17 @@ class TestPgVectorFilterConversion:
result = pgvector_module._build_pg_conditions({'unknown_field': 'value'})
assert result == []
def test_uuid_alias_resolved(self):
def test_uuid_alias_resolved(self, matching_file_ids):
"""'uuid' alias is resolved to 'chunk_uuid'."""
pgvector_module = get_pgvector_module()
assert matching_file_ids({'uuid': 'uuid_100'}) == ['deleted']
result = pgvector_module._build_pg_conditions({'uuid': 'abc'})
assert len(result) == 1
# Should reference chunk_uuid column
assert 'chunk_uuid' in str(result[0])
def test_supported_fields_only(self):
def test_supported_fields_only(self, matching_file_ids):
"""Only supported fields (text, file_id, chunk_uuid) are kept."""
pgvector_module = get_pgvector_module()
result = pgvector_module._build_pg_conditions(
assert matching_file_ids(
{
'text': {'$ne': ''},
'file_id': 'abc',
'chunk_uuid': {'$in': ['x', 'y']},
'file_id': {'$in': ['abc', 'deleted', 'outside']},
'chunk_uuid': {'$in': ['uuid_099', 'uuid_100', 'uuid_101']},
'unsupported': 'value',
}
)
assert len(result) == 3 # Only supported fields
) == ['abc']
@@ -1,7 +1,7 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
@@ -143,16 +143,24 @@ async def test_oss_smtp_configuration_delivers_the_generic_invitation_email():
}
)
)
service._send_smtp = AsyncMock(return_value=True)
link = 'https://self-hosted.example/invitations/accept#token=lbi_secret'
result = await service.deliver_invitation(
recipient_email='[email protected]',
workspace_name='Self-hosted Workspace',
invitation_link=link,
)
with patch('langbot.pkg.workspace.invitation_delivery.smtplib.SMTP') as smtp:
result = await service.deliver_invitation(
recipient_email='[email protected]',
workspace_name='Self-hosted Workspace',
invitation_link=link,
)
assert result == InvitationDeliveryResult(status='sent', provider='smtp')
service._send_smtp.assert_awaited_once()
assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link)
assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link)
send_message = smtp.return_value.__enter__.return_value.send_message
send_message.assert_called_once()
message = send_message.call_args.args[0]
assert message['From'] == 'LangBot <[email protected]>'
assert message['To'] == '[email protected]'
assert message['Subject'] == 'You were invited to Self-hosted Workspace'
for subtype in ('plain', 'html'):
content = message.get_body(preferencelist=(subtype,)).get_content()
assert 'Self-hosted Workspace' in content
assert link in content
assert 'LangBot Cloud' not in content