Files
xiaoxia-saas/tests/unit/test_gpu_relay.py
CI Bot 706fed9c08
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
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-26 13:03:47 +00:00

241 lines
11 KiB
Python
Raw Permalink 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