#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称：test_execution_cancel.py
模块描述：执行取消 / 行锁释放 单元测试

覆盖：
- run_execution 在置 status=running 后立即 commit（否则长执行期间持有
  executions 行锁，MySQL InnoDB 下取消接口报 Lock wait timeout 1205）
- 执行期间被取消时，finally 保持 cancelled 状态（不被覆盖为 completed）

作者：czj
创建日期：2026-08-19
"""

from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch

from app.models.execution import Execution
from app.services.execution_service import ExecutionService


class _Scalars:
    def __init__(self, rows):
        self._rows = rows

    def all(self):
        return list(self._rows)


class _Result:
    def __init__(self, rows):
        self._rows = rows

    def scalars(self):
        return _Scalars(self._rows)

    def scalar_one_or_none(self):
        return self._rows[0] if self._rows else None


class _FakeDB:
    """最小异步 DB 假件：仅服务 run_execution 需要的 execute/flush/commit"""

    def __init__(self, exec_obj, simulate_cancel=False, stale_entity_status=None):
        self.exec_obj = exec_obj
        self.simulate_cancel = simulate_cancel
        self.stale_entity_status = stale_entity_status
        self.commit_calls = 0
        self.executed_sql = []

    async def execute(self, stmt):
        sql = str(stmt)
        self.executed_sql.append(sql)
        # 用例结果 / 用例定义查询 → 空（用例列表为空，worker 快速结束）
        if "case_results" in sql or "test_cases" in sql:
            return _Result([])
        # 仅查状态列（执行期间取消检测 / 收尾状态读取）→ 返回当前状态字符串
        if sql.lstrip().startswith("SELECT executions.status"):
            return _Result([self.exec_obj.status])
        # 完整实体查询 → 模拟 SQLAlchemy 身份映射：expire_on_commit=False 时
        # commit 后实例不刷新，返回内存中过期的对象（stale_entity_status）
        if self.stale_entity_status is not None:
            stale = _make_execution(status=self.stale_entity_status)
            stale.id = self.exec_obj.id
            return _Result([stale])
        # 默认返回执行对象
        return _Result([self.exec_obj])

    async def flush(self):
        pass

    async def commit(self):
        self.commit_calls += 1
        if self.simulate_cancel and self.commit_calls == 1:
            # 首次 commit 是"置 running"那一次；其后模拟执行期间用户并发取消
            self.exec_obj.status = "cancelled"




def _make_execution(status="running", case_type="ui"):
    ex = Execution()
    ex.id = "exec_test_cancel_001"
    ex.name = "test"
    ex.trigger_type = "manual"
    ex.trigger_by = "user"
    ex.total_cases = 1
    ex.passed = 0
    ex.failed = 0
    ex.skipped = 0
    ex.pass_rate = 0.0
    ex.duration = 0.0
    ex.status = status
    ex.error_message = None
    ex.start_time = datetime.now()
    ex.end_time = None
    ex.environment = "default"
    ex.config = {}
    ex.case_type = case_type
    ex.created_at = datetime.now()
    return ex


def _make_service(db, exec_obj):
    service = ExecutionService(db)
    service.get_execution = AsyncMock(return_value=exec_obj)
    service._count_results = AsyncMock(return_value=0)
    return service


# 真实线程运行 worker（PlaywrightExecutor 已 mock，无需浏览器）
@patch("app.services.execution_service.PlaywrightExecutor", autospec=True)
@patch("app.services.execution_service.manager.broadcast", new_callable=AsyncMock)
async def test_run_execution_commits_running_status_early(mock_broadcast, mock_executor):
    """置 status=running 后立即 commit（释放行锁，取消接口才不会被 1205 阻塞）"""
    exec_obj = _make_execution(status="running")
    db = _FakeDB(exec_obj, simulate_cancel=False)
    service = _make_service(db, exec_obj)

    result = await service.run_execution(exec_id := exec_obj.id)

    # 关键：run_execution 内部至少 commit 过一次（置 running 后的立即提交）
    assert db.commit_calls >= 1, "run_execution 应提交 status=running（否则 MySQL 下取消接口被行锁阻塞）"
    # 未被取消 → 正常收尾为 completed
    assert result.status == "completed"


# 真实线程运行 worker（PlaywrightExecutor 已 mock，无需浏览器）
@patch("app.services.execution_service.PlaywrightExecutor", autospec=True)
@patch("app.services.execution_service.manager.broadcast", new_callable=AsyncMock)
async def test_run_execution_preserves_cancelled_status(mock_broadcast, mock_executor):
    """执行期间被取消 → finally 保持 cancelled，不被覆盖为 completed"""
    exec_obj = _make_execution(status="running")
    db = _FakeDB(exec_obj, simulate_cancel=True)
    service = _make_service(db, exec_obj)

    result = await service.run_execution(exec_id := exec_obj.id)

    assert db.commit_calls >= 1
    # 模拟并发取消后，finally 应保留 cancelled
    assert result.status == "cancelled", "执行期间被取消后状态应保持 cancelled"


# 真实线程运行 worker（PlaywrightExecutor 已 mock，无需浏览器）
@patch("app.services.execution_service.PlaywrightExecutor", autospec=True)
@patch("app.services.execution_service.manager.broadcast", new_callable=AsyncMock)
async def test_status_reads_use_locking_read(mock_broadcast, mock_executor):
    """取消检测与收尾的状态读取必须使用锁定读（FOR UPDATE）。

    背景：MySQL InnoDB REPEATABLE READ 下，普通 SELECT 走快照读，看不到
    本事务之外已提交的取消（会读到 running 旧快照），导致 cancelled 被覆盖为
    completed（已实机复现）。必须用 with_for_update 做当前读。
    """
    exec_obj = _make_execution(status="running")
    db = _FakeDB(exec_obj, simulate_cancel=True)
    service = _make_service(db, exec_obj)

    await service.run_execution(exec_obj.id)

    # 取消检测（SELECT executions.status）与收尾重查（FROM executions WHERE executions.id）
    # 都必须带 FOR UPDATE；否则快照读看不到已提交的取消
    exec_reads = [
        s for s in db.executed_sql
        if "SELECT executions.status" in s or "FROM executions WHERE executions.id" in s
    ]
    assert exec_reads, "应存在对 executions 的状态读取"
    for sql in exec_reads:
        assert "FOR UPDATE" in sql, f"状态读取必须为锁定读（快照读会把 cancelled 覆盖为 completed）: {sql}"
        # 状态判定必须是【列查询】，不能是完整实体查询——实体查询会命中身份映射
        # 返回内存过期实例（.status 仍为 running），导致 cancelled 被覆盖为 completed
        assert "SELECT executions.status" in sql, \
            f"状态读取必须为列查询（完整实体查询命中身份映射返回过期实例）: {sql}"


@patch("app.services.execution_service.PlaywrightExecutor", autospec=True)
@patch("app.services.execution_service.manager.broadcast", new_callable=AsyncMock)
async def test_cancelled_preserved_despite_stale_identity_map(mock_broadcast, mock_executor):
    """身份映射返回过期实体（status=running）时，取消状态仍须保持。

    回归：收尾曾用完整实体查询判定状态，命中 SQLAlchemy 身份映射返回 commit 后
    未刷新的过期实例（expire_on_commit=False），.status 仍是 running，把已取消的
    执行覆盖为 completed（5.60 实机复现）。现改为列查询（SELECT executions.status）
    + FOR UPDATE 当前读，绕过身份映射。
    """
    exec_obj = _make_execution(status="running")
    # 模拟生产：完整实体查询返回内存中过期的 running 对象，列查询返回实时状态
    db = _FakeDB(exec_obj, simulate_cancel=True, stale_entity_status="running")
    service = _make_service(db, exec_obj)

    result = await service.run_execution(exec_obj.id)

    assert db.commit_calls >= 1
    assert result.status == "cancelled", \
        "身份映射过期实体下，已取消的执行仍被覆盖为 completed（状态判定必须用列查询）"
