style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 39s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 10s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 28s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m21s

This commit is contained in:
CI Bot
2026-09-26 13:03:47 +00:00
parent 7b0db03875
commit 706fed9c08
2 changed files with 196 additions and 95 deletions
+175 -88
View File
@@ -1,4 +1,5 @@
"""GpuEncoderClient 单元测试:mock HTTP,覆盖 health/sync/fallback/singleton 等完整路径。"""
from __future__ import annotations
import json
@@ -62,8 +63,12 @@ def _fake_response(status: int = 200, body: dict | bytes | None = None, headers=
# ── Health check ────────────────────────────────────────────────────
class TestHealthCheck:
def test_healthy_nvenc_available(self, client):
body = {"status": "healthy", "worker": "w1",
"gpu": {"name": "Quadro P4000"}, "nvenc": {"h264_nvenc": True, "hevc_nvenc": True}}
body = {
"status": "healthy",
"worker": "w1",
"gpu": {"name": "Quadro P4000"},
"nvenc": {"h264_nvenc": True, "hevc_nvenc": True},
}
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=body)):
h = client.check_health()
assert h.healthy and h.nvenc_h264 and h.ready
@@ -98,55 +103,73 @@ class TestHealthCheck:
# ── _post_sync ──────────────────────────────────────────────────────
class TestPostSync:
def test_completed_job_returns_dict(self, client):
result_body = {"job_id":"j1","status":"completed","ffmpeg_rc":0,"uploaded":True,"duration":5.1,"size":123456}
result_body = {
"job_id": "j1",
"status": "completed",
"ffmpeg_rc": 0,
"uploaded": True,
"duration": 5.1,
"size": 123456,
}
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=result_body)) as m:
res = client._post_sync(
{"inputs":{"in.mp4":"http://x"},"ffmpeg_args":["-i","in.mp4"],
"output_url":"http://relay/k?token=s","timeout":30},
mezzanine_path=Path("/tmp/fake.mp4"))
{
"inputs": {"in.mp4": "http://x"},
"ffmpeg_args": ["-i", "in.mp4"],
"output_url": "http://relay/k?token=s",
"timeout": 30,
},
mezzanine_path=Path("/tmp/fake.mp4"),
)
assert res["status"] == "completed" and res["ffmpeg_rc"] == 0
req = m.call_args[0][0]
assert req.full_url == "http://gpu.example.com:8900/api/render/sync"
def test_ffmpeg_failure_raises(self, client):
body = {"status":"failed","ffmpeg_rc":1,"message":"Invalid data"}
body = {"status": "failed", "ffmpeg_rc": 1, "message": "Invalid data"}
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=body)):
with pytest.raises(GpuEncodeError, match="rc=1"):
client._post_sync({"inputs":{},"ffmpeg_args":[],"output_url":"","timeout":10},
mezzanine_path=Path("/tmp/x"))
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_http_4xx_raises(self, client):
err = urllib.error.HTTPError(url="http://gpu/render/sync", code=422, msg="Unprocessable",
hdrs={}, fp=BytesIO(b"bad request"))
err = urllib.error.HTTPError(
url="http://gpu/render/sync", code=422, msg="Unprocessable", hdrs={}, fp=BytesIO(b"bad request")
)
with mock.patch("urllib.request.urlopen", side_effect=err):
with pytest.raises(GpuEncodeError, match="HTTP 422"):
client._post_sync({"inputs":{},"ffmpeg_args":[],"output_url":"","timeout":10},
mezzanine_path=Path("/tmp/x"))
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_connection_error_raises(self, client):
with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("conn refused")):
with pytest.raises(GpuEncodeError, match="connection error"):
client._post_sync({"inputs":{},"ffmpeg_args":[],"output_url":"","timeout":10},
mezzanine_path=Path("/tmp/x"))
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_timeout_error_raises(self, client):
with mock.patch("urllib.request.urlopen", side_effect=socket.timeout("timed out")):
with pytest.raises(GpuEncodeError, match="connection error"):
client._post_sync({"inputs":{},"ffmpeg_args":[],"output_url":"","timeout":10},
mezzanine_path=Path("/tmp/x"))
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_bad_json_raises(self, client):
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=b"not-json")):
with pytest.raises(GpuEncodeError, match="bad JSON"):
client._post_sync({"inputs":{},"ffmpeg_args":[],"output_url":"","timeout":10},
mezzanine_path=Path("/tmp/x"))
client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
def test_uploaded_false_logs_warning_but_succeeds(self, client, caplog):
body = {"status":"completed","ffmpeg_rc":0,"uploaded":False,"job_id":"j"}
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=body)), \
caplog.at_level("WARNING"):
res = client._post_sync({"inputs":{},"ffmpeg_args":[],"output_url":"","timeout":10},
mezzanine_path=Path("/tmp/x"))
body = {"status": "completed", "ffmpeg_rc": 0, "uploaded": False, "job_id": "j"}
with mock.patch("urllib.request.urlopen", return_value=_fake_response(body=body)), caplog.at_level("WARNING"):
res = client._post_sync(
{"inputs": {}, "ffmpeg_args": [], "output_url": "", "timeout": 10}, mezzanine_path=Path("/tmp/x")
)
assert res["status"] == "completed"
assert "uploaded=false" in caplog.text
@@ -239,18 +262,28 @@ class TestDownloadToFile:
# ── encode_mezzanine_to_output ─────────────────────────────────────
class TestEncodeMezzanine:
def test_happy_path_with_audio(self, client, tmp_path):
mezz = tmp_path / "mezz.mp4"; mezz.write_bytes(b"M" * 100)
mezz = tmp_path / "mezz.mp4"
mezz.write_bytes(b"M" * 100)
out = tmp_path / "out" / "final.mp4"
with (
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/signed", "osskey1")),
mock.patch.object(client, "_post_sync", return_value={
"job_id":"j1","status":"completed","ffmpeg_rc":0,"uploaded":True,"size":5000,"duration":1.2,
}) as m_post,
mock.patch.object(
client,
"_post_sync",
return_value={
"job_id": "j1",
"status": "completed",
"ffmpeg_rc": 0,
"uploaded": True,
"size": 5000,
"duration": 1.2,
},
) as m_post,
mock.patch.object(client, "_download_to_file", return_value=5000) as m_dl,
mock.patch.object(client, "_relay_delete") as m_del,
mock.patch.object(client, "_delete_oss") as m_ossdel,
):
result = client.encode_mezzanine_to_output(mezz, out, audio_args=["-c:a","aac"])
result = client.encode_mezzanine_to_output(mezz, out, audio_args=["-c:a", "aac"])
assert result["output_size"] == 5000 and str(out) == result["output_path"]
body = m_post.call_args[0][0]
assert "-c:a" in body["ffmpeg_args"] and "aac" in body["ffmpeg_args"]
@@ -261,11 +294,16 @@ class TestEncodeMezzanine:
m_ossdel.assert_called_once_with("osskey1")
def test_happy_path_no_audio_uses_an_and_cq(self, client, tmp_path):
mezz = tmp_path / "m.mp4"; mezz.write_bytes(b"M")
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"M")
out = tmp_path / "o.mp4"
with (
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u","k")),
mock.patch.object(client, "_post_sync", return_value={"status":"completed","ffmpeg_rc":0,"uploaded":True,"job_id":"j"}) as m_post,
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(
client,
"_post_sync",
return_value={"status": "completed", "ffmpeg_rc": 0, "uploaded": True, "job_id": "j"},
) as m_post,
mock.patch.object(client, "_download_to_file", return_value=100),
mock.patch.object(client, "_relay_delete"),
mock.patch.object(client, "_delete_oss"),
@@ -276,18 +314,26 @@ class TestEncodeMezzanine:
assert str(client.crf) in body["ffmpeg_args"]
def test_bitrate_set_uses_bv_instead_of_cq(self, tmp_path):
c = GpuEncoderClient(endpoint="http://gpu", relay_base_url="http://api",
relay_internal_base_url="http://api-int:8000", relay_secret="s", bitrate="2M")
mezz = tmp_path / "m.mp4"; mezz.write_bytes(b"x")
c = GpuEncoderClient(
endpoint="http://gpu",
relay_base_url="http://api",
relay_internal_base_url="http://api-int:8000",
relay_secret="s",
bitrate="2M",
)
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
out = tmp_path / "o.mp4"
with (
mock.patch.object(c, "_upload_mezzanine", return_value=("http://oss/u","k")),
mock.patch.object(c, "_post_sync", return_value={"status":"completed","ffmpeg_rc":0,"uploaded":True,"job_id":"j"}) as m_post,
mock.patch.object(c, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(
c, "_post_sync", return_value={"status": "completed", "ffmpeg_rc": 0, "uploaded": True, "job_id": "j"}
) as m_post,
mock.patch.object(c, "_download_to_file", return_value=10),
mock.patch.object(c, "_relay_delete"),
mock.patch.object(c, "_delete_oss"),
):
c.encode_mezzanine_to_output(mezz, out, extra_video_args=["-vf","scale=1280:-2"])
c.encode_mezzanine_to_output(mezz, out, extra_video_args=["-vf", "scale=1280:-2"])
body = m_post.call_args[0][0]
assert "-b:v" in body["ffmpeg_args"] and "2M" in body["ffmpeg_args"]
assert "-cq" not in body["ffmpeg_args"]
@@ -295,44 +341,48 @@ class TestEncodeMezzanine:
def test_mezzanine_not_found_raises(self, client, tmp_path):
with pytest.raises(GpuEncodeError, match="mezzanine file not found"):
client.encode_mezzanine_to_output(tmp_path/"nope.mp4", tmp_path/"o.mp4")
client.encode_mezzanine_to_output(tmp_path / "nope.mp4", tmp_path / "o.mp4")
def test_relay_base_not_configured_raises(self, tmp_path):
c = GpuEncoderClient(endpoint="http://gpu", relay_base_url="", relay_secret="s")
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
with pytest.raises(GpuEncodeError, match="relay_base_url"):
c.encode_mezzanine_to_output(mezz, tmp_path/"o.mp4")
c.encode_mezzanine_to_output(mezz, tmp_path / "o.mp4")
def test_unexpected_exception_is_wrapped(self, client, tmp_path):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
with (
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u","k")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(client, "_post_sync", side_effect=RuntimeError("boom")),
mock.patch.object(client, "_delete_oss"),
):
with pytest.raises(GpuEncodeError, match="unexpected: boom"):
client.encode_mezzanine_to_output(mezz, tmp_path/"o.mp4")
client.encode_mezzanine_to_output(mezz, tmp_path / "o.mp4")
def test_gpu_encode_error_re_raised_directly(self, client, tmp_path):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
with (
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u","k")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "k")),
mock.patch.object(client, "_post_sync", side_effect=GpuEncodeError("direct fail")),
mock.patch.object(client, "_delete_oss"),
):
with pytest.raises(GpuEncodeError, match="direct fail"):
client.encode_mezzanine_to_output(mezz, tmp_path/"o.mp4")
client.encode_mezzanine_to_output(mezz, tmp_path / "o.mp4")
def test_oss_cleanup_runs_on_failure(self, client, tmp_path, caplog):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
with (
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u","ossk")),
mock.patch.object(client, "_upload_mezzanine", return_value=("http://oss/u", "ossk")),
mock.patch.object(client, "_post_sync", side_effect=GpuEncodeError("enc fail")),
mock.patch.object(client, "_delete_oss", side_effect=Exception("oss down")) as m_ossdel,
caplog.at_level("WARNING"),
):
with pytest.raises(GpuEncodeError):
client.encode_mezzanine_to_output(mezz, tmp_path/"o.mp4")
client.encode_mezzanine_to_output(mezz, tmp_path / "o.mp4")
m_ossdel.assert_called_once_with("ossk")
@@ -352,15 +402,18 @@ class TestRelayDelete:
# ── OSS helpers ────────────────────────────────────────────────────
class TestOssHelpers:
def test_upload_storage_import_error(self, client, tmp_path):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
# 删除 sys.modules 中 packages.shared.storage 使导入失败
saved = sys.modules.pop("packages.shared.storage", None)
try:
real_import = __builtins__.__import__ if hasattr(__builtins__,"__import__") else __import__
real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
def fake_import(name, *a, **kw):
if name == "packages.shared.storage" or name.startswith("packages.shared.storage."):
raise ImportError("no storage")
return real_import(name, *a, **kw)
with mock.patch("builtins.__import__", side_effect=fake_import):
with pytest.raises(GpuEncodeError, match="storage service unavailable"):
client._upload_mezzanine(mezz)
@@ -369,33 +422,45 @@ class TestOssHelpers:
sys.modules["packages.shared.storage"] = saved
def test_upload_storage_none(self, client, tmp_path):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
fake_mod = mock.MagicMock(); fake_mod.get_storage_service.return_value = None
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.return_value = None
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
with pytest.raises(GpuEncodeError, match="OSS storage not configured"):
client._upload_mezzanine(mezz)
def test_upload_bucket_none(self, client, tmp_path):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
svc = mock.MagicMock(); svc.bucket = None
fake_mod = mock.MagicMock(); fake_mod.get_storage_service.return_value = svc
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
svc = mock.MagicMock()
svc.bucket = None
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
with pytest.raises(GpuEncodeError, match="OSS storage not configured"):
client._upload_mezzanine(mezz)
def test_upload_failure_raises(self, client, tmp_path):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
svc = mock.MagicMock(); svc.bucket = object(); svc.upload_file.side_effect = RuntimeError("oss err")
fake_mod = mock.MagicMock(); fake_mod.get_storage_service.return_value = svc
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
svc = mock.MagicMock()
svc.bucket = object()
svc.upload_file.side_effect = RuntimeError("oss err")
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
with pytest.raises(GpuEncodeError, match="failed to upload mezzanine"):
client._upload_mezzanine(mezz)
def test_upload_success(self, client, tmp_path):
mezz = tmp_path/"m.mp4"; mezz.write_bytes(b"x")
svc = mock.MagicMock(); svc.bucket = object()
mezz = tmp_path / "m.mp4"
mezz.write_bytes(b"x")
svc = mock.MagicMock()
svc.bucket = object()
svc.get_download_url.return_value = "https://oss/signed?sig=abc"
fake_mod = mock.MagicMock(); fake_mod.get_storage_service.return_value = svc
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
url, key = client._upload_mezzanine(mezz)
assert url.startswith("https://oss/signed")
@@ -403,21 +468,26 @@ class TestOssHelpers:
svc.upload_file.assert_called_once()
def test_delete_oss_exception_swallowed(self, client, caplog):
fake_mod = mock.MagicMock(); fake_mod.get_storage_service.side_effect = RuntimeError("svc down")
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.side_effect = RuntimeError("svc down")
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}), caplog.at_level("DEBUG"):
client._delete_oss("somekey")
assert "OSS delete" in caplog.text
def test_delete_oss_bucket_none_noop(self, client):
svc = mock.MagicMock(); svc.bucket = None
fake_mod = mock.MagicMock(); fake_mod.get_storage_service.return_value = svc
svc = mock.MagicMock()
svc.bucket = None
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
client._delete_oss("k")
svc.delete_file.assert_not_called()
def test_delete_oss_success(self, client):
svc = mock.MagicMock(); svc.bucket = object()
fake_mod = mock.MagicMock(); fake_mod.get_storage_service.return_value = svc
svc = mock.MagicMock()
svc.bucket = object()
fake_mod = mock.MagicMock()
fake_mod.get_storage_service.return_value = svc
with mock.patch.dict("sys.modules", {"packages.shared.storage": fake_mod}):
client._delete_oss("k")
svc.delete_file.assert_called_once_with("k")
@@ -436,36 +506,51 @@ class TestSingletonFactory:
sys.modules["packages.config"] = saved
def test_build_client_not_enabled_returns_none(self):
s = mock.MagicMock(); s.enable_gpu_encode = False
fake_mod = mock.MagicMock(); fake_mod.get_shared_settings.return_value = s
s = mock.MagicMock()
s.enable_gpu_encode = False
fake_mod = mock.MagicMock()
fake_mod.get_shared_settings.return_value = s
with mock.patch.dict("sys.modules", {"packages.config": fake_mod}):
assert _build_client_from_settings() is None
def test_build_client_missing_endpoint(self):
s = mock.MagicMock(); s.enable_gpu_encode = True
s.gpu_encode_endpoint = ""; s.gpu_encode_relay_base_url = "http://api"
s = mock.MagicMock()
s.enable_gpu_encode = True
s.gpu_encode_endpoint = ""
s.gpu_encode_relay_base_url = "http://api"
s.gpu_encode_relay_internal_base_url = ""
fake_mod = mock.MagicMock(); fake_mod.get_shared_settings.return_value = s
fake_mod = mock.MagicMock()
fake_mod.get_shared_settings.return_value = s
with mock.patch.dict("sys.modules", {"packages.config": fake_mod}):
assert _build_client_from_settings() is None
def test_build_client_missing_relay(self):
s = mock.MagicMock(); s.enable_gpu_encode = True
s.gpu_encode_endpoint = "http://gpu"; s.gpu_encode_relay_base_url = ""
s = mock.MagicMock()
s.enable_gpu_encode = True
s.gpu_encode_endpoint = "http://gpu"
s.gpu_encode_relay_base_url = ""
s.gpu_encode_relay_internal_base_url = ""
fake_mod = mock.MagicMock(); fake_mod.get_shared_settings.return_value = s
fake_mod = mock.MagicMock()
fake_mod.get_shared_settings.return_value = s
with mock.patch.dict("sys.modules", {"packages.config": fake_mod}):
assert _build_client_from_settings() is None
def test_build_client_success(self):
s = mock.MagicMock(); s.enable_gpu_encode = True
s.gpu_encode_endpoint = "http://gpu"; s.gpu_encode_relay_base_url = "http://api/"
s = mock.MagicMock()
s.enable_gpu_encode = True
s.gpu_encode_endpoint = "http://gpu"
s.gpu_encode_relay_base_url = "http://api/"
s.gpu_encode_relay_internal_base_url = "http://api-int:8000/"
s.gpu_encode_sync_timeout = 120; s.gpu_encode_health_timeout = 1.0
s.gpu_encode_vcodec = "h264_nvenc"; s.gpu_encode_preset = "p7"
s.gpu_encode_crf = 20; s.gpu_encode_bitrate = ""; s.gpu_encode_relay_secret = "s"
s.gpu_encode_sync_timeout = 120
s.gpu_encode_health_timeout = 1.0
s.gpu_encode_vcodec = "h264_nvenc"
s.gpu_encode_preset = "p7"
s.gpu_encode_crf = 20
s.gpu_encode_bitrate = ""
s.gpu_encode_relay_secret = "s"
s.gpu_encode_oss_tmp_prefix = "tmp/x/"
fake_mod = mock.MagicMock(); fake_mod.get_shared_settings.return_value = s
fake_mod = mock.MagicMock()
fake_mod.get_shared_settings.return_value = s
with mock.patch.dict("sys.modules", {"packages.config": fake_mod}):
c = _build_client_from_settings()
assert c is not None and c.endpoint == "http://gpu"
@@ -474,8 +559,10 @@ class TestSingletonFactory:
assert c.preset == "p7" and c.crf == 20
def test_get_gpu_encoder_init_failure_returns_none(self, caplog):
with mock.patch("packages.shared.gpu_encoder._build_client_from_settings",
side_effect=RuntimeError("boom")), caplog.at_level("WARNING"):
with (
mock.patch("packages.shared.gpu_encoder._build_client_from_settings", side_effect=RuntimeError("boom")),
caplog.at_level("WARNING"),
):
assert get_gpu_encoder() is None
assert "failed to init client" in caplog.text
@@ -493,11 +580,11 @@ class TestSingletonFactory:
# ── Constructor edge cases ────────────────────────────────────────
class TestConstructor:
def test_oss_tmp_prefix_empty_uses_default(self):
c = GpuEncoderClient(endpoint="http://gpu", relay_base_url="http://api",
relay_secret="s", oss_tmp_prefix="")
c = GpuEncoderClient(endpoint="http://gpu", relay_base_url="http://api", relay_secret="s", oss_tmp_prefix="")
assert c.oss_tmp_prefix == "tmp/gpu-mezzanine/"
def test_oss_tmp_prefix_strips_and_adds_slash(self):
c = GpuEncoderClient(endpoint="http://gpu", relay_base_url="http://api",
relay_secret="s", oss_tmp_prefix="tmp/foo")
c = GpuEncoderClient(
endpoint="http://gpu", relay_base_url="http://api", relay_secret="s", oss_tmp_prefix="tmp/foo"
)
assert c.oss_tmp_prefix == "tmp/foo/"
+21 -7
View File
@@ -1,4 +1,5 @@
"""gpu_relay API 路由单元测试:覆盖 helper 函数 + PUT/GET/HEAD/DELETE handler。"""
from __future__ import annotations
import os
@@ -101,8 +102,9 @@ class TestBuildRelayUrls:
assert url == "http://api.example.com/api/v1/internal/gpu-relay/k1?token=s"
def test_get_url_same_as_put(self):
assert gpu_relay.build_relay_get_url("http://api", "k", "s") == \
gpu_relay.build_relay_put_url("http://api", "k", "s")
assert gpu_relay.build_relay_get_url("http://api", "k", "s") == gpu_relay.build_relay_put_url(
"http://api", "k", "s"
)
def test_generate_key_is_hex(self):
k = gpu_relay.generate_key()
@@ -116,11 +118,14 @@ class TestPutObject:
async def test_put_writes_file(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
# async request.stream 模拟
async def _stream():
yield b"chunk1"
yield b"chunk2"
req = mock.MagicMock(); req.stream = _stream
req = mock.MagicMock()
req.stream = _stream
resp = await gpu_relay.put_object(key="abc123", request=req, token="s")
assert resp["ok"] is True
assert resp["size"] == len(b"chunk1") + len(b"chunk2")
@@ -148,10 +153,13 @@ class TestPutObject:
async def test_put_write_error_cleans_tmp(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
async def _bad_stream():
yield b"x"
raise OSError("disk full")
req = mock.MagicMock(); req.stream = _bad_stream
req = mock.MagicMock()
req.stream = _bad_stream
with pytest.raises(HTTPException) as ei:
await gpu_relay.put_object(key="abc", request=req, token="s")
assert ei.value.status_code == 500
@@ -192,7 +200,9 @@ class TestHeadObject:
async def test_head_returns_content_length(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
p = tmp_path / "gpu_relay" / "k"; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(b"12345")
p = tmp_path / "gpu_relay" / "k"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(b"12345")
resp = await gpu_relay.head_object(key="k", token="s")
assert resp.status_code == 200
assert resp.headers["Content-Length"] == "5"
@@ -204,7 +214,9 @@ class TestDeleteObject:
async def test_delete_existing(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
p = tmp_path / "gpu_relay" / "k"; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(b"x")
p = tmp_path / "gpu_relay" / "k"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(b"x")
resp = await gpu_relay.delete_object(key="k", token="s")
assert resp["ok"] is True
assert not p.exists()
@@ -219,7 +231,9 @@ class TestDeleteObject:
async def test_delete_unlink_error_500(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
p = tmp_path / "gpu_relay" / "k"; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(b"x")
p = tmp_path / "gpu_relay" / "k"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(b"x")
with mock.patch.object(Path, "unlink", side_effect=OSError("perm denied")):
with pytest.raises(HTTPException) as ei:
await gpu_relay.delete_object(key="k", token="s")