Files
baiyue-logistics-chat/tests/test_staff_routing.py
T

282 lines
16 KiB
Python

"""Real staff login, SQLite routing and competing claims against isolated storage."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app, db
from tests.staff_fixture import login_staff
class StaffRoutingTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': root/'test.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 10000})
self.alice, self.bob = self.app.test_client(), self.app.test_client()
login_staff(self.app, self.alice, username='alice')
login_staff(self.app, self.bob, username='bob')
self.visitor = self.app.test_client()
self.visitor.post('/api/visitor/session', json={'key': 'a'*64})
def request_human(self):
cid = self.visitor.post('/api/new_chat').json['id']
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 11, 4, 10)
response = self.visitor.post('/api/visitor/human/request', json={'conv_id': cid})
self.assertEqual(response.status_code, 200)
return response.json['session_id']
def state(self):
response = self.alice.get('/api/admin/routing')
self.assertEqual(response.status_code, 200)
return response.json
def session(self, sid):
return next(item for item in self.state()['sessions'] if item['id'] == sid)
def online(self, client):
response = client.post('/api/admin/routing/presence', json={'online': True})
self.assertEqual(response.status_code, 200)
return response.json
def test_login_starts_offline_and_no_staff_leaves_customer_in_queue(self):
sid = self.request_human()
self.assertEqual([row['id'] for row in self.alice.get('/api/human/queue').json], [sid])
data = self.state()
self.assertEqual(data['groups'][0]['name'], '综合客服')
self.assertFalse(data['presence']['online'])
self.assertTrue(all(not row['online'] for row in data['agents']))
self.assertIsNone(self.session(sid)['owner_username'])
def test_each_online_agent_automatically_receives_only_one_active_customer(self):
self.online(self.alice)
self.online(self.bob)
first, second = self.request_human(), self.request_human()
third, fourth = self.request_human(), self.request_human()
self.assertEqual(self.session(first)['owner_username'], 'alice')
self.assertEqual(self.session(second)['owner_username'], 'bob')
self.assertEqual([row['id'] for row in self.alice.get('/api/human/queue').json], [third, fourth])
self.assertEqual({row['id'] for row in self.bob.get('/api/human/active').json}, {second})
def test_busy_agent_can_manually_accept_additional_customers(self):
self.online(self.alice)
first, second, third = self.request_human(), self.request_human(), self.request_human()
self.assertIsNone(self.session(second)['owner_username'])
for sid in (second, third):
self.assertEqual(self.alice.post('/api/human/accept', json={'sid': sid}).status_code, 200)
self.assertEqual({row['id'] for row in self.alice.get('/api/human/active').json}, {first, second, third})
self.assertIsNone(self.session(self.request_human())['owner_username'])
def test_queued_customer_requires_manual_accept_after_agent_becomes_idle(self):
self.online(self.alice)
first, queued = self.request_human(), self.request_human()
self.assertEqual(self.alice.post('/api/human/close', json={'sid': first}).status_code, 200)
self.assertEqual(self.alice.post('/api/admin/routing/presence', json={}).status_code, 200)
self.online(self.bob)
self.assertEqual(self.alice.post('/api/admin/routing/members', json={
'username': 'bob', 'group_id': 1}).status_code, 200)
self.assertEqual(self.session(queued)['status'], 'waiting')
self.assertIsNone(self.session(queued)['owner_username'])
with self.app.app_context():
connection = db.connect()
conv_id = connection.execute('SELECT conv_id FROM human_sessions WHERE id=?', (queued,)).fetchone()[0]
connection.close()
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 11, 4, 10)
response = self.visitor.post('/api/visitor/human/request', json={'conv_id': conv_id})
self.assertEqual(response.json['status'], 'waiting')
self.assertEqual(self.alice.post('/api/human/accept', json={'sid': queued}).status_code, 200)
def test_going_online_does_not_automatically_take_existing_queue(self):
queued = self.request_human()
self.online(self.alice)
fresh = self.request_human()
self.assertIsNone(self.session(queued)['owner_username'])
self.assertEqual(self.session(fresh)['owner_username'], 'alice')
def test_concurrent_new_requests_cannot_overfill_one_automatic_slot(self):
self.online(self.alice)
guests = [self.app.test_client(), self.app.test_client()]
for index, client in enumerate(guests):
client.post('/api/visitor/session', json={'key': str(index+1)*64})
conv_ids = [client.post('/api/new_chat').json['id'] for client in guests]
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 11, 4, 10)
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda pair: pair[0].post('/api/visitor/human/request',
json={'conv_id': pair[1]}), zip(guests, conv_ids)))
self.assertTrue(all(response.status_code == 200 for response in responses))
self.assertEqual(sorted(response.json['status'] for response in responses), ['active', 'waiting'])
self.assertEqual(len(self.alice.get('/api/human/active').json), 1)
def test_group_membership_limits_automatic_assignments_and_manual_move_is_recorded(self):
created = self.alice.post('/api/admin/routing/groups', json={'name': '售后组'})
self.assertEqual(created.status_code, 200)
group_id = created.json['group']['id']
self.assertEqual(self.alice.post('/api/admin/routing/members', json={
'username': 'bob', 'group_id': group_id}).status_code, 200)
self.online(self.bob)
sid = self.request_human()
self.assertIsNone(self.session(sid)['owner_username'])
moved = self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': group_id, 'username': 'bob'})
self.assertEqual(moved.status_code, 200)
self.assertEqual(self.session(sid)['owner_username'], 'bob')
self.assertEqual(self.session(sid)['group_name'], '售后组')
event = self.state()['events'][0]
self.assertEqual((event['actor'], event['owner_username'], event['session_id']), ('alice', 'bob', sid))
self.assertEqual(self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': 1, 'username': 'bob'}).status_code, 409)
def test_manual_cross_group_claim_does_not_change_the_agents_membership(self):
group_id = self.alice.post('/api/admin/routing/groups', json={'name': '售后组'}).json['group']['id']
sid = self.request_human()
self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': group_id, 'username': None})
self.assertEqual(self.bob.post('/api/human/accept', json={'sid': sid}).status_code, 200)
bob = next(row for row in self.state()['agents'] if row['username'] == 'bob')
self.assertEqual(bob['group_id'], 1)
self.assertEqual(self.session(sid)['group_id'], group_id)
def test_competing_staff_cannot_both_claim_or_write_other_owners_session(self):
sid = self.request_human()
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda client: client.post('/api/human/accept', json={'sid': sid}),
(self.alice, self.bob)))
self.assertEqual(sorted(row.status_code for row in responses), [200, 409])
owner = self.session(sid)['owner_username']
other = self.bob if owner == 'alice' else self.alice
for path, payload in (
('send', {'sid': sid, 'side': 'agent', 'content': '抢答'}),
('note', {'sid': sid, 'note': '覆盖备注'}), ('close', {'sid': sid}),
('transfer', {'sid': sid}), ('typing', {'sid': sid}),
('stop_typing', {'sid': sid}), ('read', {'sid': sid})):
with self.subTest(path=path):
self.assertEqual(other.post('/api/human/'+path, json=payload).status_code, 409)
self.assertEqual(self.session(sid)['owner_username'], owner)
def test_unclaimed_session_requires_acceptance_before_staff_can_reply(self):
sid = self.request_human()
response = self.alice.post('/api/human/send', json={'sid': sid, 'side': 'agent', 'content': '尚未接入'})
self.assertEqual(response.status_code, 409)
self.assertEqual(self.alice.post('/api/human/accept', json={'sid': sid}).status_code, 200)
self.assertEqual(self.alice.post('/api/human/send', json={
'sid': sid, 'side': 'agent', 'content': '现在可以回复'}).status_code, 200)
def test_transferred_session_is_not_automatically_returned_to_previous_owner(self):
self.online(self.alice)
sid = self.request_human()
self.assertEqual(self.alice.post('/api/human/transfer', json={'sid': sid}).status_code, 200)
self.alice.post('/api/admin/routing/presence', json={})
self.assertIsNone(self.session(sid)['owner_username'])
self.online(self.bob)
self.assertIsNone(self.session(sid)['owner_username'])
self.assertEqual(self.bob.post('/api/human/accept', json={'sid': sid}).status_code, 200)
self.assertEqual(self.session(sid)['owner_username'], 'bob')
def test_unspecified_manual_reassignment_queues_even_with_idle_online_staff(self):
self.online(self.alice)
sid = self.request_human()
self.online(self.bob)
response = self.alice.post('/api/admin/routing/assign', json={
'sid': sid, 'version': self.session(sid)['version'], 'group_id': 1, 'username': None})
self.assertEqual(response.status_code, 200)
self.assertEqual(self.session(sid)['status'], 'waiting')
self.assertIsNone(self.session(sid)['owner_username'])
def test_failed_audit_write_rolls_back_owner_and_status(self):
sid = self.request_human()
with self.app.app_context():
connection = db.connect()
connection.execute("CREATE TRIGGER reject_routing_event BEFORE INSERT ON routing_events "
"BEGIN SELECT RAISE(ABORT,'fixture audit failure'); END")
connection.commit()
import sqlite3
with self.assertRaises(sqlite3.IntegrityError):
self.alice.post('/api/human/accept', json={'sid': sid})
self.assertIsNone(self.session(sid)['owner_username'])
self.assertEqual(self.session(sid)['status'], 'waiting')
def test_deleted_staff_returns_assigned_sessions_to_unowned_queue(self):
self.online(self.alice)
sid = self.request_human()
with self.app.app_context():
connection = db.connect()
connection.execute("DELETE FROM agents WHERE username='alice'")
connection.commit()
data = self.bob.get('/api/admin/routing').json
row = next(item for item in data['sessions'] if item['id'] == sid)
self.assertEqual(row['status'], 'waiting')
self.assertIsNone(row['owner_username'])
def test_public_poll_does_not_expose_staff_identity_or_routing_events(self):
self.online(self.alice)
sid = self.request_human()
data = self.visitor.get(f'/api/visitor/human/poll?sid={sid}').json
self.assertNotIn('owner_username', data)
self.assertNotIn('note', data)
self.assertEqual(self.visitor.get('/api/admin/routing').status_code, 401)
def test_manual_reassignment_rejects_stale_version_and_revokes_old_owner_writes(self):
sid = self.request_human()
self.alice.post('/api/human/accept', json={'sid': sid})
version = self.session(sid)['version']
self.online(self.bob)
payload = {'sid': sid, 'version': version, 'group_id': 1, 'username': 'bob'}
self.assertEqual(self.alice.post('/api/admin/routing/assign', json=payload).status_code, 200)
self.assertEqual(self.alice.post('/api/admin/routing/assign', json=payload).status_code, 409)
self.assertEqual(self.alice.post('/api/human/send', json={
'sid': sid, 'side': 'agent', 'content': '旧负责人不能发送'}).status_code, 409)
self.assertEqual(self.bob.post('/api/human/send', json={
'sid': sid, 'side': 'agent', 'content': '新负责人接手'}).status_code, 200)
self.assertEqual(self.visitor.get(f'/api/visitor/human/poll?sid={sid}').json['messages'][-1]['content'], '新负责人接手')
def test_expired_heartbeat_logout_and_offline_stop_new_assignments(self):
self.online(self.alice)
first = self.request_human()
self.assertEqual(self.session(first)['owner_username'], 'alice')
self.assertEqual(self.alice.post('/api/admin/routing/presence', json={'online': False}).status_code, 200)
self.assertIsNone(self.session(self.request_human())['owner_username'])
self.online(self.alice)
with self.app.app_context():
connection = db.connect()
connection.execute('UPDATE staff_presence SET last_seen=0')
connection.commit()
self.assertIsNone(self.session(self.request_human())['owner_username'])
self.assertFalse(self.alice.post('/api/admin/routing/presence', json={}).json['online'])
self.online(self.alice)
self.alice.post('/admin/logout', json={})
sid = self.request_human()
response = self.bob.get('/api/admin/routing')
self.assertIsNone(next(row for row in response.json['sessions'] if row['id'] == sid)['owner_username'])
def test_routing_endpoints_require_login_and_csrf_and_reject_invalid_inputs(self):
anonymous = self.app.test_client()
self.assertEqual(anonymous.get('/api/admin/routing').status_code, 401)
self.alice.environ_base.pop('HTTP_X_CSRF_TOKEN')
self.assertEqual(self.alice.post('/api/admin/routing/presence', json={'online': True}).status_code, 403)
login_staff(self.app, self.alice, username='alice', seed=False)
for payload in ({'online': 'true'}, [], {'online': 1}):
self.assertEqual(self.alice.post('/api/admin/routing/presence', json=payload).status_code, 400)
for name in ('', 'x'*41, [], 7):
self.assertEqual(self.alice.post('/api/admin/routing/groups', json={'name': name}).status_code, 400)
self.assertEqual(self.alice.post('/api/admin/routing/groups', json={'name': '综合客服'}).status_code, 409)
def test_reinitialization_preserves_legacy_sessions_without_inventing_an_owner(self):
sid = self.request_human()
with self.app.app_context():
db.init_db()
self.assertIsNone(self.session(sid)['owner_username'])
self.assertEqual(self.session(sid)['group_id'], 1)
if __name__ == '__main__':
unittest.main()