32a1885f0d
支持随时暂停/恢复实时监测并保留槽位;TTL 到期自动暂停不占删槽;修复到期竞态导致空槽与唯一约束冲突;探针 parse_error 与中文错误;移除失败 snackbar。
241 lines
8.9 KiB
Python
241 lines
8.9 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.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)
|
|
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_pin_ttl_hours(db_session, test_admin, mock_watch_redis):
|
|
from server.application.services.watch_service import WatchService
|
|
from server.utils.watch_metrics import WATCH_PIN_TTL_EXTENDED_HOURS, WATCH_PIN_TTL_HOURS
|
|
|
|
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, ttl_hours=WATCH_PIN_TTL_HOURS)
|
|
listing = await svc.list_slots(admin.id)
|
|
assert listing["slots"][0]["ttl_hours"] == WATCH_PIN_TTL_HOURS
|
|
|
|
extended = await svc.set_slot_ttl(
|
|
admin=admin,
|
|
slot_index=0,
|
|
ttl_hours=WATCH_PIN_TTL_EXTENDED_HOURS,
|
|
)
|
|
assert extended["slots"][0]["ttl_hours"] == WATCH_PIN_TTL_EXTENDED_HOURS
|
|
assert extended["slots"][0]["remaining_sec"] >= WATCH_PIN_TTL_EXTENDED_HOURS * 3600 - 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_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
|