first commit

This commit is contained in:
2025-11-28 14:35:21 +08:00
commit c9634f672a
195 changed files with 73003 additions and 0 deletions

43
tests/conftest.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
"""
Pytest 全局配置:
- 在测试启动时自动加载项目根目录下的 .env 文件,把其中的变量注入到 os.environ。
- 这样可以在不手动 export 的情况下,将 H3C_NETCONF_* 等变量提供给 live 测试与 HTTP E2E。
"""
import os
from pathlib import Path
def _load_dotenv_from_project_root() -> None:
root = Path(__file__).resolve().parents[1]
env_file = root / ".env"
if not env_file.exists():
return
try:
content = env_file.read_text(encoding="utf-8")
except OSError:
return
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
# 去掉首尾成对的引号(支持单引号或双引号)
if (value.startswith("'") and value.endswith("'")) or (
value.startswith('"') and value.endswith('"')
):
value = value[1:-1]
# 已存在的环境变量优先生效,.env 只作为默认值
os.environ.setdefault(key, value)
_load_dotenv_from_project_root()

183
tests/test_api_devices.py Normal file
View File

@@ -0,0 +1,183 @@
from typing import Tuple
import sqlite3
import pytest
from fastapi.testclient import TestClient
from exporter.api import create_app
from exporter.config import DeviceConfig, GlobalConfig
from exporter.metrics import TransceiverCollector
from exporter.models import DeviceHealthState, DeviceMetricsSnapshot
from exporter.registry import DeviceRegistry
from exporter.sqlite_store import PasswordEncryptor, SQLiteDeviceStore
VALID_FERNET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
@pytest.fixture
def global_cfg(tmp_path) -> GlobalConfig:
cfg = GlobalConfig()
cfg.api_token = "changeme"
cfg.runtime_db_path = str(tmp_path / "devices.db")
cfg.password_secret = VALID_FERNET_KEY
return cfg
def _make_app_and_registry(global_cfg: GlobalConfig) -> Tuple[TestClient, DeviceRegistry]:
encryptor = PasswordEncryptor(global_cfg.password_secret)
store = SQLiteDeviceStore(global_cfg.runtime_db_path, encryptor)
store.init_db()
registry = DeviceRegistry(global_scrape_interval=global_cfg.scrape_interval_seconds)
# cache/health 可共享空 dict
cache: dict[str, DeviceMetricsSnapshot] = {}
health: dict[str, DeviceHealthState] = {}
collector = TransceiverCollector(cache, health)
app = create_app(registry, store, collector, global_cfg)
return TestClient(app), registry
@pytest.fixture
def app_with_registry(global_cfg) -> Tuple[TestClient, DeviceRegistry]:
return _make_app_and_registry(global_cfg)
def test_get_devices_requires_auth(app_with_registry):
client, _ = app_with_registry
resp = client.get("/api/v1/devices")
assert resp.status_code == 401
def test_get_devices_returns_list(app_with_registry):
client, _ = app_with_registry
resp = client.get("/api/v1/devices", headers={"X-API-Token": "changeme"})
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_post_device_creates_runtime_device(app_with_registry):
client, registry = app_with_registry
device_data = {
"name": "new-device",
"host": "192.168.1.100",
"port": 830,
"username": "admin",
"password": "secret",
"enabled": True,
}
resp = client.post(
"/api/v1/devices",
headers={"X-API-Token": "changeme"},
json=device_data,
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "new-device"
assert body["source"] == "runtime"
# registry 中也应该有
devices = registry.list_devices()
assert any(d.name == "new-device" and d.source == "runtime" for d in devices)
def test_post_duplicate_device_returns_409(app_with_registry):
client, _ = app_with_registry
device_data = {
"name": "dup-dev",
"host": "192.168.1.100",
"port": 830,
"username": "admin",
"password": "secret",
"enabled": True,
}
resp1 = client.post(
"/api/v1/devices",
headers={"X-API-Token": "changeme"},
json=device_data,
)
assert resp1.status_code == 201
resp2 = client.post(
"/api/v1/devices",
headers={"X-API-Token": "changeme"},
json=device_data,
)
assert resp2.status_code == 409
def test_delete_runtime_device(app_with_registry):
client, _ = app_with_registry
device_data = {
"name": "to-delete",
"host": "192.168.1.100",
"port": 830,
"username": "admin",
"password": "secret",
"enabled": True,
}
client.post(
"/api/v1/devices",
headers={"X-API-Token": "changeme"},
json=device_data,
)
resp = client.delete(
"/api/v1/devices/to-delete",
headers={"X-API-Token": "changeme"},
)
assert resp.status_code == 204
devices = client.get(
"/api/v1/devices",
headers={"X-API-Token": "changeme"},
).json()
assert "to-delete" not in [d["name"] for d in devices]
def test_delete_static_device_fails(app_with_registry):
client, registry = app_with_registry
static_dev = DeviceConfig(
name="static-1",
host="10.0.0.2",
port=830,
username="u",
password="p",
source="static",
)
registry.register_static_device(static_dev)
resp = client.delete(
"/api/v1/devices/static-1",
headers={"X-API-Token": "changeme"},
)
assert resp.status_code == 400
assert "static device" in resp.json()["detail"].lower()
def test_healthz_endpoint(app_with_registry):
client, _ = app_with_registry
resp = client.get("/healthz")
assert resp.status_code == 200
data = resp.json()
assert "status" in data
assert "devices_total" in data
def test_metrics_endpoint_returns_prometheus_format(app_with_registry):
client, _ = app_with_registry
resp = client.get("/metrics")
assert resp.status_code == 200
assert "text/plain" in resp.headers["content-type"]
assert "# HELP" in resp.text
assert "netconf_scrape_success" in resp.text

86
tests/test_config.py Normal file
View File

@@ -0,0 +1,86 @@
from pathlib import Path
import pytest
from exporter.config import Config
VALID_FERNET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" # 44 chars base64 -> 32 bytes
def test_config_loads_global_fields(tmp_path: Path):
yaml_content = f"""
global:
http_listen: "0.0.0.0:9100"
scrape_interval_seconds: 60
netconf_port: 830
connect_timeout_seconds: 5
rpc_timeout_seconds: 10
max_workers: 10
api_token: "changeme"
runtime_db_path: "./devices.db"
password_secret: "{VALID_FERNET_KEY}"
ssh_keepalive_seconds: 30
failure_threshold: 3
max_backoff_factor: 8
shutdown_timeout_seconds: 30
log_level: INFO
log_to_stdout: true
log_file: ""
log_file_max_bytes: 10485760
log_file_backup_count: 5
"""
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text(yaml_content)
cfg = Config.from_file(cfg_file)
g = cfg.global_
assert g.http_listen == "0.0.0.0:9100"
assert g.scrape_interval_seconds == 60
assert g.rpc_timeout_seconds == 10
assert g.runtime_db_path == "./devices.db"
assert g.password_secret == VALID_FERNET_KEY
assert g.ssh_keepalive_seconds == 30
assert g.failure_threshold == 3
assert g.max_backoff_factor == 8
assert g.shutdown_timeout_seconds == 30
def test_config_invalid_fernet_key_raises():
data = {
"global": {
"runtime_db_path": "./devices.db",
"password_secret": "too-short",
}
}
with pytest.raises(ValueError, match="Invalid Fernet key"):
Config.from_dict(data)
def test_shutdown_timeout_too_small_warns():
data = {
"global": {
"http_listen": "0.0.0.0:9100",
"scrape_interval_seconds": 60,
"netconf_port": 830,
"connect_timeout_seconds": 5,
"rpc_timeout_seconds": 20,
"max_workers": 10,
"api_token": "changeme",
"runtime_db_path": "./devices.db",
"password_secret": VALID_FERNET_KEY,
"ssh_keepalive_seconds": 30,
"failure_threshold": 3,
"max_backoff_factor": 8,
"shutdown_timeout_seconds": 10,
"log_level": "INFO",
"log_to_stdout": True,
"log_file": "",
"log_file_max_bytes": 10485760,
"log_file_backup_count": 5,
}
}
with pytest.warns(UserWarning):
Config.from_dict(data)

125
tests/test_connection.py Normal file
View File

@@ -0,0 +1,125 @@
from types import SimpleNamespace
import pytest
from exporter.config import DeviceConfig, GlobalConfig
from exporter.connection import ConnectionManager
class DummyManager:
def __init__(self) -> None:
self.transport = SimpleNamespace(set_keepalive=lambda interval: None)
self.closed = False
def close_session(self) -> None:
self.closed = True
@pytest.fixture
def global_cfg() -> GlobalConfig:
cfg = GlobalConfig()
cfg.ssh_keepalive_seconds = 30
return cfg
@pytest.fixture
def device_cfg() -> DeviceConfig:
return DeviceConfig(
name="dev1",
host="1.1.1.1",
port=830,
username="u",
password="p",
)
def test_acquire_session_uses_ncclient_connect(monkeypatch, global_cfg, device_cfg):
called = {}
def fake_connect(**kwargs):
called.update(kwargs)
return DummyManager()
monkeypatch.setattr("exporter.connection.ncclient.manager.connect", fake_connect)
mgr = ConnectionManager(global_cfg)
session = mgr.acquire_session(device_cfg)
assert isinstance(session, DummyManager)
assert called["host"] == device_cfg.host
assert called["port"] == device_cfg.port
assert called["username"] == device_cfg.username
assert called["password"] == device_cfg.password
def test_enable_ssh_keepalive_called(monkeypatch, global_cfg, device_cfg):
class DummyTransport:
def __init__(self) -> None:
self.keepalive_interval = None
def set_keepalive(self, interval: int) -> None:
self.keepalive_interval = interval
class ManagerWithTransport(DummyManager):
def __init__(self) -> None:
super().__init__()
self.transport = DummyTransport()
mgr_instance = ManagerWithTransport()
def fake_connect(**kwargs):
return mgr_instance
monkeypatch.setattr("exporter.connection.ncclient.manager.connect", fake_connect)
cm = ConnectionManager(global_cfg)
cm.acquire_session(device_cfg)
assert mgr_instance.transport.keepalive_interval == global_cfg.ssh_keepalive_seconds
def test_mark_session_invalid_triggers_reconnect(monkeypatch, global_cfg, device_cfg):
mgr_instances = []
def fake_connect(**kwargs):
mgr = DummyManager()
mgr_instances.append(mgr)
return mgr
monkeypatch.setattr("exporter.connection.ncclient.manager.connect", fake_connect)
cm = ConnectionManager(global_cfg)
sess1 = cm.acquire_session(device_cfg)
assert len(mgr_instances) == 1
cm.mark_session_invalid(device_cfg.name)
sess2 = cm.acquire_session(device_cfg)
assert len(mgr_instances) == 2
assert sess2 is mgr_instances[1]
# 原会话应被关闭
assert mgr_instances[0].closed is True
def test_close_all_closes_all_sessions(monkeypatch, global_cfg):
mgr_instances = []
def fake_connect(**kwargs):
mgr = DummyManager()
mgr_instances.append(mgr)
return mgr
monkeypatch.setattr("exporter.connection.ncclient.manager.connect", fake_connect)
cm = ConnectionManager(global_cfg)
dev1 = DeviceConfig(name="dev1", host="1.1.1.1", port=830, username="u", password="p")
dev2 = DeviceConfig(name="dev2", host="2.2.2.2", port=830, username="u", password="p")
cm.acquire_session(dev1)
cm.acquire_session(dev2)
assert len(mgr_instances) == 2
cm.close_all()
assert mgr_instances[0].closed is True
assert mgr_instances[1].closed is True

View File

@@ -0,0 +1,16 @@
import xml.etree.ElementTree as ET
import paramiko
from ncclient.operations.errors import TimeoutExpiredError
from exporter.errors import classify_error
def test_classify_error_from_exception():
assert classify_error(paramiko.SSHException()) == "SSHError"
assert classify_error(TimeoutError()) == "TimeoutError"
assert classify_error(TimeoutExpiredError("rpc timeout")) == "TimeoutError"
assert classify_error(ET.ParseError()) == "XMLParseError"
assert classify_error(PermissionError()) == "AuthenticationError"
assert classify_error(RuntimeError("filter failed")) == "FilterError"
assert classify_error(RuntimeError("something else")) == "UnknownError"

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
"""
与真实 H3C 设备联调的“活体”测试用例。
说明:
- 连接参数参考 exp/yangcli/run_yangcli.sh:
- server=127.0.0.1
- ncport=8830
- user=netconf_user
- password='...'
- 为避免在仓库中硬编码明文密码,本测试通过环境变量注入密码:
- H3C_NETCONF_PASSWORD
- 可选:H3C_NETCONF_HOST / H3C_NETCONF_PORT / H3C_NETCONF_USER
默认行为:
- 若未设置 H3C_NETCONF_PASSWORD,或无法建立到指定 host:port 的 TCP 连接,
则使用 pytest.skip() 自动跳过,不影响普通单元测试/CI。
- 仅在本地联调时、显式设置上述环境变量后,此测试才会真正访问设备。
"""
import os
import re
import socket
from pathlib import Path
import pytest
from ncclient import manager
from exporter.netconf_client import build_transceiver_filter, parse_netconf_response
H3C_HOST = os.getenv("H3C_NETCONF_HOST", "127.0.0.1")
H3C_PORT = int(os.getenv("H3C_NETCONF_PORT", "8830"))
H3C_USER = os.getenv("H3C_NETCONF_USER", "netconf_user")
def _load_password_from_script() -> str:
"""
尝试从 exp/yangcli 下的脚本中解析 --password='...'.
这样可以重用你已经验证过的 yangcli 参数,而不在测试里硬编码密码。
若脚本不存在或未找到 password,则返回空字符串。
"""
root = Path(__file__).resolve().parents[1]
candidates = [
root / "exp" / "yangcli" / "run_yangcli_h3c.sh",
root / "exp" / "yangcli" / "run_yangcli.sh",
]
pattern = re.compile(r"--password='([^']*)'")
for path in candidates:
if not path.exists():
continue
text = path.read_text(encoding="utf-8")
match = pattern.search(text)
if match:
return match.group(1)
return ""
H3C_PASSWORD = os.getenv("H3C_NETCONF_PASSWORD") or _load_password_from_script()
def _can_connect(host: str, port: int, timeout: float = 2.0) -> bool:
"""快速探测 host:port 是否可连,用于决定是否跳过 live 测试。"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout)
sock.connect((host, port))
return True
except OSError:
return False
finally:
sock.close()
@pytest.mark.h3c_live
def test_h3c_live_transceiver_rpc_and_parse() -> None:
"""
使用真实 H3C 设备验证:
- ncclient 能与设备建立 NETCONF 会话;
- build_transceiver_filter() 构造的 subtree filter 在设备上可用;
- parse_netconf_response() 能正确解析设备返回的 XML,并得到非空结果。
"""
if not _can_connect(H3C_HOST, H3C_PORT):
pytest.skip(f"H3C NETCONF {H3C_HOST}:{H3C_PORT} 不可达,跳过 live 测试")
flt = build_transceiver_filter()
with manager.connect(
host=H3C_HOST,
port=H3C_PORT,
username=H3C_USER,
password=H3C_PASSWORD,
hostkey_verify=False,
timeout=30,
allow_agent=False,
look_for_keys=False,
) as m:
reply = m.get(filter=("subtree", flt))
xml_str = str(reply)
transceivers, channels = parse_netconf_response(xml_str, device_name=f"h3c-{H3C_HOST}")
# 只要返回非空结果,就说明“连接 + filter + 解析”在真实设备上可以工作
assert transceivers or channels, "H3C 设备未返回任何 transceiver/channel 数据"

View File

@@ -0,0 +1,265 @@
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict
import pytest
import yaml
from cryptography.fernet import Fernet
@pytest.mark.http_e2e
def test_exporter_http_end_to_end(tmp_path) -> None:
"""
端到端集成测试:
- 使用独立子进程启动 `python -m exporter.main`;
- 通过 HTTP 访问 /healthz, /api/v1/devices, /metrics;
- 验证配置加载、SQLite 初始化、API 路由与 Prometheus 输出整体链路可用。
"""
# 1. 生成最小可用 config.yaml
key = Fernet.generate_key().decode()
db_path = tmp_path / "devices.db"
cfg_path = tmp_path / "config.yaml"
config: Dict[str, Any] = {
"global": {
"http_listen": "127.0.0.1:19100",
"scrape_interval_seconds": 2,
"rpc_timeout_seconds": 2,
"shutdown_timeout_seconds": 10,
"runtime_db_path": str(db_path),
"password_secret": key,
"api_token": "changeme",
},
"devices": [],
}
cfg_path.write_text(yaml.safe_dump(config), encoding="utf-8")
# 2. 启动 exporter 子进程
root = Path(__file__).resolve().parents[1]
env = os.environ.copy()
src_path = str(root / "src")
pythonpath = env.get("PYTHONPATH")
env["PYTHONPATH"] = src_path if not pythonpath else os.pathsep.join([src_path, pythonpath])
proc = subprocess.Popen(
[sys.executable, "-m", "exporter.main", "--config", str(cfg_path)],
cwd=str(root),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
base_url = "http://127.0.0.1:19100"
def _http_request(path: str, method: str = "GET", body: bytes | None = None, headers: Dict[str, str] | None = None):
import http.client
conn = http.client.HTTPConnection("127.0.0.1", 19100, timeout=5)
try:
conn.request(method, path, body=body, headers=headers or {})
resp = conn.getresponse()
data = resp.read()
return resp.status, resp.getheaders(), data
finally:
conn.close()
try:
# 3. 等待 HTTP server 启动(轮询 /healthz)
deadline = time.time() + 30
last_stdout = ""
while True:
if time.time() > deadline:
proc.terminate()
out, err = proc.communicate(timeout=5)
raise AssertionError(f"Exporter did not start in time.\nSTDOUT:\n{out}\nSTDERR:\n{err}")
if proc.poll() is not None:
out, err = proc.communicate(timeout=5)
raise AssertionError(f"Exporter exited early with code {proc.returncode}.\nSTDOUT:\n{out}\nSTDERR:\n{err}")
try:
status, _, data = _http_request("/healthz")
if status == 200:
health = json.loads(data.decode("utf-8"))
assert health.get("status") == "ok"
break
except Exception:
# server 可能尚未 ready,稍后重试
time.sleep(0.5)
# 4. 通过 API 注册一个 runtime 设备
device_payload = {
"name": "e2e-device-1",
"host": "192.0.2.10",
"port": 830,
"username": "netconf_user",
"password": "secret",
"enabled": True,
}
headers = {
"Content-Type": "application/json",
"X-API-Token": "changeme",
}
status, _, data = _http_request(
"/api/v1/devices",
method="POST",
body=json.dumps(device_payload).encode("utf-8"),
headers=headers,
)
assert status == 201, f"unexpected status for POST /api/v1/devices: {status}, body={data!r}"
body_json = json.loads(data.decode("utf-8"))
assert body_json["name"] == "e2e-device-1"
assert body_json["source"] == "runtime"
# 5. 访问 /metrics,验证 Prometheus 输出可用
status, headers_list, data = _http_request("/metrics")
assert status == 200
header_dict = {k.lower(): v for k, v in headers_list}
assert "text/plain" in header_dict.get("content-type", "")
text = data.decode("utf-8")
assert "# HELP netconf_scrape_success" in text
finally:
# 6. 优雅关闭 exporter 进程
if proc.poll() is None:
try:
proc.send_signal(signal.SIGTERM)
except Exception:
proc.terminate()
try:
proc.wait(timeout=20)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=10)
@pytest.mark.http_e2e
@pytest.mark.h3c_live
def test_exporter_http_with_h3c_device(tmp_path) -> None:
"""
带真实 H3C 设备的端到端测试:
- 使用 run_yangcli*.sh 中的 H3C 连接参数;
- 启动完整 exporter.main(包含 Scraper 和 HTTP server);
- 静态配置一台 H3C 设备;
- 等待至少一轮采集后,通过 /metrics 检查该设备的健康指标是否存在。
"""
# 为避免依赖其他测试模块,这里重复读取 H3C 连接参数
import socket
host = os.getenv("H3C_NETCONF_HOST", "127.0.0.1")
port = int(os.getenv("H3C_NETCONF_PORT", "8830"))
user = os.getenv("H3C_NETCONF_USER", "netconf_user")
password = os.getenv("H3C_NETCONF_PASSWORD", "")
def _can_connect_local(h: str, p: int, timeout: float = 2.0) -> bool:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.settimeout(timeout)
s.connect((h, p))
return True
except OSError:
return False
finally:
s.close()
if not _can_connect_local(host, port):
pytest.skip(f"H3C NETCONF {host}:{port} 不可达,跳过 H3C HTTP E2E 测试")
key = Fernet.generate_key().decode()
db_path = tmp_path / "devices.db"
cfg_path = tmp_path / "config_h3c.yaml"
config: Dict[str, Any] = {
"global": {
"http_listen": "127.0.0.1:19101",
"scrape_interval_seconds": 5,
"rpc_timeout_seconds": 10,
"shutdown_timeout_seconds": 20,
"runtime_db_path": str(db_path),
"password_secret": key,
"api_token": "changeme-h3c",
},
"devices": [
{
"name": "h3c-live-1",
"host": host,
"port": port,
"username": user,
"password": password,
"enabled": True,
}
],
}
cfg_path.write_text(yaml.safe_dump(config), encoding="utf-8")
root = Path(__file__).resolve().parents[1]
env = os.environ.copy()
src_path = str(root / "src")
pythonpath = env.get("PYTHONPATH")
env["PYTHONPATH"] = src_path if not pythonpath else os.pathsep.join([src_path, pythonpath])
proc = subprocess.Popen(
[sys.executable, "-m", "exporter.main", "--config", str(cfg_path)],
cwd=str(root),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def _http_request(path: str, method: str = "GET", body: bytes | None = None, headers: Dict[str, str] | None = None):
import http.client
conn = http.client.HTTPConnection("127.0.0.1", 19101, timeout=10)
try:
conn.request(method, path, body=body, headers=headers or {})
resp = conn.getresponse()
data = resp.read()
return resp.status, resp.getheaders(), data
finally:
conn.close()
try:
# 等待 server 起
deadline = time.time() + 60
while True:
if time.time() > deadline:
proc.terminate()
out, err = proc.communicate(timeout=5)
raise AssertionError(f"Exporter (H3C E2E) did not start in time.\nSTDOUT:\n{out}\nSTDERR:\n{err}")
if proc.poll() is not None:
out, err = proc.communicate(timeout=5)
raise AssertionError(f"Exporter (H3C E2E) exited early with code {proc.returncode}.\nSTDOUT:\n{out}\nSTDERR:\n{err}")
try:
status, _, data = _http_request("/healthz")
if status == 200:
break
except Exception:
time.sleep(0.5)
# 等待至少一轮 Scraper(scrape_interval_seconds=5)
time.sleep(7)
# 检查 /metrics 可访问且包含健康指标定义
status, _, data = _http_request("/metrics")
assert status == 200
text = data.decode("utf-8")
assert "# HELP netconf_scrape_success" in text
finally:
if proc.poll() is None:
try:
proc.send_signal(signal.SIGTERM)
except Exception:
proc.terminate()
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=10)

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import logging
from io import StringIO
from exporter.config import GlobalConfig
from exporter.logging_utils import DeviceLoggerAdapter, DeviceFieldFilter, init_logging
def _capture_log_output(logger: logging.Logger) -> tuple[StringIO, logging.Handler]:
stream = StringIO()
handler = logging.StreamHandler(stream)
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s [%(name)s] device=%(device)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
return stream, handler
def test_device_logger_adapter_injects_device() -> None:
logger = logging.getLogger("test_logger_adapter_injects_device")
stream, handler = _capture_log_output(logger)
try:
adapter = DeviceLoggerAdapter(logger, {"device": "dev1"})
adapter.info("hello world")
output = stream.getvalue()
finally:
logger.removeHandler(handler)
assert "device=dev1" in output
# 简单校验时间格式中包含 "T"(ISO8601 风格)
assert "T" in output.split()[0]
def test_device_logger_fallback_device_dash() -> None:
logger = logging.getLogger("test_logger_adapter_fallback")
stream, handler = _capture_log_output(logger)
try:
# 不在 extra 中显式提供 device,Adapter 应该使用 "-" 作为默认值
adapter = DeviceLoggerAdapter(logger, {})
adapter.info("no device context")
output = stream.getvalue()
finally:
logger.removeHandler(handler)
assert "device=-" in output
def test_init_logging_configures_root_logger_handlers() -> None:
"""验证 init_logging 根据 GlobalConfig 初始化根 logger。"""
gc = GlobalConfig(
log_level="INFO",
log_to_stdout=True,
log_file="",
)
# 调用被测函数
init_logging(gc)
root = logging.getLogger()
# 至少应该有一个 handler(stdout)
assert root.handlers, "root logger should have at least one handler"
for handler in root.handlers:
fmt = handler.formatter._fmt # type: ignore[attr-defined]
datefmt = handler.formatter.datefmt # type: ignore[attr-defined]
assert "device=%(device)s" in fmt
assert "%Y-%m-%dT%H:%M:%S%z" == datefmt
# 至少有一个 handler 安装了 DeviceFieldFilter,保证没有 device 字段的记录也能格式化
assert any(
isinstance(flt, DeviceFieldFilter)
for handler in root.handlers
for flt in handler.filters
)

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict
import pytest
import yaml
from cryptography.fernet import Fernet
@pytest.mark.asyncio
async def test_async_main_starts_and_stops_cleanly(tmp_path, monkeypatch) -> None:
"""
集成级测试:
- 使用最小 config.yaml;
- 替换 SQLiteDeviceStore / ConnectionManager / scraper_loop / uvicorn.Server;
- 验证 async_main 能够启动并在 server 结束后优雅关闭 Scraper 和连接。
"""
# 延迟导入以便 monkeypatch 作用于 exporter.main 模块对象
from exporter import main as exporter_main
# 准备配置文件
key = Fernet.generate_key().decode()
config_path = tmp_path / "config.yaml"
config_data: Dict[str, Any] = {
"global": {
"http_listen": "127.0.0.1:0",
"runtime_db_path": str(tmp_path / "devices.db"),
"password_secret": key,
# 其他字段使用默认值
},
"devices": [],
}
config_path.write_text(yaml.safe_dump(config_data), encoding="utf-8")
created_store: Dict[str, Any] = {}
created_cm: Dict[str, Any] = {}
seen: Dict[str, Any] = {}
class DummyStore:
def __init__(self, db_path: str, encryptor, timeout: float = 5.0) -> None:
self.db_path = db_path
self.encryptor = encryptor
self.timeout = timeout
self.init_db_called = False
self.closed = False
created_store["instance"] = self
def init_db(self) -> None:
self.init_db_called = True
def load_runtime_devices(self):
return []
def save_device(self, cfg) -> None: # pragma: no cover - not used here
raise NotImplementedError
def delete_device(self, name: str) -> None: # pragma: no cover - not used here
raise NotImplementedError
def close(self) -> None:
self.closed = True
class DummyConnectionManager:
def __init__(self, global_cfg) -> None:
self.global_cfg = global_cfg
self.closed = False
created_cm["instance"] = self
def acquire_session(self, cfg): # pragma: no cover - Scraper 不会真正调用
raise NotImplementedError
def close_all(self) -> None:
self.closed = True
def dummy_scraper_loop(
stop_event,
registry,
connection_manager,
netconf_get_rpc,
cache,
health,
global_cfg,
) -> None:
# 记录 stop_event,并等待它被置位后退出
seen["stop_event"] = stop_event
stop_event.wait(timeout=1.0)
class DummyServer:
def __init__(self, config) -> None:
self.config = config
self.served = False
async def serve(self) -> None:
# 立即返回以触发 shutdown 流程
await asyncio.sleep(0)
self.served = True
# 应用 monkeypatch
monkeypatch.setattr(exporter_main, "SQLiteDeviceStore", DummyStore)
monkeypatch.setattr(exporter_main, "ConnectionManager", DummyConnectionManager)
monkeypatch.setattr(exporter_main, "scraper_loop", dummy_scraper_loop)
monkeypatch.setattr(exporter_main.uvicorn, "Server", DummyServer)
# 运行 async_main(应当能够在短时间内返回)
await exporter_main.async_main(["--config", str(config_path)])
store = created_store["instance"]
cm = created_cm["instance"]
# 验证 DB 初始化与关闭被调用
assert store.init_db_called is True
assert store.closed is True
# 验证连接被关闭
assert cm.closed is True
# 验证 Scraper 收到 stop_event 并退出
assert "stop_event" in seen
assert seen["stop_event"].is_set()

132
tests/test_metrics.py Normal file
View File

@@ -0,0 +1,132 @@
import time
from exporter.metrics import TransceiverCollector
from exporter.models import (
DeviceHealthState,
DeviceMetricsSnapshot,
TransceiverChannelRecord,
TransceiverRecord,
)
def _collect_metrics(collector: TransceiverCollector):
return list(collector.collect())
def test_collector_exports_transceiver_and_info_with_component_name():
snapshot = DeviceMetricsSnapshot(
device="dev1",
collected_at=time.time(),
transceivers=(
TransceiverRecord(
device="dev1",
component_name="comp1",
logical_port="1/0/1",
present="PRESENT",
oper_status="ACTIVE",
temperature_c=40.0,
supply_voltage_v=3.3,
vendor="H3C",
serial="SN1",
part_number="PN",
hardware_rev="1.0",
),
),
channels=(),
)
cache = {"dev1": snapshot}
health = {
"dev1": DeviceHealthState(
last_scrape_success=True,
last_scrape_duration=0.1,
last_scrape_timestamp=time.time(),
last_error_type=None,
)
}
collector = TransceiverCollector(cache, health)
metrics = _collect_metrics(collector)
temp_metric = [m for m in metrics if m.name == "transceiver_temperature_celsius"][0]
sample = temp_metric.samples[0]
assert sample.value == 40.0
assert sample.labels["device"] == "dev1"
assert sample.labels["port"] == "1/0/1"
assert sample.labels["component_name"] == "comp1"
info_metric = [m for m in metrics if m.name == "transceiver_info"][0]
info_labels = info_metric.samples[0].labels
assert info_labels["device"] == "dev1"
assert info_labels["component_name"] == "comp1"
assert info_labels["vendor"] == "H3C"
assert info_labels["serial"] == "SN1"
def test_collector_channel_metrics_include_component_name():
snapshot = DeviceMetricsSnapshot(
device="dev1",
collected_at=time.time(),
transceivers=(),
channels=(
TransceiverChannelRecord(
device="dev1",
component_name="comp1",
logical_port="1/0/1",
channel_index=0,
logical_channel="1/0/1:1",
rx_power_dbm=-3.0,
tx_power_dbm=-1.0,
bias_current_ma=10.0,
laser_temperature_c=25.0,
),
),
)
cache = {"dev1": snapshot}
health = {
"dev1": DeviceHealthState(
last_scrape_success=True,
last_scrape_duration=0.1,
last_scrape_timestamp=time.time(),
last_error_type=None,
)
}
collector = TransceiverCollector(cache, health)
metrics = _collect_metrics(collector)
tx_metric = [m for m in metrics if m.name == "transceiver_channel_tx_power_dbm"][0]
sample = tx_metric.samples[0]
assert sample.labels["device"] == "dev1"
assert sample.labels["port"] == "1/0/1"
assert sample.labels["channel"] == "1/0/1:1"
assert sample.labels["component_name"] == "comp1"
info_metric = [m for m in metrics if m.name == "transceiver_channel_info"][0]
info_labels = info_metric.samples[0].labels
assert info_labels["device"] == "dev1"
assert info_labels["channel"] == "1/0/1:1"
assert info_labels["component_name"] == "comp1"
def test_collector_no_business_metrics_on_failure():
cache = {} # 没有成功快照
health = {
"dev1": DeviceHealthState(
last_scrape_success=False,
last_scrape_duration=0.2,
last_scrape_timestamp=time.time(),
last_error_type="TimeoutError",
)
}
collector = TransceiverCollector(cache, health)
metrics = _collect_metrics(collector)
# 有健康指标
success_metric = [m for m in metrics if m.name == "netconf_scrape_success"][0]
assert any(s.labels["device"] == "dev1" and s.value == 0 for s in success_metric.samples)
# 不应有 transceiver_temperature_celsius
temp_metrics = [m for m in metrics if m.name == "transceiver_temperature_celsius"]
assert not temp_metrics or not temp_metrics[0].samples

67
tests/test_models.py Normal file
View File

@@ -0,0 +1,67 @@
import dataclasses
import pytest
from exporter.models import (
DeviceMetricsSnapshot,
INT32_MAX,
TransceiverChannelRecord,
TransceiverRecord,
is_invalid_value,
)
def test_transceiver_record_is_frozen():
record = TransceiverRecord(
device="dev1",
component_name="comp1",
logical_port="1/0/1",
present="PRESENT",
oper_status="ACTIVE",
temperature_c=40.0,
supply_voltage_v=3.3,
vendor="H3C",
serial="SN1",
part_number="PN",
hardware_rev="1.0",
)
with pytest.raises(dataclasses.FrozenInstanceError):
record.device = "dev2"
def test_transceiver_channel_record_is_frozen():
channel = TransceiverChannelRecord(
device="dev1",
component_name="comp1",
logical_port="1/0/1",
channel_index=0,
logical_channel="1/0/1:1",
rx_power_dbm=-3.0,
tx_power_dbm=-1.0,
bias_current_ma=10.0,
laser_temperature_c=25.0,
)
with pytest.raises(dataclasses.FrozenInstanceError):
channel.channel_index = 1
def test_snapshot_is_frozen():
tx = TransceiverRecord(device="d", component_name="c", logical_port="p")
snap = DeviceMetricsSnapshot(device="d", collected_at=0.0, transceivers=(tx,), channels=())
with pytest.raises(dataclasses.FrozenInstanceError):
snap.device = "other"
@pytest.mark.parametrize(
"value, expected",
[
(float(INT32_MAX), True),
(INT32_MAX, True),
(INT32_MAX - 1, False),
(-INT32_MAX, False),
(None, False),
],
)
def test_is_invalid_value_exact_int32_max(value, expected):
assert is_invalid_value(value) is expected

View File

@@ -0,0 +1,174 @@
import xml.etree.ElementTree as ET
import pytest
from exporter.netconf_client import (
build_transceiver_filter,
parse_netconf_response,
parse_port_and_channel,
)
def test_build_transceiver_filter_contains_expected_paths():
flt = build_transceiver_filter()
assert "<components" in flt
assert "platform/transceiver" in flt
SAMPLE_XML = """\
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
<data>
<components xmlns="http://openconfig.net/yang/platform">
<component>
<name>323.FourHundredGigE1/0/66</name>
<state>
<type>TRANSCEIVER</type>
<temperature>
<instant>45.5</instant>
</temperature>
</state>
<transceiver xmlns="http://openconfig.net/yang/platform/transceiver">
<state>
<present>PRESENT</present>
<vendor>Cisco</vendor>
<serial-no>ABC123</serial-no>
</state>
<physical-channels>
<channel>
<index>0</index>
<state>
<description>1/0/66:1</description>
<output-power><instant>-2.5</instant></output-power>
</state>
</channel>
</physical-channels>
</transceiver>
</component>
</components>
</data>
</rpc-reply>
"""
def test_parse_transceiver_basic():
txs, chs = parse_netconf_response(SAMPLE_XML, "dev1")
assert len(txs) == 1
r = txs[0]
assert r.device == "dev1"
assert r.component_name == "323.FourHundredGigE1/0/66"
assert r.temperature_c == 45.5
assert r.vendor == "Cisco"
assert r.serial == "ABC123"
assert r.logical_port == "1/0/66"
assert len(chs) == 1
def test_parse_channel_and_description():
_, chs = parse_netconf_response(SAMPLE_XML, "dev1")
assert len(chs) == 1
ch = chs[0]
assert ch.logical_port == "1/0/66"
assert ch.logical_channel == "1/0/66:1"
assert ch.tx_power_dbm == -2.5
def test_missing_temperature_not_fatal():
root = ET.fromstring(SAMPLE_XML)
# remove temperature node
ns_platform = "{http://openconfig.net/yang/platform}"
temp_elem = root.find(f".//{ns_platform}temperature")
parent = root.find(f".//{ns_platform}state")
if temp_elem is not None and parent is not None:
parent.remove(temp_elem)
xml_no_temp = ET.tostring(root, encoding="unicode")
txs, _ = parse_netconf_response(xml_no_temp, "dev1")
assert txs[0].temperature_c is None
def test_invalid_power_filtered():
xml_invalid = SAMPLE_XML.replace("-2.5", "2147483647.00")
_, chs = parse_netconf_response(xml_invalid, "dev1")
assert chs[0].tx_power_dbm is None
@pytest.mark.parametrize(
"description, component_name, index, expected_port, expected_channel",
[
(None, "comp1", 0, "comp1", "comp1:ch0"),
("", "comp1", 1, "comp1", "comp1:ch1"),
("1/0/1:1", "comp1", 2, "1/0/1", "1/0/1:1"),
("GigabitEthernet1/0/1", "comp1", 3, "GigabitEthernet1/0/1", "GigabitEthernet1/0/1:ch3"),
],
)
def test_parse_port_and_channel_fallbacks(
description, component_name, index, expected_port, expected_channel
):
lp, lc = parse_port_and_channel(description, component_name, index)
assert lp == expected_port
assert lc == expected_channel
def test_multiple_channels_missing_description_produce_unique_logical_channel():
xml = """\
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
<data>
<components xmlns="http://openconfig.net/yang/platform">
<component>
<name>comp1</name>
<state><type>TRANSCEIVER</type></state>
<transceiver xmlns="http://openconfig.net/yang/platform/transceiver">
<physical-channels>
<channel>
<index>0</index>
<state></state>
</channel>
<channel>
<index>1</index>
<state></state>
</channel>
</physical-channels>
</transceiver>
</component>
</components>
</data>
</rpc-reply>
"""
_, channels = parse_netconf_response(xml, "dev1")
assert len(channels) == 2
ch0 = next(c for c in channels if c.channel_index == 0)
ch1 = next(c for c in channels if c.channel_index == 1)
assert ch0.logical_port == "comp1"
assert ch1.logical_port == "comp1"
assert ch0.logical_channel != ch1.logical_channel
def test_h3c_multi_component_same_serial():
xml_multi = """\
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
<data>
<components xmlns="http://openconfig.net/yang/platform">
<component>
<name>63.TwoHundredGigE1/0/1:1</name>
<state><type>TRANSCEIVER</type></state>
<transceiver xmlns="http://openconfig.net/yang/platform/transceiver">
<state><serial-no>SN001</serial-no></state>
</transceiver>
</component>
<component>
<name>64.TwoHundredGigE1/0/1:2</name>
<state><type>TRANSCEIVER</type></state>
<transceiver xmlns="http://openconfig.net/yang/platform/transceiver">
<state><serial-no>SN001</serial-no></state>
</transceiver>
</component>
</components>
</data>
</rpc-reply>
"""
txs, _ = parse_netconf_response(xml_multi, "h3c")
assert len(txs) == 2
assert txs[0].serial == "SN001"
assert txs[1].serial == "SN001"
assert txs[0].component_name != txs[1].component_name

View File

@@ -0,0 +1,19 @@
from cryptography.fernet import Fernet
import pytest
from exporter.sqlite_store import PasswordEncryptor
def test_encrypt_decrypt_roundtrip():
key = Fernet.generate_key().decode()
enc = PasswordEncryptor(key)
cipher = enc.encrypt("secret")
assert cipher != b"secret"
assert enc.decrypt(cipher) == "secret"
def test_encryptor_rejects_invalid_key():
with pytest.raises(ValueError):
PasswordEncryptor("too-short")

130
tests/test_registry.py Normal file
View File

@@ -0,0 +1,130 @@
import time
import pytest
from exporter.config import DeviceConfig
from exporter.registry import DeviceRegistry
def test_register_static_and_runtime_device():
registry = DeviceRegistry(global_scrape_interval=60)
static = DeviceConfig(
name="dev1",
host="1.1.1.1",
port=830,
username="u",
password="p",
source="static",
)
runtime = DeviceConfig(
name="dev1",
host="10.0.0.1",
port=830,
username="u",
password="p2",
source="runtime",
)
registry.register_static_device(static)
registry.register_runtime_device(runtime)
devices = registry.list_devices()
assert len(devices) == 1
dev = devices[0]
assert dev.name == "dev1"
assert dev.host == "10.0.0.1"
assert dev.password == "p2"
assert dev.source == "runtime"
def test_get_enabled_devices_respects_next_scrape_at(monkeypatch):
registry = DeviceRegistry(global_scrape_interval=60)
now = time.time()
dev1 = DeviceConfig(
name="dev1",
host="h1",
port=830,
username="u",
password="p",
)
dev2 = DeviceConfig(
name="dev2",
host="h2",
port=830,
username="u",
password="p",
)
registry.register_static_device(dev1)
registry.register_static_device(dev2)
# 手动调整 next_scrape_at
registry._states["dev1"].next_scrape_at = now - 1
registry._states["dev2"].next_scrape_at = now + 100
ready = registry.get_enabled_devices(now)
assert len(ready) == 1
assert ready[0].cfg.name == "dev1"
def test_backoff_increases_on_failure():
registry = DeviceRegistry(global_scrape_interval=60)
now = time.time()
dev = DeviceConfig(
name="dev",
host="h",
port=830,
username="u",
password="p",
)
registry.register_static_device(dev)
state = registry._states["dev"]
state.next_scrape_at = now
# 连续失败三次
for _ in range(3):
registry.update_after_scrape(
device_name="dev",
success=False,
now=now,
scrape_interval=60,
failure_threshold=3,
max_backoff_factor=8,
)
state = registry._states["dev"]
assert state.consecutive_failures == 3
assert state.backoff_factor >= 2
assert state.next_scrape_at > now
def test_reset_backoff_on_success():
registry = DeviceRegistry(global_scrape_interval=60)
now = time.time()
dev = DeviceConfig(
name="dev",
host="h",
port=830,
username="u",
password="p",
)
registry.register_static_device(dev)
state = registry._states["dev"]
state.consecutive_failures = 5
state.backoff_factor = 4
registry.update_after_scrape(
device_name="dev",
success=True,
now=now,
scrape_interval=60,
failure_threshold=3,
max_backoff_factor=8,
)
state = registry._states["dev"]
assert state.consecutive_failures == 0
assert state.backoff_factor == 1
assert state.next_scrape_at == pytest.approx(now + 60, rel=0.01)

View File

@@ -0,0 +1,46 @@
import threading
import time
from types import SimpleNamespace
from exporter.config import DeviceConfig, GlobalConfig
from exporter.registry import DeviceRegistry
from exporter.scraper import scraper_loop
from exporter.connection import ConnectionManager
def test_scraper_stop_during_sleep(monkeypatch):
"""
在等待间隔时设置 stop_event,应立即退出,而不是等完整周期。
"""
global_cfg = GlobalConfig()
global_cfg.scrape_interval_seconds = 60
registry = DeviceRegistry(global_scrape_interval=global_cfg.scrape_interval_seconds)
connection_mgr = ConnectionManager(global_cfg)
# netconf_get_rpc: 不做任何事
def fake_get_rpc(_mgr, _flt):
return "<rpc-reply><data/></rpc-reply>"
cache = {}
health = {}
# run_one_scrape_round 在当前实现中是真实采集,但我们不关心,只要快速返回即可
stop_event = threading.Event()
start = time.time()
t = threading.Thread(
target=scraper_loop,
args=(stop_event, registry, connection_mgr, fake_get_rpc, cache, health, global_cfg),
daemon=True,
)
t.start()
time.sleep(1.0) # 给 loop 一点时间进入 wait
stop_event.set()
t.join(timeout=5.0)
elapsed = time.time() - start
# 如果 stop_event.wait 工作正常,不应等完整 60 秒
assert elapsed < 3.0

253
tests/test_sqlite_store.py Normal file
View File

@@ -0,0 +1,253 @@
import sqlite3
import threading
import time
from pathlib import Path
from cryptography.fernet import Fernet
import pytest
from exporter.config import DeviceConfig
from exporter.sqlite_store import PasswordEncryptor, SQLiteDeviceStore
@pytest.fixture
def encryptor() -> PasswordEncryptor:
key = Fernet.generate_key().decode()
return PasswordEncryptor(key)
def test_init_db_creates_devices_table(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "test.db"
store = SQLiteDeviceStore(str(db_path), encryptor)
store.init_db()
conn = sqlite3.connect(str(db_path))
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
).fetchone()
conn.close()
assert row is not None
def test_save_and_load_device(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "test.db"
store = SQLiteDeviceStore(str(db_path), encryptor)
store.init_db()
dev = DeviceConfig(
name="dev1",
host="1.1.1.1",
port=830,
username="u",
password="p",
enabled=True,
source="runtime",
)
store.save_device(dev)
loaded = store.load_runtime_devices()
assert len(loaded) == 1
assert loaded[0].name == "dev1"
assert loaded[0].password == "p"
assert loaded[0].source == "runtime"
def test_password_not_stored_in_plaintext(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "test.db"
store = SQLiteDeviceStore(str(db_path), encryptor)
store.init_db()
dev = DeviceConfig(
name="dev1",
host="h",
port=830,
username="u",
password="plain",
enabled=True,
source="runtime",
)
store.save_device(dev)
conn = sqlite3.connect(str(db_path))
cipher = conn.execute(
"SELECT password_cipher FROM devices WHERE name='dev1'"
).fetchone()[0]
conn.close()
assert cipher != b"plain"
assert encryptor.decrypt(cipher) == "plain"
def test_concurrent_writes_are_serialized(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "devices.db"
store = SQLiteDeviceStore(str(db_path), encryptor, timeout=1.0)
store.init_db()
errors: list[Exception] = []
def worker(i: int) -> None:
try:
dev = DeviceConfig(
name=f"dev-{i}",
host="h",
port=830,
username="u",
password="p",
enabled=True,
source="runtime",
)
store.save_device(dev)
except Exception as e: # noqa: BLE001
errors.append(e)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == []
loaded = store.load_runtime_devices()
assert {d.name for d in loaded} == {f"dev-{i}" for i in range(10)}
def test_each_operation_closes_connection(tmp_path: Path, encryptor: PasswordEncryptor, monkeypatch):
db_path = tmp_path / "devices.db"
real_connect = sqlite3.connect
connect_calls: list[sqlite3.Connection] = []
close_calls: list[sqlite3.Connection] = []
class ConnWrapper:
def __init__(self, inner: sqlite3.Connection) -> None:
self._inner = inner
connect_calls.append(self)
def execute(self, *args, **kwargs):
return self._inner.execute(*args, **kwargs)
def commit(self) -> None:
return self._inner.commit()
def rollback(self) -> None:
return self._inner.rollback()
def close(self) -> None:
close_calls.append(self)
return self._inner.close()
def tracked_connect(*args, **kwargs):
conn = real_connect(*args, **kwargs)
return ConnWrapper(conn)
monkeypatch.setattr(sqlite3, "connect", tracked_connect)
store = SQLiteDeviceStore(str(db_path), encryptor, timeout=1.0)
store.init_db()
for i in range(5):
dev = DeviceConfig(
name=f"d{i}",
host="h",
port=830,
username="u",
password="p",
enabled=True,
source="runtime",
)
store.save_device(dev)
store.load_runtime_devices()
assert len(connect_calls) == len(close_calls)
def test_delete_existing_device(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "devices.db"
store = SQLiteDeviceStore(str(db_path), encryptor)
store.init_db()
dev = DeviceConfig(
name="dev1",
host="h",
port=830,
username="u",
password="p",
enabled=True,
source="runtime",
)
store.save_device(dev)
store.delete_device("dev1")
loaded = store.load_runtime_devices()
assert all(d.name != "dev1" for d in loaded)
def test_delete_non_existing_device(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "devices.db"
store = SQLiteDeviceStore(str(db_path), encryptor)
store.init_db()
# 删除不存在的设备应为 no-op
store.delete_device("non-exist")
store.load_runtime_devices() # 不应抛异常
def test_created_at_preserved_and_updated_at_changes(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "devices.db"
store = SQLiteDeviceStore(str(db_path), encryptor)
store.init_db()
dev1 = DeviceConfig(
name="dev1",
host="h1",
port=830,
username="u",
password="p",
enabled=True,
source="runtime",
)
store.save_device(dev1)
conn = sqlite3.connect(str(db_path))
created1, updated1 = conn.execute(
"SELECT created_at, updated_at FROM devices WHERE name='dev1'"
).fetchone()
time.sleep(1)
dev2 = DeviceConfig(
name="dev1",
host="h2",
port=830,
username="u",
password="p2",
enabled=False,
source="runtime",
)
store.save_device(dev2)
created2, updated2, host = conn.execute(
"SELECT created_at, updated_at, host FROM devices WHERE name='dev1'"
).fetchone()
conn.close()
assert created2 == created1
assert updated2 > updated1
assert host == "h2"
def test_supports_xpath_persisted(tmp_path: Path, encryptor: PasswordEncryptor):
db_path = tmp_path / "db.sqlite"
store = SQLiteDeviceStore(str(db_path), encryptor)
store.init_db()
dev = DeviceConfig(
name="dev1",
host="h",
port=830,
username="u",
password="p",
enabled=True,
supports_xpath=True,
source="runtime",
)
store.save_device(dev)
loaded = store.load_runtime_devices()
assert len(loaded) == 1
assert loaded[0].supports_xpath is True