mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import aclosing, asynccontextmanager
|
|
from contextvars import ContextVar
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from langbot.pkg.api.application.api import ApplicationAPI
|
|
from langbot.pkg.api.application.contracts import ApplicationAPIException, Input, Operation
|
|
from langbot.pkg.api.authz import Permission
|
|
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
|
from langbot.pkg.i18n import language
|
|
from langbot.pkg.utils.bounded_executor import current_blocking_work_scope
|
|
|
|
|
|
class Empty(Input):
|
|
pass
|
|
|
|
|
|
def context(workspace):
|
|
return RequestContext(
|
|
'instance-a',
|
|
1,
|
|
'request-a',
|
|
'user-token',
|
|
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'),
|
|
WorkspaceContext(workspace, 'membership-a', 'developer', frozenset({'resource.view'})),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def host(monkeypatch):
|
|
tenant = ContextVar('test_stream_tenant', default=None)
|
|
|
|
@asynccontextmanager
|
|
async def tenant_scope(workspace):
|
|
token = tenant.set(workspace)
|
|
try:
|
|
yield
|
|
finally:
|
|
tenant.reset(token)
|
|
|
|
ap = SimpleNamespace(
|
|
persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope),
|
|
tenant=tenant,
|
|
workspace_service=SimpleNamespace(
|
|
get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid='instance-a'))
|
|
),
|
|
logger=Mock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
'langbot.pkg.api.application.api.get_workspace_language',
|
|
AsyncMock(side_effect=lambda ap, ws: 'zh_Hans' if ws == 'workspace-a' else 'ja_JP'),
|
|
)
|
|
return ap
|
|
|
|
|
|
def api(host, handler):
|
|
return ApplicationAPI(
|
|
host,
|
|
operations=[Operation('test.stream', Permission.RESOURCE_VIEW, handler, Empty, dict[str, str], streaming=True)],
|
|
)
|
|
|
|
|
|
def assert_consumer_unscoped(host):
|
|
assert host.tenant.get() is None
|
|
assert current_blocking_work_scope() is None
|
|
assert language.get() == 'en_US'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interleaved_streams_isolate_tenant_language_and_executor_scope(host):
|
|
closed = set()
|
|
|
|
async def handler(ap, ctx, payload):
|
|
async def source():
|
|
try:
|
|
for _ in range(2):
|
|
assert host.tenant.get() == ctx.workspace_uuid
|
|
assert current_blocking_work_scope() == ctx.workspace_uuid
|
|
yield {'workspace': ctx.workspace_uuid, 'language': language.get()}
|
|
finally:
|
|
assert host.tenant.get() == ctx.workspace_uuid
|
|
closed.add(ctx.workspace_uuid)
|
|
|
|
return source()
|
|
|
|
application = api(host, handler)
|
|
first = application.stream('test.stream', context('workspace-a'), {})
|
|
second = application.stream('test.stream', context('workspace-b'), {})
|
|
async with aclosing(first), aclosing(second):
|
|
for _ in range(2):
|
|
assert await anext(first) == {'workspace': 'workspace-a', 'language': 'zh_Hans'}
|
|
assert_consumer_unscoped(host)
|
|
assert await anext(second) == {'workspace': 'workspace-b', 'language': 'ja_JP'}
|
|
assert_consumer_unscoped(host)
|
|
assert closed == {'workspace-a', 'workspace-b'}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_can_be_closed_from_another_task_without_resetting_foreign_context(host):
|
|
closed = asyncio.Event()
|
|
|
|
async def handler(ap, ctx, payload):
|
|
async def source():
|
|
try:
|
|
yield {'item': 'first'}
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
assert host.tenant.get() == ctx.workspace_uuid
|
|
closed.set()
|
|
|
|
return source()
|
|
|
|
stream = api(host, handler).stream('test.stream', context('workspace-a'), {})
|
|
assert await anext(stream) == {'item': 'first'}
|
|
assert_consumer_unscoped(host)
|
|
await asyncio.create_task(stream.aclose())
|
|
assert closed.is_set()
|
|
assert_consumer_unscoped(host)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consumer_cancel_closes_source_and_restores_scopes(host):
|
|
started, closed = asyncio.Event(), asyncio.Event()
|
|
|
|
async def handler(ap, ctx, payload):
|
|
async def source():
|
|
try:
|
|
started.set()
|
|
await asyncio.Event().wait()
|
|
yield {}
|
|
finally:
|
|
closed.set()
|
|
|
|
return source()
|
|
|
|
stream = api(host, handler).stream('test.stream', context('workspace-a'), {})
|
|
consumer = asyncio.create_task(anext(stream))
|
|
await asyncio.wait_for(started.wait(), 2)
|
|
consumer.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await consumer
|
|
assert closed.is_set()
|
|
assert_consumer_unscoped(host)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_late_stream_failure_is_mapped_without_private_provider_text(host):
|
|
closed = asyncio.Event()
|
|
|
|
async def handler(ap, ctx, payload):
|
|
async def source():
|
|
try:
|
|
yield {'item': 'first'}
|
|
raise RuntimeError('private provider credential')
|
|
finally:
|
|
closed.set()
|
|
|
|
return source()
|
|
|
|
stream = api(host, handler).stream('test.stream', context('workspace-a'), {})
|
|
async with aclosing(stream):
|
|
assert await anext(stream) == {'item': 'first'}
|
|
with pytest.raises(ApplicationAPIException) as exc:
|
|
await anext(stream)
|
|
assert exc.value.error.code == 'application_error'
|
|
assert 'private' not in exc.value.error.message
|
|
assert closed.is_set()
|
|
assert_consumer_unscoped(host)
|