test_app_api.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. from __future__ import annotations
  2. from pathlib import Path
  3. from uuid import uuid4
  4. from fastapi.testclient import TestClient
  5. from acquisition.domain import Query, QueryBatch
  6. from app.api import app
  7. from app.dependencies import (
  8. get_acquisition_repository,
  9. get_decode_repository,
  10. get_pipeline_repository,
  11. )
  12. from decode_content.models import (
  13. DecodeJob,
  14. DecodeResult,
  15. IngestRecord,
  16. KnowledgeParticle,
  17. PayloadDraft,
  18. ScopeResult,
  19. )
  20. from pipeline.models import PipelineRun
  21. class FakeAcquisitionRepo:
  22. def __init__(self):
  23. self.batch_id = uuid4()
  24. self.run_id = uuid4()
  25. self.query_id = uuid4()
  26. self.item_id = uuid4()
  27. self.media_id = uuid4()
  28. self.classification_id = uuid4()
  29. def get_query_batch(self, batch_id):
  30. assert batch_id == self.batch_id
  31. return QueryBatch(
  32. id=batch_id,
  33. name="正式 query 批次",
  34. source_type="manual",
  35. target_platforms=["xiaohongshu", "weixin", "douyin"],
  36. status="ready",
  37. )
  38. def list_queries_for_batch(self, batch_id, *, keep=None):
  39. assert batch_id == self.batch_id
  40. return [
  41. Query(
  42. id=self.query_id,
  43. batch_id=batch_id,
  44. query_text="短视频脚本 开头 怎么写",
  45. keep=True,
  46. status="ready",
  47. )
  48. ]
  49. def get_run_summary(self, run_id):
  50. assert run_id == self.run_id
  51. return {
  52. "id": run_id,
  53. "run_key": "run-1",
  54. "batch_id": self.batch_id,
  55. "status": "running",
  56. "query_count": 1,
  57. "job_count": 3,
  58. "candidate_count": 5,
  59. "creation_hit_count": 2,
  60. "queries": [
  61. {
  62. "query_id": self.query_id,
  63. "query_text": "短视频脚本 开头 怎么写",
  64. "candidate_count": 5,
  65. "creation_hit_count": 2,
  66. "platforms": {"xiaohongshu": {"status": "done"}},
  67. }
  68. ],
  69. }
  70. def get_query_detail(self, *, run_id, query_id):
  71. assert run_id == self.run_id
  72. assert query_id == self.query_id
  73. return {
  74. "query": {
  75. "id": query_id,
  76. "batch_id": self.batch_id,
  77. "query_text": "短视频脚本 开头 怎么写",
  78. "axes": {},
  79. "keep": True,
  80. "filter_reason": None,
  81. "status": "ready",
  82. "sort_order": 0,
  83. },
  84. "jobs": [{"id": uuid4(), "platform": "xiaohongshu", "status": "done"}],
  85. "items": [
  86. {
  87. "id": self.item_id,
  88. "platform": "xiaohongshu",
  89. "platform_item_id": "xhs1",
  90. "canonical_url": "https://xhs.test/1",
  91. "content_type": "图文",
  92. "title": "脚本开头",
  93. "author_name": "作者",
  94. "raw_summary": "先定受众",
  95. "status": "candidate",
  96. }
  97. ],
  98. "media_assets": [
  99. {
  100. "id": self.media_id,
  101. "item_id": self.item_id,
  102. "media_type": "image",
  103. "source_url": "https://origin.test/1.jpg",
  104. "oss_url": "https://oss.test/1.jpg",
  105. "cdn_url": "https://cdn.test/1.jpg",
  106. "position": 1,
  107. "status": "done",
  108. }
  109. ],
  110. "classifications": [
  111. {
  112. "id": self.classification_id,
  113. "item_id": self.item_id,
  114. "is_creation_knowledge": True,
  115. "label": "creation",
  116. "confidence": 0.98,
  117. "reason": "可复用",
  118. "status": "done",
  119. }
  120. ],
  121. }
  122. def get_candidate_item(self, item_id):
  123. assert item_id == self.item_id
  124. return self.get_query_detail(run_id=self.run_id, query_id=self.query_id)["items"][0]
  125. def list_media_assets_for_item(self, item_id):
  126. assert item_id == self.item_id
  127. return self.get_query_detail(run_id=self.run_id, query_id=self.query_id)["media_assets"]
  128. class FakeDecodeRepo:
  129. def __init__(self):
  130. self.decode_result_id = uuid4()
  131. self.decode_job_id = uuid4()
  132. self.payload_id = uuid4()
  133. def get_decode_result_for_item(self, item_id):
  134. return DecodeResult(
  135. id=self.decode_result_id,
  136. item_id=item_id,
  137. status="decoded",
  138. read_result={"text": "读懂后的帖子内容"},
  139. gate_result={"passed": True, "reason": "具备可复用创作方法"},
  140. framing_result={"particles": []},
  141. )
  142. def create_decode_job(self, *, item_id, status="pending", metadata=None):
  143. return DecodeJob(
  144. id=self.decode_job_id,
  145. item_id=item_id,
  146. status=status,
  147. metadata=metadata or {},
  148. )
  149. def list_payload_drafts(self, item_id=None):
  150. return [
  151. PayloadDraft(
  152. id=self.payload_id,
  153. item_id=item_id or uuid4(),
  154. payload={"title": "可审核 payload"},
  155. review_status="pending",
  156. ingest_ready=False,
  157. status="draft",
  158. )
  159. ]
  160. def list_knowledge_particles(self, item_id=None):
  161. return [
  162. KnowledgeParticle(
  163. id=uuid4(),
  164. item_id=item_id or uuid4(),
  165. particle_type="what",
  166. title="What I know",
  167. content={"content": "一个可复用知识点"},
  168. status="draft",
  169. )
  170. ]
  171. def list_scope_results(self, item_id=None):
  172. return [
  173. ScopeResult(
  174. id=uuid4(),
  175. item_id=item_id or uuid4(),
  176. scope_type="intent",
  177. scope_value="吸引注意",
  178. status="draft",
  179. )
  180. ]
  181. def list_ingest_records(self, item_id=None):
  182. return [
  183. IngestRecord(
  184. id=uuid4(),
  185. payload_draft_id=self.payload_id,
  186. target_system="dry-run",
  187. status="ingested",
  188. )
  189. ]
  190. class FakePipelineRepo:
  191. def get_pipeline_run(self, run_id):
  192. return PipelineRun(
  193. id=run_id,
  194. run_key="pipeline-run-1",
  195. status="running",
  196. current_stage="decode",
  197. )
  198. def _client(repo: FakeAcquisitionRepo):
  199. app.dependency_overrides[get_acquisition_repository] = lambda: repo
  200. return TestClient(app)
  201. def test_app_health_and_formal_acquisition_routes():
  202. repo = FakeAcquisitionRepo()
  203. client = _client(repo)
  204. try:
  205. assert client.get("/health").json()["entrypoint"] == "app.api:app"
  206. batch = client.get(f"/api/query-batches/{repo.batch_id}").json()
  207. assert batch["batch"]["name"] == "正式 query 批次"
  208. assert batch["queries"][0]["id"] == str(repo.query_id)
  209. summary = client.get(f"/api/acquisition/runs/{repo.run_id}/summary").json()
  210. assert summary["creation_hit_count"] == 2
  211. assert summary["queries"][0]["query_id"] == str(repo.query_id)
  212. detail = client.get(f"/api/acquisition/runs/{repo.run_id}/queries/{repo.query_id}").json()
  213. item = detail["platforms"]["xiaohongshu"]["items"][0]
  214. assert item["media_assets"][0]["cdn_url"] == "https://cdn.test/1.jpg"
  215. assert item["classification"]["is_creation_knowledge"] is True
  216. finally:
  217. app.dependency_overrides.clear()
  218. def test_formal_app_does_not_expose_legacy_static_mounts():
  219. paths = {route.path for route in app.routes if hasattr(route, "path")}
  220. assert "/data" not in paths
  221. assert "/frames" not in paths
  222. assert "/api/creation-search/summary" not in paths
  223. assert "/api/creation-search/query" not in paths
  224. def test_pipeline_route_returns_501_instead_of_legacy_data_when_not_wired():
  225. client = TestClient(app)
  226. run_id = uuid4()
  227. assert client.get(f"/api/pipeline/runs/{run_id}").status_code == 501
  228. def test_formal_decode_payload_pipeline_routes_have_override_success_paths():
  229. acquisition_repo = FakeAcquisitionRepo()
  230. decode_repo = FakeDecodeRepo()
  231. pipeline_repo = FakePipelineRepo()
  232. item_id = acquisition_repo.item_id
  233. run_id = uuid4()
  234. app.dependency_overrides[get_acquisition_repository] = lambda: acquisition_repo
  235. app.dependency_overrides[get_decode_repository] = lambda: decode_repo
  236. app.dependency_overrides[get_pipeline_repository] = lambda: pipeline_repo
  237. client = TestClient(app)
  238. try:
  239. result = client.get(f"/api/decode/items/{item_id}/result").json()
  240. assert result["item_id"] == str(item_id)
  241. assert result["read_result"]["text"] == "读懂后的帖子内容"
  242. job = client.post(f"/api/decode/items/{item_id}/jobs").json()["job"]
  243. assert job["item_id"] == str(item_id)
  244. assert job["status"] == "pending"
  245. drafts = client.get(f"/api/payloads/drafts?item_id={item_id}").json()["items"]
  246. assert drafts[0]["item_id"] == str(item_id)
  247. assert drafts[0]["payload"]["title"] == "可审核 payload"
  248. detail = client.get(f"/api/decode/items/{item_id}/detail").json()
  249. assert detail["item"]["id"] == str(item_id)
  250. assert detail["knowledge_particles"][0]["title"] == "What I know"
  251. assert detail["ingest_records"][0]["status"] == "ingested"
  252. run = client.get(f"/api/pipeline/runs/{run_id}").json()["run"]
  253. assert run["id"] == str(run_id)
  254. assert run["current_stage"] == "decode"
  255. finally:
  256. app.dependency_overrides.clear()
  257. def test_no_legacy_api_dependencies_in_formal_app_sources():
  258. roots = [Path("app"), Path("pipeline")]
  259. text = "\n".join(
  260. path.read_text(encoding="utf-8")
  261. for root in roots
  262. for path in root.rglob("*")
  263. if path.is_file() and path.suffix in {".py", ".jsx", ".js"}
  264. )
  265. assert "acquisition.store" not in text
  266. assert "CK_SQLITE_PATH" not in text
  267. assert "/data/queries" not in text
  268. assert "/api/creation-search" not in text
  269. assert "creation_knowledge.api" not in text
  270. def test_legacy_top_level_package_is_archived():
  271. assert not Path("creation_knowledge").exists()
  272. assert Path("archive/2026-06-30-step6/legacy_creation_knowledge/creation_knowledge").exists()
  273. def test_active_sources_do_not_import_legacy_store_or_package():
  274. roots = [Path("app"), Path("pipeline"), Path("acquisition"), Path("decode_content"), Path("scripts")]
  275. text = "\n".join(
  276. path.read_text(encoding="utf-8")
  277. for root in roots
  278. for path in root.rglob("*")
  279. if path.is_file() and path.suffix in {".py", ".jsx", ".js"}
  280. )
  281. assert "from acquisition import store" not in text
  282. assert "import acquisition.store" not in text
  283. assert "from creation_knowledge" not in text
  284. assert "import creation_knowledge" not in text