mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
259 lines
8.5 KiB
Python
259 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src.api.base.pipeline import ApiRequestPipeline
|
|
from src.api.handlers.gemini.video_adapter import GeminiVeoAdapter
|
|
from src.api.handlers.openai.video_adapter import OpenAIVideoAdapter
|
|
from src.core.api_format.conversion.internal_video import VideoStatus
|
|
|
|
|
|
def _make_request(
|
|
*,
|
|
method: str,
|
|
path: str,
|
|
headers: dict[str, str],
|
|
body: bytes,
|
|
) -> MagicMock:
|
|
req = MagicMock()
|
|
req.method = method
|
|
req.url = SimpleNamespace(path=path)
|
|
req.headers = headers
|
|
req.query_params = {}
|
|
req.client = None
|
|
req.state = SimpleNamespace()
|
|
req.body = AsyncMock(return_value=body)
|
|
return req
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_video_cancel_openai_route_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
End-to-end-ish test:
|
|
ApiRequestPipeline -> OpenAIVideoAdapter -> OpenAIVideoHandler -> TaskService.cancel
|
|
"""
|
|
pipeline = ApiRequestPipeline()
|
|
|
|
# Pipeline auth/balance/audit shortcuts
|
|
user = SimpleNamespace(id="u1", username="u1", role="user")
|
|
api_key = SimpleNamespace(id="ak1", user_id="u1", is_standalone=False)
|
|
monkeypatch.setattr(
|
|
pipeline.auth_service, "authenticate_api_key", lambda _db, _k: (user, api_key)
|
|
)
|
|
monkeypatch.setattr(
|
|
pipeline.usage_service, "check_request_balance", lambda *_args, **_kwargs: (True, "ok")
|
|
)
|
|
monkeypatch.setattr(pipeline, "_calculate_balance_remaining", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(pipeline.audit_service, "log_event", MagicMock())
|
|
|
|
# DB stubs used by TaskService.cancel
|
|
task = SimpleNamespace(
|
|
id="t1",
|
|
short_id="s1",
|
|
user_id="u1",
|
|
request_id="r1",
|
|
external_task_id="ext-1",
|
|
endpoint_id="e1",
|
|
key_id="k1",
|
|
status=VideoStatus.SUBMITTED.value,
|
|
updated_at=None,
|
|
request_metadata={},
|
|
)
|
|
endpoint = SimpleNamespace(
|
|
id="e1",
|
|
base_url="https://upstream.example.com",
|
|
api_format="openai:video",
|
|
api_family="openai",
|
|
endpoint_kind="video",
|
|
header_rules=None,
|
|
)
|
|
provider_key = SimpleNamespace(
|
|
id="k1",
|
|
api_key="encrypted",
|
|
auth_type="api_key",
|
|
)
|
|
|
|
q_task = MagicMock()
|
|
q_task.filter.return_value.first.return_value = task
|
|
q_endpoint = MagicMock()
|
|
q_endpoint.filter.return_value.first.return_value = endpoint
|
|
q_key = MagicMock()
|
|
q_key.filter.return_value.first.return_value = provider_key
|
|
|
|
db = MagicMock()
|
|
|
|
def _query(model: type) -> MagicMock:
|
|
name = getattr(model, "__name__", "")
|
|
if name == "VideoTask":
|
|
return q_task
|
|
if name == "ProviderEndpoint":
|
|
return q_endpoint
|
|
if name == "ProviderAPIKey":
|
|
return q_key
|
|
return MagicMock()
|
|
|
|
db.query.side_effect = _query
|
|
|
|
# Upstream call stubs
|
|
upstream = SimpleNamespace(
|
|
delete=AsyncMock(return_value=httpx.Response(200, json={"ok": True})),
|
|
post=AsyncMock(), # not used for openai cancel
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"src.clients.http_client.HTTPClientPool.get_default_client_async",
|
|
AsyncMock(return_value=upstream),
|
|
),
|
|
patch("src.core.crypto.crypto_service.decrypt", lambda _v: "upstream-key"),
|
|
patch(
|
|
"src.services.provider.transport.build_provider_url",
|
|
lambda _endpoint, **_kwargs: "https://upstream.example.com/v1/videos",
|
|
),
|
|
patch(
|
|
"src.services.usage.service.UsageService.finalize_void", MagicMock(return_value=True)
|
|
),
|
|
patch("src.services.usage.service.UsageService.void_settled", MagicMock()),
|
|
):
|
|
request = _make_request(
|
|
method="POST",
|
|
path="/v1/videos/t1/cancel",
|
|
headers={
|
|
"authorization": "Bearer sk-test",
|
|
"x-real-ip": "127.0.0.1",
|
|
"user-agent": "pytest",
|
|
},
|
|
body=b"",
|
|
)
|
|
adapter = OpenAIVideoAdapter()
|
|
resp = await pipeline.run(
|
|
adapter=adapter,
|
|
http_request=request,
|
|
db=db,
|
|
mode=adapter.mode,
|
|
api_format_hint=adapter.allowed_api_formats[0],
|
|
path_params={"task_id": "t1"},
|
|
)
|
|
|
|
assert getattr(resp, "status_code", None) == 200
|
|
assert upstream.delete.await_count == 1
|
|
assert upstream.delete.call_args.args[0] == "https://upstream.example.com/v1/videos/ext-1"
|
|
assert task.status == VideoStatus.CANCELLED.value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_video_cancel_gemini_route_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
End-to-end-ish test:
|
|
ApiRequestPipeline -> GeminiVeoAdapter -> GeminiVeoHandler -> TaskService.cancel
|
|
"""
|
|
pipeline = ApiRequestPipeline()
|
|
|
|
# Pipeline auth/balance/audit shortcuts
|
|
user = SimpleNamespace(id="u1", username="u1", role="user")
|
|
api_key = SimpleNamespace(id="ak1", user_id="u1", is_standalone=False)
|
|
monkeypatch.setattr(
|
|
pipeline.auth_service, "authenticate_api_key", lambda _db, _k: (user, api_key)
|
|
)
|
|
monkeypatch.setattr(
|
|
pipeline.usage_service, "check_request_balance", lambda *_args, **_kwargs: (True, "ok")
|
|
)
|
|
monkeypatch.setattr(pipeline, "_calculate_balance_remaining", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(pipeline.audit_service, "log_event", MagicMock())
|
|
|
|
# DB stubs used by TaskService.cancel
|
|
task = SimpleNamespace(
|
|
id="t1",
|
|
short_id="op123",
|
|
user_id="u1",
|
|
request_id="r1",
|
|
external_task_id="op123",
|
|
endpoint_id="e1",
|
|
key_id="k1",
|
|
status=VideoStatus.SUBMITTED.value,
|
|
updated_at=None,
|
|
request_metadata={},
|
|
)
|
|
endpoint = SimpleNamespace(
|
|
id="e1",
|
|
base_url="https://generativelanguage.googleapis.com",
|
|
api_format="gemini:video",
|
|
api_family="gemini",
|
|
endpoint_kind="video",
|
|
header_rules=None,
|
|
)
|
|
provider_key = SimpleNamespace(
|
|
id="k1",
|
|
api_key="encrypted",
|
|
auth_type="api_key",
|
|
)
|
|
|
|
q_task_id = MagicMock()
|
|
q_task_id.filter.return_value.first.return_value = None
|
|
q_task_short = MagicMock()
|
|
q_task_short.filter.return_value.first.return_value = task
|
|
q_endpoint = MagicMock()
|
|
q_endpoint.filter.return_value.first.return_value = endpoint
|
|
q_key = MagicMock()
|
|
q_key.filter.return_value.first.return_value = provider_key
|
|
|
|
db = MagicMock()
|
|
_video_query_count = {"n": 0}
|
|
|
|
def _query(model: type) -> MagicMock:
|
|
name = getattr(model, "__name__", "")
|
|
if name == "VideoTask":
|
|
_video_query_count["n"] += 1
|
|
return q_task_id if _video_query_count["n"] == 1 else q_task_short
|
|
if name == "ProviderEndpoint":
|
|
return q_endpoint
|
|
if name == "ProviderAPIKey":
|
|
return q_key
|
|
return MagicMock()
|
|
|
|
db.query.side_effect = _query
|
|
|
|
upstream = SimpleNamespace(
|
|
post=AsyncMock(return_value=httpx.Response(200, json={"done": True})),
|
|
delete=AsyncMock(), # not used for gemini cancel
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"src.clients.http_client.HTTPClientPool.get_default_client_async",
|
|
AsyncMock(return_value=upstream),
|
|
),
|
|
patch("src.core.crypto.crypto_service.decrypt", lambda _v: "upstream-key"),
|
|
patch(
|
|
"src.api.handlers.base.request_builder.get_provider_auth", AsyncMock(return_value=None)
|
|
),
|
|
patch(
|
|
"src.services.usage.service.UsageService.finalize_void", MagicMock(return_value=True)
|
|
),
|
|
patch("src.services.usage.service.UsageService.void_settled", MagicMock()),
|
|
):
|
|
request = _make_request(
|
|
method="POST",
|
|
path="/v1beta/operations/op123:cancel",
|
|
headers={"x-goog-api-key": "sk-test", "x-real-ip": "127.0.0.1", "user-agent": "pytest"},
|
|
body=b"",
|
|
)
|
|
adapter = GeminiVeoAdapter()
|
|
resp = await pipeline.run(
|
|
adapter=adapter,
|
|
http_request=request,
|
|
db=db,
|
|
mode=adapter.mode,
|
|
api_format_hint=adapter.allowed_api_formats[0],
|
|
path_params={"task_id": "op123", "action": "cancel"},
|
|
)
|
|
|
|
assert getattr(resp, "status_code", None) == 200
|
|
assert upstream.post.await_count == 1
|
|
assert upstream.post.call_args.args[0].endswith("/v1beta/operations/op123:cancel")
|
|
assert task.status == VideoStatus.CANCELLED.value
|