mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-25 03:27:15 +00:00
Merge master into dev/4.11.x
# Conflicts: # pyproject.toml # uv.lock
This commit is contained in:
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': False,
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': True,
|
||||
'space_login_enabled': False,
|
||||
}
|
||||
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': True,
|
||||
'invitation_registration_enabled': False,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
|
||||
self, quart_test_client, fake_api_app
|
||||
):
|
||||
fake_api_app.user_service.is_initialized.return_value = True
|
||||
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||
return_value={'password_login_enabled': False, 'space_login_enabled': True}
|
||||
)
|
||||
|
||||
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': False,
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
|
||||
@@ -312,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.user_service.get_workspace_owner = AsyncMock(
|
||||
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
|
||||
)
|
||||
application.space_service.get_credits = AsyncMock()
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space-credits',
|
||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||
)
|
||||
payload = await response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['data'] == {
|
||||
'credits': None,
|
||||
'owner_space_bound': False,
|
||||
'is_workspace_owner': True,
|
||||
}
|
||||
application.space_service.get_credits.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
|
||||
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
||||
sa.Column('key', sa.String(255), nullable=False),
|
||||
sa.Column('owner_type', sa.String(255), nullable=False),
|
||||
sa.Column('owner', sa.String(255), nullable=False),
|
||||
sa.Column('value', sa.LargeBinary, nullable=False),
|
||||
)
|
||||
mcp_servers = _uuid_table(
|
||||
metadata,
|
||||
@@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
||||
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
||||
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
||||
await conn.execute(
|
||||
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
|
||||
binary_storages.insert().values(
|
||||
unique_key='plugin:demo:key',
|
||||
key='key',
|
||||
owner_type='plugin',
|
||||
owner='demo',
|
||||
value=b'legacy-plugin-value',
|
||||
)
|
||||
)
|
||||
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
||||
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
||||
|
||||
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
||||
)
|
||||
assert legacy_kb['collection_id'] == 'collection-1'
|
||||
assert legacy_kb['legacy_vector_collection'] == 1
|
||||
legacy_binary_storage = (
|
||||
(
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
|
||||
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
|
||||
)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
assert legacy_binary_storage == {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'unique_key': 'plugin:demo:key',
|
||||
'key': 'key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'demo',
|
||||
'value': b'legacy-plugin-value',
|
||||
}
|
||||
assert (
|
||||
await conn.scalar(
|
||||
sa.text(
|
||||
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'INSERT INTO binary_storages '
|
||||
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
||||
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
|
||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
|
||||
@@ -9,7 +10,7 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.persistence import alembic_runner
|
||||
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
|
||||
from .resource_migration_support import create_legacy_resource_schema
|
||||
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
||||
database_path = tmp_path / 'legacy-bind-mount.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
real_open = os.open
|
||||
transient_failures = 0
|
||||
|
||||
def transient_open(path, flags, *args, **kwargs):
|
||||
nonlocal transient_failures
|
||||
candidate = pathlib.Path(path)
|
||||
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
|
||||
transient_failures += 1
|
||||
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
|
||||
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
|
||||
|
||||
await _manager(engine)._run_alembic_migrations()
|
||||
|
||||
assert transient_failures == 1
|
||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -220,13 +220,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(schema.create_all)
|
||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
||||
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
|
||||
),
|
||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
||||
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
|
||||
{'uuid': old_workspace_uuid},
|
||||
)
|
||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||
@@ -234,8 +238,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -452,6 +456,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
database_path = tmp_path / 'startup-foreign-keys.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
try:
|
||||
await _create_legacy_schema(engine)
|
||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
|
||||
application = type('Application', (), {})()
|
||||
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
|
||||
application.instance_config = type(
|
||||
'InstanceConfig',
|
||||
(),
|
||||
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
|
||||
)()
|
||||
manager = PersistenceManager(application)
|
||||
|
||||
await manager.initialize()
|
||||
try:
|
||||
async with manager.get_db_engine().connect() as conn:
|
||||
workspace = (
|
||||
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
||||
)
|
||||
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
|
||||
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
|
||||
|
||||
assert workspace['created_by_account_uuid'] == membership['account_uuid']
|
||||
assert membership['role'] == 'owner'
|
||||
assert membership['status'] == 'active'
|
||||
assert foreign_keys == 1
|
||||
finally:
|
||||
await manager.shutdown()
|
||||
|
||||
|
||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||
try:
|
||||
@@ -466,7 +509,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
assert instance_uuid
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
@@ -474,7 +517,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
)
|
||||
@@ -483,12 +526,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||
async with engine.connect() as conn:
|
||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||
) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
||||
) == expected_uuid
|
||||
assert (
|
||||
await conn.scalar(
|
||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||
)
|
||||
== expected_uuid
|
||||
)
|
||||
assert (
|
||||
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
|
||||
== expected_uuid
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
||||
'stderr': '',
|
||||
}
|
||||
async def fake_client_execute(spec):
|
||||
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
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
return BoxExecutionResult(
|
||||
session_id='s',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout='',
|
||||
duration_ms=10,
|
||||
)
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||
|
||||
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
|
||||
assert any('rm -rf' in c for c in calls)
|
||||
service.execute_tool.assert_awaited_once()
|
||||
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_outbound_empty_still_clears(self):
|
||||
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
async def fake_client_execute(spec):
|
||||
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='[]',
|
||||
duration_ms=10,
|
||||
)
|
||||
return BoxExecutionResult(
|
||||
session_id='s',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout='',
|
||||
duration_ms=10,
|
||||
)
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||
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
|
||||
assert any('rm -rf' in c for c in calls)
|
||||
service.execute_tool.assert_awaited_once()
|
||||
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passthrough_noop_when_unavailable(self):
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from langbot.pkg.command import operator
|
||||
from langbot.pkg.command.cmdmgr import CommandManager
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from tests.factories import FakeApp, command_query
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
|
||||
assert len(results) == 1
|
||||
assert results[0].text == 'plugin response'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_selects_workspace_with_trusted_context(self):
|
||||
"""Plugin command discovery receives the typed runtime scope."""
|
||||
|
||||
fake_app = FakeApp()
|
||||
mgr = CommandManager(fake_app)
|
||||
mgr.cmd_list = []
|
||||
fake_app.plugin_connector.require_workspace_context = AsyncMock()
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||
|
||||
ctx = self._create_context(command='help')
|
||||
ctx.instance_uuid = 'instance-a'
|
||||
ctx.workspace_uuid = 'workspace-a'
|
||||
ctx.placement_generation = 4
|
||||
ctx.query_uuid = 'query-a'
|
||||
|
||||
async for _ in mgr._execute(ctx, mgr.cmd_list):
|
||||
pass
|
||||
|
||||
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
|
||||
assert isinstance(selected, ExecutionContext)
|
||||
assert selected.instance_uuid == 'instance-a'
|
||||
assert selected.workspace_uuid == 'workspace-a'
|
||||
assert selected.placement_generation == 4
|
||||
assert selected.query_uuid == 'query-a'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_bound_plugins(self):
|
||||
"""_execute passes bound_plugins to plugin connector."""
|
||||
|
||||
@@ -144,3 +144,39 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
||||
assert stats['models']['providers'] == 1
|
||||
assert stats['runtimes']['plugin_installations'] == 1
|
||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
|
||||
app = Application()
|
||||
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
|
||||
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
|
||||
|
||||
task = app._start_plugin_runtime_initialization()
|
||||
await task
|
||||
|
||||
app.plugin_connector.initialize.assert_awaited_once_with()
|
||||
app.task_mgr.create_task.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
|
||||
app = Application()
|
||||
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
|
||||
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
|
||||
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
|
||||
app.http_ctrl = SimpleNamespace(mcp_mount=None)
|
||||
app.platform_mgr = None
|
||||
app.tool_mgr = None
|
||||
app.model_mgr = None
|
||||
app.box_service = None
|
||||
app.plugin_connector = None
|
||||
app.telemetry = None
|
||||
app.vector_db_mgr = None
|
||||
app.storage_mgr = None
|
||||
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
|
||||
app.deployment = None
|
||||
|
||||
await app.shutdown()
|
||||
|
||||
assert app._plugin_runtime_initialization_task.cancelled()
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot.pkg.core.app # noqa: F401
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
|
||||
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
|
||||
|
||||
|
||||
class Logger:
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
self.errors = []
|
||||
|
||||
async def warning(self, message):
|
||||
self.warnings.append(message)
|
||||
|
||||
async def error(self, message):
|
||||
self.errors.append(message)
|
||||
|
||||
async def info(self, message):
|
||||
return None
|
||||
|
||||
|
||||
class UploadClient(WecomBotWsClient):
|
||||
def __init__(self):
|
||||
super().__init__(bot_id='bot', secret='secret', logger=Logger())
|
||||
self.frames = []
|
||||
|
||||
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
|
||||
self.frames.append((cmd, body))
|
||||
if cmd == 'aibot_upload_media_init':
|
||||
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
|
||||
if cmd == 'aibot_upload_media_finish':
|
||||
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
|
||||
return {'errcode': 0}
|
||||
|
||||
|
||||
class Bot:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def upload_media(self, data, filename='attachment', media_type='file'):
|
||||
self.calls.append(('upload_media', media_type, filename, data))
|
||||
return {'media_id': 'media-1'}
|
||||
|
||||
async def reply_text(self, req_id, content):
|
||||
self.calls.append(('reply_text', req_id, content))
|
||||
|
||||
async def reply_image(self, req_id, media_id):
|
||||
self.calls.append(('reply_image', req_id, media_id))
|
||||
|
||||
async def send_message(self, target_id, content):
|
||||
self.calls.append(('send_message', target_id, content))
|
||||
|
||||
|
||||
def make_adapter(bot):
|
||||
return WecomBotAdapter.model_construct(
|
||||
bot=bot,
|
||||
config={'enable-webhook': False},
|
||||
logger=Logger(),
|
||||
message_converter=WecomBotMessageConverter(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_client_upload_media_uses_chunk_protocol():
|
||||
client = UploadClient()
|
||||
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
|
||||
|
||||
upload_result = await client.upload_media(data, 'image.png', media_type='image')
|
||||
|
||||
assert upload_result['media_id'] == 'media-1'
|
||||
assert [cmd for cmd, _ in client.frames] == [
|
||||
'aibot_upload_media_init',
|
||||
'aibot_upload_media_chunk',
|
||||
'aibot_upload_media_chunk',
|
||||
'aibot_upload_media_finish',
|
||||
]
|
||||
init_body = client.frames[0][1]
|
||||
assert init_body['type'] == 'image'
|
||||
assert init_body['filename'] == 'image.png'
|
||||
assert init_body['total_size'] == len(data)
|
||||
assert init_body['total_chunks'] == 2
|
||||
assert client.frames[1][1]['chunk_index'] == 0
|
||||
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
|
||||
assert client.frames[2][1]['chunk_index'] == 1
|
||||
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_message_uploads_and_replies_image_media():
|
||||
bot = Bot()
|
||||
adapter = make_adapter(bot)
|
||||
png_data = b'\x89PNG\r\n\x1a\nimage'
|
||||
image_b64 = base64.b64encode(png_data).decode('utf-8')
|
||||
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
|
||||
|
||||
items = await WecomBotMessageConverter.yiri2target(chain)
|
||||
await adapter._send_media(bot, 'req-1', items[0])
|
||||
|
||||
assert bot.calls == [
|
||||
('upload_media', 'image', 'attachment.image', png_data),
|
||||
('reply_image', 'req-1', 'media-1'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sends_text_and_skips_proactive_image():
|
||||
bot = Bot()
|
||||
adapter = make_adapter(bot)
|
||||
jpg_data = b'\xff\xd8\xffimage'
|
||||
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
|
||||
chain = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='before'),
|
||||
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
|
||||
platform_message.Plain(text='after'),
|
||||
]
|
||||
)
|
||||
|
||||
await adapter.send_message('group', 'chat-1', chain)
|
||||
|
||||
assert bot.calls == [
|
||||
('send_message', 'chat-1', 'beforeafter'),
|
||||
]
|
||||
@@ -107,6 +107,19 @@ def shared_connector(
|
||||
return connector
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconcile_uses_configured_cold_start_timeout():
|
||||
binding = execution_binding("workspace-a")
|
||||
setting = plugin_setting("01", "a" * 64)
|
||||
connector = shared_connector([[binding]], {"workspace-a": [setting]})
|
||||
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
|
||||
connector.handler = runtime_handler()
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
||||
|
||||
assert connector._workspace_installations == {}
|
||||
assert connector._known_desired_states == {}
|
||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -81,6 +81,18 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(
|
||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
|
||||
runtime_handler = make_handler(SimpleNamespace())
|
||||
runtime_handler.call_action = AsyncMock(return_value={})
|
||||
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
|
||||
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
|
||||
|
||||
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
|
||||
|
||||
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
|
||||
|
||||
|
||||
class TestHandlerQueryVariables:
|
||||
"""Tests for handler query variable logic."""
|
||||
|
||||
|
||||
@@ -277,6 +277,7 @@ class TestSetBinaryStorage:
|
||||
},
|
||||
}
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
@@ -313,8 +314,8 @@ class TestSetBinaryStorage:
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||
assert insert_params['unique_key'] == canonical_binary_key(
|
||||
'plugin',
|
||||
@@ -344,6 +345,69 @@ class TestSetBinaryStorage:
|
||||
assert expected_key in update_params.values()
|
||||
assert update_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopts_legacy_storage_before_updating(self, app):
|
||||
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||
adopted = SimpleNamespace(rowcount=1)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
adopted,
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||
assert expected_key in adoption_params.values()
|
||||
assert adoption_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||
lost_race = SimpleNamespace(rowcount=0)
|
||||
canonical_winner = SimpleNamespace(rowcount=1)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
lost_race,
|
||||
canonical_winner,
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 4
|
||||
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
|
||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
|
||||
assert winner_update['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||
lost_race = SimpleNamespace(rowcount=0)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
lost_race,
|
||||
SimpleNamespace(rowcount=0),
|
||||
make_result(),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 5
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
|
||||
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||
assert insert_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||
@@ -568,6 +632,46 @@ class TestGetBinaryStorage:
|
||||
in statement_params.values()
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_legacy_storage_without_mutating_key(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(
|
||||
unique_key='plugin:test-author/test-plugin:test-key',
|
||||
value=b'legacy bytes',
|
||||
)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
canonical_storage = SimpleNamespace(value=b'adopted bytes')
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(),
|
||||
make_result(canonical_storage),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_when_not_found(self, app):
|
||||
"""Missing binary storage rows return an error response."""
|
||||
@@ -610,21 +714,47 @@ class TestDeleteAndListBinaryStorage:
|
||||
|
||||
assert response.code == 0
|
||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert 'workspace-a' in statement_params.values()
|
||||
flat_values = [
|
||||
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||
]
|
||||
assert 'workspace-a' in flat_values
|
||||
assert (
|
||||
canonical_binary_key(
|
||||
'plugin',
|
||||
'test-author/test-plugin',
|
||||
'test-key',
|
||||
)
|
||||
in statement_params.values()
|
||||
in flat_values
|
||||
)
|
||||
assert 'forged-owner' not in statement_params.values()
|
||||
assert 'forged-owner' not in flat_values
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
|
||||
{
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'forged-owner',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
values = [
|
||||
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||
]
|
||||
assert 'workspace-a' in values
|
||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
|
||||
assert 'plugin:test-author/test-plugin:test-key' in values
|
||||
assert 'test-author/test-plugin' in values
|
||||
assert 'forged-owner' not in values
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||
result = Mock()
|
||||
result.scalars.return_value.all.return_value = ['first', 'second']
|
||||
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
|
||||
app.persistence_mgr.execute_async.return_value = result
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
|
||||
pyproject = tomllib.load(pyproject_file)
|
||||
|
||||
project = pyproject['project']
|
||||
base_dependencies = project['dependencies']
|
||||
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
|
||||
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.utils.import_isolation import isolated_sys_modules
|
||||
|
||||
|
||||
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
|
||||
|
||||
|
||||
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
|
||||
module_name = 'langbot.pkg.vector.vdbs.seekdb'
|
||||
|
||||
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||
seekdb_module = importlib.import_module(module_name)
|
||||
|
||||
assert seekdb_module.SEEKDB_AVAILABLE is False
|
||||
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||
seekdb_module.SeekDBVectorDatabase(MagicMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
|
||||
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
|
||||
|
||||
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||
seekdb_embedding_module = importlib.import_module(module_name)
|
||||
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
|
||||
|
||||
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||
await requester.initialize()
|
||||
Reference in New Issue
Block a user