chore: auto release commit (2026-09-15 00:32:44)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
@@ -241,7 +241,30 @@ def test_data_management_actions() -> tuple[bool, str]:
|
||||
return False, f"{action_id} 未绑定合同台账"
|
||||
if action.get("entrypoint", {}).get("command") != action_id:
|
||||
return False, f"{action_id} CLI command 映射错误"
|
||||
return True, "5 个数据管理动作均绑定合同台账并映射到 CLI"
|
||||
|
||||
# v1.1.18: download-materials 是 async form action,绑定 download_tasks 队列表
|
||||
dl = by_id.get("download-materials")
|
||||
if not dl:
|
||||
return False, "缺少 download-materials 动作"
|
||||
if dl.get("executionProfile") != "async":
|
||||
return False, f"download-materials 应为 async,实际 {dl.get('executionProfile')}"
|
||||
if dl.get("interaction") != "form":
|
||||
return False, f"download-materials 应为 form interaction"
|
||||
if "download_tasks" not in dl.get("bind", {}).get("tables", []):
|
||||
return False, f"download-materials 未绑定 download_tasks"
|
||||
schema_props = dl.get("inputSchema", {}).get("properties", {})
|
||||
if "itemIds" not in schema_props or "pickCount" not in schema_props:
|
||||
return False, f"download-materials inputSchema 缺 itemIds/pickCount: {schema_props}"
|
||||
|
||||
# v1.1.18: show-config / logs-list 是 sync 运维动作
|
||||
for sync_id in ("show-config", "logs-list"):
|
||||
a = by_id.get(sync_id)
|
||||
if not a:
|
||||
return False, f"缺少 {sync_id} 动作"
|
||||
if a.get("executionProfile") != "sync":
|
||||
return False, f"{sync_id} 应为 sync"
|
||||
|
||||
return True, "5 个数据管理动作 + download-materials (async form) + show-config/logs-list (sync) 全部就绪"
|
||||
|
||||
|
||||
def test_collect_platform_defaults_to_browser() -> tuple[bool, str]:
|
||||
@@ -741,6 +764,643 @@ def test_parse_alert_red_orange_yellow_unknown() -> tuple[bool, str]:
|
||||
return True, "四级映射 OK"
|
||||
|
||||
|
||||
# ---------- v1.1.18:日志 / 路径 / 输入目录 / 队列 / 配置 / 日志查看 ----------
|
||||
|
||||
|
||||
def test_logging_rotating_file_handler() -> tuple[bool, str]:
|
||||
"""LOG_FILE 已设时,setup_skill_logging 应追加 RotatingFileHandler。"""
|
||||
import importlib
|
||||
from util.runtime_paths import get_logs_dir
|
||||
|
||||
log_path = get_logs_dir() / "test-rotate.log"
|
||||
|
||||
os.environ["LOG_FILE"] = str(log_path)
|
||||
os.environ.pop("LOG_MAX_BYTES", None)
|
||||
os.environ.pop("LOG_BACKUP_COUNT", None)
|
||||
os.environ.pop("LOG_LEVEL", None)
|
||||
import util.logging_setup as ls
|
||||
importlib.reload(ls)
|
||||
ls.reset_for_tests()
|
||||
|
||||
logger = ls.setup_skill_logging()
|
||||
logger.info("hello rotating")
|
||||
|
||||
found = [h for h in logger.handlers if h.__class__.__name__ == "RotatingFileHandler"]
|
||||
if not found:
|
||||
ls.reset_for_tests()
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
return False, "未发现 RotatingFileHandler"
|
||||
if not log_path.exists():
|
||||
ls.reset_for_tests()
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
return False, f"日志文件未生成: {log_path}"
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
if "hello rotating" not in content:
|
||||
ls.reset_for_tests()
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
return False, f"日志内容缺 'hello rotating': {content[:200]}"
|
||||
|
||||
ls.reset_for_tests()
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
importlib.reload(ls)
|
||||
return True, "RotatingFileHandler 写入成功"
|
||||
|
||||
|
||||
def test_logging_fallback_when_log_file_unset() -> tuple[bool, str]:
|
||||
"""LOG_FILE 未设时,logger 只有 StreamHandler(行为等价 v1.1.17)。"""
|
||||
import importlib
|
||||
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
os.environ.pop("LOG_LEVEL", None)
|
||||
import util.logging_setup as ls
|
||||
importlib.reload(ls)
|
||||
ls.reset_for_tests()
|
||||
|
||||
logger = ls.setup_skill_logging()
|
||||
file_handlers = [h for h in logger.handlers if "File" in h.__class__.__name__]
|
||||
if file_handlers:
|
||||
ls.reset_for_tests()
|
||||
return False, f"应有 0 个 FileHandler,实际 {len(file_handlers)}"
|
||||
|
||||
ls.reset_for_tests()
|
||||
return True, "无 LOG_FILE 时无 FileHandler(兼容 v1.1.17)"
|
||||
|
||||
|
||||
def test_logging_level_env() -> tuple[bool, str]:
|
||||
"""LOG_LEVEL=DEBUG 应让 logger.level == logging.DEBUG。"""
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
os.environ["LOG_LEVEL"] = "DEBUG"
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
import util.logging_setup as ls
|
||||
importlib.reload(ls)
|
||||
ls.reset_for_tests()
|
||||
|
||||
logger = ls.setup_skill_logging()
|
||||
got = logger.level
|
||||
if got != logging.DEBUG:
|
||||
ls.reset_for_tests()
|
||||
os.environ.pop("LOG_LEVEL", None)
|
||||
return False, f"logger.level={got} (want {logging.DEBUG})"
|
||||
|
||||
ls.reset_for_tests()
|
||||
os.environ.pop("LOG_LEVEL", None)
|
||||
return True, "LOG_LEVEL=DEBUG 生效"
|
||||
|
||||
|
||||
def test_logs_dir_default_under_data() -> tuple[bool, str]:
|
||||
"""JIANGCHANG_LOGS_DIR 未设时,get_logs_dir() = get_data_dir() / 'logs'。"""
|
||||
os.environ.pop("JIANGCHANG_LOGS_DIR", None)
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
import util.logging_setup as ls # 触发 runtime_paths 副作用
|
||||
ls.reset_for_tests()
|
||||
|
||||
from util.runtime_paths import get_logs_dir, get_data_dir
|
||||
expected = get_data_dir() / "logs"
|
||||
if get_logs_dir() != expected:
|
||||
return False, f"get_logs_dir()={get_logs_dir()} != {expected}"
|
||||
if not expected.exists():
|
||||
return False, f"ensure_dirs 没创建 {expected}"
|
||||
return True, f"get_logs_dir={expected}"
|
||||
|
||||
|
||||
def _isolated_input_dirs(monkeypatch_env: dict[str, str | None]) -> tuple[Path, Path]:
|
||||
"""把 INPUT_DIR 重定向到 tmpdir,返回 (input_root, materials_target) 的 Path。
|
||||
|
||||
不会污染仓库 data/input/。
|
||||
"""
|
||||
import tempfile
|
||||
tmp = Path(tempfile.mkdtemp(prefix="alert-hr-importer-test-"))
|
||||
input_root = tmp / "input"
|
||||
(input_root / "contracts").mkdir(parents=True)
|
||||
(input_root / "materials").mkdir(parents=True)
|
||||
monkeypatch_env["INPUT_DIR"] = str(input_root)
|
||||
os.environ["INPUT_DIR"] = str(input_root) # 关键:进程 env 也得设,否则 runtime_paths 读不到
|
||||
return input_root, tmp
|
||||
|
||||
|
||||
def _restore_input_env(saved: dict[str, str | None]) -> None:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def test_importer_contracts_csv_upsert() -> tuple[bool, str]:
|
||||
"""input/contracts/*.csv → contracts 表 upsert。"""
|
||||
saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)}
|
||||
try:
|
||||
input_root, tmp = _isolated_input_dirs(saved)
|
||||
contracts_dir = input_root / "contracts"
|
||||
csv_path = contracts_dir / "batch1.csv"
|
||||
csv_path.write_text(
|
||||
"contract_id,teacher_name,unit_name,contract_end,employee_id\n"
|
||||
"C001,张三,物理学院,2026-04-15,T001\n"
|
||||
"C002,李四,计算机学院,2026-08-31,T002\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
from service.importer import import_input_dir
|
||||
result = import_input_dir(conn)
|
||||
|
||||
if result["contracts_added"] != 2:
|
||||
return False, f"contracts_added={result['contracts_added']} (want 2)"
|
||||
if result["errors"]:
|
||||
return False, f"errors={result['errors']}"
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT contract_id, teacher_name, unit_name, contract_end "
|
||||
"FROM contracts ORDER BY contract_id"
|
||||
).fetchall()
|
||||
if len(rows) != 2:
|
||||
return False, f"DB 中合同数={len(rows)} (want 2)"
|
||||
if rows[0]["teacher_name"] != "张三":
|
||||
return False, f"row[0].teacher_name={rows[0]['teacher_name']}"
|
||||
return True, f"contracts_added={result['contracts_added']}"
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
finally:
|
||||
_restore_input_env(saved)
|
||||
|
||||
|
||||
def test_importer_materials_pdf_enqueue() -> tuple[bool, str]:
|
||||
"""input/materials/{cid}__{f}.pdf → materials_content (parse_method='pending')。"""
|
||||
saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)}
|
||||
try:
|
||||
input_root, tmp = _isolated_input_dirs(saved)
|
||||
materials_dir = input_root / "materials"
|
||||
pdf_path = materials_dir / "RPA-C001__考核表.pdf"
|
||||
pdf_path.write_bytes(b"%PDF-1.4 fake test file")
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
# 先插入 contract(materials_content FK 到 contracts.contract_id)
|
||||
conn.execute(
|
||||
"""INSERT INTO contracts
|
||||
(contract_id, employee_id, teacher_name, unit_id, unit_name,
|
||||
position, contract_type, contract_start, contract_end,
|
||||
trial_period_end, status)
|
||||
VALUES ('RPA-C001', 'T001', '张三', 'U001', '物理学院',
|
||||
'教授', '固定期限', '2024-01-01', '2026-04-15',
|
||||
'2024-04-15', '可续聘')"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from service.importer import import_input_dir
|
||||
result = import_input_dir(conn)
|
||||
|
||||
if result["materials_added"] != 1:
|
||||
return False, f"materials_added={result['materials_added']} (want 1) errors={result['errors']}"
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT contract_id, parse_method FROM materials_content "
|
||||
"WHERE contract_id = 'RPA-C001'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False, "materials_content 未写入"
|
||||
if row["parse_method"] != "pending":
|
||||
return False, f"parse_method={row['parse_method']} (want pending)"
|
||||
return True, "PDF 入库 parse_method=pending"
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
finally:
|
||||
_restore_input_env(saved)
|
||||
|
||||
|
||||
def test_importer_idempotent() -> tuple[bool, str]:
|
||||
"""同一 CSV 跑两次,第二次 contracts_added=0(upsert 幂等)。"""
|
||||
saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)}
|
||||
try:
|
||||
input_root, tmp = _isolated_input_dirs(saved)
|
||||
(input_root / "contracts" / "x.csv").write_text(
|
||||
"contract_id,teacher_name\nC100,重复测试\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
from service.importer import import_input_dir
|
||||
r1 = import_input_dir(conn)
|
||||
r2 = import_input_dir(conn)
|
||||
if r1["contracts_added"] != 1:
|
||||
return False, f"first={r1['contracts_added']} (want 1)"
|
||||
if r2["contracts_added"] != 1:
|
||||
# 注意:CSV 仍按行 upsert,第 2 次相当于 1 次 UPDATE,contracts_added 仍计数
|
||||
return False, f"second={r2['contracts_added']} (want 1)"
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"]
|
||||
if n != 1:
|
||||
return False, f"DB count={n} (want 1,幂等)"
|
||||
return True, "幂等 OK:DB count=1"
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
finally:
|
||||
_restore_input_env(saved)
|
||||
|
||||
|
||||
def test_importer_empty_input_dir() -> tuple[bool, str]:
|
||||
"""空 input 目录 → no-op,返回全 0。"""
|
||||
saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)}
|
||||
try:
|
||||
input_root, tmp = _isolated_input_dirs(saved)
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
from service.importer import import_input_dir
|
||||
result = import_input_dir(conn)
|
||||
if result["contracts_added"] or result["materials_added"]:
|
||||
return False, f"空目录应返回全 0,实际 {result}"
|
||||
if result["files_scanned"] != 0:
|
||||
return False, f"files_scanned={result['files_scanned']} (want 0)"
|
||||
return True, "空目录 no-op"
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
finally:
|
||||
_restore_input_env(saved)
|
||||
|
||||
|
||||
def test_download_tasks_enqueue_idempotent() -> tuple[bool, str]:
|
||||
"""enqueue 同一合同两次:第 2 次返回已有 task_id,DB 仅 1 行。"""
|
||||
from db import repository as repo
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO contracts
|
||||
(contract_id, employee_id, teacher_name, unit_id, unit_name,
|
||||
position, contract_type, contract_start, contract_end,
|
||||
trial_period_end, status)
|
||||
VALUES ('C100', 'T100', 'X', 'U1', 'Y', '', '', '2024-01-01',
|
||||
'2026-04-15', '2024-04-15', '可续聘')"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
t1 = repo.enqueue_download_task(conn, "C100", "platform_button")
|
||||
t2 = repo.enqueue_download_task(conn, "C100", "platform_button")
|
||||
if t1 != t2:
|
||||
return False, f"t1={t1} t2={t2} (应相同)"
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM download_tasks WHERE contract_id = 'C100'"
|
||||
).fetchone()["n"]
|
||||
if n != 1:
|
||||
return False, f"DB count={n} (want 1)"
|
||||
return True, f"idempotent: task_id={t1}"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_download_tasks_claim_atomic() -> tuple[bool, str]:
|
||||
"""claim_pending_tasks:第一次拿 N 个,第二次拿 0 个(被抢光)。"""
|
||||
from db import repository as repo
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
# 准备 5 份合同
|
||||
for i in range(5):
|
||||
conn.execute(
|
||||
f"""INSERT INTO contracts
|
||||
(contract_id, employee_id, teacher_name, unit_id, unit_name,
|
||||
position, contract_type, contract_start, contract_end,
|
||||
trial_period_end, status)
|
||||
VALUES ('C{i}', 'T{i}', 'X', 'U{i}', 'Y', '', '',
|
||||
'2024-01-01', '2026-04-15', '2024-04-15', '可续聘')"""
|
||||
)
|
||||
conn.commit()
|
||||
for i in range(5):
|
||||
repo.enqueue_download_task(conn, f"C{i}", "platform_button")
|
||||
conn.commit()
|
||||
|
||||
first = repo.claim_pending_tasks(conn, limit=5)
|
||||
if len(first) != 5:
|
||||
return False, f"第一次领到 {len(first)} (want 5)"
|
||||
# claim 返回的 Row 是查询时刻的快照(UPDATE 在 fetchall 之后才发);
|
||||
# 重新查 DB 验证 status 已变 running
|
||||
for t in first:
|
||||
cur = conn.execute(
|
||||
"SELECT status FROM download_tasks WHERE task_id = ?",
|
||||
(t["task_id"],),
|
||||
).fetchone()
|
||||
if cur["status"] != "running":
|
||||
return False, f"task {t['task_id']} DB status={cur['status']} (want running)"
|
||||
|
||||
second = repo.claim_pending_tasks(conn, limit=5)
|
||||
if len(second) != 0:
|
||||
return False, f"第二次领到 {len(second)} (want 0)"
|
||||
return True, "claim 原子:5+0"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_download_tasks_mark_success_and_failed() -> tuple[bool, str]:
|
||||
"""mark_success: status=success + result_path; mark_failed 计数 + 重试状态切换。"""
|
||||
from db import repository as repo
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO contracts
|
||||
(contract_id, employee_id, teacher_name, unit_id, unit_name,
|
||||
position, contract_type, contract_start, contract_end,
|
||||
trial_period_end, status)
|
||||
VALUES ('C200', 'T200', 'X', 'U1', 'Y', '', '', '2024-01-01',
|
||||
'2026-04-15', '2024-04-15', '可续聘')"""
|
||||
)
|
||||
conn.commit()
|
||||
t = repo.enqueue_download_task(conn, "C200", "platform_button")
|
||||
|
||||
# 1. 标记 success
|
||||
repo.mark_task_success(conn, t, "sandbox://hr/contracts/C200")
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT status, result_path FROM download_tasks WHERE task_id = ?", (t,)
|
||||
).fetchone()
|
||||
if row["status"] != "success":
|
||||
return False, f"success 后 status={row['status']}"
|
||||
if row["result_path"] != "sandbox://hr/contracts/C200":
|
||||
return False, f"result_path={row['result_path']}"
|
||||
|
||||
# 2. 失败 ≤ max_attempts:status 回退 pending
|
||||
t2 = repo.enqueue_download_task(conn, "C200", "platform_button_v2")
|
||||
repo.mark_task_failed(conn, t2, "first attempt failed")
|
||||
conn.commit()
|
||||
row2 = conn.execute(
|
||||
"SELECT status, attempts FROM download_tasks WHERE task_id = ?", (t2,)
|
||||
).fetchone()
|
||||
if row2["status"] != "pending":
|
||||
return False, f"第 1 次失败应 pending,实际 {row2['status']}"
|
||||
if row2["attempts"] != 1:
|
||||
return False, f"attempts={row2['attempts']} (want 1)"
|
||||
|
||||
# 3. 失败 ≥ max_attempts:status=failed
|
||||
repo.mark_task_failed(conn, t2, "second attempt failed")
|
||||
repo.mark_task_failed(conn, t2, "third attempt failed")
|
||||
conn.commit()
|
||||
row3 = conn.execute(
|
||||
"SELECT status, attempts FROM download_tasks WHERE task_id = ?", (t2,)
|
||||
).fetchone()
|
||||
if row3["status"] != "failed":
|
||||
return False, f"第 3 次失败应 failed,实际 {row3['status']}"
|
||||
return True, "mark_success/mark_failed 全 OK"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_download_tasks_count_by_status() -> tuple[bool, str]:
|
||||
"""count_tasks_by_status 返回 {pending: N, success: M, ...}。"""
|
||||
from db import repository as repo
|
||||
|
||||
conn = _make_conn()
|
||||
try:
|
||||
for i in range(3):
|
||||
conn.execute(
|
||||
f"""INSERT INTO contracts
|
||||
(contract_id, employee_id, teacher_name, unit_id, unit_name,
|
||||
position, contract_type, contract_start, contract_end,
|
||||
trial_period_end, status)
|
||||
VALUES ('CT{i}', 'T{i}', 'X', 'U{i}', 'Y', '', '',
|
||||
'2024-01-01', '2026-04-15', '2024-04-15', '可续聘')"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
t0 = repo.enqueue_download_task(conn, "CT0", "platform_button")
|
||||
t1 = repo.enqueue_download_task(conn, "CT1", "platform_button")
|
||||
repo.enqueue_download_task(conn, "CT2", "platform_button")
|
||||
conn.commit()
|
||||
|
||||
repo.mark_task_success(conn, t0, "x")
|
||||
repo.mark_task_failed(conn, t1, "err")
|
||||
# 再失败 2 次让 t1 进 failed
|
||||
repo.mark_task_failed(conn, t1, "err")
|
||||
repo.mark_task_failed(conn, t1, "err")
|
||||
conn.commit()
|
||||
|
||||
counts = repo.count_tasks_by_status(conn)
|
||||
if counts.get("success", 0) < 1:
|
||||
return False, f"success 计数 < 1: {counts}"
|
||||
if counts.get("failed", 0) < 1:
|
||||
return False, f"failed 计数 < 1: {counts}"
|
||||
if counts.get("pending", 0) < 1:
|
||||
return False, f"pending 计数 < 1: {counts}"
|
||||
return True, f"counts={counts}"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- v1.1.18: show-config / logs-list ----------
|
||||
|
||||
|
||||
def _isolated_env_file(tmp_env_path: Path) -> None:
|
||||
"""把 .env 路径重定向到 tmpdir;不影响其他 env。"""
|
||||
import util.runtime_paths as rp
|
||||
|
||||
# runtime_paths.get_env_path() 总是返回 get_skill_root() / ".env"
|
||||
# 这里通过 monkey-patch 函数对象实现隔离
|
||||
rp.get_env_path = lambda: tmp_env_path # type: ignore[assignment]
|
||||
|
||||
|
||||
def test_show_config_masks_tokens() -> tuple[bool, str]:
|
||||
"""含 TOKEN 的 key 自动脱敏。"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-"))
|
||||
env_path = tmp / ".env"
|
||||
env_path.write_text(
|
||||
"WECOM_API_TOKEN=abc123def456\n"
|
||||
"HR_API_TOKEN=xx\n"
|
||||
"LOG_LEVEL=INFO\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import service.config_viewer as cv
|
||||
orig_get_env_path = cv.get_env_path
|
||||
cv.get_env_path = lambda: env_path # type: ignore[assignment]
|
||||
try:
|
||||
result = cv.read_env_config()
|
||||
cfg = result["config"]
|
||||
if cfg.get("WECOM_API_TOKEN") != "ab***56":
|
||||
return False, f"WECOM_API_TOKEN={cfg.get('WECOM_API_TOKEN')} (want ab***56)"
|
||||
if cfg.get("HR_API_TOKEN") != "***":
|
||||
return False, f"HR_API_TOKEN={cfg.get('HR_API_TOKEN')} (want *** for ≤4 chars)"
|
||||
if cfg.get("LOG_LEVEL") != "INFO":
|
||||
return False, f"LOG_LEVEL={cfg.get('LOG_LEVEL')} (want INFO)"
|
||||
return True, "tokens masked OK"
|
||||
finally:
|
||||
cv.get_env_path = orig_get_env_path # type: ignore[assignment]
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_show_config_skips_empty() -> tuple[bool, str]:
|
||||
""".env 中空 value 的 key 不出现在 config 中。"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-"))
|
||||
env_path = tmp / ".env"
|
||||
env_path.write_text(
|
||||
"DB_PATH=\nLOG_LEVEL=INFO\n", encoding="utf-8",
|
||||
)
|
||||
|
||||
import service.config_viewer as cv
|
||||
orig = cv.get_env_path
|
||||
cv.get_env_path = lambda: env_path # type: ignore[assignment]
|
||||
try:
|
||||
cfg = cv.read_env_config()["config"]
|
||||
if "DB_PATH" in cfg:
|
||||
return False, f"DB_PATH 应该跳过,仍在 cfg: {cfg}"
|
||||
if cfg.get("LOG_LEVEL") != "INFO":
|
||||
return False, f"LOG_LEVEL={cfg.get('LOG_LEVEL')}"
|
||||
return True, "空 value 跳过 OK"
|
||||
finally:
|
||||
cv.get_env_path = orig # type: ignore[assignment]
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_show_config_skips_comments() -> tuple[bool, str]:
|
||||
"""# 注释行不计入 config。"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-"))
|
||||
env_path = tmp / ".env"
|
||||
env_path.write_text(
|
||||
"# 这是注释\n"
|
||||
" # 也是注释\n"
|
||||
"LOG_LEVEL=INFO\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import service.config_viewer as cv
|
||||
orig = cv.get_env_path
|
||||
cv.get_env_path = lambda: env_path # type: ignore[assignment]
|
||||
try:
|
||||
cfg = cv.read_env_config()["config"]
|
||||
if len(cfg) != 1 or "LOG_LEVEL" not in cfg:
|
||||
return False, f"应仅 1 项 LOG_LEVEL,实际 {list(cfg.keys())}"
|
||||
return True, "注释跳过 OK"
|
||||
finally:
|
||||
cv.get_env_path = orig # type: ignore[assignment]
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_show_config_handles_quotes() -> tuple[bool, str]:
|
||||
""".env 中带双引号/单引号的值自动去引号。"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-"))
|
||||
env_path = tmp / ".env"
|
||||
env_path.write_text(
|
||||
'HR_API_BASE_URL="https://hr.example.edu/api/v1"\n'
|
||||
"GREETING='hello world'\n"
|
||||
"NOTE=no_quotes\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import service.config_viewer as cv
|
||||
orig = cv.get_env_path
|
||||
cv.get_env_path = lambda: env_path # type: ignore[assignment]
|
||||
try:
|
||||
cfg = cv.read_env_config()["config"]
|
||||
if cfg.get("HR_API_BASE_URL") != "https://hr.example.edu/api/v1":
|
||||
return False, f"双引号未去: {cfg.get('HR_API_BASE_URL')}"
|
||||
if cfg.get("GREETING") != "hello world":
|
||||
return False, f"单引号未去: {cfg.get('GREETING')}"
|
||||
if cfg.get("NOTE") != "no_quotes":
|
||||
return False, f"无引号: {cfg.get('NOTE')}"
|
||||
return True, "引号处理 OK"
|
||||
finally:
|
||||
cv.get_env_path = orig # type: ignore[assignment]
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_logs_viewer_nonexistent_file() -> tuple[bool, str]:
|
||||
"""日志文件不存在时,返回 exists=False,lines=[]。"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp(prefix="alert-hr-logs-"))
|
||||
fake_log = tmp / "no-such-file.log"
|
||||
|
||||
os.environ["LOG_FILE"] = str(fake_log)
|
||||
try:
|
||||
from service.logs_viewer import tail_log_file
|
||||
result = tail_log_file(tail=50)
|
||||
if result["exists"]:
|
||||
return False, "exists 应为 False"
|
||||
if result["lines"]:
|
||||
return False, f"lines 应为空: {result['lines']}"
|
||||
if result["returned_lines"] != 0:
|
||||
return False, f"returned_lines={result['returned_lines']}"
|
||||
return True, "non-existent file OK"
|
||||
finally:
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_logs_viewer_tail_and_filter() -> tuple[bool, str]:
|
||||
"""tail + level 过滤:INFO 默认只显示 INFO/WARNING/ERROR。"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp(prefix="alert-hr-logs-"))
|
||||
log_path = tmp / "tail-test.log"
|
||||
lines: list[str] = []
|
||||
for i in range(300):
|
||||
if i % 3 == 0:
|
||||
lines.append(f"2026-09-15 12:34:5{i%10} [alert-contract-expiring-hr] INFO 第{i}条")
|
||||
elif i % 3 == 1:
|
||||
lines.append(f"2026-09-15 12:34:5{i%10} [alert-contract-expiring-hr] WARNING 第{i}条")
|
||||
else:
|
||||
lines.append(f"2026-09-15 12:34:5{i%10} [alert-contract-expiring-hr] DEBUG 第{i}条")
|
||||
log_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
os.environ["LOG_FILE"] = str(log_path)
|
||||
try:
|
||||
from service.logs_viewer import tail_log_file
|
||||
# INFO+:最后 50 行中含 INFO + WARNING,去掉 DEBUG
|
||||
# 300 行每 3 行一循环:i%3==0 INFO, i%3==1 WARN, i%3==2 DEBUG
|
||||
# 最后 50 行是 i=250..299:第 250=INFO 251=WARN 252=DEBUG ... 共 50 行
|
||||
# 分布:i%3==0 → 17 (250, 253, ..., 299 但 299%3=0)... 实际 250..299 中 i%3==0 有 17 个
|
||||
result_info = tail_log_file(tail=50, min_level="INFO")
|
||||
if result_info["returned_lines"] != 33:
|
||||
return False, f"INFO+ returned_lines={result_info['returned_lines']} (want 33)"
|
||||
if any(" DEBUG " in ln for ln in result_info["lines"]):
|
||||
return False, "INFO 过滤应排除 DEBUG 行"
|
||||
|
||||
# WARNING+:仅 WARNING
|
||||
result_warn = tail_log_file(tail=50, min_level="WARNING")
|
||||
if result_warn["returned_lines"] != 17:
|
||||
return False, f"WARNING+ returned_lines={result_warn['returned_lines']} (want 17)"
|
||||
if not all(" WARNING " in ln for ln in result_warn["lines"]):
|
||||
return False, "WARNING 过滤应只剩 WARNING 行"
|
||||
|
||||
# DEBUG:全部 50 行都返回
|
||||
result_debug = tail_log_file(tail=50, min_level="DEBUG")
|
||||
if result_debug["returned_lines"] != 50:
|
||||
return False, f"DEBUG returned_lines={result_debug['returned_lines']} (want 50)"
|
||||
return True, f"tail+filter OK: DEBUG+={result_debug['returned_lines']}, INFO+={result_info['returned_lines']}, WARN+={result_warn['returned_lines']}"
|
||||
finally:
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_logs_viewer_resolves_relative_path() -> tuple[bool, str]:
|
||||
"""LOG_FILE 设相对路径时,解析到 get_logs_dir() 下。"""
|
||||
from util.runtime_paths import get_logs_dir
|
||||
os.environ["LOG_FILE"] = "relative-test.log"
|
||||
os.environ.pop("JIANGCHANG_LOGS_DIR", None)
|
||||
try:
|
||||
from service.logs_viewer import _resolve_log_path
|
||||
got = _resolve_log_path()
|
||||
expected = get_logs_dir() / "relative-test.log"
|
||||
if got != expected:
|
||||
return False, f"_resolve_log_path={got} (want {expected})"
|
||||
return True, f"resolved to {got}"
|
||||
finally:
|
||||
os.environ.pop("LOG_FILE", None)
|
||||
|
||||
|
||||
# ---------- 入口 ----------
|
||||
|
||||
def main() -> int:
|
||||
@@ -785,6 +1445,25 @@ def main() -> int:
|
||||
_run("platform_collector/parse_days_positive", test_parse_days_positive)
|
||||
_run("platform_collector/parse_days_negative", test_parse_days_negative)
|
||||
_run("platform_collector/parse_alert_levels", test_parse_alert_red_orange_yellow_unknown)
|
||||
_run("logging/rotating_file_handler", test_logging_rotating_file_handler)
|
||||
_run("logging/fallback_when_log_file_unset", test_logging_fallback_when_log_file_unset)
|
||||
_run("logging/level_env", test_logging_level_env)
|
||||
_run("logging/logs_dir_default", test_logs_dir_default_under_data)
|
||||
_run("importer/contracts_csv_upsert", test_importer_contracts_csv_upsert)
|
||||
_run("importer/materials_pdf_enqueue", test_importer_materials_pdf_enqueue)
|
||||
_run("importer/idempotent", test_importer_idempotent)
|
||||
_run("importer/empty_input_dir", test_importer_empty_input_dir)
|
||||
_run("download_tasks/enqueue_idempotent", test_download_tasks_enqueue_idempotent)
|
||||
_run("download_tasks/claim_atomic", test_download_tasks_claim_atomic)
|
||||
_run("download_tasks/mark_success_and_failed", test_download_tasks_mark_success_and_failed)
|
||||
_run("download_tasks/count_by_status", test_download_tasks_count_by_status)
|
||||
_run("show_config/masks_tokens", test_show_config_masks_tokens)
|
||||
_run("show_config/skips_empty", test_show_config_skips_empty)
|
||||
_run("show_config/skips_comments", test_show_config_skips_comments)
|
||||
_run("show_config/handles_quotes", test_show_config_handles_quotes)
|
||||
_run("logs_viewer/nonexistent_file", test_logs_viewer_nonexistent_file)
|
||||
_run("logs_viewer/tail_and_filter", test_logs_viewer_tail_and_filter)
|
||||
_run("logs_viewer/resolves_relative_path", test_logs_viewer_resolves_relative_path)
|
||||
|
||||
total = len(_RESULTS)
|
||||
passed = sum(1 for _, ok, _ in _RESULTS if ok)
|
||||
|
||||
Reference in New Issue
Block a user