Files
Nexus/tests/test_watch_pins.py
2026-07-08 22:31:31 +08:00

329 lines
12 KiB
Python

"""Watch pin API integration tests."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock
import bcrypt
import pytest
from sqlalchemy import select
from server.domain.models import Admin, Server
from server.utils.watch_metrics import WATCH_MAX_SLOTS
@pytest.fixture
def mock_watch_redis(monkeypatch):
mock_redis = AsyncMock()
mock_redis.lindex = AsyncMock(return_value=None)
mock_redis.lrange = AsyncMock(return_value=[])
mock_redis.get = AsyncMock(return_value=None)
mock_redis.hgetall = AsyncMock(return_value={})
mock_redis.delete = AsyncMock(return_value=1)
monkeypatch.setattr("server.application.services.watch_service.get_redis", lambda: mock_redis)
monkeypatch.setattr("server.utils.watch_state.get_redis", lambda: mock_redis)
async def noop_immediate_probe(_server_id: int) -> None:
return None
monkeypatch.setattr(
"server.application.services.watch_service.trigger_immediate_watch_probe",
noop_immediate_probe,
)
return mock_redis
@pytest.mark.asyncio
async def test_watch_pin_crud(db_session, test_admin, mock_watch_redis):
from server.application.services.watch_service import SlotsFullError, WatchService
for i in range(WATCH_MAX_SLOTS):
s = Server(name=f"watch-{i}", domain=f"w{i}.example.com", port=22, agent_version="1.0")
db_session.add(s)
await db_session.commit()
servers = (await db_session.execute(select(Server))).scalars().all()
svc = WatchService(db_session)
admin = test_admin["admin"]
r0 = await svc.pin_server(admin=admin, server_id=servers[0].id)
assert len(r0["slots"]) == 4
assert r0["slots"][0]["empty"] is False
await svc.pin_server(admin=admin, server_id=servers[0].id)
for s in servers[1:]:
await svc.pin_server(admin=admin, server_id=s.id)
with pytest.raises(SlotsFullError):
extra = Server(name="extra", domain="extra.example.com", port=22, agent_version="1.0")
db_session.add(extra)
await db_session.flush()
await svc.pin_server(admin=admin, server_id=extra.id)
await svc.remove_slot(admin=admin, slot_index=0)
listing = await svc.list_slots(admin.id)
assert listing["slots"][0]["empty"] is True
@pytest.mark.asyncio
async def test_watch_probe_record_on_failure(db_session):
from server.infrastructure.database.watch_repo import WatchRepositoryImpl
from server.utils.watch_metrics import failure_sample
repo = WatchRepositoryImpl(db_session)
admin = Admin(username="watch_probe_admin", password_hash=bcrypt.hashpw(b"x", bcrypt.gensalt()).decode(), is_active=True)
db_session.add(admin)
server = Server(name="probe-fail", domain="pf.example.com", port=22)
db_session.add(server)
await db_session.flush()
created = await repo.create_pin(admin_id=admin.id, server_id=server.id, slot_index=0)
sample = failure_sample(status="ssh_timeout", source="ssh", error="timeout")
row = await repo.insert_probe_record(
session_id=created["session_id"],
server_id=server.id,
admin_id=admin.id,
recorded_at=datetime.now(timezone.utc).replace(tzinfo=None),
source=sample.source,
probe_status=sample.probe_status,
duration_ms=8000,
is_online=False,
cpu_pct=None,
mem_pct=None,
disk_pct=None,
error=sample.error,
)
await db_session.commit()
assert row.probe_status == "ssh_timeout"
assert row.cpu_pct is None
@pytest.mark.asyncio
async def test_watch_pin_monitoring_toggle(db_session, test_admin, mock_watch_redis):
from server.application.services.watch_service import WatchService
server = Server(name="watch-toggle", domain="wt.example.com", port=22, agent_version="1.0")
db_session.add(server)
await db_session.commit()
svc = WatchService(db_session)
admin = test_admin["admin"]
await svc.pin_server(admin=admin, server_id=server.id)
listing = await svc.list_slots(admin.id)
assert listing["slots"][0]["monitoring_enabled"] is True
paused = await svc.set_slot_monitoring(
admin=admin,
slot_index=0,
monitoring_enabled=False,
)
assert paused["slots"][0]["monitoring_enabled"] is False
assert paused["slots"][0]["remaining_sec"] is None
resumed = await svc.set_slot_monitoring(
admin=admin,
slot_index=0,
monitoring_enabled=True,
)
assert resumed["slots"][0]["monitoring_enabled"] is True
assert resumed["slots"][0]["remaining_sec"] is not None
assert resumed["slots"][0]["remaining_sec"] > 0
@pytest.mark.asyncio
async def test_watch_resume_eager_probe_fills_metrics(
db_session, test_admin, mock_watch_redis, monkeypatch,
):
"""开启监测后立即探针,list_slots 应带上 metrics(不等 5s 周期)。"""
import json
from server.application.services.watch_service import WatchService
server = Server(name="watch-eager", domain="we.example.com", port=22, agent_version="1.0")
db_session.add(server)
await db_session.commit()
async def fake_immediate_probe(server_id: int) -> None:
live = json.dumps({"probe_status": "ok", "cpu_pct": 9.5, "server_id": server_id})
mock_watch_redis.lindex = AsyncMock(return_value=live)
monkeypatch.setattr(
"server.application.services.watch_service.trigger_immediate_watch_probe",
fake_immediate_probe,
)
svc = WatchService(db_session)
admin = test_admin["admin"]
await svc.pin_server(admin=admin, server_id=server.id)
await svc.set_slot_monitoring(admin=admin, slot_index=0, monitoring_enabled=False)
resumed = await svc.set_slot_monitoring(
admin=admin,
slot_index=0,
monitoring_enabled=True,
)
metrics = resumed["slots"][0]["metrics"]
assert metrics is not None
assert metrics["probe_status"] == "ok"
assert metrics["cpu_pct"] == 9.5
@pytest.mark.asyncio
async def test_watch_pin_ttl_minutes(db_session, test_admin, mock_watch_redis):
from server.application.services.watch_service import WatchService
from server.utils.watch_metrics import WATCH_PIN_TTL_DEFAULT_MINUTES
server = Server(name="watch-ttl", domain="wt2.example.com", port=22, agent_version="1.0")
db_session.add(server)
await db_session.commit()
svc = WatchService(db_session)
admin = test_admin["admin"]
await svc.pin_server(admin=admin, server_id=server.id)
listing = await svc.list_slots(admin.id)
assert listing["slots"][0]["ttl_minutes"] == WATCH_PIN_TTL_DEFAULT_MINUTES
assert listing["slots"][0]["remaining_sec"] <= 30 * 60
extended = await svc.set_slot_ttl(
admin=admin,
slot_index=0,
ttl_minutes=1440,
)
assert extended["slots"][0]["ttl_minutes"] == 1440
assert extended["slots"][0]["remaining_sec"] >= 1440 * 60 - 5
@pytest.mark.asyncio
async def test_watch_pin_ttl_expiry_list_syncs_before_display(db_session, test_admin, mock_watch_redis):
"""TTL 到期后、探针循环未跑 expire 前,list_slots 应同步为暂停态而非空槽。"""
from datetime import timedelta
from server.infrastructure.database.watch_repo import WatchRepositoryImpl, _utc_naive_now
from server.application.services.watch_service import WatchService
server = Server(name="watch-expire-list", domain="wel.example.com", port=22, agent_version="1.0")
db_session.add(server)
await db_session.commit()
svc = WatchService(db_session)
admin = test_admin["admin"]
await svc.pin_server(admin=admin, server_id=server.id)
repo = WatchRepositoryImpl(db_session)
pin = await repo.get_pin_by_slot(admin.id, 0)
assert pin is not None
pin.expires_at = _utc_naive_now() - timedelta(seconds=1)
await db_session.commit()
# 不手动调用 expire_due_pins — 模拟探针循环尚未处理的窗口
listing = await svc.list_slots(admin.id)
assert listing["slots"][0]["empty"] is False
assert listing["slots"][0]["monitoring_enabled"] is False
assert listing["slots"][0]["remaining_sec"] is None
@pytest.mark.asyncio
async def test_watch_pin_ttl_expiry_pin_server_no_duplicate(db_session, test_admin, mock_watch_redis):
"""到期 ghost pin 不应导致同槽 create_pin 唯一约束冲突。"""
from datetime import timedelta
from server.infrastructure.database.watch_repo import WatchRepositoryImpl, _utc_naive_now
from server.application.services.watch_service import WatchService
server_a = Server(name="watch-exp-a", domain="wea.example.com", port=22, agent_version="1.0")
server_b = Server(name="watch-exp-b", domain="web.example.com", port=22, agent_version="1.0")
db_session.add_all([server_a, server_b])
await db_session.commit()
svc = WatchService(db_session)
admin = test_admin["admin"]
await svc.pin_server(admin=admin, server_id=server_a.id, slot_index=0)
repo = WatchRepositoryImpl(db_session)
pin = await repo.get_pin_by_slot(admin.id, 0)
assert pin is not None
pin.expires_at = _utc_naive_now() - timedelta(seconds=1)
await db_session.commit()
# 替换槽位服务器 — 应先 finalize 再 replace,不能 IntegrityError
result = await svc.pin_server(admin=admin, server_id=server_b.id, slot_index=0)
assert result["slots"][0]["empty"] is False
assert result["slots"][0]["server_id"] == server_b.id
assert result["slots"][0]["monitoring_enabled"] is True
@pytest.mark.asyncio
async def test_watch_install_psutil_requires_pin(db_session, test_admin, mock_watch_redis):
from server.application.services.watch_service import WatchService
server = Server(name="watch-psutil", domain="wp.example.com", port=22, agent_version="1.0")
db_session.add(server)
await db_session.commit()
svc = WatchService(db_session)
admin = test_admin["admin"]
with pytest.raises(ValueError, match="不在当前监测槽"):
await svc.install_psutil_ssh(admin=admin, server_id=server.id)
@pytest.mark.asyncio
async def test_watch_install_psutil_success(db_session, test_admin, mock_watch_redis, monkeypatch):
from server.application.services.watch_service import WatchService
server = Server(name="watch-psutil2", domain="wp2.example.com", port=22, agent_version="1.0")
db_session.add(server)
await db_session.commit()
svc = WatchService(db_session)
admin = test_admin["admin"]
await svc.pin_server(admin=admin, server_id=server.id)
async def _fake_install(_server):
return {"success": True, "message": "psutil 已就绪"}
monkeypatch.setattr(
"server.utils.psutil_install.install_psutil_via_ssh",
_fake_install,
)
result = await svc.install_psutil_ssh(admin=admin, server_id=server.id)
assert result["success"] is True
assert result["message"] == "psutil 已就绪"
@pytest.mark.asyncio
async def test_watch_pin_ttl_expiry_pauses_not_deletes(db_session, test_admin, mock_watch_redis):
from datetime import timedelta
from server.infrastructure.database.watch_repo import WatchRepositoryImpl, _utc_naive_now
from server.application.services.watch_service import WatchService
server = Server(name="watch-expire", domain="we.example.com", port=22, agent_version="1.0")
db_session.add(server)
await db_session.commit()
svc = WatchService(db_session)
admin = test_admin["admin"]
await svc.pin_server(admin=admin, server_id=server.id)
repo = WatchRepositoryImpl(db_session)
pin = await repo.get_pin_by_slot(admin.id, 0)
assert pin is not None
pin.expires_at = _utc_naive_now() - timedelta(seconds=1)
await db_session.commit()
expired = await repo.expire_due_pins()
assert server.id in expired
await db_session.commit()
still = await repo.get_pin_by_slot(admin.id, 0)
assert still is not None
assert still.monitoring_enabled is False
listing = await svc.list_slots(admin.id)
assert listing["slots"][0]["empty"] is False
assert listing["slots"][0]["monitoring_enabled"] is False