test_postgres_repository_contract.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. from __future__ import annotations
  2. from uuid import uuid4
  3. from acquisition.repositories.postgres import PostgresAcquisitionRepository
  4. class RecordingPostgresRepo(PostgresAcquisitionRepository):
  5. def __init__(self):
  6. super().__init__(conn=None)
  7. self.one_calls = []
  8. self.one_or_none_calls = []
  9. self.all_calls = []
  10. self.one_results = []
  11. self.one_or_none_results = []
  12. self.all_results = []
  13. def _one(self, sql, params):
  14. self.one_calls.append((sql, params))
  15. return self.one_results.pop(0)
  16. def _one_or_none(self, sql, params):
  17. self.one_or_none_calls.append((sql, params))
  18. return self.one_or_none_results.pop(0)
  19. def _all(self, sql, params):
  20. self.all_calls.append((sql, params))
  21. return self.all_results.pop(0)
  22. def test_postgres_summary_uses_formal_run_job_item_tables():
  23. run_id = uuid4()
  24. batch_id = uuid4()
  25. query_id = uuid4()
  26. repo = RecordingPostgresRepo()
  27. repo.one_results.append(
  28. {
  29. "id": run_id,
  30. "run_key": "r1",
  31. "batch_id": batch_id,
  32. "status": "done",
  33. "query_count": 1,
  34. "job_count": 3,
  35. "candidate_count": 7,
  36. "creation_hit_count": 2,
  37. "started_at": None,
  38. "finished_at": None,
  39. }
  40. )
  41. repo.all_results.append(
  42. [
  43. {
  44. "query_id": query_id,
  45. "query_text": "脚本 开头",
  46. "job_count": 3,
  47. "candidate_count": 7,
  48. "creation_hit_count": 2,
  49. "platforms": {"xiaohongshu": {"status": "done"}},
  50. }
  51. ]
  52. )
  53. summary = repo.get_run_summary(run_id)
  54. first_sql, first_params = repo.one_calls[0]
  55. second_sql, second_params = repo.all_calls[0]
  56. assert "FROM acquisition_runs ar" in first_sql
  57. assert "LEFT JOIN acquisition_jobs aj" in first_sql
  58. assert "LEFT JOIN candidate_items ci" in first_sql
  59. assert "LEFT JOIN item_classifications ic" in first_sql
  60. assert first_params == (run_id,)
  61. assert "jsonb_object_agg" in second_sql
  62. assert "WHERE aj.run_id = %s" in second_sql
  63. assert second_params == (run_id,)
  64. assert summary["queries"][0]["query_id"] == query_id
  65. def test_postgres_repository_lists_only_creation_candidates_for_decode():
  66. run_id = uuid4()
  67. item_id = uuid4()
  68. repo = RecordingPostgresRepo()
  69. repo.all_results.append(
  70. [
  71. {
  72. "id": item_id,
  73. "platform": "xiaohongshu",
  74. "platform_item_id": "x1",
  75. "unique_key": "xhs:x1",
  76. "canonical_url": "https://xhs.test/1",
  77. "status": "candidate",
  78. }
  79. ]
  80. )
  81. items = repo.list_creation_candidate_items(run_id=run_id, limit=20)
  82. sql, params = repo.all_calls[0]
  83. assert "JOIN acquisition_jobs aj ON aj.id = ci.job_id" in sql
  84. assert "JOIN item_classifications ic ON ic.item_id = ci.id" in sql
  85. assert "ic.is_creation_knowledge IS TRUE" in sql
  86. assert "NOT EXISTS" in sql
  87. assert "FROM decode_results dr" in sql
  88. assert "LIMIT %s" in sql
  89. assert params == (run_id, 20)
  90. assert items[0].id == item_id
  91. assert items[0].platform == "xiaohongshu"
  92. def test_postgres_ensure_acquisition_job_merges_metadata_on_conflict():
  93. job_id = uuid4()
  94. run_id = uuid4()
  95. query_id = uuid4()
  96. repo = RecordingPostgresRepo()
  97. repo.one_results.append(
  98. {
  99. "id": job_id,
  100. "run_id": run_id,
  101. "query_id": query_id,
  102. "platform": "douyin",
  103. "status": "done",
  104. "search_limit": 10,
  105. "display_limit": 5,
  106. "attempt_count": 1,
  107. "metadata": {"pagination": {"pages": [{"page_index": 1}]}, "query_text": "q"},
  108. "error_message": None,
  109. "started_at": None,
  110. "finished_at": None,
  111. "created_at": None,
  112. "updated_at": None,
  113. }
  114. )
  115. job = repo.ensure_acquisition_job(
  116. run_id=run_id,
  117. query_id=query_id,
  118. platform="douyin",
  119. search_limit=10,
  120. display_limit=5,
  121. status="pending",
  122. metadata={"query_text": "q"},
  123. )
  124. sql, params = repo.one_calls[0]
  125. assert "metadata = acquisition_jobs.metadata || EXCLUDED.metadata" in sql
  126. assert params[0] == run_id
  127. assert params[1] == query_id
  128. assert job.metadata["pagination"]["pages"][0]["page_index"] == 1
  129. def test_postgres_upsert_candidate_item_prefers_unique_key_for_existing_rows():
  130. item_id = uuid4()
  131. repo = RecordingPostgresRepo()
  132. repo.one_or_none_results.append({"id": item_id})
  133. repo.one_results.append(
  134. {
  135. "id": item_id,
  136. "job_id": None,
  137. "query_id": None,
  138. "platform": "xiaohongshu",
  139. "platform_item_id": "x1",
  140. "unique_key": "xhs:x1",
  141. "canonical_url": "https://xhs.test/1",
  142. "content_type": "图文",
  143. "content_mode": "image_post",
  144. "title": "更新标题",
  145. "author_name": None,
  146. "published_at": None,
  147. "body_text": "完整正文",
  148. "raw_summary": None,
  149. "status": "candidate",
  150. "source_payload": {},
  151. "metadata": {},
  152. "error_message": None,
  153. "created_at": None,
  154. "updated_at": None,
  155. }
  156. )
  157. item = repo.upsert_candidate_item(
  158. platform="xiaohongshu",
  159. platform_item_id="x1",
  160. unique_key="xhs:x1",
  161. canonical_url="https://xhs.test/1",
  162. content_type="图文",
  163. content_mode="image_post",
  164. title="更新标题",
  165. body_text="完整正文",
  166. )
  167. lookup_sql, lookup_params = repo.one_or_none_calls[0]
  168. update_sql, update_params = repo.one_calls[0]
  169. assert "WHERE unique_key = %s" in lookup_sql
  170. assert lookup_params == ("xhs:x1",)
  171. assert "source_payload =" not in update_sql
  172. assert "unique_key = COALESCE(%s, unique_key)" in update_sql
  173. assert "content_mode = %s" in update_sql
  174. assert "body_text = %s" in update_sql
  175. assert "INSERT INTO candidate_items" not in update_sql
  176. assert update_params[2] == "xhs:x1"
  177. assert update_params[5] == "image_post"
  178. assert update_params[8] == "完整正文"
  179. assert item.unique_key == "xhs:x1"
  180. assert item.content_mode == "image_post"
  181. assert item.body_text == "完整正文"
  182. def test_postgres_query_detail_loads_media_and_classifications_only_when_items_exist():
  183. run_id = uuid4()
  184. query_id = uuid4()
  185. item_id = uuid4()
  186. repo = RecordingPostgresRepo()
  187. repo.one_results.append({"id": query_id, "query_text": "q", "status": "ready"})
  188. repo.all_results.extend(
  189. [
  190. [{"id": uuid4(), "run_id": run_id, "query_id": query_id, "platform": "weixin"}],
  191. [{"id": item_id, "platform": "weixin", "query_id": query_id, "status": "candidate"}],
  192. [{"id": uuid4(), "item_id": item_id, "media_type": "image", "status": "done"}],
  193. [{"id": uuid4(), "item_id": item_id, "status": "classified"}],
  194. [{"item_id": item_id, "decode_status": "decoded", "payload_count": 2}],
  195. ]
  196. )
  197. detail = repo.get_query_detail(run_id=run_id, query_id=query_id)
  198. assert len(repo.all_calls) == 5
  199. assert "SELECT * FROM media_assets" in repo.all_calls[2][0]
  200. assert "SELECT * FROM item_classifications" in repo.all_calls[3][0]
  201. assert "FROM decode_results dr" in repo.all_calls[4][0]
  202. assert detail["items"][0]["id"] == item_id
  203. assert detail["media_assets"][0]["item_id"] == item_id
  204. assert detail["decode_summaries"][0]["payload_count"] == 2
  205. def test_postgres_latest_query_result_list_uses_lightweight_projection():
  206. batch_id = uuid4()
  207. run_id = uuid4()
  208. query_id = uuid4()
  209. item_id = uuid4()
  210. repo = RecordingPostgresRepo()
  211. repo.one_results.append(
  212. {"id": query_id, "batch_id": batch_id, "query_text": "q", "status": "ready"}
  213. )
  214. repo.one_or_none_results.append({"id": run_id, "batch_id": batch_id, "status": "running"})
  215. repo.all_results.extend(
  216. [
  217. [{"id": uuid4(), "run_id": run_id, "query_id": query_id, "platform": "weixin"}],
  218. [
  219. {
  220. "id": item_id,
  221. "query_id": query_id,
  222. "job_id": uuid4(),
  223. "platform": "weixin",
  224. "title": "标题",
  225. "raw_summary": "摘要",
  226. "status": "candidate",
  227. "content_mode": "article",
  228. "metadata": {},
  229. }
  230. ],
  231. [{"id": uuid4(), "item_id": item_id, "media_type": "image", "status": "done"}],
  232. [
  233. {
  234. "id": uuid4(),
  235. "item_id": item_id,
  236. "is_creation_knowledge": True,
  237. "label": "creation",
  238. "confidence": 0.9,
  239. "status": "done",
  240. "error_message": None,
  241. }
  242. ],
  243. [{"item_id": item_id, "decode_status": "decoded", "particle_count": 1, "payload_count": 1}],
  244. ]
  245. )
  246. detail = repo.get_latest_query_result_list(query_id)
  247. item_sql = repo.all_calls[1][0]
  248. media_sql = repo.all_calls[2][0]
  249. classification_sql = repo.all_calls[3][0]
  250. assert "SELECT ci.*" not in item_sql
  251. assert "source_payload" not in item_sql
  252. assert "body_text" not in item_sql
  253. assert "LEFT(ci.raw_summary, 700) AS raw_summary" in item_sql
  254. assert "SELECT DISTINCT ON (item_id)" in media_sql
  255. assert "media_type IN ('cover', 'image', 'frame')" in media_sql
  256. assert "SELECT DISTINCT ON (item_id)" in classification_sql
  257. assert "reason" not in classification_sql
  258. assert detail["run"]["id"] == run_id
  259. assert detail["items"][0]["raw_summary"] == "摘要"
  260. assert detail["media_assets"][0]["item_id"] == item_id
  261. assert detail["decode_summaries"][0]["particle_count"] == 1
  262. def test_postgres_attach_existing_candidate_updates_query_job_and_metadata():
  263. item_id = uuid4()
  264. job_id = uuid4()
  265. query_id = uuid4()
  266. repo = RecordingPostgresRepo()
  267. repo.one_results.append(
  268. {
  269. "id": item_id,
  270. "job_id": job_id,
  271. "query_id": query_id,
  272. "platform": "weixin",
  273. "platform_item_id": "wx1",
  274. "unique_key": "wx:key",
  275. "canonical_url": "https://mp.weixin.qq.com/s?...",
  276. "content_mode": "article",
  277. "body_text": None,
  278. "status": "candidate",
  279. "source_payload": {},
  280. "metadata": {"acquisition_match_status": "existing"},
  281. "created_at": None,
  282. "updated_at": None,
  283. }
  284. )
  285. item = repo.attach_existing_candidate_item(
  286. item_id,
  287. job_id=job_id,
  288. query_id=query_id,
  289. metadata={"acquisition_match_status": "existing"},
  290. )
  291. sql, params = repo.one_calls[0]
  292. assert "UPDATE candidate_items SET" in sql
  293. assert "metadata = metadata || %s" in sql
  294. assert params[0] == job_id
  295. assert params[1] == query_id
  296. assert params[3] == item_id
  297. assert item.metadata["acquisition_match_status"] == "existing"