test_account_survival.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. from pathlib import Path
  2. from ops.account_survival import account_survival_once
  3. from ops.scan import ScanResult
  4. def _write_token(path: Path, *, email: str, created_at: str) -> None:
  5. path.write_text(
  6. (
  7. "{\n"
  8. f' "email": "{email}",\n'
  9. ' "access_token": "tok",\n'
  10. ' "account_id": "acct",\n'
  11. f' "created_at": "{created_at}"\n'
  12. "}\n"
  13. ),
  14. encoding="utf-8",
  15. )
  16. def test_account_survival_seeds_fixed_recent_cohort_and_records_first_401(monkeypatch, tmp_path: Path) -> None:
  17. newest = tmp_path / "newest@example.com.json"
  18. older = tmp_path / "older@example.com.json"
  19. _write_token(newest, email="newest@example.com", created_at="2026-03-26T12:00:00+08:00")
  20. _write_token(older, email="older@example.com", created_at="2026-03-26T11:59:00+08:00")
  21. state_file = tmp_path / "account_survival.json"
  22. def fake_classify(path, proxy, timeout): # type: ignore[no-untyped-def]
  23. del proxy, timeout
  24. if path.name == newest.name:
  25. return ScanResult(file=path.name, category="normal", status_code=200, detail="ok")
  26. return ScanResult(file=path.name, category="invalid", status_code=401, detail="401 invalidated")
  27. monkeypatch.setattr("ops.account_survival.classify_token_file", fake_classify)
  28. result = account_survival_once(
  29. pool_dir=tmp_path,
  30. state_file=state_file,
  31. cohort_size=2,
  32. proxy=None,
  33. timeout_seconds=15,
  34. )
  35. assert result["seeded"] is True
  36. assert [member["email"] for member in result["members"]] == ["newest@example.com", "older@example.com"]
  37. assert result["summary"]["tracked"] == 2
  38. assert result["summary"]["alive"] == 1
  39. assert result["summary"]["invalid"] == 1
  40. invalid_member = next(member for member in result["members"] if member["email"] == "older@example.com")
  41. assert invalid_member["first_invalid_at"]
  42. assert invalid_member["survival_seconds"] is not None
  43. assert invalid_member["survival_seconds"] >= 0
  44. def test_account_survival_keeps_existing_fixed_members_without_reseed(monkeypatch, tmp_path: Path) -> None:
  45. tracked = tmp_path / "tracked@example.com.json"
  46. ignored = tmp_path / "ignored@example.com.json"
  47. _write_token(tracked, email="tracked@example.com", created_at="2026-03-26T12:00:00+08:00")
  48. _write_token(ignored, email="ignored@example.com", created_at="2026-03-26T12:01:00+08:00")
  49. state_file = tmp_path / "account_survival.json"
  50. state_file.write_text(
  51. (
  52. "{\n"
  53. f' "state_file": "{state_file}",\n'
  54. f' "pool_dir": "{tmp_path}",\n'
  55. ' "cohort_size": 1,\n'
  56. ' "members": [\n'
  57. " {\n"
  58. ' "email": "tracked@example.com",\n'
  59. f' "file_name": "{tracked.name}",\n'
  60. f' "path": "{tracked}",\n'
  61. ' "created_at": "2026-03-26T12:00:00+08:00",\n'
  62. ' "selected_at": "2026-03-26T12:00:10+08:00",\n'
  63. ' "first_probe_at": "",\n'
  64. ' "last_probe_at": "",\n'
  65. ' "probe_count": 0,\n'
  66. ' "last_probe_status_code": null,\n'
  67. ' "last_probe_category": "",\n'
  68. ' "last_probe_detail": "",\n'
  69. ' "transport_error_count": 0,\n'
  70. ' "suspicious_count": 0,\n'
  71. ' "missing_at": "",\n'
  72. ' "first_invalid_at": "",\n'
  73. ' "survival_seconds": null,\n'
  74. ' "state": "tracking"\n'
  75. " }\n"
  76. " ]\n"
  77. "}\n"
  78. ),
  79. encoding="utf-8",
  80. )
  81. monkeypatch.setattr(
  82. "ops.account_survival.classify_token_file",
  83. lambda path, proxy, timeout: ScanResult(file=path.name, category="normal", status_code=200, detail="ok"),
  84. )
  85. result = account_survival_once(
  86. pool_dir=tmp_path,
  87. state_file=state_file,
  88. cohort_size=1,
  89. proxy=None,
  90. timeout_seconds=15,
  91. )
  92. assert result["seeded"] is False
  93. assert [member["email"] for member in result["members"]] == ["tracked@example.com"]
  94. def test_account_survival_reseed_replaces_members_with_latest_ten(monkeypatch, tmp_path: Path) -> None:
  95. for idx in range(12):
  96. _write_token(
  97. tmp_path / f"user{idx:02d}@example.com.json",
  98. email=f"user{idx:02d}@example.com",
  99. created_at=f"2026-03-26T12:{idx:02d}:00+08:00",
  100. )
  101. state_file = tmp_path / "account_survival.json"
  102. state_file.write_text(
  103. (
  104. "{\n"
  105. ' "seed_source": "recent_existing_pool_files",\n'
  106. ' "members": [\n'
  107. " {\n"
  108. ' "email": "legacy@example.com",\n'
  109. ' "file_name": "legacy@example.com.json",\n'
  110. f' "path": "{tmp_path / "legacy@example.com.json"}",\n'
  111. ' "created_at": "2026-03-25T12:00:00+08:00",\n'
  112. ' "selected_at": "2026-03-25T12:00:00+08:00",\n'
  113. ' "first_probe_at": "",\n'
  114. ' "last_probe_at": "",\n'
  115. ' "probe_count": 0,\n'
  116. ' "last_probe_status_code": null,\n'
  117. ' "last_probe_category": "",\n'
  118. ' "last_probe_detail": "",\n'
  119. ' "transport_error_count": 0,\n'
  120. ' "suspicious_count": 0,\n'
  121. ' "missing_at": "",\n'
  122. ' "first_invalid_at": "",\n'
  123. ' "survival_seconds": null,\n'
  124. ' "state": "tracking"\n'
  125. " }\n"
  126. " ]\n"
  127. "}\n"
  128. ),
  129. encoding="utf-8",
  130. )
  131. monkeypatch.setattr(
  132. "ops.account_survival.classify_token_file",
  133. lambda path, proxy, timeout: ScanResult(file=path.name, category="normal", status_code=200, detail="ok"),
  134. )
  135. result = account_survival_once(
  136. pool_dir=tmp_path,
  137. state_file=state_file,
  138. cohort_size=10,
  139. proxy=None,
  140. timeout_seconds=15,
  141. reseed=True,
  142. )
  143. assert result["seeded"] is True
  144. assert result["reseeded"] is True
  145. assert result["seed_source"] == "latest_generated_pool_files"
  146. assert result["summary"]["tracked"] == 10
  147. assert [member["email"] for member in result["members"]] == [
  148. "user11@example.com",
  149. "user10@example.com",
  150. "user09@example.com",
  151. "user08@example.com",
  152. "user07@example.com",
  153. "user06@example.com",
  154. "user05@example.com",
  155. "user04@example.com",
  156. "user03@example.com",
  157. "user02@example.com",
  158. ]
  159. def test_account_survival_preserves_401_semantics_after_pool_file_is_removed(monkeypatch, tmp_path: Path) -> None:
  160. tracked = tmp_path / "tracked@example.com.json"
  161. _write_token(tracked, email="tracked@example.com", created_at="2026-03-31T12:00:00+08:00")
  162. state_file = tmp_path / "account_survival.json"
  163. monkeypatch.setattr(
  164. "ops.account_survival.classify_token_file",
  165. lambda path, proxy, timeout: ScanResult(file=path.name, category="invalid", status_code=401, detail="401 invalidated"),
  166. )
  167. first = account_survival_once(
  168. pool_dir=tmp_path,
  169. state_file=state_file,
  170. cohort_size=1,
  171. proxy=None,
  172. timeout_seconds=15,
  173. reseed=True,
  174. )
  175. first_member = first["members"][0]
  176. assert first_member["last_probe_category"] == "invalid"
  177. assert first_member["state"] == "invalid"
  178. tracked.unlink()
  179. monkeypatch.setattr(
  180. "ops.account_survival.classify_token_file",
  181. lambda path, proxy, timeout: ScanResult(file=path.name, category="missing", status_code=None, detail=f"missing_file: {path}"),
  182. )
  183. second = account_survival_once(
  184. pool_dir=tmp_path,
  185. state_file=state_file,
  186. cohort_size=1,
  187. proxy=None,
  188. timeout_seconds=15,
  189. reseed=False,
  190. )
  191. member = second["members"][0]
  192. assert member["last_probe_category"] == "invalid"
  193. assert member["state"] == "invalid_removed"
  194. assert member["missing_at"]
  195. assert second["summary"]["invalid"] == 1
  196. assert second["summary"]["missing"] == 0
  197. assert second["summary"]["removed_after_invalid"] == 1
  198. assert second["changes"][-1]["from"] == "invalid"
  199. assert second["changes"][-1]["to"] == "invalid_removed"
  200. def test_account_survival_keeps_first_401_terminal_after_later_transport_error(monkeypatch, tmp_path: Path) -> None:
  201. tracked = tmp_path / "tracked@example.com.json"
  202. _write_token(tracked, email="tracked@example.com", created_at="2026-03-31T12:00:00+08:00")
  203. state_file = tmp_path / "account_survival.json"
  204. monkeypatch.setattr(
  205. "ops.account_survival.classify_token_file",
  206. lambda path, proxy, timeout: ScanResult(file=path.name, category="invalid", status_code=401, detail="401 invalidated"),
  207. )
  208. account_survival_once(
  209. pool_dir=tmp_path,
  210. state_file=state_file,
  211. cohort_size=1,
  212. proxy=None,
  213. timeout_seconds=15,
  214. reseed=True,
  215. )
  216. monkeypatch.setattr(
  217. "ops.account_survival.classify_token_file",
  218. lambda path, proxy, timeout: ScanResult(file=path.name, category="transport_error", status_code=None, detail="tls error"),
  219. )
  220. second = account_survival_once(
  221. pool_dir=tmp_path,
  222. state_file=state_file,
  223. cohort_size=1,
  224. proxy=None,
  225. timeout_seconds=15,
  226. reseed=False,
  227. )
  228. member = second["members"][0]
  229. assert member["last_probe_category"] == "invalid"
  230. assert member["state"] == "invalid"
  231. assert second["summary"]["invalid"] == 1
  232. assert second["summary"]["transport_error"] == 0