Initial commit: iOS Build Server
- FastAPI backend with build queue, WebSocket logs, task management - Vue 3 frontend with build/config/history views - Xcode project build automation with IPA export - Fix: initialize build_dir before try block to ensure cleanup on early failure
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""测试公共配置"""
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# 使用临时数据库,避免污染开发数据库
|
||||
_test_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_test_db.close()
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_test_db.name}"
|
||||
|
||||
from backend.main import app
|
||||
from backend.database import init_db, engine, Base
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""每个测试前重建数据库"""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI 测试客户端"""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path, monkeypatch):
|
||||
"""使用临时 config.json"""
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({
|
||||
"apps": {
|
||||
"1": {
|
||||
"name": "测试App",
|
||||
"server": "测试环境",
|
||||
"AppGuid": "test-guid",
|
||||
"API": "https://test.api.com",
|
||||
"certificates": {
|
||||
"Ad_Hoc": {"name": "com.test.app", "cer": "test-cert", "pro": "test-pro", "theme": "test"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemes": {
|
||||
"1": {"name": "testScheme", "ossFloder": "test"}
|
||||
},
|
||||
"servers": {
|
||||
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}
|
||||
},
|
||||
"branches": ["main", "dev"],
|
||||
}))
|
||||
monkeypatch.setattr("backend.routers.config.CONFIG_JSON_PATH", config_path)
|
||||
return config_path
|
||||
@@ -0,0 +1,21 @@
|
||||
"""打包选择接口测试(只读)"""
|
||||
|
||||
|
||||
def test_get_apps(client, tmp_config):
|
||||
resp = client.get("/api/apps")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_get_schemes(client, tmp_config):
|
||||
resp = client.get("/api/schemes")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_get_branches(client, tmp_config):
|
||||
resp = client.get("/api/branches")
|
||||
assert resp.status_code == 200
|
||||
branches = resp.json()
|
||||
assert "main" in branches
|
||||
assert "dev" in branches
|
||||
@@ -0,0 +1,19 @@
|
||||
"""认证接口测试"""
|
||||
|
||||
|
||||
def test_login_success(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["token"] == "admin-token"
|
||||
assert data["is_admin"] is True
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_login_wrong_username(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "nobody", "password": "admin123"})
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,146 @@
|
||||
"""配置管理接口测试"""
|
||||
import json
|
||||
|
||||
|
||||
def test_get_full_config(client, tmp_config):
|
||||
resp = client.get("/api/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "apps" in data
|
||||
assert "schemes" in data
|
||||
assert "branches" in data
|
||||
|
||||
|
||||
# ---- Apps ----
|
||||
|
||||
def test_get_apps(client, tmp_config):
|
||||
resp = client.get("/api/config/apps")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_create_app(client, tmp_config):
|
||||
resp = client.post("/api/config/apps", json={"name": "新App", "AppGuid": "new-guid"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "2"
|
||||
|
||||
# 验证已创建
|
||||
apps = client.get("/api/config/apps").json()
|
||||
assert "2" in apps
|
||||
assert apps["2"]["name"] == "新App"
|
||||
|
||||
|
||||
def test_update_app(client, tmp_config):
|
||||
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/config/apps").json()["1"]["name"] == "改名App"
|
||||
|
||||
|
||||
def test_delete_app(client, tmp_config):
|
||||
resp = client.delete("/api/config/apps/1")
|
||||
assert resp.status_code == 200
|
||||
assert "1" not in client.get("/api/config/apps").json()
|
||||
|
||||
|
||||
def test_update_nonexistent_app(client, tmp_config):
|
||||
resp = client.put("/api/config/apps/999", json={"name": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- Schemes ----
|
||||
|
||||
def test_get_schemes(client, tmp_config):
|
||||
resp = client.get("/api/config/schemes")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_create_scheme(client, tmp_config):
|
||||
resp = client.post("/api/config/schemes", json={"name": "newScheme", "ossFloder": "new"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "2"
|
||||
|
||||
|
||||
def test_delete_scheme(client, tmp_config):
|
||||
resp = client.delete("/api/config/schemes/1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---- Branches ----
|
||||
|
||||
def test_get_branches(client, tmp_config):
|
||||
resp = client.get("/api/config/branches")
|
||||
assert resp.status_code == 200
|
||||
branches = resp.json()
|
||||
assert "main" in branches
|
||||
assert "dev" in branches
|
||||
|
||||
|
||||
def test_add_branch(client, tmp_config):
|
||||
resp = client.post("/api/config/branches", json={"name": "release/1.0"})
|
||||
assert resp.status_code == 200
|
||||
branches = client.get("/api/config/branches").json()
|
||||
assert "release/1.0" in branches
|
||||
|
||||
|
||||
def test_add_duplicate_branch(client, tmp_config):
|
||||
resp = client.post("/api/config/branches", json={"name": "main"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_add_empty_branch(client, tmp_config):
|
||||
resp = client.post("/api/config/branches", json={"name": ""})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_delete_branch(client, tmp_config):
|
||||
resp = client.delete("/api/config/branches/dev")
|
||||
assert resp.status_code == 200
|
||||
branches = client.get("/api/config/branches").json()
|
||||
assert "dev" not in branches
|
||||
|
||||
|
||||
def test_delete_nonexistent_branch(client, tmp_config):
|
||||
resp = client.delete("/api/config/branches/notexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- Servers ----
|
||||
|
||||
def test_get_servers(client, tmp_config):
|
||||
resp = client.get("/api/config/servers")
|
||||
assert resp.status_code == 200
|
||||
assert "测试环境" in resp.json()
|
||||
|
||||
|
||||
def test_create_server(client, tmp_config):
|
||||
resp = client.post("/api/config/servers", json={
|
||||
"name": "新环境",
|
||||
"api": "https://new.api.com",
|
||||
"assDom": "applinks:new.com",
|
||||
"universalLink": "https://new.com",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "新环境" in client.get("/api/config/servers").json()
|
||||
|
||||
|
||||
def test_delete_server_in_use(client, tmp_config):
|
||||
# 测试环境被 App 1 使用,应该删除失败
|
||||
resp = client.delete("/api/config/servers/测试环境")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---- Build Settings ----
|
||||
|
||||
def test_get_build_settings(client, tmp_config):
|
||||
resp = client.get("/api/config/build")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "max_concurrent_builds" in data
|
||||
assert "build_dir_retention_hours" in data
|
||||
|
||||
|
||||
def test_update_build_settings(client, tmp_config):
|
||||
resp = client.put("/api/config/build", json={"max_concurrent_builds": 4})
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4
|
||||
@@ -0,0 +1,8 @@
|
||||
"""健康检查测试"""
|
||||
|
||||
|
||||
def test_health(client):
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
@@ -0,0 +1,114 @@
|
||||
"""任务接口测试"""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
|
||||
def test_list_tasks_empty(client, tmp_config):
|
||||
resp = client.get("/api/tasks")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
"obfuscation": True,
|
||||
"branch": "main",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "pending"
|
||||
assert data["app_name"] == "测试App"
|
||||
assert data["scheme_name"] == "testScheme"
|
||||
assert data["branch"] == "main"
|
||||
assert data["build_type"] == "Ad_Hoc"
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_default_branch(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["branch"] == "main"
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_invalid_app(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "999",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_invalid_scheme(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "999",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_list_tasks_after_create(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
tasks = client.get("/api/tasks").json()
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["app_name"] == "测试App"
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_get_task_detail(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
create_resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
task_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.get(f"/api/tasks/{task_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == task_id
|
||||
|
||||
|
||||
def test_get_nonexistent_task(client, tmp_config):
|
||||
resp = client.get("/api/tasks/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_cancel_task(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
mock_queue.cancel = lambda x: None
|
||||
|
||||
create_resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
task_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.delete(f"/api/tasks/{task_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 验证状态已更新
|
||||
task = client.get(f"/api/tasks/{task_id}").json()
|
||||
assert task["status"] == "cancelled"
|
||||
@@ -0,0 +1,118 @@
|
||||
"""打包队列测试"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from backend.services.build_queue import BuildQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queue():
|
||||
return BuildQueue(max_concurrent=2)
|
||||
|
||||
|
||||
def test_initial_state(queue):
|
||||
"""初始状态"""
|
||||
assert queue.queue_size == 0
|
||||
assert queue.running_count == 0
|
||||
|
||||
|
||||
def test_submit_starts_workers(queue):
|
||||
"""提交任务后自动启动 worker"""
|
||||
|
||||
async def dummy_build(task_id):
|
||||
pass
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", dummy_build)
|
||||
# 等待任务完成
|
||||
await asyncio.sleep(0.1)
|
||||
assert queue._started
|
||||
assert len(queue._workers) == 2
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(run())
|
||||
|
||||
|
||||
def test_task_executes(queue):
|
||||
"""任务被执行"""
|
||||
executed = []
|
||||
|
||||
async def mock_build(task_id):
|
||||
executed.append(task_id)
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", mock_build)
|
||||
await asyncio.sleep(0.2)
|
||||
return executed
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert "t1" in result
|
||||
|
||||
|
||||
def test_concurrent_limit(queue):
|
||||
"""并发数不超过限制"""
|
||||
running = []
|
||||
max_concurrent = 0
|
||||
|
||||
async def slow_build(task_id):
|
||||
nonlocal max_concurrent
|
||||
running.append(task_id)
|
||||
max_concurrent = max(max_concurrent, len(running))
|
||||
await asyncio.sleep(0.3)
|
||||
running.remove(task_id)
|
||||
|
||||
async def run():
|
||||
tasks = []
|
||||
for i in range(5):
|
||||
tasks.append(queue.submit(f"t{i}", slow_build))
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.sleep(1) # 等所有任务完成
|
||||
return max_concurrent
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert result <= 2 # max_concurrent = 2
|
||||
|
||||
|
||||
def test_cancel_running_task(queue):
|
||||
"""取消运行中的任务"""
|
||||
cancelled = False
|
||||
|
||||
async def long_build(task_id):
|
||||
nonlocal cancelled
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
raise
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", long_build)
|
||||
await asyncio.sleep(0.1) # 等任务开始
|
||||
queue.cancel("t1")
|
||||
await asyncio.sleep(0.2) # 等取消生效
|
||||
return cancelled
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_cancel_nonexistent_task(queue):
|
||||
"""取消不存在的任务(不应报错)"""
|
||||
queue.cancel("nonexistent")
|
||||
|
||||
|
||||
def test_multiple_tasks(queue):
|
||||
"""多个任务顺序执行"""
|
||||
completed = []
|
||||
|
||||
async def mock_build(task_id):
|
||||
await asyncio.sleep(0.05)
|
||||
completed.append(task_id)
|
||||
|
||||
async def run():
|
||||
for i in range(4):
|
||||
await queue.submit(f"t{i}", mock_build)
|
||||
await asyncio.sleep(1)
|
||||
return completed
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert len(result) == 4
|
||||
assert set(result) == {"t0", "t1", "t2", "t3"}
|
||||
@@ -0,0 +1,292 @@
|
||||
"""打包服务核心逻辑测试"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.build_service import (
|
||||
update_source,
|
||||
copy_source_code,
|
||||
generate_config,
|
||||
_cleanup_old_builds,
|
||||
)
|
||||
from backend.services.log_streamer import LogStreamer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_streamer():
|
||||
return LogStreamer()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dirs(tmp_path):
|
||||
"""创建临时源码目录和打包目录"""
|
||||
source_dir = tmp_path / "source"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "readoor").mkdir()
|
||||
(source_dir / "readoor" / "AppDelegate.swift").write_text("// app")
|
||||
(source_dir / "readoor.xcodeproj").mkdir()
|
||||
(source_dir / "Pods").mkdir()
|
||||
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
|
||||
(source_dir / "readoor.xcworkspace").mkdir()
|
||||
(source_dir / "AutoPacking").mkdir()
|
||||
(source_dir / "readoorTests").mkdir()
|
||||
(source_dir / "podfile").write_text("pod 'AFNetworking'")
|
||||
|
||||
build_dir = tmp_path / "build"
|
||||
return source_dir, build_dir
|
||||
|
||||
|
||||
def _make_mock_process(returncode=0, output=b"ok\n"):
|
||||
"""创建 mock 进程,支持 async for stdout"""
|
||||
proc = AsyncMock()
|
||||
proc.returncode = returncode
|
||||
proc.wait = AsyncMock(return_value=returncode)
|
||||
|
||||
async def iter_stdout():
|
||||
yield output
|
||||
|
||||
proc.stdout = iter_stdout()
|
||||
return proc
|
||||
|
||||
|
||||
# ---- update_source ----
|
||||
|
||||
async def test_update_source_clone(tmp_path, log_streamer):
|
||||
"""目录不存在时执行 clone"""
|
||||
source_dir = tmp_path / "branches" / "main"
|
||||
source_dir.parent.mkdir()
|
||||
|
||||
with patch("backend.services.build_service.GIT_REMOTE_URL", "git@github.com:test/repo.git"):
|
||||
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
|
||||
await update_source("t1", source_dir, "main")
|
||||
mock_exec.assert_called_once()
|
||||
args = mock_exec.call_args[0]
|
||||
assert "git" in args
|
||||
assert "clone" in args
|
||||
|
||||
|
||||
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
||||
"""目录不存在且未配置远程仓库时抛异常"""
|
||||
source_dir = tmp_path / "branches" / "main"
|
||||
|
||||
with patch("backend.services.build_service.GIT_REMOTE_URL", ""):
|
||||
with pytest.raises(Exception, match="GIT_REMOTE_URL"):
|
||||
await update_source("t1", source_dir, "main")
|
||||
|
||||
|
||||
async def test_update_source_pull_existing(tmp_path, log_streamer):
|
||||
"""目录已存在时执行 fetch + checkout + pull"""
|
||||
source_dir = tmp_path / "branches" / "dev"
|
||||
source_dir.parent.mkdir(parents=True)
|
||||
source_dir.mkdir()
|
||||
(source_dir / ".git").mkdir()
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_exec(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _make_mock_process()
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
|
||||
assert call_count == 3 # fetch, checkout, pull
|
||||
|
||||
|
||||
async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||
"""fetch 失败时抛异常"""
|
||||
source_dir = tmp_path / "branches" / "dev"
|
||||
source_dir.parent.mkdir(parents=True)
|
||||
source_dir.mkdir()
|
||||
|
||||
async def mock_exec(*args, **kwargs):
|
||||
return _make_mock_process(returncode=1, output=b"error\n")
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||
with pytest.raises(Exception, match="git fetch 失败"):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
|
||||
|
||||
# ---- copy_source_code ----
|
||||
|
||||
async def test_copy_source_code(tmp_dirs, log_streamer):
|
||||
"""正常拷贝源码"""
|
||||
source_dir, build_dir_parent = tmp_dirs
|
||||
|
||||
task = MagicMock()
|
||||
task.branch = "main"
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
|
||||
result = await copy_source_code("t1", task, source_dir)
|
||||
|
||||
assert result.exists()
|
||||
assert (result / "readoor" / "AppDelegate.swift").exists()
|
||||
assert (result / "Pods").exists()
|
||||
assert (result / "Podfile.lock").exists()
|
||||
assert (result / "readoor.xcworkspace").exists()
|
||||
|
||||
|
||||
async def test_copy_source_code_excludes_git(tmp_dirs, log_streamer):
|
||||
"""拷贝时排除 .git 目录"""
|
||||
source_dir, build_dir_parent = tmp_dirs
|
||||
(source_dir / "readoor" / ".git").mkdir()
|
||||
|
||||
task = MagicMock()
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
|
||||
result = await copy_source_code("t1", task, source_dir)
|
||||
|
||||
assert not (result / "readoor" / ".git").exists()
|
||||
|
||||
|
||||
async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
|
||||
"""打包目录已存在时覆盖"""
|
||||
source_dir, build_dir_parent = tmp_dirs
|
||||
old_dir = build_dir_parent / "build_readoor_t1"
|
||||
old_dir.mkdir(parents=True)
|
||||
(old_dir / "old_file.txt").write_text("old")
|
||||
|
||||
task = MagicMock()
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
|
||||
result = await copy_source_code("t1", task, source_dir)
|
||||
|
||||
assert not (result / "old_file.txt").exists()
|
||||
assert (result / "readoor").exists()
|
||||
|
||||
|
||||
# ---- generate_config ----
|
||||
|
||||
async def test_generate_config(tmp_path, log_streamer):
|
||||
"""生成打包配置"""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
|
||||
task = MagicMock()
|
||||
task.app_id = "1"
|
||||
task.scheme_id = "1"
|
||||
task.build_type = "Ad_Hoc"
|
||||
task.obfuscation = True
|
||||
|
||||
mock_config = {
|
||||
"apps": {
|
||||
"1": {
|
||||
"name": "测试App",
|
||||
"server": "测试环境",
|
||||
"AppGuid": "guid-123",
|
||||
"API": "https://test.api.com",
|
||||
"certificates": {
|
||||
"Ad_Hoc": {"name": "com.test.app", "cer": "cert", "pro": "pro", "theme": "t"}
|
||||
},
|
||||
}
|
||||
},
|
||||
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
|
||||
}
|
||||
|
||||
with patch("backend.services.build_service.AUTOPACKING_DIR", tmp_path / "AutoPacking"):
|
||||
with patch("backend.routers.config.load_config", return_value=mock_config):
|
||||
result = await generate_config("t1", task, build_dir)
|
||||
|
||||
assert result["APPID"] == "guid-123"
|
||||
assert result["SCHEME"] == "testScheme"
|
||||
assert result["BUILD_TYPE"] == "Ad_Hoc"
|
||||
assert result["BUNDLE_ID"] == "com.test.app"
|
||||
assert result["CERTIFICATE"] == "cert"
|
||||
|
||||
config_file = build_dir / "config_output.json"
|
||||
assert config_file.exists()
|
||||
saved = json.loads(config_file.read_text())
|
||||
assert saved["APPID"] == "guid-123"
|
||||
|
||||
|
||||
async def test_generate_config_reads_version(tmp_path, log_streamer):
|
||||
"""从 start_build_app.py 读取版本号"""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
|
||||
autopacking = tmp_path / "AutoPacking"
|
||||
autopacking.mkdir()
|
||||
(autopacking / "start_build_app.py").write_text(
|
||||
'App_Ver = "3.0.1"\nBuild_Ver = "3.0.1.0"\n'
|
||||
)
|
||||
|
||||
task = MagicMock()
|
||||
task.app_id = "1"
|
||||
task.scheme_id = "1"
|
||||
task.build_type = "Ad_Hoc"
|
||||
task.obfuscation = False
|
||||
|
||||
mock_config = {
|
||||
"apps": {"1": {"name": "App", "certificates": {}}},
|
||||
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
|
||||
}
|
||||
|
||||
with patch("backend.services.build_service.AUTOPACKING_DIR", autopacking):
|
||||
with patch("backend.routers.config.load_config", return_value=mock_config):
|
||||
result = await generate_config("t1", task, build_dir)
|
||||
|
||||
assert result["VERSION"] == "3.0.1"
|
||||
assert result["BUILD_VERSION"] == "3.0.1.0"
|
||||
|
||||
|
||||
# ---- _cleanup_old_builds ----
|
||||
|
||||
def test_cleanup_old_builds(tmp_path):
|
||||
"""清理过期打包目录"""
|
||||
from backend.database import SessionLocal, init_db
|
||||
from backend.models import BuildConfig
|
||||
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
if config:
|
||||
config.value = "1"
|
||||
else:
|
||||
db.add(BuildConfig(key="build_dir_retention_hours", value="1"))
|
||||
db.commit()
|
||||
|
||||
old_dir = tmp_path / "build_readoor_old"
|
||||
old_dir.mkdir()
|
||||
# 设置修改时间为 24 小时前(确保超过 1 小时保留期)
|
||||
old_time = time.time() - 86400
|
||||
os.utime(old_dir, (old_time, old_time))
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path):
|
||||
_cleanup_old_builds(db)
|
||||
|
||||
assert not old_dir.exists()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cleanup_keeps_recent(tmp_path):
|
||||
"""保留未过期的打包目录"""
|
||||
from backend.database import SessionLocal, init_db
|
||||
from backend.models import BuildConfig
|
||||
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
if config:
|
||||
config.value = "24"
|
||||
else:
|
||||
db.add(BuildConfig(key="build_dir_retention_hours", value="24"))
|
||||
db.commit()
|
||||
|
||||
recent_dir = tmp_path / "build_readoor_recent"
|
||||
recent_dir.mkdir()
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path):
|
||||
_cleanup_old_builds(db)
|
||||
|
||||
assert recent_dir.exists()
|
||||
db.close()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""文件下载接口测试"""
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import Task
|
||||
|
||||
|
||||
def _create_task_with_files(client, tmp_config, **extra_fields):
|
||||
"""创建一个带文件路径的任务"""
|
||||
with patch("backend.services.build_queue.build_queue") as mock_queue:
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
task_id = resp.json()["id"]
|
||||
|
||||
# 直接更新数据库中的文件路径
|
||||
db = SessionLocal()
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
for k, v in extra_fields.items():
|
||||
setattr(task, k, v)
|
||||
db.commit()
|
||||
db.close()
|
||||
return task_id
|
||||
|
||||
|
||||
# ---- dSYM 下载 ----
|
||||
|
||||
def test_download_dsym_not_exist(client, tmp_config):
|
||||
"""dSYM 路径未设置"""
|
||||
task_id = _create_task_with_files(client, tmp_config)
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_dsym_file_missing(client, tmp_config):
|
||||
"""dSYM 路径设置了但文件不存在"""
|
||||
task_id = _create_task_with_files(client, tmp_config, dsym_path="/tmp/nonexistent.dSYM")
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 404
|
||||
assert "已被清理" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_download_dsym_success(client, tmp_config):
|
||||
"""dSYM 文件正常下载"""
|
||||
# 创建临时文件
|
||||
fd, path = tempfile.mkstemp(suffix=".dSYM")
|
||||
os.write(fd, b"fake dsym content")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(client, tmp_config, dsym_path=path)
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"fake dsym content"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_download_dsym_nonexistent_task(client, tmp_config):
|
||||
resp = client.get("/api/tasks/nonexistent/dsym")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- 混淆映射表下载 ----
|
||||
|
||||
def test_download_obfuscation_maps_not_exist(client, tmp_config):
|
||||
"""混淆映射表路径未设置"""
|
||||
task_id = _create_task_with_files(client, tmp_config)
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_obfuscation_maps_file_missing(client, tmp_config):
|
||||
"""路径设置了但文件不存在"""
|
||||
task_id = _create_task_with_files(client, tmp_config, obfuscation_maps_path="/tmp/nonexistent")
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_obfuscation_maps_single_file(client, tmp_config):
|
||||
"""单文件下载"""
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
os.write(fd, b'{"mappings": []}')
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(client, tmp_config, obfuscation_maps_path=path)
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b'{"mappings": []}'
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_download_obfuscation_maps_directory(client, tmp_config):
|
||||
"""目录打包成 zip 下载"""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
with open(os.path.join(tmpdir, "map.json"), "w") as f:
|
||||
f.write("{}")
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(
|
||||
client, tmp_config,
|
||||
obfuscation_maps_path=tmpdir,
|
||||
app_name="测试App",
|
||||
)
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/zip"
|
||||
# filename is URL-encoded, check the raw header
|
||||
disposition = resp.headers["content-disposition"]
|
||||
assert ".zip" in disposition
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
# ---- 二维码下载 ----
|
||||
|
||||
def test_download_qrcode_not_exist(client, tmp_config):
|
||||
"""二维码路径未设置"""
|
||||
task_id = _create_task_with_files(client, tmp_config)
|
||||
resp = client.get(f"/api/tasks/{task_id}/qrcode")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_qrcode_file_missing(client, tmp_config):
|
||||
"""路径设置了但文件不存在"""
|
||||
task_id = _create_task_with_files(client, tmp_config, qr_code_path="/tmp/nonexistent.png")
|
||||
resp = client.get(f"/api/tasks/{task_id}/qrcode")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_qrcode_success(client, tmp_config):
|
||||
"""二维码正常下载"""
|
||||
fd, path = tempfile.mkstemp(suffix=".png")
|
||||
os.write(fd, b"\x89PNG fake")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(client, tmp_config, qr_code_path=path)
|
||||
resp = client.get(f"/api/tasks/{task_id}/qrcode")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""边界情况测试"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def test_invalid_json_body(client, tmp_config):
|
||||
"""无效 JSON 请求体"""
|
||||
resp = client.post(
|
||||
"/api/tasks",
|
||||
content="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_missing_required_fields(client, tmp_config):
|
||||
"""缺少必填字段"""
|
||||
resp = client.post("/api/tasks", json={"app_id": "1"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_empty_app_id(client, tmp_config):
|
||||
"""空 app_id"""
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_server_name_with_special_chars(client, tmp_config):
|
||||
"""服务器名称包含特殊字符"""
|
||||
resp = client.post("/api/config/servers", json={
|
||||
"name": "环境/测试",
|
||||
"api": "https://test.com",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_branch_name_with_slash(client, tmp_config):
|
||||
"""分支名包含斜杠"""
|
||||
resp = client.post("/api/config/branches", json={"name": "feature/my-branch"})
|
||||
assert resp.status_code == 200
|
||||
assert "feature/my-branch" in client.get("/api/config/branches").json()
|
||||
|
||||
|
||||
def test_branch_name_with_space(client, tmp_config):
|
||||
"""分支名包含空格(应被 trim)"""
|
||||
resp = client.post("/api/config/branches", json={"name": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_config_backup_on_save(client, tmp_config):
|
||||
"""保存配置时自动备份"""
|
||||
# 先写一次产生文件
|
||||
client.put("/api/config", json={"apps": {}, "schemes": {}, "branches": ["main"]})
|
||||
|
||||
# 再写一次,应该产生 .bak 文件
|
||||
client.put("/api/config", json={"apps": {"1": {"name": "test"}}, "schemes": {}, "branches": ["main"]})
|
||||
|
||||
backup = tmp_config.with_suffix(".json.bak")
|
||||
assert backup.exists()
|
||||
|
||||
|
||||
def test_sequential_config_writes(client, tmp_config):
|
||||
"""顺序多次写入配置"""
|
||||
for i in range(5):
|
||||
resp = client.post("/api/config/branches", json={"name": f"branch-{i}"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
branches = client.get("/api/config/branches").json()
|
||||
for i in range(5):
|
||||
assert f"branch-{i}" in branches
|
||||
|
||||
|
||||
def test_task_status_filter(client, tmp_config):
|
||||
"""按状态过滤任务"""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
with patch("backend.services.build_queue.build_queue") as mock_queue:
|
||||
mock_queue.submit = AsyncMock()
|
||||
client.post("/api/tasks", json={
|
||||
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1",
|
||||
})
|
||||
|
||||
# 过滤 pending
|
||||
resp = client.get("/api/tasks?status=pending")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
# 过滤 running(应该没有)
|
||||
resp = client.get("/api/tasks?status=running")
|
||||
assert len(resp.json()) == 0
|
||||
|
||||
|
||||
def test_task_pagination(client, tmp_config):
|
||||
"""任务分页"""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
with patch("backend.services.build_queue.build_queue") as mock_queue:
|
||||
mock_queue.submit = AsyncMock()
|
||||
for _ in range(5):
|
||||
client.post("/api/tasks", json={
|
||||
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1",
|
||||
})
|
||||
|
||||
resp = client.get("/api/tasks?limit=2&offset=0")
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
resp = client.get("/api/tasks?limit=2&offset=2")
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
def test_delete_server_not_in_use(client, tmp_config):
|
||||
"""删除未被使用的服务器"""
|
||||
# 先添加一个不被使用的服务器
|
||||
client.post("/api/config/servers", json={"name": "临时环境", "api": "https://tmp.com"})
|
||||
resp = client.delete("/api/config/servers/临时环境")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_update_full_config_replaces_all(client, tmp_config):
|
||||
"""整体替换配置"""
|
||||
new_config = {
|
||||
"apps": {"99": {"name": "新App"}},
|
||||
"schemes": {},
|
||||
"servers": {},
|
||||
"branches": ["release"],
|
||||
}
|
||||
resp = client.put("/api/config", json=new_config)
|
||||
assert resp.status_code == 200
|
||||
|
||||
config = client.get("/api/config").json()
|
||||
assert "99" in config["apps"]
|
||||
assert config["branches"] == ["release"]
|
||||
|
||||
|
||||
def test_scheme_id_auto_increment(client, tmp_config):
|
||||
"""Scheme ID 自动递增"""
|
||||
client.post("/api/config/schemes", json={"name": "sch2", "ossFloder": "f2"})
|
||||
client.post("/api/config/schemes", json={"name": "sch3", "ossFloder": "f3"})
|
||||
schemes = client.get("/api/config/schemes").json()
|
||||
assert "2" in schemes
|
||||
assert "3" in schemes
|
||||
|
||||
|
||||
def test_build_settings_validation(client, tmp_config):
|
||||
"""打包设置更新"""
|
||||
# 更新为有效值
|
||||
resp = client.put("/api/config/build", json={"max_concurrent_builds": 3})
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 3
|
||||
|
||||
# 更新部分字段
|
||||
resp = client.put("/api/config/build", json={"build_dir_retention_hours": 48})
|
||||
assert resp.status_code == 200
|
||||
settings = client.get("/api/config/build").json()
|
||||
assert settings["build_dir_retention_hours"] == 48
|
||||
assert settings["max_concurrent_builds"] == 3 # 未修改的保持不变
|
||||
@@ -0,0 +1,131 @@
|
||||
"""WebSocket 日志流测试"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from backend.services.log_streamer import LogStreamer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def streamer():
|
||||
return LogStreamer()
|
||||
|
||||
|
||||
async def test_emit_and_subscribe(streamer):
|
||||
"""测试发送和接收日志"""
|
||||
streamer.create_queue("t1")
|
||||
await streamer.emit("t1", "hello", level="info")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t1"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0]["message"] == "hello"
|
||||
assert received[0]["level"] == "info"
|
||||
|
||||
|
||||
async def test_emit_step(streamer):
|
||||
"""测试步骤标记"""
|
||||
streamer.create_queue("t2")
|
||||
await streamer.emit_step("t2", "构建中")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t2"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert received[0]["message"] == "[构建中]"
|
||||
assert received[0]["level"] == "step"
|
||||
|
||||
|
||||
async def test_emit_error(streamer):
|
||||
"""测试错误消息"""
|
||||
streamer.create_queue("t3")
|
||||
await streamer.emit_error("t3", "出错了")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t3"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert received[0]["level"] == "error"
|
||||
assert received[0]["message"] == "出错了"
|
||||
|
||||
|
||||
async def test_emit_warning(streamer):
|
||||
"""测试警告消息"""
|
||||
streamer.create_queue("t4")
|
||||
await streamer.emit_warning("t4", "警告")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t4"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert received[0]["level"] == "warn"
|
||||
|
||||
|
||||
async def test_emit_to_nonexistent_queue(streamer):
|
||||
"""测试向不存在的队列发送消息(不应报错)"""
|
||||
await streamer.emit("nonexistent", "msg")
|
||||
|
||||
|
||||
async def test_complete_signal(streamer):
|
||||
"""测试完成信号终止订阅"""
|
||||
streamer.create_queue("t5")
|
||||
await streamer.emit("t5", "msg1")
|
||||
streamer.complete("t5")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t5"):
|
||||
received.append(msg)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0]["message"] == "msg1"
|
||||
|
||||
|
||||
async def test_multiple_messages(streamer):
|
||||
"""测试多条消息顺序"""
|
||||
streamer.create_queue("t6")
|
||||
for i in range(5):
|
||||
await streamer.emit("t6", f"msg{i}")
|
||||
streamer.complete("t6")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t6"):
|
||||
received.append(msg)
|
||||
|
||||
assert len(received) == 5
|
||||
for i, msg in enumerate(received):
|
||||
assert msg["message"] == f"msg{i}"
|
||||
|
||||
|
||||
async def test_queue_full_drops_oldest(streamer):
|
||||
"""测试队列满时丢弃最旧消息"""
|
||||
q = streamer.create_queue("t7")
|
||||
for i in range(q.maxsize):
|
||||
await streamer.emit("t7", f"old{i}")
|
||||
await streamer.emit("t7", "new")
|
||||
|
||||
messages = []
|
||||
while not q.empty():
|
||||
messages.append(await q.get())
|
||||
assert messages[-1]["message"] == "new"
|
||||
|
||||
|
||||
async def test_cleanup(streamer):
|
||||
"""测试清理队列"""
|
||||
streamer.create_queue("t8")
|
||||
await streamer.emit("t8", "msg")
|
||||
streamer.cleanup("t8")
|
||||
assert "t8" not in streamer._queues
|
||||
# 清理后再发送不应报错
|
||||
await streamer.emit("t8", "msg2")
|
||||
|
||||
|
||||
async def test_subscribe_without_queue(streamer):
|
||||
"""测试订阅不存在的队列(应立即结束)"""
|
||||
collected = []
|
||||
async for msg in streamer.subscribe("nonexistent"):
|
||||
collected.append(msg)
|
||||
assert collected == []
|
||||
Reference in New Issue
Block a user