"""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