test_postgres_repository_contract.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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 "LIMIT %s" in sql
  87. assert params == (run_id, 20)
  88. assert items[0].id == item_id
  89. assert items[0].platform == "xiaohongshu"
  90. def test_postgres_ensure_acquisition_job_merges_metadata_on_conflict():
  91. job_id = uuid4()
  92. run_id = uuid4()
  93. query_id = uuid4()
  94. repo = RecordingPostgresRepo()
  95. repo.one_results.append(
  96. {
  97. "id": job_id,
  98. "run_id": run_id,
  99. "query_id": query_id,
  100. "platform": "douyin",
  101. "status": "done",
  102. "search_limit": 10,
  103. "display_limit": 5,
  104. "attempt_count": 1,
  105. "metadata": {"pagination": {"pages": [{"page_index": 1}]}, "query_text": "q"},
  106. "error_message": None,
  107. "started_at": None,
  108. "finished_at": None,
  109. "created_at": None,
  110. "updated_at": None,
  111. }
  112. )
  113. job = repo.ensure_acquisition_job(
  114. run_id=run_id,
  115. query_id=query_id,
  116. platform="douyin",
  117. search_limit=10,
  118. display_limit=5,
  119. status="pending",
  120. metadata={"query_text": "q"},
  121. )
  122. sql, params = repo.one_calls[0]
  123. assert "metadata = acquisition_jobs.metadata || EXCLUDED.metadata" in sql
  124. assert params[0] == run_id
  125. assert params[1] == query_id
  126. assert job.metadata["pagination"]["pages"][0]["page_index"] == 1
  127. def test_postgres_upsert_candidate_item_prefers_unique_key_for_existing_rows():
  128. item_id = uuid4()
  129. repo = RecordingPostgresRepo()
  130. repo.one_or_none_results.append({"id": item_id})
  131. repo.one_results.append(
  132. {
  133. "id": item_id,
  134. "job_id": None,
  135. "query_id": None,
  136. "platform": "xiaohongshu",
  137. "platform_item_id": "x1",
  138. "unique_key": "xhs:x1",
  139. "canonical_url": "https://xhs.test/1",
  140. "content_type": "图文",
  141. "content_mode": "image_post",
  142. "title": "更新标题",
  143. "author_name": None,
  144. "published_at": None,
  145. "body_text": "完整正文",
  146. "raw_summary": None,
  147. "status": "candidate",
  148. "source_payload": {},
  149. "metadata": {},
  150. "error_message": None,
  151. "created_at": None,
  152. "updated_at": None,
  153. }
  154. )
  155. item = repo.upsert_candidate_item(
  156. platform="xiaohongshu",
  157. platform_item_id="x1",
  158. unique_key="xhs:x1",
  159. canonical_url="https://xhs.test/1",
  160. content_type="图文",
  161. content_mode="image_post",
  162. title="更新标题",
  163. body_text="完整正文",
  164. )
  165. lookup_sql, lookup_params = repo.one_or_none_calls[0]
  166. update_sql, update_params = repo.one_calls[0]
  167. assert "WHERE unique_key = %s" in lookup_sql
  168. assert lookup_params == ("xhs:x1",)
  169. assert "source_payload =" not in update_sql
  170. assert "unique_key = COALESCE(%s, unique_key)" in update_sql
  171. assert "content_mode = %s" in update_sql
  172. assert "body_text = %s" in update_sql
  173. assert "INSERT INTO candidate_items" not in update_sql
  174. assert update_params[2] == "xhs:x1"
  175. assert update_params[5] == "image_post"
  176. assert update_params[8] == "完整正文"
  177. assert item.unique_key == "xhs:x1"
  178. assert item.content_mode == "image_post"
  179. assert item.body_text == "完整正文"
  180. def test_postgres_query_detail_loads_media_and_classifications_only_when_items_exist():
  181. run_id = uuid4()
  182. query_id = uuid4()
  183. item_id = uuid4()
  184. repo = RecordingPostgresRepo()
  185. repo.one_results.append({"id": query_id, "query_text": "q", "status": "ready"})
  186. repo.all_results.extend(
  187. [
  188. [{"id": uuid4(), "run_id": run_id, "query_id": query_id, "platform": "weixin"}],
  189. [{"id": item_id, "platform": "weixin", "query_id": query_id, "status": "candidate"}],
  190. [{"id": uuid4(), "item_id": item_id, "media_type": "image", "status": "done"}],
  191. [{"id": uuid4(), "item_id": item_id, "status": "classified"}],
  192. [{"item_id": item_id, "decode_status": "decoded", "payload_count": 2}],
  193. ]
  194. )
  195. detail = repo.get_query_detail(run_id=run_id, query_id=query_id)
  196. assert len(repo.all_calls) == 5
  197. assert "SELECT * FROM media_assets" in repo.all_calls[2][0]
  198. assert "SELECT * FROM item_classifications" in repo.all_calls[3][0]
  199. assert "FROM decode_results dr" in repo.all_calls[4][0]
  200. assert detail["items"][0]["id"] == item_id
  201. assert detail["media_assets"][0]["item_id"] == item_id
  202. assert detail["decode_summaries"][0]["payload_count"] == 2
  203. def test_postgres_attach_existing_candidate_updates_query_job_and_metadata():
  204. item_id = uuid4()
  205. job_id = uuid4()
  206. query_id = uuid4()
  207. repo = RecordingPostgresRepo()
  208. repo.one_results.append(
  209. {
  210. "id": item_id,
  211. "job_id": job_id,
  212. "query_id": query_id,
  213. "platform": "weixin",
  214. "platform_item_id": "wx1",
  215. "unique_key": "wx:key",
  216. "canonical_url": "https://mp.weixin.qq.com/s?...",
  217. "content_mode": "article",
  218. "body_text": None,
  219. "status": "candidate",
  220. "source_payload": {},
  221. "metadata": {"acquisition_match_status": "existing"},
  222. "created_at": None,
  223. "updated_at": None,
  224. }
  225. )
  226. item = repo.attach_existing_candidate_item(
  227. item_id,
  228. job_id=job_id,
  229. query_id=query_id,
  230. metadata={"acquisition_match_status": "existing"},
  231. )
  232. sql, params = repo.one_calls[0]
  233. assert "UPDATE candidate_items SET" in sql
  234. assert "metadata = metadata || %s" in sql
  235. assert params[0] == job_id
  236. assert params[1] == query_id
  237. assert params[3] == item_id
  238. assert item.metadata["acquisition_match_status"] == "existing"