268 lines
16 KiB
Python
268 lines
16 KiB
Python
"""Full-scope review snapshot, permissions, transactions and pagination."""
|
|
import json
|
|
from unittest import TestCase, mock
|
|
|
|
import test_knowledge as fixtures
|
|
from archive_store import ArchiveStore, new_id
|
|
from knowledge_review import KnowledgeReviewStore, KnowledgeReviewWorker
|
|
|
|
|
|
class FullReviewTest(TestCase):
|
|
setUp = fixtures.KnowledgeTest.setUp
|
|
tearDown = fixtures.KnowledgeTest.tearDown
|
|
payload = fixtures.KnowledgeTest.payload
|
|
start_job = fixtures.KnowledgeTest.start_job
|
|
process = fixtures.KnowledgeTest.process
|
|
first = fixtures.KnowledgeTest.first
|
|
|
|
def seed(self, count=75, tenant='default'):
|
|
self.process(tenant)
|
|
original = self.first(tenant)
|
|
original['conditions'] = '仅用于虚构门店测试'
|
|
original = self.store.save(tenant, original['id'], original, self.actor, '')
|
|
ids = [original['id']]
|
|
with self.db.connect() as db:
|
|
for index in range(count - 1):
|
|
item_id = new_id(); ids.append(item_id)
|
|
db.execute("""INSERT INTO knowledge_item(id,tenant_id,job_id,dedup_hash,title,question,answer,conditions,
|
|
category,kind,flags_json,status,revision,valid_until,created_at,updated_at)
|
|
SELECT ?,tenant_id,job_id,?,title||?,question,answer,conditions,category,kind,flags_json,
|
|
status,revision,valid_until,created_at,updated_at FROM knowledge_item WHERE id=?""",
|
|
(item_id, item_id, str(index), original['id']))
|
|
db.execute('INSERT INTO knowledge_source SELECT ?,message_id,version_no,role,content,sent_at '
|
|
'FROM knowledge_source WHERE item_id=?', (item_id, original['id']))
|
|
return ids
|
|
|
|
def test_all_pages_approve_without_publishing_or_model_calls(self):
|
|
self.seed(237)
|
|
reviews = KnowledgeReviewStore(self.store)
|
|
preview = reviews.preview('*', -1, self.actor, '')
|
|
self.assertEqual((preview['total'], preview['preview']['eligible']), (237, 237))
|
|
self.assertEqual(len(self.store.list_items('default')['items']), 30)
|
|
self.assertEqual(self.store.stats('default').get('approved', 0), 0)
|
|
with self.assertRaises(ValueError):
|
|
reviews.action(preview['id'], 'start', self.actor, '', False)
|
|
reviews.action(preview['id'], 'start', self.actor, '', True)
|
|
reviews.action(preview['id'], 'start', self.actor, '', True)
|
|
worker = KnowledgeReviewWorker(self.store)
|
|
with mock.patch('model_gateway.call_outlet', side_effect=AssertionError('No model call allowed')):
|
|
worker.step()
|
|
self.assertEqual(reviews.get(preview['id'], self.actor)['processed'], 100)
|
|
worker.step(); worker.step()
|
|
result = reviews.get(preview['id'], self.actor)
|
|
self.assertEqual((result['status'], result['approved'], result['skipped'], result['progress_percent']), ('completed', 237, 0, 100))
|
|
self.assertEqual(self.store.stats('default'), {'approved': 237})
|
|
with self.db.connect() as db:
|
|
self.assertEqual(db.execute('SELECT COUNT(*) FROM knowledge_fts').fetchone()[0], 0)
|
|
self.assertEqual(db.execute("SELECT COUNT(*) FROM knowledge_revision WHERE action='approve'").fetchone()[0], 237)
|
|
results = reviews.results(preview['id'], self.actor, '*', 'approved', 8, 30)
|
|
self.assertEqual((results['total'], len(results['items'])), (237, 27))
|
|
|
|
def test_preview_reports_missing_conditions_fields_expiry_and_sources(self):
|
|
ids = self.seed(6)
|
|
with self.db.connect() as db:
|
|
db.execute("UPDATE knowledge_item SET conditions='' WHERE id=?", (ids[0],))
|
|
db.execute("UPDATE knowledge_item SET question='' WHERE id=?", (ids[1],))
|
|
db.execute("UPDATE knowledge_item SET valid_until='2000-01-01' WHERE id=?", (ids[2],))
|
|
db.execute('DELETE FROM knowledge_source WHERE item_id=?', (ids[3],))
|
|
db.execute('UPDATE knowledge_source SET version_no=99 WHERE item_id=?', (ids[4],))
|
|
reviews = KnowledgeReviewStore(self.store)
|
|
job = reviews.preview('default', 0, self.actor, '')
|
|
self.assertEqual((job['total'], job['preview']['eligible'], job['preview']['blocked']), (6, 1, 5))
|
|
self.assertEqual({reason['code'] for reason in job['preview']['reasons']},
|
|
{'missing_conditions', 'invalid_fields', 'expired', 'missing_source', 'changed_source'})
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
KnowledgeReviewWorker(self.store).step()
|
|
final = reviews.get(job['id'], self.actor)
|
|
self.assertEqual((final['approved'], final['skipped']), (1, 5))
|
|
self.assertEqual(self.store.stats('default'), {'approved': 1, 'draft': 5})
|
|
|
|
def test_changed_deleted_and_new_items_are_not_approved_from_old_snapshot(self):
|
|
ids = self.seed(4)
|
|
reviews = KnowledgeReviewStore(self.store)
|
|
job = reviews.preview('default', 0, self.actor, '')
|
|
edit = self.store.detail('default', ids[0]); edit['answer'] = '已经编辑的新答案,需要重新审核。'
|
|
self.store.save('default', ids[0], edit, self.actor, '')
|
|
self.store.transition('default', ids[1], 2, 'reject', self.actor, '')
|
|
self.store.delete_rejected('default', ids[1], 2, self.actor, '')
|
|
with self.db.connect() as db:
|
|
db.execute("UPDATE knowledge_item SET status='rejected' WHERE id=?", (ids[2],))
|
|
self.seed(1, 'new-tenant')
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
KnowledgeReviewWorker(self.store).step()
|
|
final = reviews.get(job['id'], self.actor)
|
|
self.assertEqual((final['total'], final['approved'], final['skipped']), (4, 1, 3))
|
|
self.assertEqual(self.store.stats('new-tenant'), {'draft': 1})
|
|
reasons = {row['reason'] for row in reviews.results(job['id'], self.actor, '*')['items']}
|
|
self.assertEqual(reasons, {'changed_item', 'deleted'})
|
|
|
|
def test_single_account_scope_and_snapshot_excludes_later_drafts(self):
|
|
self.seed(33); self.seed(4, 'other')
|
|
reviews = KnowledgeReviewStore(self.store)
|
|
job = reviews.preview('default', 0, self.actor, '')
|
|
self.seed(2, 'later')
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
KnowledgeReviewWorker(self.store).step()
|
|
self.assertEqual(self.store.stats('default'), {'approved': 33})
|
|
self.assertEqual(self.store.stats('other'), {'draft': 4})
|
|
self.assertEqual(self.store.stats('later'), {'draft': 2})
|
|
|
|
def test_pause_resume_cancel_and_new_worker_are_durable(self):
|
|
self.seed(7)
|
|
reviews = KnowledgeReviewStore(self.store)
|
|
job = reviews.preview('*', -1, self.actor, '')
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
KnowledgeReviewWorker(self.store).step(2)
|
|
reviews.action(job['id'], 'pause', self.actor, '')
|
|
self.assertFalse(KnowledgeReviewWorker(self.store).step())
|
|
reviews.action(job['id'], 'resume', self.actor, '')
|
|
KnowledgeReviewWorker(self.store).step(2)
|
|
reviews.action(job['id'], 'cancel', self.actor, '')
|
|
self.assertFalse(KnowledgeReviewWorker(self.store).step())
|
|
result = reviews.get(job['id'], self.actor)
|
|
self.assertEqual((result['status'], result['approved'], result['processed']), ('cancelled', 4, 4))
|
|
self.assertEqual(self.store.stats('default'), {'approved': 4, 'draft': 3})
|
|
with self.assertRaises(ValueError):
|
|
reviews.action(job['id'], 'resume', self.actor, '')
|
|
|
|
def test_batch_failure_rolls_back_status_results_and_audit_together(self):
|
|
self.seed(4)
|
|
reviews = KnowledgeReviewStore(self.store); job = reviews.preview('*', -1, self.actor, '')
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
original = self.store._record; calls = []
|
|
def broken(*args, **kwargs):
|
|
calls.append(1)
|
|
if len(calls) == 2:
|
|
raise RuntimeError('Simulated crash')
|
|
return original(*args, **kwargs)
|
|
with mock.patch.object(self.store, '_record', side_effect=broken), self.assertRaises(RuntimeError):
|
|
KnowledgeReviewWorker(self.store).step()
|
|
self.assertEqual(self.store.stats('default'), {'draft': 4})
|
|
self.assertEqual(reviews.get(job['id'], self.actor)['processed'], 0)
|
|
KnowledgeReviewWorker(self.store).step()
|
|
self.assertEqual(reviews.get(job['id'], self.actor)['approved'], 4)
|
|
|
|
def test_reviewer_permission_revoked_mid_job_stops_further_approvals(self):
|
|
self.seed(5)
|
|
reviews = KnowledgeReviewStore(self.store); job = reviews.preview('*', -1, self.actor, '')
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
KnowledgeReviewWorker(self.store).step(2)
|
|
with self.db.connect() as db:
|
|
db.execute("DELETE FROM role_permissions WHERE role_code='admin' AND permission_code='knowledge:review'")
|
|
KnowledgeReviewWorker(self.store).step()
|
|
result = reviews.get(job['id'], self.actor)
|
|
self.assertEqual((result['status'], result['approved']), ('failed', 2))
|
|
with self.db.connect() as db:
|
|
db.execute("INSERT INTO role_permissions VALUES ('admin','knowledge:review')")
|
|
reviews.action(job['id'], 'resume', self.actor, '')
|
|
KnowledgeReviewWorker(self.store).step()
|
|
self.assertEqual(reviews.get(job['id'], self.actor)['approved'], 5)
|
|
|
|
def test_expiry_and_replaced_previews(self):
|
|
self.seed(2)
|
|
reviews = KnowledgeReviewStore(self.store); old = reviews.preview('*', -1, self.actor, '')
|
|
job = reviews.preview('*', -1, self.actor, '')
|
|
with self.assertRaises(ValueError):
|
|
reviews.action(old['id'], 'start', self.actor, '', True)
|
|
with self.db.connect() as db:
|
|
db.execute('UPDATE knowledge_review_job SET expires_at=0 WHERE id=?', (job['id'],))
|
|
with self.assertRaises(ValueError):
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
while KnowledgeReviewWorker(self.store).step():
|
|
pass
|
|
with self.assertRaises(KeyError):
|
|
reviews.get(job['id'], self.actor)
|
|
self.assertEqual(self.store.stats('default'), {'draft': 2})
|
|
|
|
|
|
def test_disabled_reviewer_cannot_start_or_continue(self):
|
|
self.seed(3)
|
|
reviews = KnowledgeReviewStore(self.store); job = reviews.preview('*', -1, self.actor, '')
|
|
with self.db.connect() as db:
|
|
db.execute('UPDATE users SET active=0 WHERE id=?', (self.actor,))
|
|
with self.assertRaises(ValueError):
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
with self.db.connect() as db:
|
|
db.execute('UPDATE users SET active=1 WHERE id=?', (self.actor,))
|
|
reviews.action(job['id'], 'start', self.actor, '', True)
|
|
KnowledgeReviewWorker(self.store).step(1)
|
|
with self.db.connect() as db:
|
|
db.execute('UPDATE users SET active=0 WHERE id=?', (self.actor,))
|
|
KnowledgeReviewWorker(self.store).step()
|
|
final = reviews.get(job['id'], self.actor)
|
|
self.assertEqual((final['status'], final['approved']), ('failed', 1))
|
|
self.assertEqual(self.store.stats('default'), {'approved': 1, 'draft': 2})
|
|
|
|
|
|
class FullReviewApiTest(TestCase):
|
|
payload = FullReviewTest.payload
|
|
start_job = FullReviewTest.start_job
|
|
process = FullReviewTest.process
|
|
first = FullReviewTest.first
|
|
seed = FullReviewTest.seed
|
|
setUp = fixtures.KnowledgeApiTest.setUp
|
|
tearDown = fixtures.KnowledgeApiTest.tearDown
|
|
|
|
def test_auth_confirmation_owner_and_read_only_pagination(self):
|
|
url = '/api/v2/knowledge/review-tasks'
|
|
self.assertEqual(self.client.post(url + '/preview').status_code, 401)
|
|
response = self.client.post(url + '/preview?account_id=-1', headers=self.headers)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
job = response.json()
|
|
self.assertEqual(job['total'], 0)
|
|
self.assertEqual(self.client.post(url + '/' + job['id'] + '/start', headers=self.headers,
|
|
json={'confirmed': False}).status_code, 400)
|
|
self.assertEqual(self.client.get(url + '/' + job['id'] + '/results?page_size=1000', headers=self.headers).status_code, 422)
|
|
actor = self.db.authenticate('admin', 'ChangedAdmin@2026')['id']
|
|
self.db.create_user('reviewer', 'InitialReader123', 'viewer', actor, '')
|
|
user_id = self.db.authenticate('reviewer', 'InitialReader123')['id']
|
|
self.db.change_password(user_id, 'InitialReader123', 'ChangedReader@2026', '')
|
|
login = self.client.post('/api/v2/auth/login', json={'username': 'reviewer', 'password': 'ChangedReader@2026'})
|
|
headers = {'Authorization': 'Bearer ' + login.json()['access_token']}
|
|
self.assertEqual(self.client.post(url + '/preview', headers=headers).status_code, 403)
|
|
with self.db.connect() as db:
|
|
db.executemany("INSERT OR IGNORE INTO role_permissions VALUES ('viewer',?)", [(p,) for p in ('knowledge:read','knowledge:review','im:content:read')])
|
|
self.assertEqual(self.client.get(url + '/' + job['id'], headers=headers).status_code, 404)
|
|
self.assertEqual(self.client.post(url + '/' + job['id'] + '/cancel', headers=headers, json={}).status_code, 404)
|
|
|
|
|
|
def test_restricted_all_accounts_and_revocation_during_background_execution(self):
|
|
self.actor = self.db.authenticate('admin', 'ChangedAdmin@2026')['id']
|
|
self.store = self.client.app.state.knowledge_store
|
|
self.archive = ArchiveStore(self.db)
|
|
accounts = []
|
|
for index in range(3):
|
|
_, account = self.db.create_desktop_session(
|
|
{'user_id': f'full-review-{index}', 'sn': f'REV{index}', 'nickname': f'测试账号{index}'},
|
|
f'review-device-{index}', 'Review QA', '1.0', '')
|
|
accounts.append(account)
|
|
self.seed(10 + index, account['tenant_id'])
|
|
self.db.create_user('limited-reviewer', 'InitialReader123', 'viewer', self.actor, '')
|
|
reviewer = self.db.authenticate('limited-reviewer', 'InitialReader123')['id']
|
|
self.db.change_password(reviewer, 'InitialReader123', 'ChangedReader@2026', '')
|
|
with self.db.connect() as db:
|
|
db.executemany("INSERT OR IGNORE INTO role_permissions VALUES ('viewer',?)",
|
|
[(p,) for p in ('knowledge:read','knowledge:review','im:content:read')])
|
|
for account in accounts[:2]:
|
|
self.db.set_desktop_account_admins(account['id'], [reviewer], self.actor, '')
|
|
login = self.client.post('/api/v2/auth/login', json={'username': 'limited-reviewer', 'password': 'ChangedReader@2026'})
|
|
headers = {'Authorization': 'Bearer ' + login.json()['access_token']}
|
|
url = '/api/v2/knowledge/review-tasks'
|
|
self.assertEqual(self.client.post(url + '/preview?account_id=' + str(accounts[2]['id']), headers=headers).status_code, 403)
|
|
single = self.client.post(url + '/preview?account_id=' + str(accounts[0]['id']), headers=headers)
|
|
self.assertEqual(single.json()['total'], 10)
|
|
snapshot = self.client.post(url + '/preview?account_id=-1', headers=headers)
|
|
self.assertEqual(snapshot.status_code, 200, snapshot.text)
|
|
job = snapshot.json(); self.assertEqual(job['total'], 21)
|
|
self.assertEqual(len(job['preview']['accounts']), 2)
|
|
self.db.set_desktop_account_admins(accounts[1]['id'], [], self.actor, '')
|
|
response = self.client.post(url + '/' + job['id'] + '/start', headers=headers, json={'confirmed': True})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
KnowledgeReviewWorker(self.store).step()
|
|
final = self.client.get(url + '/' + job['id'], headers=headers).json()
|
|
self.assertEqual((final['approved'], final['skipped']), (10, 11))
|
|
self.assertEqual(self.store.stats(accounts[2]['tenant_id']), {'draft': 12})
|
|
self.assertEqual(self.store.stats(accounts[1]['tenant_id']), {'draft': 11})
|
|
results = self.client.get(url + '/' + job['id'] + '/results', headers=headers).json()
|
|
self.assertEqual(results['total'], 0, 'Do not expose titles from revoked accounts')
|