test_agent_batch.py 6.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. import hashlib
  2. import json
  3. from pathlib import Path
  4. import sqlite3
  5. import tempfile
  6. import unittest
  7. import agent_batch
  8. import board_library
  9. import update_library
  10. import voc_db
  11. class AgentBatchTests(unittest.TestCase):
  12. def setUp(self):
  13. self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
  14. self.root = Path(self.tmp.name); (self.root / 'raw').mkdir()
  15. self.raw = self.root / 'raw/response.json'
  16. self.raw.write_text(json.dumps({'product': {'id': '123', 'name': '康师傅香辣拌面110g*5袋', 'price': '19.9'},
  17. 'comments': [{'item': '123', 'id': 'r1', 'text': '一包根本吃不饱', 'default': False, 'sku': '香辣拌面'}]}, ensure_ascii=False), encoding='utf-8')
  18. self.batch = {'schema': agent_batch.SCHEMA, 'batchId': 'batch-test', 'producer': 'test-agent/v1',
  19. 'sources': [{'id': 'run1', 'path': 'raw/response.json', 'sha256': agent_batch.digest(self.raw),
  20. 'platform': 'jd', 'marketplace': 'jd', 'requestItemId': '123', 'observedAt': '2026-11-07T10:00:00+08:00'}],
  21. 'products': [{'sourceId': 'run1', 'pointer': '/product', 'fields': {'itemId': '/id', 'title': '/name', 'price': '/price'}}],
  22. 'reviews': [{'sourceId': 'run1', 'pointer': '/comments/0', 'fields': {'itemId': '/item', 'reviewId': '/id',
  23. 'content': '/text', 'isDefault': '/default', 'purchasedSku': '/sku'}}],
  24. 'themes': [{'reviewId': 'jd:123:r1', 'theme': 'portion', 'quote': '一包根本吃不饱', 'quoteField': 'content'}]}
  25. self.path = self.root / 'batch.json'; self.output = self.root / 'new.sqlite'; self.save()
  26. def save(self): self.path.write_text(json.dumps(self.batch, ensure_ascii=False), encoding='utf-8')
  27. def test_mapping_validation_is_readonly_then_build_publish_and_query(self):
  28. before = {str(p): (agent_batch.digest(p), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()}
  29. dry = agent_batch.build_batch(self.path, self.output)
  30. self.assertTrue(dry['readOnly']); self.assertFalse(self.output.exists())
  31. self.assertEqual(before, {str(p): (agent_batch.digest(p), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()})
  32. built = agent_batch.build_batch(self.path, self.output, True)
  33. self.assertTrue(built['applied']); self.assertFalse(built['activeLibraryModified']); self.assertTrue(self.output.with_suffix('.source.zip').is_file())
  34. registry = self.root / 'registry'; update_library.publish(self.output, registry, True)
  35. d = board_library.run({'registry': str(registry)}, {'op': 'detail', 'entity': 'products', 'id': 'jd:123'})
  36. self.assertEqual(d['record']['overview']['reviews']['eligible'], 1)
  37. theme = next(t for t in d['record']['overview']['themes'] if t['id'] == 'portion')
  38. self.assertIn('agent', theme['methods']); self.assertEqual(theme['examples'][0]['quote'], '一包根本吃不饱')
  39. with voc_db.readonly(self.output) as c:
  40. r = c.execute('SELECT source_record_id FROM reviews').fetchone()
  41. citation = voc_db.citation(c, r[0]); self.assertTrue(citation['archiveSha256'])
  42. with self.assertRaises(voc_db.LibraryError): agent_batch.build_batch(self.path, self.output, True)
  43. def test_wrong_product_default_type_fabricated_quote_and_missing_map_rejected(self):
  44. cases = [('source-mismatch', lambda: self.batch['sources'][0].update(requestItemId='999')),
  45. ('fabricated-quote', lambda: self.batch['themes'][0].update(quote='味道鲜美')),
  46. ('missing-content', lambda: self.batch['reviews'][0]['fields'].pop('content')),
  47. ('duplicate', lambda: self.batch['reviews'].append(self.batch['reviews'][0]))]
  48. original = json.loads(json.dumps(self.batch))
  49. for name, mutate in cases:
  50. with self.subTest(name=name):
  51. self.batch = json.loads(json.dumps(original)); mutate(); self.save()
  52. with self.assertRaises(voc_db.LibraryError): agent_batch.build_batch(self.path, self.output)
  53. self.assertFalse(self.output.exists())
  54. def test_wrong_hash_and_source_path_escape_rejected(self):
  55. self.batch['sources'][0]['sha256'] = '0' * 64; self.save()
  56. with self.assertRaisesRegex(voc_db.LibraryError, 'SOURCE_HASH_MISMATCH'): agent_batch.build_batch(self.path, self.output)
  57. self.batch['sources'][0]['path'] = '../outside.json'; self.save()
  58. with self.assertRaisesRegex(voc_db.LibraryError, 'INVALID_SOURCE_PATH'): agent_batch.build_batch(self.path, self.output)
  59. def test_null_title_and_non_boolean_default_rejected(self):
  60. raw = json.loads(self.raw.read_text(encoding='utf-8'))
  61. raw['product']['name'] = None
  62. self.raw.write_text(json.dumps(raw), encoding='utf-8')
  63. self.batch['sources'][0]['sha256'] = agent_batch.digest(self.raw); self.save()
  64. with self.assertRaisesRegex(voc_db.LibraryError, 'PRODUCT_TITLE_REQUIRED'): agent_batch.build_batch(self.path, self.output)
  65. raw['product']['name'] = '康师傅拌面'
  66. raw['comments'][0]['default'] = 'false'
  67. self.raw.write_text(json.dumps(raw), encoding='utf-8')
  68. self.batch['sources'][0]['sha256'] = agent_batch.digest(self.raw); self.save()
  69. with self.assertRaisesRegex(voc_db.LibraryError, 'DEFAULT_STATE_MUST_BE_BOOLEAN_OR_NULL'): agent_batch.build_batch(self.path, self.output)
  70. def test_new_batch_merged_annotations_survive_old_snapshot_and_updates(self):
  71. agent_batch.build_batch(self.path, self.output, True)
  72. registry = self.root / 'registry'; update_library.publish(self.output, registry, True)
  73. self.batch['batchId'] = 'batch-two'; self.batch['producer'] = 'test-agent/v2'; self.save()
  74. second = self.root / 'second.sqlite'; agent_batch.build_batch(self.path, second, True)
  75. change = update_library.publish(second, registry, True)
  76. self.assertTrue(change['applied'])
  77. d = board_library.run({'registry': str(registry)}, {'op': 'detail', 'entity': 'products', 'id': 'jd:123'})
  78. self.assertEqual(d['record']['overview']['reviews']['collected'], 1)
  79. self.assertIn('agent', next(t for t in d['record']['overview']['themes'] if t['id'] == 'portion')['methods'])
  80. if __name__ == '__main__': unittest.main()