test_store.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. """Unit tests for cq2_sdk.store (UserStore + RunStore).
  2. Strategy: mock pymysql.connections.Connection with a fake cursor that
  3. records SQL calls and returns canned rows. This avoids needing a real
  4. MySQL server to run unit tests (the demo script covers the real case).
  5. """
  6. from __future__ import annotations
  7. from contextlib import contextmanager
  8. from datetime import datetime
  9. from typing import Any
  10. from unittest.mock import MagicMock
  11. import pytest
  12. from cq2_sdk.db_models import Run, Server, TaskLog, User, Zone
  13. from cq2_sdk.store import (
  14. RunStore,
  15. ServerStore,
  16. TaskLogStore,
  17. UserStore,
  18. ZoneStore,
  19. connection,
  20. get_connection,
  21. )
  22. # ---------- fakes ----------
  23. class FakeCursor:
  24. """Records execute() calls and returns pre-canned rows.
  25. Use ``script.fetchone_return = {...}`` to control what fetchone() returns.
  26. Use ``script.fetchall_return = [{...}, ...]`` to control fetchall().
  27. Use ``script.lastrowid = N`` to set INSERT id.
  28. Use ``script.execute_calls`` to assert SQL was what you expected.
  29. """
  30. def __init__(self) -> None:
  31. self.execute_calls: list[tuple[str, tuple | None]] = []
  32. self.executemany_calls: list[tuple[str, list | tuple]] = []
  33. self.fetchone_return: dict | None = None
  34. self.fetchall_return: list[dict] = []
  35. self.lastrowid: int | None = None
  36. self.rowcount: int = 0
  37. def __enter__(self) -> "FakeCursor":
  38. return self
  39. def __exit__(self, *exc) -> None:
  40. pass
  41. def execute(self, sql: str, params: tuple | None = None) -> None:
  42. self.execute_calls.append((sql, params))
  43. def executemany(self, sql: str, rows) -> None:
  44. self.executemany_calls.append((sql, rows))
  45. def fetchone(self) -> dict | None:
  46. # If execute() was a SELECT, return the next fetchone return
  47. return self.fetchone_return
  48. def fetchall(self) -> list[dict]:
  49. return list(self.fetchall_return)
  50. class FakeConn:
  51. def __init__(self) -> None:
  52. self.cursor_obj = FakeCursor()
  53. self.closed = False
  54. def cursor(self) -> FakeCursor:
  55. return self.cursor_obj
  56. def close(self) -> None:
  57. self.closed = True
  58. # ---------- UserStore tests ----------
  59. def test_upsert_by_username_calls_insert_with_correct_params():
  60. conn = FakeConn()
  61. conn.cursor_obj.lastrowid = 1
  62. conn.cursor_obj.fetchone_return = {
  63. "id": 1, "username": "alice", "phone": None, "password_hash": None,
  64. "token": "T1", "token_expires_at": None,
  65. "last_login_at": datetime(2026, 9, 16, 10, 0),
  66. "created_at": datetime(2026, 9, 16, 9, 0),
  67. }
  68. store = UserStore(conn)
  69. user = store.upsert_by_username("alice", "T1")
  70. assert user.id == 1
  71. assert user.username == "alice"
  72. # Should be 2 execute calls: INSERT, then SELECT to fetch back
  73. assert len(conn.cursor_obj.execute_calls) == 2
  74. insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
  75. assert "INSERT INTO users" in insert_sql
  76. assert "ON DUPLICATE KEY UPDATE" in insert_sql
  77. # params: (username, password, phone, token, token_expires_at)
  78. assert insert_params == ("alice", None, None, "T1", None)
  79. # 2nd call is the SELECT to fetch back the row
  80. select_sql, select_params = conn.cursor_obj.execute_calls[1]
  81. assert "SELECT * FROM users WHERE username = %s" in select_sql
  82. assert select_params == ("alice",)
  83. def test_upsert_by_username_with_password_persists_it():
  84. """新参数 password 会被写到 INSERT, 且 update 时不覆盖已有 password。"""
  85. conn = FakeConn()
  86. conn.cursor_obj.fetchone_return = {
  87. "id": 1, "username": "alice", "phone": None,
  88. "password_hash": "PWD_ENC_xxx", "token": "T1",
  89. "token_expires_at": None,
  90. "last_login_at": None, "created_at": None,
  91. }
  92. store = UserStore(conn)
  93. store.upsert_by_username("alice", "T1", password="PWD_ENC_xxx")
  94. insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
  95. # password 在 params 里, 第 2 个位置 (username, password, phone, token, token_expires_at)
  96. assert insert_params[1] == "PWD_ENC_xxx"
  97. # update 子句里用 COALESCE 保护已有 password
  98. assert "COALESCE(VALUES(password_hash), password_hash)" in insert_sql
  99. def test_upsert_by_username_with_phone_persists_it():
  100. """新参数 phone 跟 password 一样, UPDATE 路径用 COALESCE 保护已有 phone。"""
  101. conn = FakeConn()
  102. conn.cursor_obj.fetchone_return = {
  103. "id": 1, "username": "alice", "phone": "13800138000",
  104. "password_hash": None, "token": "T1",
  105. "token_expires_at": None,
  106. "last_login_at": None, "created_at": None,
  107. }
  108. store = UserStore(conn)
  109. store.upsert_by_username("alice", "T1", phone="13800138000")
  110. insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
  111. # phone 在 params 第 3 个位置
  112. assert insert_params[2] == "13800138000"
  113. # update 子句里用 COALESCE 保护已有 phone
  114. assert "COALESCE(VALUES(phone), phone)" in insert_sql
  115. def test_userstore_update_token_preserves_other_fields():
  116. """update_token 只刷 token/expires/last_login, 其它字段 (username/phone/password) 保留。
  117. 用于 A2: SMS 登录后用现有 user.id update token, 不重置其他认证字段。
  118. """
  119. conn = FakeConn()
  120. conn.cursor_obj.fetchone_return = {
  121. "id": 5, "username": "longwu988", "phone": "17756576757",
  122. "password_hash": "Longwu1988",
  123. "token": "NEW_SMS_TOKEN_xxxx", "token_expires_at": None,
  124. "last_login_at": datetime(2026, 9, 16, 17, 0),
  125. "created_at": datetime(2026, 9, 16, 15, 0),
  126. }
  127. users = UserStore(conn)
  128. u = users.update_token(user_id=5, token="NEW_SMS_TOKEN_xxxx")
  129. assert u.id == 5
  130. assert u.token == "NEW_SMS_TOKEN_xxxx"
  131. assert u.username == "longwu988" # 没被覆盖
  132. assert u.phone == "17756576757" # 没被覆盖
  133. assert u.password_hash == "Longwu1988" # 没被覆盖
  134. # SQL 应该是 UPDATE users (不是 runs — 之前错位进过 RunStore, 回归保护)
  135. sql, params = conn.cursor_obj.execute_calls[0]
  136. assert sql.strip().startswith("UPDATE users")
  137. assert "token = %s" in sql
  138. assert "last_login_at = CURRENT_TIMESTAMP" in sql
  139. # params: (token, token_expires_at, user_id)
  140. assert params[0] == "NEW_SMS_TOKEN_xxxx"
  141. assert params[2] == 5
  142. # 接着有个 SELECT 拉回 row
  143. assert len(conn.cursor_obj.execute_calls) == 2
  144. def test_userstore_update_token_raises_if_user_not_found():
  145. """UPDATE 后 SELECT 拉不回来 → RuntimeError, 不静默吞错。"""
  146. conn = FakeConn()
  147. conn.cursor_obj.fetchone_return = None # 模拟 row 不存在
  148. users = UserStore(conn)
  149. with pytest.raises(RuntimeError, match="UPDATE succeeded but row not found"):
  150. users.update_token(user_id=999, token="xxx")
  151. def test_upsert_by_phone_uses_phone_key():
  152. conn = FakeConn()
  153. conn.cursor_obj.fetchone_return = {
  154. "id": 2, "username": None, "phone": "13800138000", "token": "T2",
  155. "token_expires_at": None, "last_login_at": None, "created_at": None,
  156. }
  157. store = UserStore(conn)
  158. user = store.upsert_by_phone("13800138000", "T2")
  159. assert user.id == 2
  160. assert user.phone == "13800138000"
  161. insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
  162. assert "phone" in insert_sql
  163. assert insert_params[0] == "13800138000"
  164. def test_get_by_username_returns_user_when_found():
  165. conn = FakeConn()
  166. conn.cursor_obj.fetchone_return = {
  167. "id": 5, "username": "bob", "phone": None, "token": "T5",
  168. "token_expires_at": None, "last_login_at": None, "created_at": None,
  169. }
  170. store = UserStore(conn)
  171. user = store.get_by_username("bob")
  172. assert user is not None
  173. assert user.id == 5
  174. sql, params = conn.cursor_obj.execute_calls[0]
  175. assert "WHERE username = %s" in sql
  176. assert params == ("bob",)
  177. def test_get_by_username_returns_none_when_not_found():
  178. conn = FakeConn()
  179. conn.cursor_obj.fetchone_return = None
  180. store = UserStore(conn)
  181. assert store.get_by_username("nobody") is None
  182. def test_upsert_raises_if_row_not_found_after_insert():
  183. conn = FakeConn()
  184. conn.cursor_obj.fetchone_return = None # simulate race / DB inconsistency
  185. store = UserStore(conn)
  186. with pytest.raises(RuntimeError, match="upsert succeeded but row not found"):
  187. store.upsert_by_username("alice", "T1")
  188. # ---------- RunStore tests ----------
  189. def test_create_run_inserts_and_returns():
  190. conn = FakeConn()
  191. conn.cursor_obj.lastrowid = 42
  192. # After INSERT, create() calls get(42), which needs fetchone
  193. conn.cursor_obj.fetchone_return = {
  194. "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
  195. "started_at": datetime(2026, 9, 16, 10, 0),
  196. "completed_at": None, "current_task": None, "status": "running",
  197. "snapshot_path": None,
  198. }
  199. store = RunStore(conn)
  200. run = store.create(user_id=1, server_id=2, zone_id=3)
  201. assert run.id == 42
  202. assert run.status == "running"
  203. # First call: INSERT
  204. insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
  205. assert "INSERT INTO runs" in insert_sql
  206. assert insert_params == (1, 2, 3, "running")
  207. # Second call: SELECT WHERE id = ... (uses the lastrowid)
  208. select_sql, _ = conn.cursor_obj.execute_calls[1]
  209. assert "SELECT * FROM runs WHERE id = %s" in select_sql
  210. def test_update_status_only_sets_status_when_no_extras():
  211. conn = FakeConn()
  212. conn.cursor_obj.fetchone_return = {
  213. "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
  214. "started_at": datetime(2026, 9, 16, 10, 0),
  215. "completed_at": None, "current_task": "t01", "status": "running",
  216. "snapshot_path": None,
  217. }
  218. store = RunStore(conn)
  219. store.update_status(42, "running")
  220. sql, params = conn.cursor_obj.execute_calls[0]
  221. assert sql == "UPDATE runs SET status = %s WHERE id = %s"
  222. assert tuple(params) == ("running", 42) # tuple() to normalize list vs tuple
  223. def test_update_status_with_current_task_and_explicit_completed_at():
  224. conn = FakeConn()
  225. conn.cursor_obj.fetchone_return = {
  226. "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
  227. "started_at": None, "completed_at": datetime(2026, 9, 16, 11, 0),
  228. "current_task": "t05", "status": "running",
  229. "snapshot_path": None,
  230. }
  231. store = RunStore(conn)
  232. store.update_status(42, "running", current_task="t05",
  233. completed_at=datetime(2026, 9, 16, 11, 0))
  234. sql, params = conn.cursor_obj.execute_calls[0]
  235. assert "status = %s" in sql
  236. assert "current_task = %s" in sql
  237. assert "completed_at = %s" in sql
  238. assert params == ["running", "t05", datetime(2026, 9, 16, 11, 0), 42]
  239. def test_update_status_auto_sets_completed_at_for_terminal_status():
  240. """When status flips to success/failed, completed_at defaults to NOW()
  241. unless the caller passes one explicitly."""
  242. conn = FakeConn()
  243. conn.cursor_obj.fetchone_return = {
  244. "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
  245. "started_at": None, "completed_at": datetime(2026, 9, 16, 11, 0),
  246. "current_task": None, "status": "success", "snapshot_path": None,
  247. }
  248. store = RunStore(conn)
  249. store.update_status(42, "success")
  250. sql, params = conn.cursor_obj.execute_calls[0]
  251. assert "completed_at = CURRENT_TIMESTAMP" in sql
  252. assert tuple(params) == ("success", 42)
  253. def test_update_status_does_not_touch_completed_at_for_running():
  254. conn = FakeConn()
  255. conn.cursor_obj.fetchone_return = {
  256. "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
  257. "started_at": None, "completed_at": None,
  258. "current_task": "t03", "status": "running", "snapshot_path": None,
  259. }
  260. store = RunStore(conn)
  261. store.update_status(42, "running", current_task="t03")
  262. sql, params = conn.cursor_obj.execute_calls[0]
  263. # "running" is not terminal, so completed_at clause should NOT appear
  264. assert "completed_at" not in sql
  265. assert tuple(params) == ("running", "t03", 42)
  266. def test_list_recent_returns_typed_runs():
  267. conn = FakeConn()
  268. conn.cursor_obj.fetchall_return = [
  269. {"id": 2, "user_id": 1, "server_id": 2, "zone_id": 3,
  270. "started_at": datetime(2026, 9, 16, 11, 0),
  271. "completed_at": None, "current_task": "t10", "status": "running",
  272. "snapshot_path": None},
  273. {"id": 1, "user_id": 1, "server_id": 2, "zone_id": 3,
  274. "started_at": datetime(2026, 9, 16, 10, 0),
  275. "completed_at": datetime(2026, 9, 16, 10, 30),
  276. "current_task": None, "status": "success", "snapshot_path": None},
  277. ]
  278. store = RunStore(conn)
  279. runs = store.list_recent(user_id=1, limit=10)
  280. assert len(runs) == 2
  281. assert all(isinstance(r, Run) for r in runs)
  282. assert runs[0].id == 2
  283. assert runs[1].id == 1
  284. sql, params = conn.cursor_obj.execute_calls[0]
  285. assert "ORDER BY started_at DESC" in sql
  286. assert params == (1, 10)
  287. # ---------- connection helpers ----------
  288. def test_connection_context_manager_closes(monkeypatch):
  289. """connection() should call conn.close() on exit."""
  290. fake = FakeConn()
  291. # Patch get_connection to return our fake
  292. import cq2_sdk.store as store_mod
  293. monkeypatch.setattr(store_mod, "get_connection", lambda: fake)
  294. with connection() as c:
  295. assert c is fake
  296. assert fake.closed is True
  297. def test_get_connection_reads_env(monkeypatch):
  298. """get_connection() must read CQ2_DB_* env vars."""
  299. monkeypatch.setenv("CQ2_DB_HOST", "h")
  300. monkeypatch.setenv("CQ2_DB_PORT", "3306")
  301. monkeypatch.setenv("CQ2_DB_USER", "u")
  302. monkeypatch.setenv("CQ2_DB_PASSWORD", "p")
  303. monkeypatch.setenv("CQ2_DB_NAME", "n")
  304. captured: dict[str, Any] = {}
  305. def fake_pymysql_connect(**kwargs):
  306. captured.update(kwargs)
  307. return MagicMock()
  308. import pymysql
  309. monkeypatch.setattr(pymysql, "connect", fake_pymysql_connect)
  310. get_connection()
  311. assert captured["host"] == "h"
  312. assert captured["port"] == 3306
  313. assert captured["charset"] == "utf8mb4"
  314. assert captured["autocommit"] is True
  315. assert captured["cursorclass"] is pymysql.cursors.DictCursor
  316. # ---------- ServerStore tests ----------
  317. def test_server_upsert_inserts_then_fetches():
  318. conn = FakeConn()
  319. conn.cursor_obj.fetchone_return = {
  320. "id": 7, "server_name": "玖扬复古176", "server_id": 14414738,
  321. "game_id": 206923, "first_seen_at": datetime(2026, 9, 16, 9, 0),
  322. }
  323. store = ServerStore(conn)
  324. srv = store.upsert("玖扬复古176", 14414738)
  325. assert srv.id == 7
  326. assert srv.server_name == "玖扬复古176"
  327. assert srv.server_id == 14414738
  328. insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
  329. assert "INSERT INTO servers" in insert_sql
  330. assert "ON DUPLICATE KEY UPDATE" in insert_sql
  331. assert insert_params == ("玖扬复古176", 14414738, 206923)
  332. def test_server_get_by_name_found_and_not_found():
  333. conn = FakeConn()
  334. conn.cursor_obj.fetchone_return = {
  335. "id": 1, "server_name": "x", "server_id": 1, "game_id": 206923,
  336. "first_seen_at": None,
  337. }
  338. store = ServerStore(conn)
  339. assert store.get_by_name("x") is not None
  340. conn.cursor_obj.fetchone_return = None
  341. assert store.get_by_name("y") is None
  342. def test_server_get_by_server_id_distinguishes_from_db_pk():
  343. """get_by_server_id uses 996box's numeric id, get_by_id uses local PK."""
  344. conn = FakeConn()
  345. conn.cursor_obj.fetchone_return = {
  346. "id": 99, "server_name": "x", "server_id": 12345, "game_id": 206923,
  347. "first_seen_at": None,
  348. }
  349. store = ServerStore(conn)
  350. srv = store.get_by_server_id(12345)
  351. assert srv is not None
  352. assert srv.id == 99
  353. sql, params = conn.cursor_obj.execute_calls[0]
  354. assert "WHERE server_id = %s" in sql
  355. assert params == (12345,)
  356. def test_server_list_all_returns_sorted_servers():
  357. conn = FakeConn()
  358. conn.cursor_obj.fetchall_return = [
  359. {"id": 1, "server_name": "a", "server_id": 1, "game_id": 206923,
  360. "first_seen_at": None},
  361. {"id": 2, "server_name": "b", "server_id": 2, "game_id": 206923,
  362. "first_seen_at": None},
  363. ]
  364. store = ServerStore(conn)
  365. servers = store.list_all()
  366. assert len(servers) == 2
  367. assert all(isinstance(s, Server) for s in servers)
  368. sql, _ = conn.cursor_obj.execute_calls[0]
  369. assert "ORDER BY server_name" in sql
  370. # ---------- ZoneStore tests ----------
  371. def test_zone_bulk_upsert_empty_list_is_noop():
  372. conn = FakeConn()
  373. store = ZoneStore(conn)
  374. assert store.bulk_upsert(server_id=1, zones=[]) == 0
  375. assert conn.cursor_obj.execute_calls == [] # no SQL executed
  376. def test_zone_bulk_upsert_uses_executemany_with_on_duplicate():
  377. conn = FakeConn()
  378. conn.cursor_obj.rowcount = 3
  379. store = ZoneStore(conn)
  380. zones = [
  381. Zone(zone_id="z1", zone_name="Zone 1", host="h1", port=1,
  382. open_time=datetime(2026, 9, 16, 7, 0), status="open"),
  383. Zone(zone_id="z2", zone_name="Zone 2", host="h2", port=2,
  384. open_time=datetime(2026, 9, 16, 10, 0), status="open"),
  385. Zone(zone_id="z3", zone_name="Zone 3", host="h3", port=3,
  386. open_time=datetime(2026, 9, 16, 19, 0), status="open"),
  387. ]
  388. n = store.bulk_upsert(server_id=42, zones=zones)
  389. assert n == 3
  390. assert len(conn.cursor_obj.executemany_calls) == 1
  391. sql, rows = conn.cursor_obj.executemany_calls[0]
  392. assert "INSERT INTO zones" in sql
  393. assert "ON DUPLICATE KEY UPDATE" in sql
  394. assert len(rows) == 3
  395. assert rows[0] == (
  396. 42, "z1", "Zone 1", "h1", 1, zones[0].open_time,
  397. None, None, None, None, "open",
  398. )
  399. def test_zone_bulk_upsert_defaults_status_to_open():
  400. conn = FakeConn()
  401. conn.cursor_obj.rowcount = 1
  402. store = ZoneStore(conn)
  403. zones = [Zone(zone_id="z1", host="h", port=1,
  404. open_time=datetime(2026, 9, 16, 19, 0))]
  405. store.bulk_upsert(server_id=1, zones=zones)
  406. _, rows = conn.cursor_obj.executemany_calls[0]
  407. # status defaulted to "open" when None
  408. assert rows[0][10] == "open"
  409. def test_zone_get_today_open_uses_curdate():
  410. conn = FakeConn()
  411. conn.cursor_obj.fetchall_return = [
  412. {"id": 1, "server_id": 1, "zone_id": "z", "zone_name": "Z",
  413. "host": "h", "port": 1, "open_time": datetime(2026, 9, 16, 7, 0),
  414. "status": "open", "fetched_at": None},
  415. ]
  416. store = ZoneStore(conn)
  417. zones = store.get_today_open(server_id=1)
  418. assert len(zones) == 1
  419. assert isinstance(zones[0], Zone)
  420. sql, params = conn.cursor_obj.execute_calls[0]
  421. assert "DATE(open_time) = CURDATE()" in sql
  422. assert "ORDER BY open_time ASC" in sql
  423. assert params == (1,)
  424. def test_zone_get_latest_openable_filters_and_orders_desc():
  425. """B3: status=open AND open_time <= NOW(), ordered DESC, LIMIT 1."""
  426. conn = FakeConn()
  427. conn.cursor_obj.fetchone_return = {
  428. "id": 5, "server_id": 1, "zone_id": "z_latest", "zone_name": "Z",
  429. "host": "h", "port": 1, "open_time": datetime(2026, 9, 16, 19, 0),
  430. "status": "open", "fetched_at": None,
  431. }
  432. store = ZoneStore(conn)
  433. zone = store.get_latest_openable(server_id=1)
  434. assert zone is not None
  435. assert zone.id == 5
  436. assert zone.zone_id == "z_latest"
  437. sql, params = conn.cursor_obj.execute_calls[0]
  438. assert "status = 'open'" in sql
  439. assert "open_time <= NOW()" in sql
  440. assert "ORDER BY open_time DESC" in sql
  441. assert "LIMIT 1" in sql
  442. assert params == (1,)
  443. def test_zone_get_latest_openable_returns_none_when_none():
  444. conn = FakeConn()
  445. conn.cursor_obj.fetchone_return = None
  446. store = ZoneStore(conn)
  447. assert store.get_latest_openable(server_id=999) is None
  448. def test_zone_mark_missing_closed_with_keep_set():
  449. conn = FakeConn()
  450. conn.cursor_obj.rowcount = 2
  451. store = ZoneStore(conn)
  452. n = store.mark_missing_closed(
  453. server_id=1, keep_zone_ids={"z1", "z2"}, today_only=True
  454. )
  455. assert n == 2
  456. sql, params = conn.cursor_obj.execute_calls[0]
  457. assert "UPDATE zones SET status = 'closed'" in sql
  458. assert "zone_id NOT IN" in sql
  459. assert "DATE(open_time) = CURDATE()" in sql
  460. # set iteration order is non-deterministic; compare as sorted tuple
  461. assert tuple(params) == (1, "z1", "z2") or tuple(params) == (1, "z2", "z1")
  462. def test_zone_mark_missing_closed_with_empty_keep_closes_all():
  463. """If API returns no zones today, close all of today's open ones."""
  464. conn = FakeConn()
  465. conn.cursor_obj.rowcount = 5
  466. store = ZoneStore(conn)
  467. n = store.mark_missing_closed(server_id=1, keep_zone_ids=set())
  468. assert n == 5
  469. sql, params = conn.cursor_obj.execute_calls[0]
  470. assert "NOT IN" not in sql # no IN clause when keep set is empty
  471. assert "DATE(open_time) = CURDATE()" in sql
  472. assert params == (1,)
  473. def test_zone_mark_missing_closed_can_skip_today_filter():
  474. conn = FakeConn()
  475. conn.cursor_obj.rowcount = 1
  476. store = ZoneStore(conn)
  477. n = store.mark_missing_closed(
  478. server_id=1, keep_zone_ids=set(), today_only=False
  479. )
  480. assert n == 1
  481. sql, _ = conn.cursor_obj.execute_calls[0]
  482. assert "CURDATE" not in sql
  483. # ---------- TaskLogStore tests ----------
  484. def test_tasklog_append_writes_correct_columns():
  485. conn = FakeConn()
  486. conn.cursor_obj.lastrowid = 100
  487. conn.cursor_obj.fetchone_return = {
  488. "id": 100, "run_id": 1, "task_id": "t01", "success": 1,
  489. "error": None, "elapsed_seconds": 12.5,
  490. "started_at": datetime(2026, 9, 16, 10, 0),
  491. "finished_at": datetime(2026, 9, 16, 10, 0, 12, 500_000),
  492. }
  493. store = TaskLogStore(conn)
  494. log = store.append(run_id=1, task_id="t01", success=True,
  495. elapsed_seconds=12.5)
  496. assert log.id == 100
  497. assert log.task_id == "t01"
  498. assert log.success is True
  499. sql, params = conn.cursor_obj.execute_calls[0]
  500. assert "INSERT INTO task_logs" in sql
  501. assert "COALESCE(%s, CURRENT_TIMESTAMP)" in sql
  502. # params: run_id, task_id, success (0/1), error, elapsed, started, finished
  503. assert params == (1, "t01", 1, None, 12.5, None, None)
  504. def test_tasklog_append_converts_success_bool_to_tinyint():
  505. conn = FakeConn()
  506. conn.cursor_obj.lastrowid = 1
  507. conn.cursor_obj.fetchone_return = {
  508. "id": 1, "run_id": 1, "task_id": "t02", "success": 0,
  509. "error": "boom", "elapsed_seconds": None,
  510. "started_at": None, "finished_at": None,
  511. }
  512. TaskLogStore(conn).append(run_id=1, task_id="t02", success=False,
  513. error="boom")
  514. _, params = conn.cursor_obj.execute_calls[0]
  515. # success=False → 0 in SQL
  516. assert params[2] == 0
  517. def test_tasklog_append_passes_explicit_timestamps():
  518. conn = FakeConn()
  519. conn.cursor_obj.lastrowid = 1
  520. conn.cursor_obj.fetchone_return = {
  521. "id": 1, "run_id": 1, "task_id": "t01", "success": 1,
  522. "error": None, "elapsed_seconds": 1.0,
  523. "started_at": datetime(2026, 9, 16, 9, 0),
  524. "finished_at": datetime(2026, 9, 16, 9, 1),
  525. }
  526. started = datetime(2026, 9, 16, 9, 0)
  527. finished = datetime(2026, 9, 16, 9, 1)
  528. TaskLogStore(conn).append(run_id=1, task_id="t01", success=True,
  529. elapsed_seconds=1.0,
  530. started_at=started, finished_at=finished)
  531. _, params = conn.cursor_obj.execute_calls[0]
  532. assert params[5] == started
  533. assert params[6] == finished
  534. def test_tasklog_list_for_run_orders_ascending():
  535. conn = FakeConn()
  536. conn.cursor_obj.fetchall_return = [
  537. {"id": 1, "run_id": 5, "task_id": "t01", "success": 1,
  538. "error": None, "elapsed_seconds": 1.0,
  539. "started_at": datetime(2026, 9, 16, 10, 0),
  540. "finished_at": datetime(2026, 9, 16, 10, 0, 1)},
  541. {"id": 2, "run_id": 5, "task_id": "t02", "success": 1,
  542. "error": None, "elapsed_seconds": 1.0,
  543. "started_at": datetime(2026, 9, 16, 10, 1),
  544. "finished_at": datetime(2026, 9, 16, 10, 1, 1)},
  545. ]
  546. store = TaskLogStore(conn)
  547. logs = store.list_for_run(run_id=5)
  548. assert len(logs) == 2
  549. assert all(isinstance(l, TaskLog) for l in logs)
  550. sql, params = conn.cursor_obj.execute_calls[0]
  551. assert "ORDER BY started_at ASC, id ASC" in sql
  552. assert params == (5,)
  553. def test_tasklog_list_for_run_task_returns_latest():
  554. conn = FakeConn()
  555. conn.cursor_obj.fetchone_return = {
  556. "id": 7, "run_id": 5, "task_id": "t01", "success": 0,
  557. "error": "first attempt failed", "elapsed_seconds": 0.5,
  558. "started_at": datetime(2026, 9, 16, 10, 0),
  559. "finished_at": datetime(2026, 9, 16, 10, 0, 0, 500_000),
  560. }
  561. log = TaskLogStore(conn).list_for_run_task(run_id=5, task_id="t01")
  562. assert log is not None
  563. assert log.id == 7
  564. assert log.success is False
  565. sql, params = conn.cursor_obj.execute_calls[0]
  566. assert "ORDER BY id DESC LIMIT 1" in sql
  567. assert params == (5, "t01")
  568. def test_tasklog_count_for_run_no_filter():
  569. conn = FakeConn()
  570. conn.cursor_obj.fetchone_return = {"n": 13}
  571. n = TaskLogStore(conn).count_for_run(run_id=5)
  572. assert n == 13
  573. sql, params = conn.cursor_obj.execute_calls[0]
  574. assert "COUNT(*)" in sql
  575. assert "success" not in sql # no success filter
  576. assert params == (5,)
  577. def test_tasklog_count_for_run_success_true():
  578. conn = FakeConn()
  579. conn.cursor_obj.fetchone_return = {"n": 8}
  580. n = TaskLogStore(conn).count_for_run(run_id=5, success=True)
  581. assert n == 8
  582. sql, params = conn.cursor_obj.execute_calls[0]
  583. assert "success = %s" in sql
  584. # success=True → 1
  585. assert params == (5, 1)
  586. def test_tasklog_count_for_run_success_false():
  587. conn = FakeConn()
  588. conn.cursor_obj.fetchone_return = {"n": 5}
  589. n = TaskLogStore(conn).count_for_run(run_id=5, success=False)
  590. assert n == 5
  591. _, params = conn.cursor_obj.execute_calls[0]
  592. assert params == (5, 0) # success=False → 0
  593. def test_tasklog_completed_task_ids_returns_set():
  594. conn = FakeConn()
  595. conn.cursor_obj.fetchall_return = [
  596. {"task_id": "t01"},
  597. {"task_id": "t02"},
  598. {"task_id": "t05"},
  599. ]
  600. completed = TaskLogStore(conn).completed_task_ids(run_id=5)
  601. assert completed == {"t01", "t02", "t05"}
  602. sql, params = conn.cursor_obj.execute_calls[0]
  603. assert "success = 1" in sql
  604. assert params == (5,)
  605. def test_tasklog_completed_task_ids_empty():
  606. conn = FakeConn()
  607. conn.cursor_obj.fetchall_return = []
  608. assert TaskLogStore(conn).completed_task_ids(run_id=999) == set()