first commit
This commit is contained in:
BIN
tests/__pycache__/conftest.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_api_devices.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_api_devices.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_config.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_config.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_connection.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_connection.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/__pycache__/test_metrics.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_metrics.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_models.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_models.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/__pycache__/test_registry.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_registry.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/__pycache__/test_sqlite_store.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_sqlite_store.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
43
tests/conftest.py
Normal file
43
tests/conftest.py
Normal 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
183
tests/test_api_devices.py
Normal 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
86
tests/test_config.py
Normal 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
125
tests/test_connection.py
Normal 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
|
||||
|
||||
16
tests/test_error_classification.py
Normal file
16
tests/test_error_classification.py
Normal 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"
|
||||
106
tests/test_h3c_live_netconf.py
Normal file
106
tests/test_h3c_live_netconf.py
Normal 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 数据"
|
||||
265
tests/test_http_e2e_exporter.py
Normal file
265
tests/test_http_e2e_exporter.py
Normal 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)
|
||||
77
tests/test_logging_utils.py
Normal file
77
tests/test_logging_utils.py
Normal 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
|
||||
)
|
||||
121
tests/test_main_lifecycle.py
Normal file
121
tests/test_main_lifecycle.py
Normal 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
132
tests/test_metrics.py
Normal 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
67
tests/test_models.py
Normal 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
|
||||
|
||||
174
tests/test_netconf_parser.py
Normal file
174
tests/test_netconf_parser.py
Normal 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
|
||||
|
||||
19
tests/test_password_encryptor.py
Normal file
19
tests/test_password_encryptor.py
Normal 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
130
tests/test_registry.py
Normal 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)
|
||||
46
tests/test_scraper_stop_event.py
Normal file
46
tests/test_scraper_stop_event.py
Normal 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
253
tests/test_sqlite_store.py
Normal 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
|
||||
Reference in New Issue
Block a user