test_d1_cleanup.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. from __future__ import annotations
  2. from urllib.error import HTTPError
  3. from ops import d1_cleanup
  4. def _set_cloudflare_env(monkeypatch) -> None: # type: ignore[no-untyped-def]
  5. monkeypatch.setenv("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", "user@example.com")
  6. monkeypatch.setenv("ZHUCE6_CFMAIL_CF_AUTH_KEY", "secret")
  7. monkeypatch.setenv("ZHUCE6_CFMAIL_CF_ACCOUNT_ID", "acct-123")
  8. def test_d1_cleanup_skips_missing_credentials_only_warns_once(monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
  9. monkeypatch.delenv("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", raising=False)
  10. monkeypatch.delenv("ZHUCE6_CFMAIL_CF_AUTH_KEY", raising=False)
  11. monkeypatch.delenv("ZHUCE6_CFMAIL_CF_ACCOUNT_ID", raising=False)
  12. monkeypatch.setattr(d1_cleanup, "_missing_credentials_warned", False)
  13. first = d1_cleanup.d1_cleanup_once(database_id="db-1")
  14. second = d1_cleanup.d1_cleanup_once(database_id="db-1")
  15. captured = capsys.readouterr()
  16. assert first["skipped_reason"] == "missing_cloudflare_credentials"
  17. assert second["skipped_reason"] == "missing_cloudflare_credentials"
  18. assert captured.out.count("missing Cloudflare credentials") == 1
  19. def test_d1_cleanup_skips_missing_database_id(monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
  20. _set_cloudflare_env(monkeypatch)
  21. monkeypatch.setattr(d1_cleanup, "_missing_credentials_warned", False)
  22. summary = d1_cleanup.d1_cleanup_once(database_id="")
  23. captured = capsys.readouterr()
  24. assert summary["skipped_reason"] == "missing_database_id"
  25. assert captured.out == ""
  26. def test_delete_in_batches_loops_until_changes_zero(monkeypatch) -> None: # type: ignore[no-untyped-def]
  27. _set_cloudflare_env(monkeypatch)
  28. monkeypatch.setattr(d1_cleanup, "_missing_credentials_warned", False)
  29. calls: list[tuple[str, str]] = []
  30. responses = iter(
  31. [
  32. ([], {"changes": 5000, "size_after": 9000}),
  33. ([], {"changes": 1200, "size_after": 7000}),
  34. ([], {"changes": 0, "size_after": 6800}),
  35. ]
  36. )
  37. def fake_query_once(database_id: str, sql: str, params=None): # type: ignore[no-untyped-def]
  38. calls.append((database_id, sql))
  39. return next(responses)
  40. monkeypatch.setattr(d1_cleanup, "_query_once", fake_query_once)
  41. deleted, size_after = d1_cleanup._delete_in_batches("db-1", "raw_mails", 2, 5000)
  42. assert deleted == 6200
  43. assert size_after == 6800
  44. assert len(calls) == 3
  45. assert all(database_id == "db-1" for database_id, _sql in calls)
  46. assert all("DELETE FROM raw_mails" in sql for _database_id, sql in calls)
  47. assert all("datetime('now', '-2 hours')" in sql for _database_id, sql in calls)
  48. assert all("LIMIT 5000" in sql for _database_id, sql in calls)
  49. def test_d1_cleanup_skips_when_counts_are_zero(monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
  50. _set_cloudflare_env(monkeypatch)
  51. monkeypatch.setattr(d1_cleanup, "_missing_credentials_warned", False)
  52. counts = {
  53. "raw_mails": (0, 1024),
  54. "address": (0, 2048),
  55. }
  56. monkeypatch.setattr(d1_cleanup, "_count_rows", lambda database_id, table: counts[table])
  57. monkeypatch.setattr(
  58. d1_cleanup,
  59. "_delete_in_batches",
  60. lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("delete should not run")),
  61. )
  62. summary = d1_cleanup.d1_cleanup_once(database_id="db-1")
  63. captured = capsys.readouterr()
  64. assert summary["skipped_reason"] == "nothing_to_clean"
  65. assert summary["size_after_bytes"] == 2048
  66. assert "nothing to clean" in captured.out
  67. def test_d1_cleanup_deletes_each_table_and_skips_missing_sender(monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
  68. _set_cloudflare_env(monkeypatch)
  69. monkeypatch.setattr(d1_cleanup, "_missing_credentials_warned", False)
  70. monkeypatch.setattr(
  71. d1_cleanup,
  72. "_count_rows",
  73. lambda database_id, table: (12, 4096) if table == "raw_mails" else (3, 4096),
  74. )
  75. calls: list[tuple[str, int, int]] = []
  76. def fake_delete_in_batches(database_id: str, table: str, retention_hours: int, batch_size: int): # type: ignore[no-untyped-def]
  77. calls.append((table, retention_hours, batch_size))
  78. if table == "raw_mails":
  79. return 6200, 8192
  80. if table == "address":
  81. return 120, 6144
  82. raise d1_cleanup.D1TableMissingError("no such table: address_sender")
  83. monkeypatch.setattr(d1_cleanup, "_delete_in_batches", fake_delete_in_batches)
  84. monkeypatch.setattr(d1_cleanup, "_final_size_after", lambda database_id: 5120)
  85. summary = d1_cleanup.d1_cleanup_once(
  86. database_id="db-1",
  87. mail_retention_hours=2,
  88. address_retention_hours=24,
  89. )
  90. captured = capsys.readouterr()
  91. assert summary["deleted_mails"] == 6200
  92. assert summary["deleted_addresses"] == 120
  93. assert summary["deleted_senders"] == 0
  94. assert summary["size_after_bytes"] == 5120
  95. assert summary["skipped_reason"] is None
  96. assert calls == [
  97. ("raw_mails", 2, d1_cleanup.DEFAULT_D1_CLEANUP_BATCH_SIZE),
  98. ("address", 24, d1_cleanup.DEFAULT_D1_CLEANUP_BATCH_SIZE),
  99. ("address_sender", 24, d1_cleanup.DEFAULT_D1_CLEANUP_BATCH_SIZE),
  100. ]
  101. assert "table address_sender not found, skip cleanup" in captured.out
  102. assert "清理完成 | raw_mails=-6200 | address=-120 | address_sender=-0 | size=0.0MB" in captured.out
  103. def test_query_converts_http_table_missing_into_d1_table_missing(monkeypatch) -> None: # type: ignore[no-untyped-def]
  104. _set_cloudflare_env(monkeypatch)
  105. monkeypatch.setattr(d1_cleanup, "_missing_credentials_warned", False)
  106. class FakeHttpResponse:
  107. def __init__(self, payload: bytes) -> None:
  108. self._payload = payload
  109. def read(self) -> bytes:
  110. return self._payload
  111. def close(self) -> None:
  112. return
  113. payload = (
  114. b'{"messages":[],"result":[],"success":false,'
  115. b'"errors":[{"code":7500,"message":"no such table: raw_mails: SQLITE_ERROR"}]}'
  116. )
  117. def fake_urlopen(*args, **kwargs): # type: ignore[no-untyped-def]
  118. raise HTTPError(
  119. url="https://api.cloudflare.com/client/v4/accounts/acct-123/d1/database/db-1/query",
  120. code=400,
  121. msg="Bad Request",
  122. hdrs=None,
  123. fp=FakeHttpResponse(payload),
  124. )
  125. monkeypatch.setattr(d1_cleanup, "urlopen", fake_urlopen)
  126. try:
  127. d1_cleanup._query("db-1", "SELECT COUNT(*) AS count FROM raw_mails")
  128. except d1_cleanup.D1TableMissingError as exc:
  129. assert "no such table: raw_mails" in str(exc)
  130. else:
  131. raise AssertionError("expected D1TableMissingError")