mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 01:15:24 +08:00
chore(merge): sync master and preserve monitoring test coverage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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...'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user