Files
Neo-ZQYY/apps/backend/tests/test_ws_logs.py

187 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""WebSocket 日志推送端点测试
测试 /ws/logs/{execution_id} 端点的连接、日志回放、实时推送和断开行为。
利用 TaskExecutor 已有的 subscribe/broadcast 机制进行验证。
"""
from __future__ import annotations
import asyncio
import pytest
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.main import app
from app.services.task_executor import task_executor
@pytest.fixture(autouse=True)
def _cleanup_executor():
"""每个测试前后清理 TaskExecutor 内部状态。"""
yield
# 清理所有残留的缓冲区和订阅者
for eid in list(task_executor._log_buffers.keys()):
task_executor.cleanup(eid)
task_executor._subscribers.clear()
task_executor._log_buffers.clear()
class TestWebSocketConnection:
"""WebSocket 连接/断开基本行为"""
def test_connect_and_disconnect(self):
"""客户端能成功建立和关闭 WebSocket 连接。"""
client = TestClient(app)
with client.websocket_connect("/ws/logs/test-exec-001") as ws:
# 连接成功,直接关闭
pass # __exit__ 会关闭连接
def test_connect_registers_subscriber(self):
"""连接后 TaskExecutor 应注册订阅者。"""
client = TestClient(app)
# 预先初始化缓冲区(模拟有任务在运行)
task_executor._log_buffers["test-exec-002"] = []
with client.websocket_connect("/ws/logs/test-exec-002"):
# 连接期间应有订阅者
assert "test-exec-002" in task_executor._subscribers
assert len(task_executor._subscribers["test-exec-002"]) >= 1
class TestLogReplay:
"""历史日志回放"""
def test_replay_existing_logs(self):
"""连接时应先收到内存缓冲区中已有的日志行。"""
eid = "test-exec-replay"
# 预填充日志缓冲区
task_executor._log_buffers[eid] = [
"[stdout] 第一行",
"[stdout] 第二行",
"[stderr] 警告信息",
]
client = TestClient(app)
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
# 应按顺序收到 3 条历史日志
msg1 = ws.receive_text()
msg2 = ws.receive_text()
msg3 = ws.receive_text()
assert msg1 == "[stdout] 第一行"
assert msg2 == "[stdout] 第二行"
assert msg3 == "[stderr] 警告信息"
def test_no_logs_no_replay(self):
"""没有历史日志时不应收到回放消息。"""
eid = "test-exec-empty"
task_executor._log_buffers[eid] = []
client = TestClient(app)
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
# 发送结束信号让连接正常关闭
task_executor._broadcast_end(eid)
# 不应有回放消息,直接收到的是后续的结束信号处理
class TestBroadcastReceive:
"""实时日志广播接收"""
def test_receive_broadcast_messages(self):
"""连接后应能收到 TaskExecutor 广播的实时日志。"""
eid = "test-exec-broadcast"
task_executor._log_buffers[eid] = []
client = TestClient(app)
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
# 模拟 TaskExecutor 广播日志
task_executor._broadcast(eid, "[stdout] 实时日志行1")
task_executor._broadcast(eid, "[stderr] 实时错误行")
msg1 = ws.receive_text()
msg2 = ws.receive_text()
assert msg1 == "[stdout] 实时日志行1"
assert msg2 == "[stderr] 实时错误行"
def test_end_signal_closes_connection(self):
"""收到 None 结束信号后 WebSocket 应正常关闭。"""
eid = "test-exec-end"
task_executor._log_buffers[eid] = []
client = TestClient(app)
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
# 广播一条日志后发送结束信号
task_executor._broadcast(eid, "[stdout] 最后一行")
task_executor._broadcast_end(eid)
msg = ws.receive_text()
assert msg == "[stdout] 最后一行"
def test_replay_then_broadcast(self):
"""先回放历史日志,再接收实时广播。"""
eid = "test-exec-mixed"
task_executor._log_buffers[eid] = ["[stdout] 历史行"]
client = TestClient(app)
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
# 先收到历史回放
replay = ws.receive_text()
assert replay == "[stdout] 历史行"
# 再收到实时广播
task_executor._broadcast(eid, "[stdout] 新行")
task_executor._broadcast_end(eid)
live = ws.receive_text()
assert live == "[stdout] 新行"
class TestLogBroadcasterUnit:
"""直接测试 TaskExecutor 的 subscribe/unsubscribe/broadcast 方法
(作为 LogBroadcaster 功能的单元测试)。
"""
def test_subscribe_creates_queue(self):
eid = "unit-sub"
q = task_executor.subscribe(eid)
assert isinstance(q, asyncio.Queue)
assert eid in task_executor._subscribers
task_executor.unsubscribe(eid, q)
def test_unsubscribe_removes_queue(self):
eid = "unit-unsub"
q = task_executor.subscribe(eid)
task_executor.unsubscribe(eid, q)
# 最后一个订阅者移除后key 也应被清理
assert eid not in task_executor._subscribers
def test_broadcast_delivers_to_all_subscribers(self):
eid = "unit-multi"
q1 = task_executor.subscribe(eid)
q2 = task_executor.subscribe(eid)
task_executor._broadcast(eid, "测试消息")
assert q1.get_nowait() == "测试消息"
assert q2.get_nowait() == "测试消息"
task_executor.unsubscribe(eid, q1)
task_executor.unsubscribe(eid, q2)
def test_broadcast_end_sends_none(self):
eid = "unit-end"
q = task_executor.subscribe(eid)
task_executor._broadcast_end(eid)
assert q.get_nowait() is None
task_executor.unsubscribe(eid, q)
def test_broadcast_no_subscribers_is_safe(self):
"""没有订阅者时广播不应报错。"""
task_executor._broadcast("nonexistent", "无人接收")
task_executor._broadcast_end("nonexistent")