ebe75aa834
Раньше любой сбой get_or_create обнулял ID и шёл в create. Теперь привязка через _attach_mattermost_profile, поиск по username, а при «email уже есть» — линк существующей учётки. Co-authored-by: Cursor <cursoragent@cursor.com>
541 lines
22 KiB
Python
Executable File
541 lines
22 KiB
Python
Executable File
"""
|
||
Тесты Mattermost-коннектора: генерация учётных данных и логика синхронизации
|
||
(MattermostService замокан — сеть не используется).
|
||
"""
|
||
from datetime import date
|
||
from unittest import mock
|
||
|
||
import httpx
|
||
from django.contrib.auth import get_user_model
|
||
from django.test import TestCase
|
||
from mattermostautodriver.exceptions import ResourceNotFound
|
||
|
||
from apps.mattermost_connector.models import MattermostUser
|
||
from apps.mattermost_connector.services import sync_service, token_service
|
||
|
||
Employee = get_user_model()
|
||
|
||
_employee_seq = 0
|
||
|
||
|
||
def make_employee(**overrides):
|
||
"""Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)"""
|
||
global _employee_seq
|
||
_employee_seq += 1
|
||
defaults = {
|
||
'username': f'mm.user{_employee_seq}',
|
||
'email': f'mm.user{_employee_seq}@company.local',
|
||
'first_name': 'Іван',
|
||
'last_name': 'Тестовий',
|
||
'hire_date': date(2026, 1, 1),
|
||
'user_location_type': 'office',
|
||
'mattermost_sync_enabled': False,
|
||
'ad_sync_enabled': False,
|
||
}
|
||
defaults.update(overrides)
|
||
return Employee.objects.create(**defaults)
|
||
|
||
|
||
def not_found():
|
||
return ResourceNotFound('not found', 'store.sql_user.missing', 'rid', False)
|
||
|
||
|
||
class GenerateUsernameTests(TestCase):
|
||
def test_uses_existing_username(self):
|
||
employee = make_employee(username='petrov.p')
|
||
self.assertEqual(sync_service.generate_username(employee), 'petrov.p')
|
||
|
||
def test_generates_from_email_when_no_username(self):
|
||
employee = make_employee()
|
||
employee.username = ''
|
||
employee.email = 'Ivan+Petrov@company.local'
|
||
username = sync_service.generate_username(employee)
|
||
self.assertEqual(username, 'ivan_petrov')
|
||
|
||
|
||
class GenerateRandomPasswordTests(TestCase):
|
||
def test_default_length(self):
|
||
self.assertEqual(len(sync_service.generate_random_password()), 16)
|
||
|
||
def test_custom_length(self):
|
||
self.assertEqual(len(sync_service.generate_random_password(24)), 24)
|
||
|
||
|
||
class SyncEmployeeTests(TestCase):
|
||
"""sync_employee с замоканным MattermostService"""
|
||
|
||
def setUp(self):
|
||
self.service = mock.MagicMock()
|
||
patcher = mock.patch.object(
|
||
sync_service, 'MattermostService', return_value=self.service,
|
||
)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
|
||
def test_finds_existing_user_by_email(self):
|
||
employee = make_employee()
|
||
self.service.get_user_by_email.return_value = {
|
||
'id': 'mm-abc', 'username': 'found.user',
|
||
'email': employee.email, 'delete_at': 0,
|
||
}
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertTrue(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_user_id, 'mm-abc')
|
||
self.assertEqual(employee.mattermost_username, 'found.user')
|
||
self.assertEqual(employee.mattermost_sync_status, 'synced')
|
||
self.assertTrue(MattermostUser.objects.filter(mattermost_id='mm-abc').exists())
|
||
# существующего обновляем, нового не создаём
|
||
self.service.update_user.assert_called_once()
|
||
self.service.create_user.assert_not_called()
|
||
|
||
def test_creates_new_user_when_not_found(self):
|
||
employee = make_employee(username='new.user')
|
||
self.service.get_user_by_email.side_effect = not_found()
|
||
self.service.get_user_by_username.side_effect = not_found()
|
||
self.service.create_user.return_value = {
|
||
'id': 'mm-new', 'username': 'new.user', 'email': employee.email,
|
||
}
|
||
|
||
result = sync_service.sync_employee(employee, password='Secret123')
|
||
|
||
self.assertTrue(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_user_id, 'mm-new')
|
||
self.assertEqual(employee.mattermost_sync_status, 'synced')
|
||
user_data = self.service.create_user.call_args.args[0]
|
||
self.assertEqual(user_data['username'], 'new.user')
|
||
self.assertEqual(user_data['password'], 'Secret123')
|
||
self.assertEqual(user_data['email'], employee.email)
|
||
|
||
def test_username_conflict_appends_counter(self):
|
||
employee = make_employee(username='taken.name')
|
||
self.service.get_user_by_email.side_effect = not_found()
|
||
# lookup по username — свободно; при create имя уже занято → суффикс
|
||
self.service.get_user_by_username.side_effect = [
|
||
not_found(),
|
||
{'id': 'mm-other', 'username': 'taken.name'},
|
||
not_found(),
|
||
]
|
||
self.service.create_user.return_value = {
|
||
'id': 'mm-new2', 'username': 'taken.name1', 'email': employee.email,
|
||
}
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertTrue(result)
|
||
user_data = self.service.create_user.call_args.args[0]
|
||
self.assertEqual(user_data['username'], 'taken.name1')
|
||
|
||
def test_username_check_does_not_swallow_network_errors(self):
|
||
"""Сетевая ошибка при проверке username — это ошибка синхронизации,
|
||
а не сигнал «имя свободно» (регресс голого except)."""
|
||
employee = make_employee()
|
||
self.service.get_user_by_email.side_effect = not_found()
|
||
self.service.get_user_by_username.side_effect = OSError('connection refused')
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertFalse(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_sync_status, 'error')
|
||
self.service.create_user.assert_not_called()
|
||
|
||
def test_stale_mattermost_id_is_reset_and_user_recreated(self):
|
||
employee = make_employee(mattermost_user_id='mm-stale')
|
||
self.service.get_user.side_effect = not_found()
|
||
self.service.get_user_by_email.side_effect = not_found()
|
||
self.service.get_user_by_username.side_effect = not_found()
|
||
self.service.create_user.return_value = {
|
||
'id': 'mm-fresh', 'username': employee.username, 'email': employee.email,
|
||
}
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertTrue(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_user_id, 'mm-fresh')
|
||
|
||
def test_relinks_when_profile_points_at_duplicate(self):
|
||
"""Ручной mattermost_user_id на старую учётку при профиле на дубль — без create."""
|
||
employee = make_employee(
|
||
username='lokses',
|
||
email='admin@lokses.od.ua',
|
||
mattermost_user_id='mm-old',
|
||
)
|
||
MattermostUser.objects.create(
|
||
django_user=employee,
|
||
mattermost_id='mm-dup',
|
||
username='lokses1',
|
||
email=employee.email,
|
||
access_token_encrypted='old-pat-cipher',
|
||
)
|
||
self.service.get_user.return_value = {
|
||
'id': 'mm-old', 'username': 'lokses',
|
||
'email': employee.email, 'delete_at': 0,
|
||
}
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertTrue(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_user_id, 'mm-old')
|
||
self.assertEqual(employee.mattermost_username, 'lokses')
|
||
profile = MattermostUser.objects.get(django_user=employee)
|
||
self.assertEqual(profile.mattermost_id, 'mm-old')
|
||
self.assertEqual(profile.username, 'lokses')
|
||
self.assertEqual(profile.access_token_encrypted, '')
|
||
self.service.create_user.assert_not_called()
|
||
self.service.update_user.assert_called_once()
|
||
|
||
def test_links_by_username_before_create(self):
|
||
employee = make_employee(username='lokses', email='other@example.com')
|
||
self.service.get_user_by_email.side_effect = not_found()
|
||
self.service.get_user_by_username.return_value = {
|
||
'id': 'mm-by-name', 'username': 'lokses',
|
||
'email': 'mm@example.com', 'delete_at': 0,
|
||
}
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertTrue(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_user_id, 'mm-by-name')
|
||
self.service.create_user.assert_not_called()
|
||
|
||
def test_create_email_conflict_links_existing(self):
|
||
from mattermostautodriver.exceptions import InvalidMattermostError
|
||
|
||
employee = make_employee(username='lokses')
|
||
self.service.get_user_by_email.side_effect = [
|
||
not_found(),
|
||
{
|
||
'id': 'mm-exist', 'username': 'lokses',
|
||
'email': employee.email, 'delete_at': 0,
|
||
},
|
||
]
|
||
self.service.get_user_by_username.side_effect = not_found()
|
||
self.service.create_user.side_effect = InvalidMattermostError(
|
||
'Учётная запись с таким email-адресом уже существует.', 400,
|
||
)
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertTrue(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_user_id, 'mm-exist')
|
||
self.service.update_user.assert_called_once()
|
||
|
||
def test_error_marks_sync_status(self):
|
||
employee = make_employee()
|
||
self.service.get_user_by_email.side_effect = not_found()
|
||
self.service.get_user_by_username.side_effect = not_found()
|
||
self.service.create_user.side_effect = OSError('boom')
|
||
|
||
result = sync_service.sync_employee(employee)
|
||
|
||
self.assertFalse(result)
|
||
employee.refresh_from_db()
|
||
self.assertEqual(employee.mattermost_sync_status, 'error')
|
||
|
||
|
||
class DeactivateActivateTests(TestCase):
|
||
def setUp(self):
|
||
self.service = mock.MagicMock()
|
||
patcher = mock.patch.object(
|
||
sync_service, 'MattermostService', return_value=self.service,
|
||
)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
|
||
def test_deactivate_requires_linked_account(self):
|
||
employee = make_employee()
|
||
self.assertFalse(sync_service.deactivate_mattermost_user(employee))
|
||
self.service.deactivate_user.assert_not_called()
|
||
|
||
def test_deactivate_linked_user(self):
|
||
employee = make_employee(mattermost_user_id='mm-abc')
|
||
result = sync_service.deactivate_mattermost_user(employee)
|
||
self.assertTrue(result)
|
||
self.service.deactivate_user.assert_called_once_with('mm-abc')
|
||
|
||
|
||
class MattermostSignalTests(TestCase):
|
||
def test_create_employee_with_sync_queues_task(self):
|
||
with mock.patch(
|
||
'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay',
|
||
) as delay:
|
||
employee = make_employee(mattermost_sync_enabled=True)
|
||
delay.assert_called_once_with(employee.pk)
|
||
|
||
def test_create_employee_without_sync_does_not_queue(self):
|
||
with mock.patch(
|
||
'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay',
|
||
) as delay:
|
||
make_employee(mattermost_sync_enabled=False)
|
||
delay.assert_not_called()
|
||
|
||
def test_block_employee_deactivates_account(self):
|
||
employee = make_employee(mattermost_user_id='mm-abc')
|
||
with mock.patch(
|
||
'apps.mattermost_connector.services.sync_service.deactivate_mattermost_user',
|
||
) as deactivate:
|
||
employee.is_active = False
|
||
employee.save()
|
||
deactivate.assert_called_once()
|
||
|
||
|
||
class EnsurePersonalAccessTokenTests(TestCase):
|
||
def test_no_mattermost_user_id_returns_none(self):
|
||
employee = make_employee()
|
||
self.assertIsNone(token_service.ensure_personal_access_token(employee))
|
||
|
||
def test_no_mattermost_user_row_returns_none(self):
|
||
employee = make_employee(mattermost_user_id='mm-abc')
|
||
self.assertIsNone(token_service.ensure_personal_access_token(employee))
|
||
|
||
def test_creates_and_encrypts_token_on_first_call(self):
|
||
employee = make_employee(mattermost_user_id='mm-abc')
|
||
MattermostUser.objects.create(
|
||
django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email,
|
||
)
|
||
service = mock.Mock()
|
||
service.create_user_access_token.return_value = {'token': 'plain-secret-token'}
|
||
with mock.patch.object(token_service, 'MattermostService', return_value=service):
|
||
token = token_service.ensure_personal_access_token(employee)
|
||
|
||
self.assertEqual(token, 'plain-secret-token')
|
||
service.create_user_access_token.assert_called_once_with('mm-abc', 'ACORE Portal Chat')
|
||
|
||
mm_user = MattermostUser.objects.get(mattermost_id='mm-abc')
|
||
self.assertTrue(mm_user.access_token_encrypted)
|
||
self.assertNotEqual(mm_user.access_token_encrypted, 'plain-secret-token')
|
||
self.assertEqual(mm_user.get_access_token(), 'plain-secret-token')
|
||
|
||
def test_reuses_stored_token_without_recreating(self):
|
||
employee = make_employee(mattermost_user_id='mm-abc')
|
||
mm_user = MattermostUser.objects.create(
|
||
django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email,
|
||
)
|
||
mm_user.set_access_token('already-stored-token')
|
||
mm_user.save(update_fields=['access_token_encrypted'])
|
||
|
||
service = mock.Mock()
|
||
with mock.patch.object(token_service, 'MattermostService', return_value=service):
|
||
token = token_service.ensure_personal_access_token(employee)
|
||
|
||
self.assertEqual(token, 'already-stored-token')
|
||
service.create_user_access_token.assert_not_called()
|
||
|
||
def test_recreates_token_when_ciphertext_unreadable(self):
|
||
employee = make_employee(mattermost_user_id='mm-abc')
|
||
mm_user = MattermostUser.objects.create(
|
||
django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email,
|
||
access_token_encrypted='gAAAAABnot-a-valid-fernet-token',
|
||
)
|
||
service = mock.Mock()
|
||
service.create_user_access_token.return_value = {'token': 'fresh-token'}
|
||
with mock.patch.object(token_service, 'MattermostService', return_value=service):
|
||
token = token_service.ensure_personal_access_token(employee)
|
||
|
||
self.assertEqual(token, 'fresh-token')
|
||
service.create_user_access_token.assert_called_once()
|
||
mm_user.refresh_from_db()
|
||
self.assertEqual(mm_user.get_access_token(), 'fresh-token')
|
||
|
||
|
||
class TaskDoneActionAuthTests(TestCase):
|
||
"""task_done_action требует MATTERMOST_INTERACTIVE_TOKEN + подпись."""
|
||
|
||
def setUp(self):
|
||
from django.core import signing
|
||
from apps.tasks.models import Task
|
||
from apps.mattermost_connector.services.bot_service import TASK_ACTION_SALT
|
||
|
||
self.employee = make_employee(mattermost_user_id='mm-user-1')
|
||
self.task = Task.objects.create(
|
||
title='Test task',
|
||
creator=self.employee,
|
||
status='in_progress',
|
||
)
|
||
self.task.responsible.add(self.employee)
|
||
self.signed = signing.dumps(self.task.pk, salt=TASK_ACTION_SALT)
|
||
|
||
def _post(self, *, token=None, user_id='mm-user-1'):
|
||
import json
|
||
url = '/mattermost/actions/task-done/'
|
||
context = {'t': self.signed}
|
||
if token is not None:
|
||
context['token'] = token # токен в context (тело POST), не в URL
|
||
body = {'user_id': user_id, 'context': context}
|
||
return self.client.post(
|
||
url, data=json.dumps(body), content_type='application/json',
|
||
)
|
||
|
||
def test_rejects_without_configured_token(self):
|
||
with self.settings(MATTERMOST_INTERACTIVE_TOKEN=''):
|
||
resp = self._post(token='x')
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
def test_rejects_wrong_token(self):
|
||
with self.settings(MATTERMOST_INTERACTIVE_TOKEN='secret'):
|
||
resp = self._post(token='wrong')
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
def test_accepts_context_token(self):
|
||
with self.settings(MATTERMOST_INTERACTIVE_TOKEN='secret'):
|
||
resp = self._post(token='secret')
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.task.refresh_from_db()
|
||
self.assertEqual(self.task.status, 'done')
|
||
|
||
|
||
from django.test import override_settings
|
||
from apps.mattermost_connector.services.bot_service import _task_done_attachment
|
||
|
||
|
||
class TaskDoneAttachmentTokenTests(TestCase):
|
||
@override_settings(MATTERMOST_INTERACTIVE_TOKEN='shhh', PORTAL_BASE_URL='https://portal.example')
|
||
def test_token_in_context_not_url(self):
|
||
class _T:
|
||
pk = 42
|
||
integ = _task_done_attachment(_T())['attachments'][0]['actions'][0]['integration']
|
||
self.assertNotIn('?', integ['url'])
|
||
self.assertNotIn('token', integ['url'])
|
||
self.assertEqual(integ['context']['token'], 'shhh')
|
||
self.assertIn('t', integ['context'])
|
||
|
||
@override_settings(MATTERMOST_INTERACTIVE_TOKEN='', PORTAL_BASE_URL='https://portal.example')
|
||
def test_no_token_key_when_unset(self):
|
||
class _T:
|
||
pk = 42
|
||
integ = _task_done_attachment(_T())['attachments'][0]['actions'][0]['integration']
|
||
self.assertNotIn('token', integ['context'])
|
||
|
||
|
||
class ChatApiProxyTests(TestCase):
|
||
def setUp(self):
|
||
from django.contrib.auth.models import Group
|
||
from core.models import ConnectorConfig
|
||
from core.test_utils import login_2fa
|
||
|
||
self.user = make_employee(mattermost_user_id='mm-proxy')
|
||
self.user.groups.add(Group.objects.create(name='chat-proxy-testers'))
|
||
MattermostUser.objects.create(
|
||
django_user=self.user, mattermost_id='mm-proxy', username='proxy',
|
||
email=self.user.email, is_active=True,
|
||
)
|
||
mm = MattermostUser.objects.get(django_user=self.user)
|
||
mm.set_access_token('user-pat-token')
|
||
mm.save(update_fields=['access_token_encrypted'])
|
||
ConnectorConfig.objects.create(
|
||
connector_type='mattermost',
|
||
is_enabled=True,
|
||
host='mm.test.local',
|
||
port=443,
|
||
use_ssl=True,
|
||
extra_config={'public_url': 'https://chat.example.com'},
|
||
)
|
||
login_2fa(self.client, self.user)
|
||
|
||
@mock.patch('apps.mattermost_connector.chat_proxy.httpx.Client')
|
||
def test_proxies_get_channels(self, client_cls):
|
||
upstream = mock.Mock()
|
||
upstream.status_code = 200
|
||
upstream.content = b'[{"id":"ch1","type":"O"}]'
|
||
upstream.headers = {'content-type': 'application/json'}
|
||
client = mock.MagicMock()
|
||
client.__enter__.return_value = client
|
||
client.__exit__.return_value = False
|
||
client.request.return_value = upstream
|
||
client_cls.return_value = client
|
||
|
||
resp = self.client.get('/chat/api/users/me/channels?include_deleted=true')
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertIn(b'ch1', resp.content)
|
||
args, kwargs = client.request.call_args
|
||
self.assertEqual(args[0], 'GET')
|
||
self.assertTrue(args[1].startswith('https://mm.test.local/api/v4/users/me/channels'))
|
||
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer user-pat-token')
|
||
|
||
def test_rejects_path_traversal(self):
|
||
resp = self.client.get('/chat/api/users/../admin')
|
||
self.assertEqual(resp.status_code, 400)
|
||
|
||
@mock.patch('apps.mattermost_connector.chat_proxy.httpx.Client')
|
||
def test_post_users_ids_requires_csrf(self, client_cls):
|
||
"""POST без CSRF → 403 (как в проде при HttpOnly cookie + пустой X-CSRFToken)."""
|
||
from django.test import Client
|
||
|
||
csrf_client = Client(enforce_csrf_checks=True)
|
||
from core.test_utils import login_2fa
|
||
login_2fa(csrf_client, self.user)
|
||
|
||
resp = csrf_client.post(
|
||
'/chat/api/users/ids',
|
||
data='["mm-proxy"]',
|
||
content_type='application/json',
|
||
)
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
upstream = mock.Mock()
|
||
upstream.status_code = 200
|
||
upstream.content = b'[{"id":"mm-proxy","username":"proxy"}]'
|
||
upstream.headers = {'content-type': 'application/json'}
|
||
client = mock.MagicMock()
|
||
client.__enter__.return_value = client
|
||
client.__exit__.return_value = False
|
||
client.request.return_value = upstream
|
||
client_cls.return_value = client
|
||
|
||
csrf_client.get('/chat/') # ensure csrf cookie
|
||
token = csrf_client.cookies.get('csrftoken')
|
||
self.assertIsNotNone(token)
|
||
resp_ok = csrf_client.post(
|
||
'/chat/api/users/ids',
|
||
data='["mm-proxy"]',
|
||
content_type='application/json',
|
||
HTTP_X_CSRFTOKEN=token.value,
|
||
)
|
||
self.assertEqual(resp_ok.status_code, 200)
|
||
self.assertIn(b'mm-proxy', resp_ok.content)
|
||
|
||
@mock.patch('apps.mattermost_connector.chat_proxy.httpx.Client')
|
||
def test_proxies_multipart_file_upload(self, client_cls):
|
||
"""После разбора multipart Django request.body недоступен — прокси пересобирает."""
|
||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||
|
||
upstream = mock.Mock()
|
||
upstream.status_code = 201
|
||
upstream.content = b'{"file_infos":[{"id":"f1","name":"a.txt"}]}'
|
||
upstream.headers = {'content-type': 'application/json'}
|
||
client = mock.MagicMock()
|
||
client.__enter__.return_value = client
|
||
client.__exit__.return_value = False
|
||
client.request.return_value = upstream
|
||
client_cls.return_value = client
|
||
|
||
uploaded = SimpleUploadedFile('a.txt', b'hello', content_type='text/plain')
|
||
resp = self.client.post(
|
||
'/chat/api/files',
|
||
data={'channel_id': 'ch1', 'files': uploaded},
|
||
)
|
||
self.assertEqual(resp.status_code, 201)
|
||
self.assertIn(b'file_infos', resp.content)
|
||
_args, kwargs = client.request.call_args
|
||
self.assertEqual(_args[0], 'POST')
|
||
self.assertTrue(_args[1].endswith('/api/v4/files'))
|
||
self.assertIn('files', kwargs)
|
||
self.assertNotIn('data', kwargs)
|
||
self.assertNotIn('content', kwargs)
|
||
self.assertNotIn('Content-Type', kwargs['headers'])
|
||
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer user-pat-token')
|
||
# httpx реально умеет закодировать этот формат
|
||
built = httpx.Request('POST', 'https://mm.test.local/api/v4/files', files=kwargs['files'])
|
||
self.assertIn('multipart/form-data', built.headers['content-type'])
|
||
body = built.read()
|
||
self.assertIn(b'channel_id', body)
|
||
self.assertIn(b'hello', body)
|