| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722 |
- """Unit tests for cq2_sdk.store (UserStore + RunStore).
- Strategy: mock pymysql.connections.Connection with a fake cursor that
- records SQL calls and returns canned rows. This avoids needing a real
- MySQL server to run unit tests (the demo script covers the real case).
- """
- from __future__ import annotations
- from contextlib import contextmanager
- from datetime import datetime
- from typing import Any
- from unittest.mock import MagicMock
- import pytest
- from cq2_sdk.db_models import Run, Server, TaskLog, User, Zone
- from cq2_sdk.store import (
- RunStore,
- ServerStore,
- TaskLogStore,
- UserStore,
- ZoneStore,
- connection,
- get_connection,
- )
- # ---------- fakes ----------
- class FakeCursor:
- """Records execute() calls and returns pre-canned rows.
- Use ``script.fetchone_return = {...}`` to control what fetchone() returns.
- Use ``script.fetchall_return = [{...}, ...]`` to control fetchall().
- Use ``script.lastrowid = N`` to set INSERT id.
- Use ``script.execute_calls`` to assert SQL was what you expected.
- """
- def __init__(self) -> None:
- self.execute_calls: list[tuple[str, tuple | None]] = []
- self.executemany_calls: list[tuple[str, list | tuple]] = []
- self.fetchone_return: dict | None = None
- self.fetchall_return: list[dict] = []
- self.lastrowid: int | None = None
- self.rowcount: int = 0
- def __enter__(self) -> "FakeCursor":
- return self
- def __exit__(self, *exc) -> None:
- pass
- def execute(self, sql: str, params: tuple | None = None) -> None:
- self.execute_calls.append((sql, params))
- def executemany(self, sql: str, rows) -> None:
- self.executemany_calls.append((sql, rows))
- def fetchone(self) -> dict | None:
- # If execute() was a SELECT, return the next fetchone return
- return self.fetchone_return
- def fetchall(self) -> list[dict]:
- return list(self.fetchall_return)
- class FakeConn:
- def __init__(self) -> None:
- self.cursor_obj = FakeCursor()
- self.closed = False
- def cursor(self) -> FakeCursor:
- return self.cursor_obj
- def close(self) -> None:
- self.closed = True
- # ---------- UserStore tests ----------
- def test_upsert_by_username_calls_insert_with_correct_params():
- conn = FakeConn()
- conn.cursor_obj.lastrowid = 1
- conn.cursor_obj.fetchone_return = {
- "id": 1, "username": "alice", "phone": None, "password_hash": None,
- "token": "T1", "token_expires_at": None,
- "last_login_at": datetime(2026, 9, 16, 10, 0),
- "created_at": datetime(2026, 9, 16, 9, 0),
- }
- store = UserStore(conn)
- user = store.upsert_by_username("alice", "T1")
- assert user.id == 1
- assert user.username == "alice"
- # Should be 2 execute calls: INSERT, then SELECT to fetch back
- assert len(conn.cursor_obj.execute_calls) == 2
- insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
- assert "INSERT INTO users" in insert_sql
- assert "ON DUPLICATE KEY UPDATE" in insert_sql
- # params: (username, password, phone, token, token_expires_at)
- assert insert_params == ("alice", None, None, "T1", None)
- # 2nd call is the SELECT to fetch back the row
- select_sql, select_params = conn.cursor_obj.execute_calls[1]
- assert "SELECT * FROM users WHERE username = %s" in select_sql
- assert select_params == ("alice",)
- def test_upsert_by_username_with_password_persists_it():
- """新参数 password 会被写到 INSERT, 且 update 时不覆盖已有 password。"""
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 1, "username": "alice", "phone": None,
- "password_hash": "PWD_ENC_xxx", "token": "T1",
- "token_expires_at": None,
- "last_login_at": None, "created_at": None,
- }
- store = UserStore(conn)
- store.upsert_by_username("alice", "T1", password="PWD_ENC_xxx")
- insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
- # password 在 params 里, 第 2 个位置 (username, password, phone, token, token_expires_at)
- assert insert_params[1] == "PWD_ENC_xxx"
- # update 子句里用 COALESCE 保护已有 password
- assert "COALESCE(VALUES(password_hash), password_hash)" in insert_sql
- def test_upsert_by_username_with_phone_persists_it():
- """新参数 phone 跟 password 一样, UPDATE 路径用 COALESCE 保护已有 phone。"""
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 1, "username": "alice", "phone": "13800138000",
- "password_hash": None, "token": "T1",
- "token_expires_at": None,
- "last_login_at": None, "created_at": None,
- }
- store = UserStore(conn)
- store.upsert_by_username("alice", "T1", phone="13800138000")
- insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
- # phone 在 params 第 3 个位置
- assert insert_params[2] == "13800138000"
- # update 子句里用 COALESCE 保护已有 phone
- assert "COALESCE(VALUES(phone), phone)" in insert_sql
- def test_userstore_update_token_preserves_other_fields():
- """update_token 只刷 token/expires/last_login, 其它字段 (username/phone/password) 保留。
- 用于 A2: SMS 登录后用现有 user.id update token, 不重置其他认证字段。
- """
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 5, "username": "longwu988", "phone": "17756576757",
- "password_hash": "Longwu1988",
- "token": "NEW_SMS_TOKEN_xxxx", "token_expires_at": None,
- "last_login_at": datetime(2026, 9, 16, 17, 0),
- "created_at": datetime(2026, 9, 16, 15, 0),
- }
- users = UserStore(conn)
- u = users.update_token(user_id=5, token="NEW_SMS_TOKEN_xxxx")
- assert u.id == 5
- assert u.token == "NEW_SMS_TOKEN_xxxx"
- assert u.username == "longwu988" # 没被覆盖
- assert u.phone == "17756576757" # 没被覆盖
- assert u.password_hash == "Longwu1988" # 没被覆盖
- # SQL 应该是 UPDATE users (不是 runs — 之前错位进过 RunStore, 回归保护)
- sql, params = conn.cursor_obj.execute_calls[0]
- assert sql.strip().startswith("UPDATE users")
- assert "token = %s" in sql
- assert "last_login_at = CURRENT_TIMESTAMP" in sql
- # params: (token, token_expires_at, user_id)
- assert params[0] == "NEW_SMS_TOKEN_xxxx"
- assert params[2] == 5
- # 接着有个 SELECT 拉回 row
- assert len(conn.cursor_obj.execute_calls) == 2
- def test_userstore_update_token_raises_if_user_not_found():
- """UPDATE 后 SELECT 拉不回来 → RuntimeError, 不静默吞错。"""
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = None # 模拟 row 不存在
- users = UserStore(conn)
- with pytest.raises(RuntimeError, match="UPDATE succeeded but row not found"):
- users.update_token(user_id=999, token="xxx")
- def test_upsert_by_phone_uses_phone_key():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 2, "username": None, "phone": "13800138000", "token": "T2",
- "token_expires_at": None, "last_login_at": None, "created_at": None,
- }
- store = UserStore(conn)
- user = store.upsert_by_phone("13800138000", "T2")
- assert user.id == 2
- assert user.phone == "13800138000"
- insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
- assert "phone" in insert_sql
- assert insert_params[0] == "13800138000"
- def test_get_by_username_returns_user_when_found():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 5, "username": "bob", "phone": None, "token": "T5",
- "token_expires_at": None, "last_login_at": None, "created_at": None,
- }
- store = UserStore(conn)
- user = store.get_by_username("bob")
- assert user is not None
- assert user.id == 5
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "WHERE username = %s" in sql
- assert params == ("bob",)
- def test_get_by_username_returns_none_when_not_found():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = None
- store = UserStore(conn)
- assert store.get_by_username("nobody") is None
- def test_upsert_raises_if_row_not_found_after_insert():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = None # simulate race / DB inconsistency
- store = UserStore(conn)
- with pytest.raises(RuntimeError, match="upsert succeeded but row not found"):
- store.upsert_by_username("alice", "T1")
- # ---------- RunStore tests ----------
- def test_create_run_inserts_and_returns():
- conn = FakeConn()
- conn.cursor_obj.lastrowid = 42
- # After INSERT, create() calls get(42), which needs fetchone
- conn.cursor_obj.fetchone_return = {
- "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
- "started_at": datetime(2026, 9, 16, 10, 0),
- "completed_at": None, "current_task": None, "status": "running",
- "snapshot_path": None,
- }
- store = RunStore(conn)
- run = store.create(user_id=1, server_id=2, zone_id=3)
- assert run.id == 42
- assert run.status == "running"
- # First call: INSERT
- insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
- assert "INSERT INTO runs" in insert_sql
- assert insert_params == (1, 2, 3, "running")
- # Second call: SELECT WHERE id = ... (uses the lastrowid)
- select_sql, _ = conn.cursor_obj.execute_calls[1]
- assert "SELECT * FROM runs WHERE id = %s" in select_sql
- def test_update_status_only_sets_status_when_no_extras():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
- "started_at": datetime(2026, 9, 16, 10, 0),
- "completed_at": None, "current_task": "t01", "status": "running",
- "snapshot_path": None,
- }
- store = RunStore(conn)
- store.update_status(42, "running")
- sql, params = conn.cursor_obj.execute_calls[0]
- assert sql == "UPDATE runs SET status = %s WHERE id = %s"
- assert tuple(params) == ("running", 42) # tuple() to normalize list vs tuple
- def test_update_status_with_current_task_and_explicit_completed_at():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
- "started_at": None, "completed_at": datetime(2026, 9, 16, 11, 0),
- "current_task": "t05", "status": "running",
- "snapshot_path": None,
- }
- store = RunStore(conn)
- store.update_status(42, "running", current_task="t05",
- completed_at=datetime(2026, 9, 16, 11, 0))
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "status = %s" in sql
- assert "current_task = %s" in sql
- assert "completed_at = %s" in sql
- assert params == ["running", "t05", datetime(2026, 9, 16, 11, 0), 42]
- def test_update_status_auto_sets_completed_at_for_terminal_status():
- """When status flips to success/failed, completed_at defaults to NOW()
- unless the caller passes one explicitly."""
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
- "started_at": None, "completed_at": datetime(2026, 9, 16, 11, 0),
- "current_task": None, "status": "success", "snapshot_path": None,
- }
- store = RunStore(conn)
- store.update_status(42, "success")
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "completed_at = CURRENT_TIMESTAMP" in sql
- assert tuple(params) == ("success", 42)
- def test_update_status_does_not_touch_completed_at_for_running():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 42, "user_id": 1, "server_id": 2, "zone_id": 3,
- "started_at": None, "completed_at": None,
- "current_task": "t03", "status": "running", "snapshot_path": None,
- }
- store = RunStore(conn)
- store.update_status(42, "running", current_task="t03")
- sql, params = conn.cursor_obj.execute_calls[0]
- # "running" is not terminal, so completed_at clause should NOT appear
- assert "completed_at" not in sql
- assert tuple(params) == ("running", "t03", 42)
- def test_list_recent_returns_typed_runs():
- conn = FakeConn()
- conn.cursor_obj.fetchall_return = [
- {"id": 2, "user_id": 1, "server_id": 2, "zone_id": 3,
- "started_at": datetime(2026, 9, 16, 11, 0),
- "completed_at": None, "current_task": "t10", "status": "running",
- "snapshot_path": None},
- {"id": 1, "user_id": 1, "server_id": 2, "zone_id": 3,
- "started_at": datetime(2026, 9, 16, 10, 0),
- "completed_at": datetime(2026, 9, 16, 10, 30),
- "current_task": None, "status": "success", "snapshot_path": None},
- ]
- store = RunStore(conn)
- runs = store.list_recent(user_id=1, limit=10)
- assert len(runs) == 2
- assert all(isinstance(r, Run) for r in runs)
- assert runs[0].id == 2
- assert runs[1].id == 1
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "ORDER BY started_at DESC" in sql
- assert params == (1, 10)
- # ---------- connection helpers ----------
- def test_connection_context_manager_closes(monkeypatch):
- """connection() should call conn.close() on exit."""
- fake = FakeConn()
- # Patch get_connection to return our fake
- import cq2_sdk.store as store_mod
- monkeypatch.setattr(store_mod, "get_connection", lambda: fake)
- with connection() as c:
- assert c is fake
- assert fake.closed is True
- def test_get_connection_reads_env(monkeypatch):
- """get_connection() must read CQ2_DB_* env vars."""
- monkeypatch.setenv("CQ2_DB_HOST", "h")
- monkeypatch.setenv("CQ2_DB_PORT", "3306")
- monkeypatch.setenv("CQ2_DB_USER", "u")
- monkeypatch.setenv("CQ2_DB_PASSWORD", "p")
- monkeypatch.setenv("CQ2_DB_NAME", "n")
- captured: dict[str, Any] = {}
- def fake_pymysql_connect(**kwargs):
- captured.update(kwargs)
- return MagicMock()
- import pymysql
- monkeypatch.setattr(pymysql, "connect", fake_pymysql_connect)
- get_connection()
- assert captured["host"] == "h"
- assert captured["port"] == 3306
- assert captured["charset"] == "utf8mb4"
- assert captured["autocommit"] is True
- assert captured["cursorclass"] is pymysql.cursors.DictCursor
- # ---------- ServerStore tests ----------
- def test_server_upsert_inserts_then_fetches():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 7, "server_name": "玖扬复古176", "server_id": 14414738,
- "game_id": 206923, "first_seen_at": datetime(2026, 9, 16, 9, 0),
- }
- store = ServerStore(conn)
- srv = store.upsert("玖扬复古176", 14414738)
- assert srv.id == 7
- assert srv.server_name == "玖扬复古176"
- assert srv.server_id == 14414738
- insert_sql, insert_params = conn.cursor_obj.execute_calls[0]
- assert "INSERT INTO servers" in insert_sql
- assert "ON DUPLICATE KEY UPDATE" in insert_sql
- assert insert_params == ("玖扬复古176", 14414738, 206923)
- def test_server_get_by_name_found_and_not_found():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 1, "server_name": "x", "server_id": 1, "game_id": 206923,
- "first_seen_at": None,
- }
- store = ServerStore(conn)
- assert store.get_by_name("x") is not None
- conn.cursor_obj.fetchone_return = None
- assert store.get_by_name("y") is None
- def test_server_get_by_server_id_distinguishes_from_db_pk():
- """get_by_server_id uses 996box's numeric id, get_by_id uses local PK."""
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 99, "server_name": "x", "server_id": 12345, "game_id": 206923,
- "first_seen_at": None,
- }
- store = ServerStore(conn)
- srv = store.get_by_server_id(12345)
- assert srv is not None
- assert srv.id == 99
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "WHERE server_id = %s" in sql
- assert params == (12345,)
- def test_server_list_all_returns_sorted_servers():
- conn = FakeConn()
- conn.cursor_obj.fetchall_return = [
- {"id": 1, "server_name": "a", "server_id": 1, "game_id": 206923,
- "first_seen_at": None},
- {"id": 2, "server_name": "b", "server_id": 2, "game_id": 206923,
- "first_seen_at": None},
- ]
- store = ServerStore(conn)
- servers = store.list_all()
- assert len(servers) == 2
- assert all(isinstance(s, Server) for s in servers)
- sql, _ = conn.cursor_obj.execute_calls[0]
- assert "ORDER BY server_name" in sql
- # ---------- ZoneStore tests ----------
- def test_zone_bulk_upsert_empty_list_is_noop():
- conn = FakeConn()
- store = ZoneStore(conn)
- assert store.bulk_upsert(server_id=1, zones=[]) == 0
- assert conn.cursor_obj.execute_calls == [] # no SQL executed
- def test_zone_bulk_upsert_uses_executemany_with_on_duplicate():
- conn = FakeConn()
- conn.cursor_obj.rowcount = 3
- store = ZoneStore(conn)
- zones = [
- Zone(zone_id="z1", zone_name="Zone 1", host="h1", port=1,
- open_time=datetime(2026, 9, 16, 7, 0), status="open"),
- Zone(zone_id="z2", zone_name="Zone 2", host="h2", port=2,
- open_time=datetime(2026, 9, 16, 10, 0), status="open"),
- Zone(zone_id="z3", zone_name="Zone 3", host="h3", port=3,
- open_time=datetime(2026, 9, 16, 19, 0), status="open"),
- ]
- n = store.bulk_upsert(server_id=42, zones=zones)
- assert n == 3
- assert len(conn.cursor_obj.executemany_calls) == 1
- sql, rows = conn.cursor_obj.executemany_calls[0]
- assert "INSERT INTO zones" in sql
- assert "ON DUPLICATE KEY UPDATE" in sql
- assert len(rows) == 3
- assert rows[0] == (
- 42, "z1", "Zone 1", "h1", 1, zones[0].open_time,
- None, None, None, None, "open",
- )
- def test_zone_bulk_upsert_defaults_status_to_open():
- conn = FakeConn()
- conn.cursor_obj.rowcount = 1
- store = ZoneStore(conn)
- zones = [Zone(zone_id="z1", host="h", port=1,
- open_time=datetime(2026, 9, 16, 19, 0))]
- store.bulk_upsert(server_id=1, zones=zones)
- _, rows = conn.cursor_obj.executemany_calls[0]
- # status defaulted to "open" when None
- assert rows[0][10] == "open"
- def test_zone_get_today_open_uses_curdate():
- conn = FakeConn()
- conn.cursor_obj.fetchall_return = [
- {"id": 1, "server_id": 1, "zone_id": "z", "zone_name": "Z",
- "host": "h", "port": 1, "open_time": datetime(2026, 9, 16, 7, 0),
- "status": "open", "fetched_at": None},
- ]
- store = ZoneStore(conn)
- zones = store.get_today_open(server_id=1)
- assert len(zones) == 1
- assert isinstance(zones[0], Zone)
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "DATE(open_time) = CURDATE()" in sql
- assert "ORDER BY open_time ASC" in sql
- assert params == (1,)
- def test_zone_get_latest_openable_filters_and_orders_desc():
- """B3: status=open AND open_time <= NOW(), ordered DESC, LIMIT 1."""
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 5, "server_id": 1, "zone_id": "z_latest", "zone_name": "Z",
- "host": "h", "port": 1, "open_time": datetime(2026, 9, 16, 19, 0),
- "status": "open", "fetched_at": None,
- }
- store = ZoneStore(conn)
- zone = store.get_latest_openable(server_id=1)
- assert zone is not None
- assert zone.id == 5
- assert zone.zone_id == "z_latest"
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "status = 'open'" in sql
- assert "open_time <= NOW()" in sql
- assert "ORDER BY open_time DESC" in sql
- assert "LIMIT 1" in sql
- assert params == (1,)
- def test_zone_get_latest_openable_returns_none_when_none():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = None
- store = ZoneStore(conn)
- assert store.get_latest_openable(server_id=999) is None
- def test_zone_mark_missing_closed_with_keep_set():
- conn = FakeConn()
- conn.cursor_obj.rowcount = 2
- store = ZoneStore(conn)
- n = store.mark_missing_closed(
- server_id=1, keep_zone_ids={"z1", "z2"}, today_only=True
- )
- assert n == 2
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "UPDATE zones SET status = 'closed'" in sql
- assert "zone_id NOT IN" in sql
- assert "DATE(open_time) = CURDATE()" in sql
- # set iteration order is non-deterministic; compare as sorted tuple
- assert tuple(params) == (1, "z1", "z2") or tuple(params) == (1, "z2", "z1")
- def test_zone_mark_missing_closed_with_empty_keep_closes_all():
- """If API returns no zones today, close all of today's open ones."""
- conn = FakeConn()
- conn.cursor_obj.rowcount = 5
- store = ZoneStore(conn)
- n = store.mark_missing_closed(server_id=1, keep_zone_ids=set())
- assert n == 5
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "NOT IN" not in sql # no IN clause when keep set is empty
- assert "DATE(open_time) = CURDATE()" in sql
- assert params == (1,)
- def test_zone_mark_missing_closed_can_skip_today_filter():
- conn = FakeConn()
- conn.cursor_obj.rowcount = 1
- store = ZoneStore(conn)
- n = store.mark_missing_closed(
- server_id=1, keep_zone_ids=set(), today_only=False
- )
- assert n == 1
- sql, _ = conn.cursor_obj.execute_calls[0]
- assert "CURDATE" not in sql
- # ---------- TaskLogStore tests ----------
- def test_tasklog_append_writes_correct_columns():
- conn = FakeConn()
- conn.cursor_obj.lastrowid = 100
- conn.cursor_obj.fetchone_return = {
- "id": 100, "run_id": 1, "task_id": "t01", "success": 1,
- "error": None, "elapsed_seconds": 12.5,
- "started_at": datetime(2026, 9, 16, 10, 0),
- "finished_at": datetime(2026, 9, 16, 10, 0, 12, 500_000),
- }
- store = TaskLogStore(conn)
- log = store.append(run_id=1, task_id="t01", success=True,
- elapsed_seconds=12.5)
- assert log.id == 100
- assert log.task_id == "t01"
- assert log.success is True
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "INSERT INTO task_logs" in sql
- assert "COALESCE(%s, CURRENT_TIMESTAMP)" in sql
- # params: run_id, task_id, success (0/1), error, elapsed, started, finished
- assert params == (1, "t01", 1, None, 12.5, None, None)
- def test_tasklog_append_converts_success_bool_to_tinyint():
- conn = FakeConn()
- conn.cursor_obj.lastrowid = 1
- conn.cursor_obj.fetchone_return = {
- "id": 1, "run_id": 1, "task_id": "t02", "success": 0,
- "error": "boom", "elapsed_seconds": None,
- "started_at": None, "finished_at": None,
- }
- TaskLogStore(conn).append(run_id=1, task_id="t02", success=False,
- error="boom")
- _, params = conn.cursor_obj.execute_calls[0]
- # success=False → 0 in SQL
- assert params[2] == 0
- def test_tasklog_append_passes_explicit_timestamps():
- conn = FakeConn()
- conn.cursor_obj.lastrowid = 1
- conn.cursor_obj.fetchone_return = {
- "id": 1, "run_id": 1, "task_id": "t01", "success": 1,
- "error": None, "elapsed_seconds": 1.0,
- "started_at": datetime(2026, 9, 16, 9, 0),
- "finished_at": datetime(2026, 9, 16, 9, 1),
- }
- started = datetime(2026, 9, 16, 9, 0)
- finished = datetime(2026, 9, 16, 9, 1)
- TaskLogStore(conn).append(run_id=1, task_id="t01", success=True,
- elapsed_seconds=1.0,
- started_at=started, finished_at=finished)
- _, params = conn.cursor_obj.execute_calls[0]
- assert params[5] == started
- assert params[6] == finished
- def test_tasklog_list_for_run_orders_ascending():
- conn = FakeConn()
- conn.cursor_obj.fetchall_return = [
- {"id": 1, "run_id": 5, "task_id": "t01", "success": 1,
- "error": None, "elapsed_seconds": 1.0,
- "started_at": datetime(2026, 9, 16, 10, 0),
- "finished_at": datetime(2026, 9, 16, 10, 0, 1)},
- {"id": 2, "run_id": 5, "task_id": "t02", "success": 1,
- "error": None, "elapsed_seconds": 1.0,
- "started_at": datetime(2026, 9, 16, 10, 1),
- "finished_at": datetime(2026, 9, 16, 10, 1, 1)},
- ]
- store = TaskLogStore(conn)
- logs = store.list_for_run(run_id=5)
- assert len(logs) == 2
- assert all(isinstance(l, TaskLog) for l in logs)
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "ORDER BY started_at ASC, id ASC" in sql
- assert params == (5,)
- def test_tasklog_list_for_run_task_returns_latest():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {
- "id": 7, "run_id": 5, "task_id": "t01", "success": 0,
- "error": "first attempt failed", "elapsed_seconds": 0.5,
- "started_at": datetime(2026, 9, 16, 10, 0),
- "finished_at": datetime(2026, 9, 16, 10, 0, 0, 500_000),
- }
- log = TaskLogStore(conn).list_for_run_task(run_id=5, task_id="t01")
- assert log is not None
- assert log.id == 7
- assert log.success is False
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "ORDER BY id DESC LIMIT 1" in sql
- assert params == (5, "t01")
- def test_tasklog_count_for_run_no_filter():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {"n": 13}
- n = TaskLogStore(conn).count_for_run(run_id=5)
- assert n == 13
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "COUNT(*)" in sql
- assert "success" not in sql # no success filter
- assert params == (5,)
- def test_tasklog_count_for_run_success_true():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {"n": 8}
- n = TaskLogStore(conn).count_for_run(run_id=5, success=True)
- assert n == 8
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "success = %s" in sql
- # success=True → 1
- assert params == (5, 1)
- def test_tasklog_count_for_run_success_false():
- conn = FakeConn()
- conn.cursor_obj.fetchone_return = {"n": 5}
- n = TaskLogStore(conn).count_for_run(run_id=5, success=False)
- assert n == 5
- _, params = conn.cursor_obj.execute_calls[0]
- assert params == (5, 0) # success=False → 0
- def test_tasklog_completed_task_ids_returns_set():
- conn = FakeConn()
- conn.cursor_obj.fetchall_return = [
- {"task_id": "t01"},
- {"task_id": "t02"},
- {"task_id": "t05"},
- ]
- completed = TaskLogStore(conn).completed_task_ids(run_id=5)
- assert completed == {"t01", "t02", "t05"}
- sql, params = conn.cursor_obj.execute_calls[0]
- assert "success = 1" in sql
- assert params == (5,)
- def test_tasklog_completed_task_ids_empty():
- conn = FakeConn()
- conn.cursor_obj.fetchall_return = []
- assert TaskLogStore(conn).completed_task_ids(run_id=999) == set()
|