282 lines
16 KiB
Python
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()
|