test_responses_survival.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import json
  2. from datetime import datetime
  3. from pathlib import Path
  4. from ops.scan import ScanResult
  5. def _write_token(path: Path, *, email: str, created_at: str) -> None:
  6. path.write_text(
  7. (
  8. "{\n"
  9. f' "email": "{email}",\n'
  10. ' "access_token": "tok",\n'
  11. ' "account_id": "acct",\n'
  12. f' "created_at": "{created_at}"\n'
  13. "}\n"
  14. ),
  15. encoding="utf-8",
  16. )
  17. def _write_token_with_provenance(path: Path, *, email: str, created_at: str) -> None:
  18. path.write_text(
  19. (
  20. "{\n"
  21. f' "email": "{email}",\n'
  22. ' "access_token": "tok",\n'
  23. ' "account_id": "acct",\n'
  24. f' "created_at": "{created_at}",\n'
  25. ' "registration_fingerprint_profile": "chrome120_win",\n'
  26. ' "registration_proxy_key": "台湾原生-01",\n'
  27. ' "registration_proxy_region": "tw",\n'
  28. ' "registration_post_create_gate": "add_phone"\n'
  29. "}\n"
  30. ),
  31. encoding="utf-8",
  32. )
  33. def test_responses_survival_seeds_recent_cohort_and_records_first_401(monkeypatch, tmp_path: Path) -> None:
  34. from ops.responses_survival import responses_survival_once
  35. newer = tmp_path / "newer@example.com.json"
  36. older = tmp_path / "older@example.com.json"
  37. _write_token(newer, email="newer@example.com", created_at="2026-03-30T20:20:00+08:00")
  38. _write_token(older, email="older@example.com", created_at="2026-03-30T20:19:00+08:00")
  39. state_file = tmp_path / "responses_survival.json"
  40. def fake_probe(path, proxy, timeout): # type: ignore[no-untyped-def]
  41. del proxy, timeout
  42. if path.name == newer.name:
  43. return ScanResult(file=path.name, category="normal", status_code=200, detail="responses_ok")
  44. return ScanResult(file=path.name, category="invalid", status_code=401, detail="unauthorized")
  45. monkeypatch.setattr("ops.responses_survival.probe_responses_token_file", fake_probe)
  46. result = responses_survival_once(
  47. pool_dir=tmp_path,
  48. state_file=state_file,
  49. cohort_size=2,
  50. proxy=None,
  51. timeout_seconds=30,
  52. reseed=True,
  53. )
  54. assert result["probe_mode"] == "responses"
  55. assert result["seeded"] is True
  56. assert [member["email"] for member in result["members"]] == ["newer@example.com", "older@example.com"]
  57. assert result["summary"]["tracked"] == 2
  58. assert result["summary"]["alive"] == 1
  59. assert result["summary"]["invalid"] == 1
  60. invalid_member = next(member for member in result["members"] if member["email"] == "older@example.com")
  61. assert invalid_member["first_invalid_at"]
  62. assert invalid_member["survival_seconds"] is not None
  63. assert invalid_member["survival_seconds"] >= 0
  64. assert {item["to"] for item in result["changes"]} == {"normal", "invalid"}
  65. def test_responses_survival_preserves_401_semantics_after_pool_file_is_removed(
  66. monkeypatch, tmp_path: Path
  67. ) -> None:
  68. from ops.responses_survival import responses_survival_once
  69. tracked = tmp_path / "tracked@example.com.json"
  70. _write_token(tracked, email="tracked@example.com", created_at="2026-03-31T12:00:00+08:00")
  71. state_file = tmp_path / "responses_survival.json"
  72. monkeypatch.setattr(
  73. "ops.responses_survival.probe_responses_token_file",
  74. lambda path, proxy, timeout: ScanResult(file=path.name, category="invalid", status_code=401, detail="no_organization"),
  75. )
  76. first = responses_survival_once(
  77. pool_dir=tmp_path,
  78. state_file=state_file,
  79. cohort_size=1,
  80. proxy=None,
  81. timeout_seconds=30,
  82. reseed=True,
  83. )
  84. first_member = first["members"][0]
  85. assert first_member["last_probe_category"] == "invalid"
  86. assert first_member["state"] == "invalid"
  87. assert first["summary"]["invalid"] == 1
  88. assert first["summary"]["missing"] == 0
  89. tracked.unlink()
  90. monkeypatch.setattr(
  91. "ops.responses_survival.probe_responses_token_file",
  92. lambda path, proxy, timeout: ScanResult(file=path.name, category="missing", status_code=None, detail=f"missing_file: {path}"),
  93. )
  94. second = responses_survival_once(
  95. pool_dir=tmp_path,
  96. state_file=state_file,
  97. cohort_size=1,
  98. proxy=None,
  99. timeout_seconds=30,
  100. reseed=False,
  101. )
  102. member = second["members"][0]
  103. assert member["last_probe_category"] == "invalid"
  104. assert member["state"] == "invalid_removed"
  105. assert member["first_invalid_at"] == first_member["first_invalid_at"]
  106. assert member["missing_at"]
  107. assert second["summary"]["invalid"] == 1
  108. assert second["summary"]["missing"] == 0
  109. assert second["summary"]["removed_after_invalid"] == 1
  110. assert second["changes"][-1]["from"] == "invalid"
  111. assert second["changes"][-1]["to"] == "invalid_removed"
  112. def test_responses_survival_keeps_first_401_terminal_after_later_transport_error(
  113. monkeypatch, tmp_path: Path
  114. ) -> None:
  115. from ops.responses_survival import responses_survival_once
  116. tracked = tmp_path / "tracked@example.com.json"
  117. _write_token(tracked, email="tracked@example.com", created_at="2026-03-31T12:00:00+08:00")
  118. state_file = tmp_path / "responses_survival.json"
  119. monkeypatch.setattr(
  120. "ops.responses_survival.probe_responses_token_file",
  121. lambda path, proxy, timeout: ScanResult(file=path.name, category="invalid", status_code=401, detail="no_organization"),
  122. )
  123. responses_survival_once(
  124. pool_dir=tmp_path,
  125. state_file=state_file,
  126. cohort_size=1,
  127. proxy=None,
  128. timeout_seconds=30,
  129. reseed=True,
  130. )
  131. monkeypatch.setattr(
  132. "ops.responses_survival.probe_responses_token_file",
  133. lambda path, proxy, timeout: ScanResult(file=path.name, category="transport_error", status_code=None, detail="tls error"),
  134. )
  135. second = responses_survival_once(
  136. pool_dir=tmp_path,
  137. state_file=state_file,
  138. cohort_size=1,
  139. proxy=None,
  140. timeout_seconds=30,
  141. reseed=False,
  142. )
  143. member = second["members"][0]
  144. assert member["last_probe_category"] == "invalid"
  145. assert member["state"] == "invalid"
  146. assert second["summary"]["invalid"] == 1
  147. assert second["summary"]["transport_error"] == 0
  148. def test_responses_survival_records_registration_provenance_and_first_use_metadata(
  149. monkeypatch,
  150. tmp_path: Path,
  151. ) -> None:
  152. from ops.responses_survival import responses_survival_once
  153. tracked = tmp_path / "tracked@example.com.json"
  154. _write_token_with_provenance(tracked, email="tracked@example.com", created_at="2026-03-31T12:00:00+08:00")
  155. state_file = tmp_path / "responses_survival.json"
  156. monkeypatch.setattr(
  157. "ops.responses_survival.probe_responses_token_file",
  158. lambda path, proxy, timeout: ScanResult(file=path.name, category="normal", status_code=200, detail="responses_ok"),
  159. )
  160. result = responses_survival_once(
  161. pool_dir=tmp_path,
  162. state_file=state_file,
  163. cohort_size=1,
  164. proxy="http://127.0.0.1:7899",
  165. timeout_seconds=30,
  166. reseed=True,
  167. )
  168. member = result["members"][0]
  169. assert member["registration_fingerprint_profile"] == "chrome120_win"
  170. assert member["registration_proxy_key"] == "台湾原生-01"
  171. assert member["registration_proxy_region"] == "tw"
  172. assert member["registration_post_create_gate"] == "add_phone"
  173. assert member["first_use_at"]
  174. assert member["first_use_age_seconds"] is not None
  175. assert member["first_use_fingerprint_profile"] == "chrome120_win"
  176. assert member["fingerprint_consistent"] is True
  177. def test_responses_survival_prefers_recent_provenance_seed(monkeypatch, tmp_path: Path) -> None:
  178. from ops.responses_survival import responses_survival_once
  179. latest_without_provenance = tmp_path / "latest@example.com.json"
  180. recent_with_provenance = tmp_path / "recent@example.com.json"
  181. _write_token(latest_without_provenance, email="latest@example.com", created_at="2026-03-31T14:04:00+08:00")
  182. _write_token_with_provenance(recent_with_provenance, email="recent@example.com", created_at="2026-03-31T14:03:30+08:00")
  183. state_file = tmp_path / "responses_survival.json"
  184. monkeypatch.setattr(
  185. "ops.responses_survival.probe_responses_token_file",
  186. lambda path, proxy, timeout: ScanResult(file=path.name, category="normal", status_code=200, detail=str(proxy or "")),
  187. )
  188. result = responses_survival_once(
  189. pool_dir=tmp_path,
  190. state_file=state_file,
  191. cohort_size=1,
  192. proxy=None,
  193. timeout_seconds=30,
  194. reseed=True,
  195. )
  196. assert [member["email"] for member in result["members"]] == ["recent@example.com"]
  197. def test_responses_survival_reuses_registration_proxy_affinity(monkeypatch, tmp_path: Path) -> None:
  198. from ops.responses_survival import responses_survival_once
  199. tracked = tmp_path / "tracked@example.com.json"
  200. _write_token_with_provenance(tracked, email="tracked@example.com", created_at="2026-03-31T12:00:00+08:00")
  201. state_file = tmp_path / "responses_survival.json"
  202. recorded: dict[str, object] = {}
  203. class FakeLease:
  204. name = "台湾原生-01"
  205. proxy_url = "socks5://127.0.0.1:17891"
  206. local_port = 17891
  207. class FakePool:
  208. def acquire(self, timeout=5.0, preferred_name=None, preferred_regions=()): # type: ignore[no-untyped-def]
  209. recorded["preferred_name"] = preferred_name
  210. recorded["preferred_regions"] = tuple(preferred_regions)
  211. return FakeLease()
  212. def release(self, lease, *, success, stage=None): # type: ignore[no-untyped-def]
  213. recorded["released"] = (lease.name, success, stage)
  214. def fake_probe(path, proxy, timeout): # type: ignore[no-untyped-def]
  215. recorded["proxy"] = proxy
  216. return ScanResult(file=path.name, category="normal", status_code=200, detail="responses_ok")
  217. monkeypatch.setattr("ops.responses_survival.probe_responses_token_file", fake_probe)
  218. result = responses_survival_once(
  219. pool_dir=tmp_path,
  220. state_file=state_file,
  221. cohort_size=1,
  222. proxy="http://127.0.0.1:7899",
  223. timeout_seconds=30,
  224. reseed=True,
  225. proxy_pool=FakePool(),
  226. )
  227. member = result["members"][0]
  228. assert recorded["preferred_name"] == "台湾原生-01"
  229. assert recorded["preferred_regions"] == ("tw",)
  230. assert recorded["proxy"] == "socks5://127.0.0.1:17891"
  231. assert member["first_use_proxy_key"] == "台湾原生-01"
  232. assert member["first_use_proxy_region"] == "tw"
  233. def test_responses_survival_marks_add_phone_warmup_passed_after_two_successful_probes(
  234. monkeypatch, tmp_path: Path
  235. ) -> None:
  236. from ops.responses_survival import responses_survival_once
  237. tracked = tmp_path / "tracked@example.com.json"
  238. _write_token_with_provenance(tracked, email="tracked@example.com", created_at="2026-03-31T12:00:00+08:00")
  239. state_file = tmp_path / "responses_survival.json"
  240. monkeypatch.setattr(
  241. "ops.responses_survival.probe_responses_token_file",
  242. lambda path, proxy, timeout: ScanResult(file=path.name, category="normal", status_code=200, detail="responses_ok"),
  243. )
  244. first = responses_survival_once(
  245. pool_dir=tmp_path,
  246. state_file=state_file,
  247. cohort_size=1,
  248. proxy=None,
  249. timeout_seconds=30,
  250. reseed=True,
  251. warmup_min_age_seconds=0,
  252. )
  253. second = responses_survival_once(
  254. pool_dir=tmp_path,
  255. state_file=state_file,
  256. cohort_size=1,
  257. proxy=None,
  258. timeout_seconds=30,
  259. reseed=False,
  260. warmup_min_age_seconds=0,
  261. )
  262. assert first["members"][0]["warmup_state"] == "pending"
  263. assert second["members"][0]["warmup_state"] == "passed"
  264. assert second["members"][0]["warmup_passed"] is True
  265. def test_responses_survival_requires_min_age_before_single_probe_promotion(
  266. monkeypatch, tmp_path: Path
  267. ) -> None:
  268. from ops.responses_survival import responses_survival_once
  269. pool_dir = tmp_path / "pool"
  270. pool_dir.mkdir()
  271. state_file = tmp_path / "responses_survival.json"
  272. created_at = datetime.now().astimezone().isoformat(timespec="seconds")
  273. token_path = pool_dir / "fresh@example.com.json"
  274. token_path.write_text(
  275. json.dumps(
  276. {
  277. "email": "fresh@example.com",
  278. "access_token": "tok",
  279. "account_id": "acct",
  280. "created_at": created_at,
  281. "registration_post_create_gate": "add_phone",
  282. "warmup_required": True,
  283. "cpa_sync_status": "warmup_pending",
  284. },
  285. ensure_ascii=False,
  286. ),
  287. encoding="utf-8",
  288. )
  289. monkeypatch.setattr(
  290. "ops.responses_survival.probe_responses_token_file",
  291. lambda *_args, **_kwargs: ScanResult(
  292. file=str(token_path),
  293. category="normal",
  294. status_code=200,
  295. detail="responses_ok",
  296. ),
  297. )
  298. result = responses_survival_once(
  299. pool_dir=pool_dir,
  300. state_file=state_file,
  301. cohort_size=1,
  302. proxy=None,
  303. timeout_seconds=10,
  304. reseed=True,
  305. warmup_min_age_seconds=90,
  306. warmup_min_successful_probes=1,
  307. )
  308. member = result["members"][0]
  309. assert member["successful_probe_count"] == 1
  310. assert member["warmup_state"] == "pending"
  311. assert member["warmup_passed"] is False
  312. def test_responses_survival_promotes_passed_warmup_account_to_cpa(monkeypatch, tmp_path: Path) -> None:
  313. from core.settings import AppSettings
  314. from ops.responses_survival import responses_survival_once
  315. tracked = tmp_path / "tracked@example.com.json"
  316. tracked.write_text(
  317. (
  318. "{\n"
  319. ' "email": "tracked@example.com",\n'
  320. ' "access_token": "tok",\n'
  321. ' "account_id": "acct",\n'
  322. ' "created_at": "2026-03-31T12:00:00+08:00",\n'
  323. ' "registration_fingerprint_profile": "chrome120_win",\n'
  324. ' "registration_proxy_key": "台湾原生-01",\n'
  325. ' "registration_proxy_region": "tw",\n'
  326. ' "registration_post_create_gate": "add_phone",\n'
  327. ' "warmup_required": true,\n'
  328. ' "warmup_state": "pending",\n'
  329. ' "warmup_passed": false,\n'
  330. ' "cpa_sync_status": "warmup_pending"\n'
  331. "}\n"
  332. ),
  333. encoding="utf-8",
  334. )
  335. state_file = tmp_path / "responses_survival.json"
  336. uploaded: list[str] = []
  337. monkeypatch.setattr(
  338. "ops.responses_survival.probe_responses_token_file",
  339. lambda path, proxy, timeout: ScanResult(file=path.name, category="normal", status_code=200, detail="responses_ok"),
  340. )
  341. monkeypatch.setattr("ops.responses_survival.get_management_key", lambda: "secret") # type: ignore[no-untyped-def]
  342. monkeypatch.setattr(
  343. "platforms.chatgpt.cpa_upload.upload_to_cpa",
  344. lambda token_data, api_url=None, api_key=None, proxy=None: uploaded.append(token_data["email"]) or (True, "ok"),
  345. )
  346. settings = AppSettings(cpa_management_base_url="http://127.0.0.1:8317/v0/management", backend="cpa")
  347. responses_survival_once(
  348. pool_dir=tmp_path,
  349. state_file=state_file,
  350. cohort_size=1,
  351. proxy=None,
  352. timeout_seconds=30,
  353. reseed=True,
  354. warmup_min_age_seconds=0,
  355. warmup_min_successful_probes=2,
  356. settings=settings,
  357. )
  358. second = responses_survival_once(
  359. pool_dir=tmp_path,
  360. state_file=state_file,
  361. cohort_size=1,
  362. proxy=None,
  363. timeout_seconds=30,
  364. reseed=False,
  365. warmup_min_age_seconds=0,
  366. warmup_min_successful_probes=2,
  367. settings=settings,
  368. )
  369. assert uploaded == ["tracked@example.com"]
  370. assert second["members"][0]["warmup_state"] == "passed"
  371. assert second["promotion_stats"]["promoted_success_total"] == 1
  372. assert second["promotion_stats"]["promoted_failure_total"] == 0
  373. persisted = json.loads(tracked.read_text(encoding="utf-8"))
  374. assert persisted["cpa_sync_status"] == "synced"
  375. third = responses_survival_once(
  376. pool_dir=tmp_path,
  377. state_file=state_file,
  378. cohort_size=1,
  379. proxy=None,
  380. timeout_seconds=30,
  381. reseed=False,
  382. warmup_min_age_seconds=0,
  383. warmup_min_successful_probes=2,
  384. settings=settings,
  385. )
  386. assert third["promotion_stats"]["promoted_success_total"] == 1
  387. assert third["promotion_stats"]["promoted_failure_total"] == 0
  388. def test_responses_survival_auto_enrolls_new_warmup_member_into_existing_cohort(
  389. monkeypatch, tmp_path: Path
  390. ) -> None:
  391. from ops.responses_survival import responses_survival_once
  392. stable = tmp_path / "stable@example.com.json"
  393. _write_token(stable, email="stable@example.com", created_at="2026-03-31T18:00:00+08:00")
  394. state_file = tmp_path / "responses_survival.json"
  395. monkeypatch.setattr(
  396. "ops.responses_survival.probe_responses_token_file",
  397. lambda path, proxy, timeout: ScanResult(file=path.name, category="normal", status_code=200, detail="responses_ok"),
  398. )
  399. first = responses_survival_once(
  400. pool_dir=tmp_path,
  401. state_file=state_file,
  402. cohort_size=1,
  403. proxy=None,
  404. timeout_seconds=30,
  405. reseed=True,
  406. )
  407. assert [member["email"] for member in first["members"]] == ["stable@example.com"]
  408. pending = tmp_path / "pending@example.com.json"
  409. pending.write_text(
  410. (
  411. "{\n"
  412. ' "email": "pending@example.com",\n'
  413. ' "access_token": "tok",\n'
  414. ' "account_id": "acct",\n'
  415. ' "created_at": "2026-03-31T18:30:00+08:00",\n'
  416. ' "registration_fingerprint_profile": "chrome120_win",\n'
  417. ' "registration_proxy_key": "台湾原生-01",\n'
  418. ' "registration_proxy_region": "tw",\n'
  419. ' "registration_post_create_gate": "add_phone",\n'
  420. ' "warmup_required": true,\n'
  421. ' "warmup_state": "pending",\n'
  422. ' "warmup_passed": false,\n'
  423. ' "cpa_sync_status": "warmup_pending"\n'
  424. "}\n"
  425. ),
  426. encoding="utf-8",
  427. )
  428. second = responses_survival_once(
  429. pool_dir=tmp_path,
  430. state_file=state_file,
  431. cohort_size=1,
  432. proxy=None,
  433. timeout_seconds=30,
  434. reseed=False,
  435. warmup_min_age_seconds=999999,
  436. warmup_min_successful_probes=2,
  437. )
  438. assert [member["email"] for member in second["members"]] == ["pending@example.com"]
  439. assert second["members"][0]["warmup_state"] == "pending"
  440. assert second["members"][0]["successful_probe_count"] == 1