mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(telemetry): require acknowledged adapter evidence and prepare beta.3
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Exercise release tag selection without building or pushing an image."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _build_args(tmp_path, step_name, version):
|
||||
workflow = yaml.safe_load((ROOT / '.github/workflows/build-docker-image.yml').read_text())
|
||||
steps = workflow['jobs']['publish-docker-image']['steps']
|
||||
step = next(step for step in steps if step['name'] == step_name)
|
||||
command = step['run'].replace('${{ github.sha }}', 'a' * 40)
|
||||
command = command.replace('${{ steps.check_version.outputs.version }}', version)
|
||||
capture = tmp_path / 'docker-args'
|
||||
script = 'docker() { printf "%s\\n" "$@" > "$CAPTURE"; };\n' + command
|
||||
if 'env' in step:
|
||||
assert step['env']['RELEASE_VERSION'] == '${{ steps.check_version.outputs.version }}'
|
||||
subprocess.run(
|
||||
['bash', '-euo', 'pipefail', '-c', script],
|
||||
check=True,
|
||||
env={**os.environ, 'CAPTURE': str(capture), 'RELEASE_VERSION': version},
|
||||
)
|
||||
args = capture.read_text().splitlines()
|
||||
assert '--push' in args
|
||||
assert args[args.index('--platform') + 1] == 'linux/arm64,linux/amd64'
|
||||
assert args[args.index('--build-arg') + 1] == 'LANGBOT_BUILD_REVISION=' + 'a' * 40
|
||||
return [args[i + 1] for i, value in enumerate(args) if value == '-t'], step['if']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('version', ['v4.11.0-beta.3', 'v4.12.0-beta.1'])
|
||||
def test_beta_release_updates_beta_but_not_latest(tmp_path, version):
|
||||
tags, condition = _build_args(tmp_path, 'Build for Pre-release', version)
|
||||
assert tags == [f'rockchin/langbot:{version}', 'rockchin/langbot:beta']
|
||||
assert condition == '${{ github.event.release.prerelease == true }}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('version', ['v4.11.0-rc.1', 'v4.12.0-alpha.1'])
|
||||
def test_other_prereleases_do_not_update_channels(tmp_path, version):
|
||||
tags, _ = _build_args(tmp_path, 'Build for Pre-release', version)
|
||||
assert tags == [f'rockchin/langbot:{version}']
|
||||
|
||||
|
||||
def test_stable_release_keeps_latest_without_updating_beta(tmp_path):
|
||||
tags, condition = _build_args(tmp_path, 'Build for Release', 'v4.11.0')
|
||||
assert tags == ['rockchin/langbot:v4.11.0', 'rockchin/langbot:latest']
|
||||
assert condition == '${{ github.event.release.prerelease == false }}'
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Real adapter acceptance must reflect confirmed operations, not normal returns."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain, Plain
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
|
||||
|
||||
def make_adapter(version='4.11.0b3', **config):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid', **config}}))
|
||||
manager = d.DiagnosticsManager(ap, version=version, instance_id='instance-test')
|
||||
ap.diagnostics = manager
|
||||
context = ExecutionContext(instance_uuid=manager.instance_id, workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
sdk = SimpleNamespace(edit_message_text=AsyncMock(return_value=True), send_message=AsyncMock(return_value=True))
|
||||
adapter = TelegramAdapter.model_construct(
|
||||
bot=sdk, config={'markdown_card': False}, logger=SimpleNamespace(ap=ap, execution_context=context), listeners={}
|
||||
)
|
||||
return adapter, manager, sdk
|
||||
|
||||
|
||||
async def wire_events(manager):
|
||||
batches = []
|
||||
|
||||
async def sender(request):
|
||||
batch = json.loads(request.content)
|
||||
batches.append(batch)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'code': 200,
|
||||
'data': {'accepted_event_ids': [e['event_id'] for e in batch['events']], 'rejected': []},
|
||||
},
|
||||
)
|
||||
|
||||
manager.client = httpx.AsyncClient(transport=httpx.MockTransport(sender))
|
||||
await manager.flush_once()
|
||||
assert not manager.pending
|
||||
await manager.shutdown(drain_timeout=0)
|
||||
assert 'PRIVATE_' not in json.dumps(batches)
|
||||
return [event for batch in batches for event in batch['events']]
|
||||
|
||||
|
||||
def successes(events):
|
||||
return [e for e in events if e['attributes'].get('adapter_evidence') and e['outcome'] == 'succeeded']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_file_only_edit_is_not_acceptance_through_sender():
|
||||
adapter, manager, sdk = make_adapter()
|
||||
for _ in range(2):
|
||||
result = await adapter.edit_message(
|
||||
'person',
|
||||
'PRIVATE_CHAT',
|
||||
'PRIVATE_MESSAGE',
|
||||
MessageChain([File(name='PRIVATE_FILE', base64=base64.b64encode(b'PRIVATE_BYTES').decode())]),
|
||||
)
|
||||
assert result is None
|
||||
sdk.edit_message_text.assert_not_awaited()
|
||||
events = await wire_events(manager)
|
||||
assert len([e for e in events if e['operation'] == 'edit_message']) == 4
|
||||
assert not successes(events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_acknowledged_void_edit_is_acceptance_through_sender():
|
||||
adapter, manager, sdk = make_adapter()
|
||||
assert (
|
||||
await adapter.edit_message(
|
||||
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
|
||||
)
|
||||
is None
|
||||
)
|
||||
sdk.edit_message_text.assert_awaited_once_with(
|
||||
chat_id='PRIVATE_CHAT', message_id='PRIVATE_MESSAGE', text='PRIVATE_TEXT'
|
||||
)
|
||||
events = await wire_events(manager)
|
||||
assert len(successes(events)) == 1
|
||||
assert successes(events)[0]['attributes']['content_type'] == 'text'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_empty_send_is_not_acceptance():
|
||||
adapter, manager, sdk = make_adapter()
|
||||
assert await adapter.send_message('person', 'PRIVATE_CHAT', MessageChain([])) is None
|
||||
sdk.send_message.assert_not_awaited()
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_edit_exception_is_unchanged():
|
||||
adapter, manager, sdk = make_adapter()
|
||||
error = ValueError('PRIVATE_ERROR')
|
||||
sdk.edit_message_text.side_effect = error
|
||||
with pytest.raises(ValueError) as raised:
|
||||
await adapter.edit_message(
|
||||
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
|
||||
)
|
||||
assert raised.value is error
|
||||
events = await wire_events(manager)
|
||||
assert not successes(events)
|
||||
assert any(e['outcome'] == 'failed' and e['operation'] == 'edit_message' for e in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'version,config',
|
||||
[('4.11.0', {}), ('4.11.0b3', {'disable_telemetry': True}), ('4.11.0b3', {'disable_beta_diagnostics': True})],
|
||||
)
|
||||
async def test_real_adapter_disabled_gates_keep_business_behavior(version, config):
|
||||
adapter, manager, sdk = make_adapter(version, **config)
|
||||
assert (
|
||||
await adapter.edit_message(
|
||||
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
|
||||
)
|
||||
is None
|
||||
)
|
||||
sdk.edit_message_text.assert_awaited_once()
|
||||
assert await wire_events(manager) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('result', [None, False, {}, {'queued': True}, {'stream': False}])
|
||||
async def test_unconfirmed_normal_return_is_not_acceptance(result):
|
||||
adapter, manager, _ = make_adapter()
|
||||
|
||||
async def call(self):
|
||||
return result
|
||||
|
||||
call.__module__ = 'langbot.pkg.platform.adapters.telegram.adapter'
|
||||
observed = d.observe('api', 'send_message', source='platform', stage='accepted')(call)
|
||||
assert await observed(adapter) is result
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('raw', [{}, {'results': []}, {'result': None}, {'queued': True}])
|
||||
async def test_message_result_source_id_is_not_confirmation(raw):
|
||||
from langbot_plugin.api.entities.builtin.platform.events import MessageResult
|
||||
|
||||
adapter, manager, _ = make_adapter()
|
||||
result = MessageResult(message_id='PRIVATE_SOURCE_ID', raw=raw)
|
||||
|
||||
async def call(self):
|
||||
return result
|
||||
|
||||
call.__module__ = 'langbot.pkg.platform.adapters.wecom.adapter'
|
||||
observed = d.observe('api', 'reply_message', source='platform', stage='accepted')(call)
|
||||
assert await observed(adapter) is result
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_discord_void_delete_is_confirmed():
|
||||
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter, manager, _ = make_adapter()
|
||||
message = SimpleNamespace(delete=AsyncMock(return_value=None))
|
||||
channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
|
||||
discord = DiscordAdapter.model_construct(
|
||||
bot=SimpleNamespace(get_channel=lambda _: channel), logger=adapter.logger, config={}, listeners={}
|
||||
)
|
||||
assert await discord.delete_message('group', '123', '456') is None
|
||||
message.delete.assert_awaited_once()
|
||||
assert len(successes(await wire_events(manager))) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('result', [False, None])
|
||||
async def test_real_telegram_unacknowledged_edit_keeps_void_return(result):
|
||||
adapter, manager, sdk = make_adapter()
|
||||
sdk.edit_message_text.return_value = result
|
||||
assert (
|
||||
await adapter.edit_message(
|
||||
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
|
||||
)
|
||||
is None
|
||||
)
|
||||
sdk.edit_message_text.assert_awaited_once()
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_acknowledged_void_delete_is_acceptance():
|
||||
adapter, manager, sdk = make_adapter()
|
||||
sdk.delete_message = AsyncMock(return_value=True)
|
||||
assert await adapter.delete_message('person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE') is None
|
||||
sdk.delete_message.assert_awaited_once()
|
||||
assert len(successes(await wire_events(manager))) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_sdk_message_send_is_acceptance():
|
||||
import datetime
|
||||
import telegram
|
||||
|
||||
adapter, manager, sdk = make_adapter()
|
||||
sdk.send_message.return_value = telegram.Message(
|
||||
message_id=123, date=datetime.datetime.now(datetime.timezone.utc), chat=telegram.Chat(id=456, type='private')
|
||||
)
|
||||
assert await adapter.send_message('person', 'PRIVATE_CHAT', MessageChain([Plain(text='PRIVATE_TEXT')])) is None
|
||||
sdk.send_message.assert_awaited_once()
|
||||
assert len(successes(await wire_events(manager))) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'raw', [{'ok': True, 'raw': None}, {'ok': True, 'raw': {'errcode': 42}}, {'results': [{'ok': True}, None]}]
|
||||
)
|
||||
async def test_wrapped_ack_does_not_hide_missing_or_failed_response(raw):
|
||||
adapter, manager, _ = make_adapter()
|
||||
|
||||
async def call(self):
|
||||
return raw
|
||||
|
||||
call.__module__ = 'langbot.pkg.platform.adapters.wecombot.adapter'
|
||||
observed = d.observe('api', 'reply_message', source='platform', stage='accepted')(call)
|
||||
assert await observed(adapter) is raw
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'action', ['check_tenant_access_token', 'refresh_app_access_token', 'refresh_tenant_access_token']
|
||||
)
|
||||
async def test_real_lark_self_app_token_noop_is_not_acceptance(action):
|
||||
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
|
||||
|
||||
adapter, manager, _ = make_adapter()
|
||||
lark = LarkAdapter.model_construct(config={'app_type': 'self'}, logger=adapter.logger, listeners={})
|
||||
assert await lark.call_platform_api(action, {}) == {'ok': True}
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_mixed_edit_cannot_confirm_ignored_file():
|
||||
adapter, manager, sdk = make_adapter()
|
||||
message = MessageChain([Plain(text='PRIVATE_TEXT'), File(name='PRIVATE_FILE', base64='eA==')])
|
||||
assert await adapter.edit_message('person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', message) is None
|
||||
sdk.edit_message_text.assert_awaited_once()
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_discord_sent_message_result_is_confirmed():
|
||||
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter, manager, _ = make_adapter()
|
||||
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=123)))
|
||||
discord = DiscordAdapter.model_construct(
|
||||
bot=SimpleNamespace(get_channel=lambda _: channel), logger=adapter.logger, config={}, listeners={}
|
||||
)
|
||||
result = await discord.send_message('group', '456', MessageChain([Plain(text='PRIVATE_TEXT')]))
|
||||
assert result.message_id == 123
|
||||
channel.send.assert_awaited_once_with(content='PRIVATE_TEXT')
|
||||
assert len(successes(await wire_events(manager))) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_aiocqhttp_empty_forward_is_not_acceptance():
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
|
||||
from langbot_plugin.api.entities.builtin.platform.message import Forward
|
||||
|
||||
adapter, manager, _ = make_adapter()
|
||||
sdk = SimpleNamespace(call_action=AsyncMock())
|
||||
onebot = AiocqhttpAdapter.model_construct(bot=sdk, logger=adapter.logger, config={}, listeners={})
|
||||
result = await onebot.send_message('group', '123', MessageChain([Forward(node_list=[])]))
|
||||
assert result.message_id is None and result.raw == {}
|
||||
sdk.call_action.assert_not_awaited()
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_unsupported_exception_is_unchanged():
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
adapter, manager, _ = make_adapter()
|
||||
with pytest.raises(NotSupportedError):
|
||||
await adapter.upload_file(b'PRIVATE_BYTES', 'PRIVATE_FILE')
|
||||
assert not successes(await wire_events(manager))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_lark_sent_message_result_is_confirmed():
|
||||
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
|
||||
|
||||
adapter, manager, _ = make_adapter()
|
||||
create = AsyncMock(
|
||||
return_value=SimpleNamespace(success=lambda: True, data=SimpleNamespace(message_id='PRIVATE_SENT_ID'))
|
||||
)
|
||||
lark = LarkAdapter.model_construct(
|
||||
config={'app_type': 'self'},
|
||||
logger=adapter.logger,
|
||||
listeners={},
|
||||
api_client=SimpleNamespace(im=SimpleNamespace(v1=SimpleNamespace(message=SimpleNamespace(acreate=create)))),
|
||||
)
|
||||
result = await lark.send_message('group', 'PRIVATE_CHAT', MessageChain([Plain(text='PRIVATE_TEXT')]))
|
||||
assert result.message_id == 'PRIVATE_SENT_ID'
|
||||
create.assert_awaited_once()
|
||||
assert len(successes(await wire_events(manager))) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_returned_user_info_is_confirmed():
|
||||
adapter, manager, sdk = make_adapter()
|
||||
sdk.get_chat = AsyncMock(return_value=SimpleNamespace(id=123, first_name='PRIVATE_NAME', username='PRIVATE_USER'))
|
||||
result = await adapter.get_user_info('PRIVATE_USER')
|
||||
assert result.id == 123
|
||||
sdk.get_chat.assert_awaited_once_with(chat_id='PRIVATE_USER')
|
||||
assert len(successes(await wire_events(manager))) == 1
|
||||
Reference in New Issue
Block a user