Files
xiaoxia-saas/tests/unit/test_gpu_relay.py
T
saas-backend-agent 7b0db03875
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m48s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (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 / Retag skipped Staging 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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m33s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 4m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m21s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 7m56s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 13m11s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
test(gpu-encoder): 补充分支覆盖率测试(encode全流程/错误路径/singleton/relay路由)+ 持久化宿主机 nginx :8092 vhost 配置
- test_gpu_encoder.py: 扩展至 ~45 个用例,覆盖 encode_mezzanine_to_output happy path
  (audio/no-audio/bitrate/extra_video_args)、post_sync 错误分支(URLError/timeout/bad JSON/uploaded=false)、
  _download_to_file 非200/网络错误清理、_relay_delete 异常吞掉、
  OSS helper(import失败/bucket=None/upload失败/delete异常吞掉)、
  _build_client_from_settings 所有分支(import失败/disabled/缺endpoint/缺relay/正常配置)、
  get_gpu_encoder singleton+init异常、constructor 默认值。
- test_gpu_relay.py: 新增 30+ 个用例,覆盖 _relay_dir/_secret/_safe_key/_check_token、
  build_relay_put_url/get_url/generate_key,以及 PUT/GET/HEAD/DELETE 四个 handler
  (写入/404/401/400/写入错误清理/unlink错误)。
- infra/nginx/gpu-relay-staging.conf: 宿主机 nginx :8092 纯 HTTP vhost,
  给 P4000 通过 Tailscale 回传编码结果使用。部署命令见文件注释。
2026-09-26 20:43:21 +08:00

227 lines
11 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.
"""gpu_relay API 路由单元测试:覆盖 helper 函数 + PUT/GET/HEAD/DELETE handler。"""
from __future__ import annotations
import os
from pathlib import Path
from unittest import mock
import pytest
from fastapi import HTTPException
from apps.api.app.api.routes import gpu_relay
# ── _relay_dir ────────────────────────────────────────────────────────
class TestRelayDir:
def test_default_dir(self, tmp_path, monkeypatch):
monkeypatch.delenv("GENERATED_FILES_DIR", raising=False)
monkeypatch.delenv("GPU_ENCODE_RELAY_DIR", raising=False)
# 用 tmp_path 作 base
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
p = gpu_relay._relay_dir()
assert p == tmp_path / "gpu_relay"
assert p.exists()
def test_custom_subdir(self, tmp_path, monkeypatch):
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
monkeypatch.setenv("GPU_ENCODE_RELAY_DIR", "custom_relay")
p = gpu_relay._relay_dir()
assert p == tmp_path / "custom_relay"
assert p.exists()
# ── _secret ──────────────────────────────────────────────────────────
class TestSecret:
def test_explicit_secret_returned(self, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "topsecret")
gpu_relay._DEFAULT_SECRET_LOGGED = False
assert gpu_relay._secret() == "topsecret"
def test_prod_without_secret_raises(self, monkeypatch):
monkeypatch.delenv("GPU_ENCODE_RELAY_SECRET", raising=False)
monkeypatch.setenv("APP_ENV", "production")
with pytest.raises(RuntimeError, match="GPU_ENCODE_RELAY_SECRET must be set"):
gpu_relay._secret()
def test_dev_without_secret_generates_ephemeral(self, monkeypatch, caplog):
monkeypatch.delenv("GPU_ENCODE_RELAY_SECRET", raising=False)
monkeypatch.setenv("APP_ENV", "development")
gpu_relay._DEFAULT_SECRET_LOGGED = False
with caplog.at_level("WARNING"):
secret = gpu_relay._secret()
assert len(secret) > 16
assert "ephemeral dev token" in caplog.text
# 第二次调用不再 log(_DEFAULT_SECRET_LOGGED=True)
before = len(caplog.records)
secret2 = gpu_relay._secret()
assert secret2 == secret
assert len(caplog.records) == before
# 清理
monkeypatch.delenv("GPU_ENCODE_RELAY_SECRET", raising=False)
# ── _safe_key ────────────────────────────────────────────────────────
class TestSafeKey:
@pytest.mark.parametrize("bad", ["", "../etc", "a/b", "a\\b", ".", "..", "a b", "a%b"])
def test_invalid_keys_rejected(self, bad):
with pytest.raises(HTTPException) as ei:
gpu_relay._safe_key(bad)
assert ei.value.status_code == 400
@pytest.mark.parametrize("good", ["abc123", "ABC-Def_01", "a" * 32])
def test_valid_keys_accepted(self, good):
assert gpu_relay._safe_key(good) == good
def test_strips_whitespace(self):
assert gpu_relay._safe_key(" abc ") == "abc"
# ── _check_token ─────────────────────────────────────────────────────
class TestCheckToken:
def test_missing_token_401(self):
with pytest.raises(HTTPException) as ei:
gpu_relay._check_token(None)
assert ei.value.status_code == 401
def test_wrong_token_401(self, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "correct")
with pytest.raises(HTTPException) as ei:
gpu_relay._check_token("wrong")
assert ei.value.status_code == 401
def test_correct_token_passes(self, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "correct")
assert gpu_relay._check_token("correct") is None
# ── build_relay_* helpers ───────────────────────────────────────────
class TestBuildRelayUrls:
def test_put_url(self):
url = gpu_relay.build_relay_put_url("http://api.example.com/", "k1", "s")
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")
def test_generate_key_is_hex(self):
k = gpu_relay.generate_key()
assert len(k) == 32
int(k, 16) # valid hex
# ── PUT endpoint ────────────────────────────────────────────────────
@pytest.mark.asyncio
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
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")
p = tmp_path / "gpu_relay" / "abc123"
assert p.read_bytes() == b"chunk1chunk2"
# .part 临时文件应已 rename
assert not p.with_suffix(p.suffix + ".part").exists()
async def test_put_invalid_key_400(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
req = mock.MagicMock()
with pytest.raises(HTTPException) as ei:
await gpu_relay.put_object(key="../bad", request=req, token="s")
assert ei.value.status_code == 400
async def test_put_bad_token_401(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "correct")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
req = mock.MagicMock()
with pytest.raises(HTTPException) as ei:
await gpu_relay.put_object(key="abc", request=req, token="wrong")
assert ei.value.status_code == 401
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
with pytest.raises(HTTPException) as ei:
await gpu_relay.put_object(key="abc", request=req, token="s")
assert ei.value.status_code == 500
# tmp 文件被清理
part = tmp_path / "gpu_relay" / "abc.part"
assert not part.exists()
# ── GET endpoint ────────────────────────────────────────────────────
@pytest.mark.asyncio
class TestGetObject:
async def test_get_missing_404(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
with pytest.raises(HTTPException) as ei:
await gpu_relay.get_object(key="nope", token="s")
assert ei.value.status_code == 404
async def test_get_returns_file(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
p = tmp_path / "gpu_relay" / "exist"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(b"viddata")
resp = await gpu_relay.get_object(key="exist", token="s")
assert resp.media_type == "video/mp4"
# ── HEAD endpoint ───────────────────────────────────────────────────
@pytest.mark.asyncio
class TestHeadObject:
async def test_head_missing_404(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
resp = await gpu_relay.head_object(key="nope", token="s")
assert resp.status_code == 404
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")
resp = await gpu_relay.head_object(key="k", token="s")
assert resp.status_code == 200
assert resp.headers["Content-Length"] == "5"
# ── DELETE endpoint ──────────────────────────────────────────────────
@pytest.mark.asyncio
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")
resp = await gpu_relay.delete_object(key="k", token="s")
assert resp["ok"] is True
assert not p.exists()
async def test_delete_missing_is_noop(self, tmp_path, monkeypatch):
monkeypatch.setenv("GPU_ENCODE_RELAY_SECRET", "s")
monkeypatch.setenv("GENERATED_FILES_DIR", str(tmp_path))
# 不存在时不应 404,返回 ok
resp = await gpu_relay.delete_object(key="nope", token="s")
assert resp["ok"] is True
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")
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")
assert ei.value.status_code == 500