Files
baiyue-logistics-chat/tests/test_tickets.py
T
2026-11-02 17:51:10 +08:00

267 lines
17 KiB
Python

"""Offline ticket workflow uses authenticated staff and isolated SQLite storage."""
from contextlib import closing
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier
from unittest.mock import patch
from datetime import datetime
import tempfile
import unittest
from logistics_chat import create_app, db
from staff_fixture import login_staff
class TicketTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups',
'LOG_DIR': self.root/'logs', 'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
self.other = self.app.test_client()
login_staff(self.app, self.client, username='alice')
login_staff(self.app, self.other, username='bob')
self.client.post('/api/visitor/session', json={'key':'a'*64})
self.assertEqual(self.client.post('/api/ticket', json={'content':'请查询派送', 'phone':'123'}).status_code, 200)
self.ticket = self.client.get('/api/tickets').json[0]['id']
def claim(self, client=None):
return (client or self.client).post(f'/api/tickets/{self.ticket}/claim', json={})
def update(self, payload, client=None):
return (client or self.client).patch(f'/api/tickets/{self.ticket}', json=payload)
def test_claim_notes_and_completion_remain_queryable(self):
self.assertEqual(self.update({'version':0,'note':'不能跳过领取','status':'completed'}).status_code, 409)
claimed = self.claim()
self.assertEqual(claimed.status_code, 200)
ticket = claimed.json['ticket']
self.assertEqual((ticket['status'], ticket['owner_username']), ('processing', 'alice'))
saved = self.update({'version':ticket['version'],'note':'已联系派送站'})
self.assertEqual(saved.status_code, 200)
done = self.update({'version':saved.json['ticket']['version'],'note':'已联系并处理完成','status':'completed'})
self.assertEqual(done.status_code, 200)
self.assertTrue(done.json['ticket']['completed_at'])
self.assertEqual(self.client.get('/api/tickets').json, [])
record = self.client.get('/api/tickets?status=completed').json[0]
self.assertEqual(record['note'], '已联系并处理完成')
self.assertEqual(record['owner_username'], 'alice')
self.assertEqual(self.update({'version':record['version'],'note':'重新修改'}).status_code, 409)
def test_other_staff_cannot_take_over_or_change_owner(self):
ticket = self.claim().json['ticket']
self.assertEqual(self.claim(self.other).status_code, 409)
self.assertEqual(self.update({'version':ticket['version'],'note':'覆盖'}, self.other).status_code, 403)
self.assertEqual(self.update({'version':ticket['version'],'note':'伪造','owner_username':'bob'}).status_code, 400)
def test_concurrent_claim_has_one_winner(self):
gate = Barrier(2)
def claim(client):
gate.wait(timeout=5)
return client.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code
with ThreadPoolExecutor(max_workers=2) as workers:
results = list(workers.map(claim, (self.client, self.other)))
self.assertEqual(sorted(results), [200, 409])
events = self.client.get(f'/api/tickets/{self.ticket}/events').json
self.assertEqual(sum(event['action'] == 'claimed' for event in events), 1)
def test_stale_update_and_invalid_input_cannot_overwrite_notes(self):
ticket = self.claim().json['ticket']
version = ticket['version']
self.assertEqual(self.update({'version':version,'note':'第一份备注'}).status_code, 200)
self.assertEqual(self.update({'version':version,'note':'旧页面覆盖'}).status_code, 409)
for payload in ([], {'note':'缺少版本'}, {'version':True,'note':'坏版本'},
{'version':version+1,'note':'x'*2001}, {'version':version+1,'status':'pending'},
{'version':version+1,'note':' ','status':'completed'}):
with self.subTest(payload=str(payload)[:60]):
self.assertEqual(self.update(payload).status_code, 400)
self.assertEqual(self.client.get('/api/tickets?status=processing').json[0]['note'], '第一份备注')
def test_login_csrf_and_missing_ticket_are_enforced(self):
guest = self.app.test_client()
self.assertEqual(guest.post(f'/api/tickets/{self.ticket}/claim', json={}).status_code, 401)
self.assertEqual(guest.patch(f'/api/tickets/{self.ticket}', json={}).status_code, 401)
self.assertEqual(self.client.post(f'/api/tickets/{self.ticket}/claim', json={}, headers={'X-CSRF-Token':''}).status_code, 403)
self.assertEqual(self.client.post('/api/tickets/9999/claim', json={}).status_code, 404)
def test_oversized_ids_and_cursors_return_client_errors(self):
too_large = 2**63
self.assertEqual(self.client.get(f'/api/tickets?before={too_large}').status_code, 400)
self.assertEqual(self.client.get(f'/api/tickets/{too_large}').status_code, 404)
self.assertEqual(self.client.post(f'/api/tickets/{too_large}/claim', json={}).status_code, 404)
self.assertEqual(self.client.patch(f'/api/tickets/{too_large}', json={'version':0,'note':'x'}).status_code, 404)
def test_filters_and_pagination_do_not_hide_old_tickets(self):
for index in range(4):
self.client.post('/api/ticket', json={'content':str(index)})
first = self.client.get('/api/tickets?status=all&limit=2').json
second = self.client.get(f'/api/tickets?status=all&limit=2&before={first[-1]["id"]}').json
self.assertEqual(len({row['id'] for row in first + second}), 4)
self.assertEqual(len(first), 2)
for query in ('status=bad', 'limit=0', 'limit=101', 'before=bad'):
self.assertEqual(self.client.get('/api/tickets?' + query).status_code, 400)
def test_version_one_schema_is_upgraded_without_changing_ticket_or_account(self):
with self.app.app_context(), closing(db.connect()) as connection:
connection.executescript('DROP TABLE tickets; CREATE TABLE tickets('
'id INTEGER PRIMARY KEY,content TEXT,phone TEXT,ctime TEXT,status TEXT);'
"INSERT INTO tickets VALUES(42,'旧留言','123','yesterday','pending');"
"UPDATE schema_meta SET value='1' WHERE key='schema_version';")
db.init_db()
row = connection.execute('SELECT content,status,owner_username,note,version FROM tickets WHERE id=42').fetchone()
self.assertEqual(row, ('旧留言','pending',None,'',0))
self.assertEqual(connection.execute('SELECT COUNT(*) FROM agents').fetchone()[0], 2)
self.assertEqual(connection.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0], str(db.SCHEMA_VERSION))
def test_search_supports_content_phone_owner_and_notes_with_status(self):
ticket = self.claim().json['ticket']
self.update({'version':ticket['version'],'note':'已联系新加坡派送站'})
for keyword in ('派送', '23', 'alice', 'Fixture Staff', '新加坡'):
with self.subTest(keyword=keyword):
result = self.client.get('/api/tickets', query_string={'status':'processing','q':keyword})
self.assertEqual([row['id'] for row in result.json], [self.ticket])
self.assertEqual(self.client.get('/api/tickets?q=派送').json, [])
self.assertEqual(self.client.get('/api/tickets?status=all&q=找不到').json, [])
self.assertEqual(self.client.get('/api/tickets', query_string={'q':'x'*101}).status_code, 400)
def test_search_treats_sql_wildcards_as_literal_and_keeps_pagination(self):
for content in ('含有 100%_\\ 的留言', '普通记录', '相同查询甲', '相同查询乙', '相同查询丙'):
self.client.post('/api/ticket', json={'content':content})
for keyword in ('%', '_', '\\', '100%_\\'):
result = self.client.get('/api/tickets', query_string={'q':keyword}).json
self.assertEqual(len(result), 1)
self.assertIn('100%', result[0]['content'])
first = self.client.get('/api/tickets?q=相同&limit=2').json
second = self.client.get('/api/tickets', query_string={'q':'相同','limit':2,'before':first[-1]['id']}).json
self.assertEqual(len(first), 2)
self.assertEqual(len(second), 1)
self.assertEqual(len({row['id'] for row in first+second}), 3)
def test_summary_counts_all_pending_and_new_arrivals_independently_of_pages(self):
response = self.client.get('/api/tickets/summary')
self.assertEqual(response.status_code, 200)
baseline = response.json
self.assertEqual({key:baseline[key] for key in ('pending_count','latest_id','new_count')},
{'pending_count':1,'latest_id':self.ticket,'new_count':0})
self.claim()
self.client.post('/api/ticket', json={'content':'新留言'})
changed = self.client.get('/api/tickets/summary', query_string={'after':baseline['latest_id']}).json
self.assertEqual(changed['pending_count'], 1)
self.assertEqual(changed['new_count'], 1)
self.assertGreater(changed['latest_id'], baseline['latest_id'])
self.assertEqual(self.client.get('/api/tickets/summary', query_string={'after':changed['latest_id']}).json['new_count'], 0)
for cursor in ('bad', '-1', str(2**63)):
self.assertEqual(self.client.get('/api/tickets/summary',query_string={'after':cursor}).status_code, 400)
self.assertEqual(self.app.test_client().get('/api/tickets/summary').status_code, 401)
def test_history_records_actor_and_old_new_notes_only_for_successful_changes(self):
ticket = self.claim().json['ticket']
saved = self.update({'version':ticket['version'],'note':'原备注'}).json['ticket']
self.assertEqual(self.update({'version':ticket['version'],'note':'旧版本'}).status_code, 409)
self.assertEqual(self.update({'version':saved['version'],'note':'越权'},self.other).status_code, 403)
done = self.update({'version':saved['version'],'note':'完成结果','status':'completed'})
self.assertEqual(done.status_code, 200)
result = self.client.get(f'/api/tickets/{self.ticket}/events')
self.assertEqual(result.status_code, 200)
events = list(reversed(result.json))
self.assertEqual([event['action'] for event in events], ['submitted','claimed','note_updated','note_updated','completed'])
self.assertEqual(events[1]['actor_username'], 'alice')
self.assertEqual((events[3]['old_note'],events[3]['new_note']), ('原备注','完成结果'))
self.assertTrue(all(event['ctime'] for event in events))
self.assertEqual(self.app.test_client().get(f'/api/tickets/{self.ticket}/events').status_code, 401)
self.assertEqual(self.client.get('/api/tickets/9999/events').status_code, 404)
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?limit=0').status_code, 400)
page = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2').json
older = self.client.get(f'/api/tickets/{self.ticket}/events?limit=2&before={page[-1]["id"]}').json
self.assertEqual(len({event['id'] for event in page+older}),4)
def test_audit_failure_rolls_back_ticket_mutation(self):
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
with self.assertRaisesRegex(RuntimeError,'audit failure'):
self.claim()
self.assertEqual(self.client.get('/api/tickets').json[0]['status'],'pending')
def test_audit_failure_rolls_back_submission_and_note_completion(self):
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
with self.assertRaises(RuntimeError):
self.client.post('/api/ticket',json={'content':'不应留下未审计的记录'})
self.assertEqual(len(self.client.get('/api/tickets').json),1)
ticket = self.claim().json['ticket']
with patch('logistics_chat.ticket_service.record_event', side_effect=RuntimeError('fixture audit failure')):
with self.assertRaises(RuntimeError):
self.update({'version':ticket['version'],'note':'结果','status':'completed'})
current = self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
self.assertEqual((current['status'],current['note'],current['version']),('processing','',ticket['version']))
def test_overdue_filters_and_unusable_legacy_times_are_safe(self):
for bad_time in ('not a date','2026-02-30 00:00:00'):
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute('UPDATE tickets SET ctime=? WHERE id=?',(bad_time,self.ticket))
connection.commit()
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute("UPDATE tickets SET ctime='not a date' WHERE id=?",(self.ticket,))
connection.commit()
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
self.assertEqual(self.client.get('/api/tickets?overdue=1').json,[])
self.assertEqual(self.client.get('/api/tickets?overdue=bad').status_code,400)
self.assertEqual(self.client.get(f'/api/tickets/{2**63}/events').status_code,404)
for query in ('limit=101','before=bad',f'before={2**63}'):
self.assertEqual(self.client.get(f'/api/tickets/{self.ticket}/events?'+query).status_code,400)
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute("UPDATE tickets SET ctime='2026-01-01 00:00:00'")
connection.commit()
self.assertEqual(len(self.client.get('/api/tickets?overdue=1&q=派送').json),1)
self.assertEqual(self.client.get('/api/tickets?overdue=1&q=找不到').json,[])
self.assertEqual(self.client.get('/api/tickets?status=completed&overdue=1').json,[])
def test_overdue_boundaries_and_note_edits_do_not_reset_claim_clock(self):
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute("UPDATE tickets SET ctime='2026-11-02 09:00:00' WHERE id=?",(self.ticket,))
connection.commit()
with patch('logistics_chat.ticket_service.datetime') as clock:
clock.now.return_value=datetime(2026,11,2,9,30)
self.assertFalse(self.client.get('/api/tickets').json[0]['overdue_kind'])
clock.now.return_value=datetime(2026,11,2,9,31)
row=self.client.get('/api/tickets').json[0]
self.assertEqual((row['overdue_kind'],row['overdue_minutes']),('pending',1))
claimed=self.claim().json['ticket']
self.assertEqual(claimed['claimed_at'],'2026-11-02 09:31:00')
clock.now.return_value=datetime(2026,11,2,9,51)
self.assertFalse(self.client.get('/api/tickets?status=processing').json[0]['overdue_kind'])
clock.now.return_value=datetime(2026,11,2,9,52)
saved=self.update({'version':claimed['version'],'note':'仍在跟进'}).json['ticket']
self.assertEqual(saved['overdue_kind'],'processing')
summary=self.client.get('/api/tickets/summary').json
self.assertEqual((summary['overdue_count'],summary['processing_overdue_count']), (1,1))
self.assertEqual(len(self.client.get('/api/tickets?status=all&overdue=1').json),1)
done=self.update({'version':saved['version'],'status':'completed'}).json['ticket']
self.assertFalse(done['overdue_kind'])
self.assertEqual(self.client.get('/api/tickets/summary').json['overdue_count'],0)
def test_v2_upgrade_marks_legacy_baseline_without_inventing_history(self):
ticket=self.claim().json['ticket']
with self.app.app_context(), closing(db.connect()) as connection:
connection.execute('DROP TABLE ticket_events')
connection.execute('DROP INDEX IF EXISTS idx_ticket_claimed_time')
connection.execute('DROP INDEX IF EXISTS idx_ticket_pending_time')
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at')
connection.execute('ALTER TABLE tickets DROP COLUMN claimed_at_estimated')
connection.execute("UPDATE tickets SET updated_at='2026-11-02 08:00:00',note='旧备注'")
connection.execute("UPDATE schema_meta SET value='2' WHERE key='schema_version'")
connection.commit()
db.init_db()
db.init_db()
events=self.client.get(f'/api/tickets/{self.ticket}/events').json
self.assertEqual([event['action'] for event in events],['legacy_snapshot'])
self.assertIsNone(events[0]['actor_username'])
row=self.client.get(f'/api/tickets/{self.ticket}').json['ticket']
self.assertEqual((row['owner_username'],row['note']),('alice','旧备注'))
self.assertEqual((row['claimed_at'],row['claimed_at_estimated']),('2026-11-02 08:00:00',1))