267 lines
17 KiB
Python
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))
|