test_restore_voc_product_fields.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. """Real-data regression for the one-off repair; only newly created local fixtures."""
  2. import importlib.util
  3. import json
  4. import os
  5. from pathlib import Path
  6. import shutil
  7. import sqlite3
  8. import sys
  9. import tempfile
  10. import unittest
  11. ROOT = Path(__file__).resolve().parents[1]
  12. sys.dont_write_bytecode = True
  13. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  14. import voc_db as v
  15. import update_library as u
  16. spec = importlib.util.spec_from_file_location('restore_product_fields', ROOT / 'scripts/restore-voc-product-fields.py')
  17. r = importlib.util.module_from_spec(spec)
  18. spec.loader.exec_module(r)
  19. def fixture(destination, package):
  20. base = ROOT / 'work/voc-handoff-20261106-r1/merge-policy-r1/isolated-registry/versions/6d9ac4ee65c75f7247edaedb5fb6b0bea03136df5d9c649ff79ff9b7615d738d.sqlite'
  21. with r.readonly(base) as original:
  22. c = sqlite3.connect(destination)
  23. original.backup(c)
  24. c.row_factory = sqlite3.Row
  25. c.execute('PRAGMA foreign_keys=ON')
  26. data = json.loads((package / 'evidence/affected-products.json').read_text(encoding='utf-8'))
  27. for item in data['products'].values():
  28. row = item['rows']['current_8857847']
  29. s = item['sources']['current_8857847']
  30. asset = s['asset']
  31. c.execute('INSERT OR IGNORE INTO assets VALUES(?,?,?)', (asset['path'], asset['sha256'], asset['byte_size']))
  32. old = c.execute('SELECT * FROM source_records WHERE id=?', (s['source_record_id'],)).fetchone()
  33. if not old:
  34. c.execute('INSERT INTO source_records VALUES(?,?,?,?,?,?)', (s['source_record_id'], s['kind'],
  35. s['asset_path'], s['line_number'], s['payload_sha256'], s['payload_json']))
  36. c.execute('INSERT OR IGNORE INTO source_origins VALUES(?,?)', (s['source_record_id'], 'a' * 64))
  37. c.execute('UPDATE products SET ' + ','.join(k + '=?' for k in row if k != 'id') + ' WHERE id=?',
  38. [*[row[k] for k in row if k != 'id'], row['id']])
  39. c.execute("UPDATE meta SET value=? WHERE key='generation'", (r.BASE,))
  40. c.execute("INSERT INTO meta VALUES('fixture_notice','LOCAL SIMULATION: 95 exported rows on initial/corrected fixture, not a production copy')")
  41. c.commit()
  42. c.execute('DROP TABLE retrieval_fts')
  43. c.execute('DROP TABLE retrieval_documents')
  44. import retrieval
  45. retrieval.create_index(c)
  46. c.commit()
  47. c.close()
  48. class ProductRestorationTests(unittest.TestCase):
  49. @classmethod
  50. def setUpClass(cls):
  51. cls.package = ROOT / 'work/voc-product-restoration-20261106-r1/prepared'
  52. cls.work = tempfile.TemporaryDirectory(prefix='product-field-repair-tests-')
  53. cls.root = Path(cls.work.name)
  54. cls.base = cls.root / 'fixture.sqlite'
  55. fixture(cls.base, cls.package)
  56. @classmethod
  57. def tearDownClass(cls):
  58. cls.work.cleanup()
  59. def test_01_real_originals_and_exact_scope(self):
  60. plan = r.plan(self.package, v)
  61. self.assertEqual(len(plan['products']), 60)
  62. self.assertEqual(sum(plan['fields'].values()), 70)
  63. self.assertEqual(plan['fields']['shop_id'], 2)
  64. self.assertEqual(plan['fields']['category_code'], 1)
  65. def test_02_simulation_preserves_reviews_and_current_conflicts(self):
  66. target = self.root / 'simulation.sqlite'
  67. before = r.digest(self.base)
  68. report, archive = r.simulate(self.base, self.package, target, v, u)
  69. self.assertTrue(all(report['checks'].values()))
  70. self.assertEqual(report['before']['analysis'], 771)
  71. self.assertEqual(report['after']['strict'], {'analysis': 18, 'audit': 39})
  72. self.assertEqual(report['after']['overview']['collected'], 19)
  73. self.assertEqual(report['after']['overview']['eligible'], 18)
  74. self.assertEqual(report['after']['themeCount'], 8)
  75. self.assertEqual(before, r.digest(self.base))
  76. self.assertEqual(r.sha(archive), report['sourceArchiveSha256'])
  77. with r.readonly(target) as c:
  78. p = dict(c.execute("SELECT * FROM products WHERE id='taobao:1006335950392'").fetchone())
  79. self.assertEqual(p['shop_id'], '250707370')
  80. self.assertEqual(p['url'], 'https://detail.tmall.com/item.htm?id=1006335950392')
  81. p = dict(c.execute("SELECT * FROM products WHERE id='jd:100048349676'").fetchone())
  82. self.assertEqual(p['category_code'], 'dry_snack_noodle')
  83. self.assertEqual(p['classification_state'], 'supported')
  84. self.assertEqual(p['title'], '康师傅香爆脆4口味33g*30包整箱')
  85. (self.package.parent / 'local-simulation.json').write_text(json.dumps({
  86. **report, 'scope': 'LOCAL FIXTURE ONLY; actual production physical hash unavailable here',
  87. 'productionImported': False}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  88. def test_03_changed_current_row_is_rejected(self):
  89. path = self.root / 'changed.sqlite'
  90. shutil.copyfile(self.base, path)
  91. with sqlite3.connect(path) as c:
  92. c.execute("UPDATE products SET shop_id='new-known-shop' WHERE id='jd:100005702440'")
  93. c.close()
  94. with self.assertRaisesRegex(ValueError, 'CURRENT_ROW_CHANGED'):
  95. r.simulate(path, self.package, self.root / 'must-not-build.sqlite', v, u)
  96. self.assertFalse((self.root / 'must-not-build.sqlite').exists())
  97. def test_04_publish_dry_apply_status_replay_rollback(self):
  98. registry = self.root / 'registry'
  99. (registry / 'versions').mkdir(parents=True)
  100. base = registry / 'versions' / (r.BASE + '.sqlite')
  101. shutil.copyfile(self.base, base)
  102. entry = {'generation': r.BASE, 'file': 'versions/' + base.name, 'databaseSha256': r.digest(base)}
  103. r.write_json(registry / 'current.json', {**entry, 'history': [entry]})
  104. pointer = (registry / 'current.json').read_bytes()
  105. saved_hash, saved_check = r.BASE_SHA, r.module_check
  106. # This injection exists ONLY in this local test; the CLI has no bypass.
  107. r.BASE_SHA, r.module_check = r.digest(base), lambda path: (v, u)
  108. try:
  109. dry_path = self.root / 'dry.json'
  110. dry = r.execute(self.package, ROOT / 'server/voc-library', registry, dry_path)
  111. self.assertFalse(dry['applied'])
  112. self.assertEqual(pointer, (registry / 'current.json').read_bytes())
  113. with self.assertRaisesRegex(ValueError, 'DRY_PLAN_REQUIRED'):
  114. r.execute(self.package, ROOT / 'server/voc-library', registry, self.root / 'no-plan.json', True)
  115. (registry / '.update.lock').write_text('another publisher', encoding='utf-8')
  116. with self.assertRaises(FileExistsError):
  117. r.execute(self.package, ROOT / 'server/voc-library', registry, self.root / 'locked.json', True, dry_path)
  118. self.assertEqual(pointer, (registry / 'current.json').read_bytes())
  119. (registry / '.update.lock').unlink()
  120. r.BASE_SHA = '0' * 64
  121. with self.assertRaisesRegex(ValueError, 'PHYSICAL_BASELINE_CHANGED'):
  122. r.target(registry)
  123. r.BASE_SHA = r.digest(base)
  124. tampered_plan = {**dry, 'plannedGeneration': '0' * 64}
  125. r.write_json(self.root / 'bad-plan.json', tampered_plan)
  126. with self.assertRaisesRegex(ValueError, 'APPROVED_PLAN_CHANGED'):
  127. r.execute(self.package, ROOT / 'server/voc-library', registry,
  128. self.root / 'bad-apply.json', True, self.root / 'bad-plan.json')
  129. self.assertEqual(pointer, (registry / 'current.json').read_bytes())
  130. apply = r.execute(self.package, ROOT / 'server/voc-library', registry,
  131. self.root / 'apply.json', True, dry_path)
  132. self.assertEqual(dry['plannedGeneration'], apply['generation'])
  133. new_pointer = (registry / 'current.json').read_bytes()
  134. status = r.execute(self.package, ROOT / 'server/voc-library', registry,
  135. self.root / 'status.json', status=True)
  136. self.assertTrue(status['publicationFound'])
  137. replay = r.execute(self.package, ROOT / 'server/voc-library', registry,
  138. self.root / 'replay.json', True, dry_path)
  139. self.assertEqual(replay['operation'], 'already-published')
  140. self.assertEqual(new_pointer, (registry / 'current.json').read_bytes())
  141. rollback = u.publish(None, registry, True, rollback=r.BASE, expected_generation=apply['generation'])
  142. self.assertEqual(rollback['generation'], r.BASE)
  143. self.assertTrue((registry / apply['file']).exists() if 'file' in apply else len(list((registry / 'versions').glob('*.sqlite'))) == 2)
  144. finally:
  145. r.BASE_SHA, r.module_check = saved_hash, saved_check
  146. def test_05_tampered_original_rejected(self):
  147. root = self.root / 'tampered'
  148. shutil.copytree(self.package, root)
  149. (root / 'original/products.jsonl').write_bytes(b'{}\n')
  150. with self.assertRaisesRegex(ValueError, 'ORIGINAL_PRODUCTS_CHANGED'):
  151. r.plan(root, v)
  152. if __name__ == '__main__':
  153. unittest.main()