Files
crawler-plugin/backend-java/scripts/test_tx_audit.py
T

205 lines
7.1 KiB
Python

"""task-124 @Transactional 方法审计清单测试。
对应 plan 07 任务 124 的 8 条用例 + 2 条补强:
1. test_audit_covers_all_modules 覆盖全部业务模块
2. test_audit_classification 分类完整(必须/可移出)
3. test_audit_result_endpoints /result 相关方法都在清单
4. test_audit_python_facing Python 回调相关标记
5. test_audit_priority_order 按收益排序
6. test_audit_no_code_change 本任务零代码变更
7. test_audit_documented 文档产出
8. test_audit_repeatable 可重复执行
9. test_audit_detects_tx_method 识别 @Transactional 方法
10. test_audit_classifies_movable 事务内可移出段分类
"""
from __future__ import annotations
import json
import subprocess
import unittest
from pathlib import Path
from tx_audit import MODULES_ROOT, scan_modules, scan_text
SCRIPTS_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPTS_DIR.parent.parent
DOC_PATH = REPO_ROOT / "backend-java" / "docs" / "transaction-boundary-audit.md"
FIXTURE_TX = """package com.example;
@Service
public class DemoService {
@Transactional
public boolean submitResult(Long taskId, List<RowVo> rows) {
List<ItemVo> items = rows.stream()
.map(row -> toVo(row))
.collect(Collectors.toList());
int updated = fileResultMapper.update(null, new LambdaUpdateWrapper<FileResultEntity>()
.eq(FileResultEntity::getId, taskId)
.set(FileResultEntity::getStatus, "SUCCESS"));
log.info("submitted taskId={} count={}", taskId, items.size());
Files.deleteIfExists(Path.of("/tmp/task-" + taskId));
return updated > 0;
}
public void noTxMethod(Long id) {
fileTaskMapper.selectById(id);
}
}
"""
FIXTURE_WRITE_ONLY = """package com.example;
@Service
public class DemoService {
@Transactional
public int markFailed(Long id) {
return fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, id)
.set(FileTaskEntity::getStatus, "FAILED"));
}
}
"""
FIXTURE_LOCK = """package com.example;
@Service
public class DemoService {
@Transactional
public void finalizeTask(Long taskId) {
TaskDistributedLockService.LockHandle handle = acquireTaskLock("MODULE", taskId);
if (handle == null) {
return;
}
try (handle) {
taskMapper.update(null, new LambdaUpdateWrapper<TaskFileEntity>()
.eq(TaskFileEntity::getId, taskId));
}
}
}
"""
FIXTURE_PLAIN = """package com.example;
@Service
public class DemoService {
public int sum(List<Integer> nums) {
return nums.stream().mapToInt(Integer::intValue).sum();
}
}
"""
class TxAuditDetectTest(unittest.TestCase):
def test_audit_detects_tx_method(self):
findings = scan_text(FIXTURE_TX, "DemoService.java")
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["method"], "submitResult")
self.assertFalse(findings[0]["class_level"])
def test_audit_classifies_movable(self):
findings = scan_text(FIXTURE_TX, "DemoService.java")
self.assertEqual(len(findings), 1)
movable = findings[0]["movable"]
self.assertIn("compute", movable)
self.assertIn("assembly", movable)
self.assertIn("log", movable)
self.assertIn("cleanup", movable)
self.assertIn("write", findings[0]["must_stay"])
self.assertGreater(findings[0]["priority"], 0)
def test_audit_no_tx_not_reported(self):
self.assertEqual(len(scan_text(FIXTURE_PLAIN, "Plain.java")), 0)
def test_audit_write_only_no_movable(self):
findings = scan_text(FIXTURE_WRITE_ONLY, "DemoService.java")
self.assertEqual(len(findings), 1)
self.assertIn("write", findings[0]["must_stay"])
self.assertEqual(findings[0]["movable"], [])
def test_audit_lock_flagged_must_stay(self):
findings = scan_text(FIXTURE_LOCK, "DemoService.java")
self.assertEqual(len(findings), 1)
self.assertIn("lock", findings[0]["must_stay"])
class TxAuditCoverageTest(unittest.TestCase):
def test_audit_covers_all_modules(self):
report = scan_modules(MODULES_ROOT)
self.assertGreaterEqual(report.scanned_files, 600)
self.assertGreater(len(report.findings), 50)
modules = {f["module"] for f in report.findings}
self.assertGreaterEqual(len(modules), 15)
def test_audit_classification(self):
for finding in scan_modules(MODULES_ROOT).findings:
self.assertIn("must_stay", finding)
self.assertIn("movable", finding)
self.assertIsInstance(finding["must_stay"], list)
self.assertIsInstance(finding["movable"], list)
def test_audit_result_endpoints(self):
report = scan_modules(MODULES_ROOT)
result_methods = [
f
for f in report.findings
if "result" in f["method"].lower() or "submit" in f["method"].lower()
]
self.assertGreater(
len(result_methods), 0, "应找到 /result 相关 @Transactional 方法"
)
modules_with_result = {f["module"] for f in result_methods}
self.assertGreaterEqual(len(modules_with_result), 3)
def test_audit_python_facing(self):
report = scan_modules(MODULES_ROOT)
flagged = [f for f in report.findings if f["python_facing"]]
self.assertGreater(len(flagged), 0, "应标记 Python 回调相关方法")
for f in flagged:
name = f["method"].lower()
self.assertTrue(
any(
k in name
for k in (
"result",
"submit",
"upload",
"chunk",
"done",
"ack",
"report",
)
),
f"python_facing 标记与命名不符: {f['method']}",
)
def test_audit_priority_order(self):
findings = scan_modules(MODULES_ROOT).findings
priorities = [f["priority"] for f in findings]
self.assertEqual(priorities, sorted(priorities, reverse=True))
def test_audit_repeatable(self):
first = scan_modules(MODULES_ROOT).to_dict()
second = scan_modules(MODULES_ROOT).to_dict()
self.assertEqual(
json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True)
)
def test_audit_documented(self):
self.assertTrue(DOC_PATH.is_file(), f"audit doc missing: {DOC_PATH}")
text = DOC_PATH.read_text(encoding="utf-8")
self.assertIn("事务", text)
self.assertIn("必须", text)
self.assertIn("可移出", text)
def test_audit_no_code_change(self):
changed = subprocess.run(
["git", "diff", "--name-only", "HEAD", "--", "backend-java/src"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
).stdout.strip()
self.assertEqual(changed, "", f"生产代码被改动: {changed}")
if __name__ == "__main__":
unittest.main()