mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
@@ -6,6 +6,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.services.auth.oauth.models import OAuthFlowError
|
||||
from src.services.auth.oauth.providers.linuxdo import LinuxDoOAuthProvider
|
||||
|
||||
|
||||
@@ -33,6 +34,28 @@ def test_linuxdo_authorization_url_omits_empty_scope() -> None:
|
||||
assert params["state"] == ["state-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linuxdo_exchange_code_requires_rust_executor() -> None:
|
||||
provider = LinuxDoOAuthProvider()
|
||||
|
||||
with pytest.raises(OAuthFlowError) as exc_info:
|
||||
await provider.exchange_code(_make_config(), "code-0")
|
||||
|
||||
assert exc_info.value.error_code == "provider_unavailable"
|
||||
assert exc_info.value.detail == "OAuth 仅支持 Rust executor"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linuxdo_get_user_info_requires_rust_executor() -> None:
|
||||
provider = LinuxDoOAuthProvider()
|
||||
|
||||
with pytest.raises(OAuthFlowError) as exc_info:
|
||||
await provider.get_user_info(_make_config(), "access-token")
|
||||
|
||||
assert exc_info.value.error_code == "provider_unavailable"
|
||||
assert exc_info.value.detail == "OAuth 仅支持 Rust executor"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linuxdo_exchange_code_uses_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = LinuxDoOAuthProvider()
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.core.enums import AuthSource, UserRole
|
||||
from src.models.database import Base, OAuthProvider, User, UserOAuthLink
|
||||
from src.services.auth.oauth.models import OAuthFlowError
|
||||
from src.services.auth.oauth.service import OAuthService
|
||||
from src.services.auth.oauth.state import OAuthStateData
|
||||
|
||||
@@ -133,6 +134,127 @@ async def test_handle_callback_allows_bind_state_without_device_id(
|
||||
assert parse_qs(parsed.query)["oauth_bound"] == ["GitHub"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_callback_redirects_when_provider_http_is_rust_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
provider = SimpleNamespace(
|
||||
exchange_code=AsyncMock(
|
||||
side_effect=OAuthFlowError("provider_unavailable", "OAuth 仅支持 Rust executor")
|
||||
),
|
||||
get_user_info=AsyncMock(),
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
frontend_callback_url="https://app.example.com/auth/callback",
|
||||
display_name="GitHub",
|
||||
is_enabled=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._require_module_active",
|
||||
lambda _db: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
|
||||
lambda _provider_type: provider,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_config",
|
||||
lambda _db, _provider_type: config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.get_redis_client",
|
||||
AsyncMock(return_value=object()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.consume_oauth_state",
|
||||
AsyncMock(
|
||||
return_value=OAuthStateData(
|
||||
nonce="state-1",
|
||||
provider_type="github",
|
||||
action="login",
|
||||
user_id=None,
|
||||
client_device_id="device-1",
|
||||
created_at=123,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = await OAuthService.handle_callback(
|
||||
db=db,
|
||||
provider_type="github",
|
||||
state="state-1",
|
||||
code="code-1",
|
||||
error=None,
|
||||
error_description=None,
|
||||
client_ip=None,
|
||||
user_agent="pytest-agent",
|
||||
headers={},
|
||||
)
|
||||
|
||||
parsed = urlparse(result.redirect_url)
|
||||
params = parse_qs(parsed.query)
|
||||
assert params["error_code"] == ["provider_unavailable"]
|
||||
assert params["error_detail"] == ["OAuth 仅支持 Rust executor"]
|
||||
provider.get_user_info.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_provider_config_returns_rust_only_failure_without_network(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
|
||||
lambda _provider_type: SimpleNamespace(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_config",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("Python OAuth config probe should not read DB config")
|
||||
),
|
||||
)
|
||||
|
||||
result = await OAuthService.test_provider_config(db, "github")
|
||||
assert result == {
|
||||
"authorization_url_reachable": False,
|
||||
"token_url_reachable": False,
|
||||
"secret_status": "unsupported",
|
||||
"details": "OAuth 配置测试仅支持 Rust executor",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_provider_config_with_data_returns_rust_only_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
provider = SimpleNamespace(
|
||||
authorization_url="https://provider.example/authorize",
|
||||
token_url="https://provider.example/token",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.auth.oauth.service.OAuthService._get_provider_impl",
|
||||
lambda _provider_type: provider,
|
||||
)
|
||||
|
||||
result = await OAuthService.test_provider_config_with_data(
|
||||
provider_type="github",
|
||||
client_id="client-id",
|
||||
client_secret="secret",
|
||||
authorization_url_override=None,
|
||||
token_url_override=None,
|
||||
redirect_uri="https://api.example.com/api/oauth/github/callback",
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"authorization_url_reachable": False,
|
||||
"token_url_reachable": False,
|
||||
"secret_status": "unsupported",
|
||||
"details": "OAuth 配置测试仅支持 Rust executor",
|
||||
}
|
||||
|
||||
|
||||
def test_handle_login_sync_returns_user_snapshot_outside_db_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -173,6 +173,49 @@ def test_persist_refreshed_token_preserves_true_account_block(
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
|
||||
|
||||
|
||||
def test_mark_oauth_token_expired_preserves_existing_account_block() -> None:
|
||||
key = SimpleNamespace(
|
||||
id="key-1",
|
||||
oauth_invalid_at="old-invalid-at",
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google requires verification",
|
||||
)
|
||||
|
||||
module._mark_oauth_token_expired(key, expires_at=1_900_000_000)
|
||||
|
||||
assert key.oauth_invalid_at == "old-invalid-at"
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
|
||||
|
||||
|
||||
def test_mark_oauth_token_expired_marks_detached_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key = SimpleNamespace(id="key-1", oauth_invalid_at=None, oauth_invalid_reason=None)
|
||||
row = SimpleNamespace(id="key-1", oauth_invalid_at=None, oauth_invalid_reason=None)
|
||||
fake_db = _FakeDB(row)
|
||||
|
||||
monkeypatch.setattr(
|
||||
module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError())
|
||||
)
|
||||
_install_module(
|
||||
monkeypatch,
|
||||
"src.database",
|
||||
{"create_session": lambda: _FakeSessionCtx(fake_db)},
|
||||
)
|
||||
_install_module(
|
||||
monkeypatch,
|
||||
"src.models.database",
|
||||
{"ProviderAPIKey": type("ProviderAPIKey", (), {"id": "id"})},
|
||||
)
|
||||
|
||||
module._mark_oauth_token_expired(key, expires_at=1_900_000_000)
|
||||
|
||||
assert fake_db.committed is True
|
||||
assert key.oauth_invalid_at is not None
|
||||
assert row.oauth_invalid_at is not None
|
||||
assert str(key.oauth_invalid_reason).startswith("[OAUTH_EXPIRED] Token 已过期且续期失败")
|
||||
assert "expired_at=1900000000" in str(row.oauth_invalid_reason)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_generic_oauth_token_persists_enriched_account_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -252,6 +252,54 @@ def test_clear_oauth_invalid_response_invalidates_caches(
|
||||
assert cache_calls == [("key", "key-1"), ("models", None)]
|
||||
|
||||
|
||||
def test_clear_oauth_invalid_response_noops_without_invalid_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cache_calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def _fake_invalidate_key_cache(key_id: str) -> None:
|
||||
cache_calls.append(("key", key_id))
|
||||
|
||||
async def _fake_invalidate_models_cache() -> None:
|
||||
cache_calls.append(("models", None))
|
||||
|
||||
fake_provider_cache_module = types.ModuleType("src.services.cache.provider_cache")
|
||||
|
||||
class _FakeProviderCacheService:
|
||||
@staticmethod
|
||||
async def invalidate_provider_api_key_cache(key_id: str) -> None:
|
||||
await _fake_invalidate_key_cache(key_id)
|
||||
|
||||
setattr(fake_provider_cache_module, "ProviderCacheService", _FakeProviderCacheService)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "src.services.cache.provider_cache", fake_provider_cache_module
|
||||
)
|
||||
|
||||
fake_models_service_module = types.ModuleType("src.services.cache.model_list_cache")
|
||||
setattr(
|
||||
fake_models_service_module, "invalidate_models_list_cache", _fake_invalidate_models_cache
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "src.services.cache.model_list_cache", fake_models_service_module
|
||||
)
|
||||
|
||||
key = SimpleNamespace(
|
||||
oauth_invalid_at=None,
|
||||
oauth_invalid_reason=None,
|
||||
is_active=False,
|
||||
)
|
||||
db = _FakeClearOAuthDB(key=key)
|
||||
|
||||
result = command_module.clear_oauth_invalid_response(cast(Any, db), key_id="key-1")
|
||||
|
||||
assert result["message"] == "该 Key 当前无失效标记,无需清除"
|
||||
assert key.oauth_invalid_at is None
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert key.is_active is False
|
||||
assert db.commit_count == 0
|
||||
assert cache_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_delete_key_side_effects_skip_disassociate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -89,6 +89,18 @@ def _install_module(monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str,
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
|
||||
|
||||
def _patch_rust_codex_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
module: Any,
|
||||
response: Any | None,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_try_rust_codex_quota_response",
|
||||
AsyncMock(return_value=response),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_endpoint_missing_returns_error() -> None:
|
||||
key = SimpleNamespace(id="k1", name="K1")
|
||||
@@ -128,15 +140,12 @@ async def test_codex_refresher_http_non_200_returns_error(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
|
||||
response = _FakeResponse(status_code=503, payload={"x": 1})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -241,15 +250,12 @@ async def test_codex_refresher_http_401_marks_auth_invalid_without_disabling_key
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
|
||||
response = _FakeResponse(status_code=401, payload={"error": {"message": "token expired"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -295,7 +301,6 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -309,9 +314,7 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -362,7 +365,6 @@ async def test_codex_refresher_success_preserves_refresh_failed_marker(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -379,9 +381,7 @@ async def test_codex_refresher_success_preserves_refresh_failed_marker(
|
||||
module, "parse_codex_wham_usage_response", lambda _data: {"used_percent": 10.0}
|
||||
)
|
||||
response = _FakeResponse(status_code=200, payload={"ok": True})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -430,7 +430,6 @@ async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -444,9 +443,7 @@ async def test_codex_refresher_quota_exhausted_preserves_refresh_failed_marker(
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=402, payload={"error": {"message": "payment required"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -490,7 +487,6 @@ async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -499,9 +495,7 @@ async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
||||
status_code=403,
|
||||
payload={"error": {"message": "Authentication token has been invalidated."}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -541,7 +535,6 @@ async def test_codex_refresher_http_403_generic_marks_soft_request_failed(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -550,9 +543,7 @@ async def test_codex_refresher_http_403_generic_marks_soft_request_failed(
|
||||
status_code=403,
|
||||
payload={"error": {"message": "Access forbidden for this account."}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -599,7 +590,6 @@ async def test_codex_refresher_success_updates_metadata(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -608,9 +598,7 @@ async def test_codex_refresher_success_updates_metadata(
|
||||
module, "parse_codex_wham_usage_response", lambda _data: {"used_percent": 10.0}
|
||||
)
|
||||
response = _FakeResponse(status_code=200, payload={"ok": True})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -647,7 +635,6 @@ async def test_codex_refresher_parse_error_is_diagnostic(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -660,9 +647,7 @@ async def test_codex_refresher_parse_error_is_diagnostic(
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=200, payload={"ok": True})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -706,7 +691,6 @@ async def test_codex_refresher_oauth_missing_plan_type_adds_account_header(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -717,12 +701,11 @@ async def test_codex_refresher_oauth_missing_plan_type_adds_account_header(
|
||||
module.crypto_service, "decrypt", lambda _v: json.dumps({"account_id": "acc-1"})
|
||||
)
|
||||
|
||||
def _client_factory(**kwargs: Any) -> _FakeAsyncClient:
|
||||
client = _FakeAsyncClient(response, **kwargs)
|
||||
client_ref["client"] = client
|
||||
return client
|
||||
async def _fake_rust_response(**kwargs: Any) -> Any:
|
||||
client_ref["client"] = SimpleNamespace(last_headers=dict(kwargs["headers"]))
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(module.httpx, "AsyncClient", _client_factory)
|
||||
monkeypatch.setattr(module, "_try_rust_codex_quota_response", _fake_rust_response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -766,7 +749,6 @@ async def test_codex_refresher_oauth_uppercase_free_does_not_add_account_header(
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -779,12 +761,11 @@ async def test_codex_refresher_oauth_uppercase_free_does_not_add_account_header(
|
||||
lambda _v: json.dumps({"account_id": "acc-1", "plan_type": "FREE"}),
|
||||
)
|
||||
|
||||
def _client_factory(**kwargs: Any) -> _FakeAsyncClient:
|
||||
client = _FakeAsyncClient(response, **kwargs)
|
||||
client_ref["client"] = client
|
||||
return client
|
||||
async def _fake_rust_response(**kwargs: Any) -> Any:
|
||||
client_ref["client"] = SimpleNamespace(last_headers=dict(kwargs["headers"]))
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(module.httpx, "AsyncClient", _client_factory)
|
||||
monkeypatch.setattr(module, "_try_rust_codex_quota_response", _fake_rust_response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
@@ -1216,7 +1197,6 @@ async def test_codex_refresher_http_402_workspace_deactivated_marks_account_bloc
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
@@ -1230,9 +1210,7 @@ async def test_codex_refresher_http_402_workspace_deactivated_marks_account_bloc
|
||||
),
|
||||
)
|
||||
response = _FakeResponse(status_code=402, payload={"detail": {"code": "deactivated_workspace"}})
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
_patch_rust_codex_response(monkeypatch, module, response)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import config as runtime_config
|
||||
from src.services.provider_ops.service import ProviderOpsService
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.services.provider_ops.types import (
|
||||
ActionStatus,
|
||||
ConnectorAuthType,
|
||||
ProviderActionType,
|
||||
ProviderOpsConfig,
|
||||
)
|
||||
from src.services.request.rust_executor_client import RustExecutorSyncResult
|
||||
|
||||
|
||||
@@ -53,6 +60,14 @@ class _FakeRegistry:
|
||||
return self._architecture
|
||||
|
||||
|
||||
class _ExplodingConnector:
|
||||
async def is_authenticated(self) -> bool:
|
||||
raise AssertionError("Python connector auth check should not run")
|
||||
|
||||
def get_client(self) -> Any:
|
||||
raise AssertionError("Python connector client should not be created")
|
||||
|
||||
|
||||
class _SuccessResult:
|
||||
def __init__(self) -> None:
|
||||
self.success = True
|
||||
@@ -165,3 +180,106 @@ async def test_verify_auth_prefers_rust_executor(
|
||||
assert captured["plan"].proxy.mode == "tunnel"
|
||||
assert captured["plan"].proxy.node_id == "node-1"
|
||||
cache_balance.assert_awaited_once_with("provider-1", 2.0, {"window": "day"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_auth_returns_explicit_failure_when_rust_verifier_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = ProviderOpsService(_FakeDB())
|
||||
architecture = _SuccessArchitecture()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_ops.service.get_registry",
|
||||
lambda: _FakeRegistry(architecture),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_try_rust_verify_response",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.resolve_ops_proxy_config_async",
|
||||
AsyncMock(return_value=(None, None)),
|
||||
)
|
||||
|
||||
result = await service.verify_auth(
|
||||
base_url="https://example.com",
|
||||
architecture_id="sub2api",
|
||||
auth_type=ConnectorAuthType.SESSION_LOGIN,
|
||||
config={},
|
||||
credentials={"access_token": "token"},
|
||||
)
|
||||
|
||||
assert result == {"success": False, "message": "认证验证仅支持 Rust executor"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("backend", ["python", "rust"])
|
||||
async def test_connect_returns_explicit_failure_without_python_connector(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
backend: str,
|
||||
) -> None:
|
||||
service = ProviderOpsService(_FakeDB())
|
||||
service._connectors["provider-1"] = object() # type: ignore[assignment]
|
||||
monkeypatch.setattr(runtime_config, "executor_backend", backend)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_get_provider",
|
||||
lambda _provider_id: SimpleNamespace(base_url="https://example.com"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_config",
|
||||
lambda _provider_id: ProviderOpsConfig(
|
||||
architecture_id="sub2api",
|
||||
base_url="https://example.com",
|
||||
connector_auth_type=ConnectorAuthType.API_KEY,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_ops.service.get_registry",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("Python provider connector registry should not be used")
|
||||
),
|
||||
)
|
||||
|
||||
success, message = await service.connect("provider-1", {"api_key": "token"})
|
||||
|
||||
assert success is False
|
||||
assert message == "Provider 连接仅支持 Rust executor"
|
||||
assert "provider-1" not in service._connectors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("backend", ["python", "rust"])
|
||||
async def test_execute_action_returns_not_supported_without_python_connector(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
backend: str,
|
||||
) -> None:
|
||||
service = ProviderOpsService(_FakeDB())
|
||||
service._connectors["provider-1"] = _ExplodingConnector() # type: ignore[assignment]
|
||||
monkeypatch.setattr(runtime_config, "executor_backend", backend)
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_config",
|
||||
lambda _provider_id: ProviderOpsConfig(
|
||||
architecture_id="sub2api",
|
||||
base_url="https://example.com",
|
||||
connector_auth_type=ConnectorAuthType.API_KEY,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_ops.service.get_registry",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("Python provider action architecture should not be used")
|
||||
),
|
||||
)
|
||||
|
||||
result = await service.execute_action("provider-1", ProviderActionType.QUERY_BALANCE)
|
||||
|
||||
assert result.status == ActionStatus.NOT_SUPPORTED
|
||||
assert result.action_type == ProviderActionType.QUERY_BALANCE
|
||||
assert result.message == "Provider 操作仅支持 Rust executor"
|
||||
|
||||
@@ -79,6 +79,22 @@ def test_codex_openai_cli_does_not_duplicate_responses_suffix() -> None:
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses"
|
||||
|
||||
|
||||
def test_codex_openai_cli_supports_backendapi_variant() -> None:
|
||||
endpoint = _DummyEndpoint(
|
||||
base_url="https://chatgpt.com/backendapi/codex",
|
||||
api_format="openai:cli",
|
||||
provider=SimpleNamespace(provider_type="codex"),
|
||||
)
|
||||
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=True,
|
||||
)
|
||||
|
||||
assert url == "https://chatgpt.com/backendapi/codex/responses"
|
||||
|
||||
|
||||
def test_codex_openai_cli_uses_compact_suffix_when_context_marked_compact() -> None:
|
||||
endpoint = _DummyEndpoint(
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -49,6 +49,34 @@ async def test_maintenance_scheduler_start_skips_startup_task_when_disabled(
|
||||
assert created is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_start_skips_startup_task_when_no_python_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", True)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
|
||||
scheduler = MaintenanceScheduler()
|
||||
safe_create_task = MagicMock()
|
||||
|
||||
monkeypatch.setattr("src.utils.async_utils.safe_create_task", safe_create_task)
|
||||
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
|
||||
monkeypatch.setattr(
|
||||
maintenance_scheduler_module,
|
||||
"get_scheduler",
|
||||
lambda: SimpleNamespace(
|
||||
add_cron_job=lambda *args, **kwargs: None,
|
||||
add_interval_job=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
await scheduler.start()
|
||||
|
||||
safe_create_task.assert_not_called()
|
||||
assert scheduler._startup_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_stop_cancels_startup_task_and_removes_jobs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -89,6 +117,518 @@ async def test_maintenance_scheduler_stop_cancels_startup_task_and_removes_jobs(
|
||||
assert scheduler._registered_job_ids == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_start_skips_rust_owned_maintenance_jobs_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", False)
|
||||
monkeypatch.setattr(config, "audit_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "db_maintenance_python_enabled", False)
|
||||
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pool_monitor_python_enabled", False)
|
||||
monkeypatch.setattr(config, "provider_checkin_python_enabled", False)
|
||||
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
monkeypatch.setattr(config, "usage_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", False)
|
||||
|
||||
scheduler = MaintenanceScheduler()
|
||||
cron_job_ids: list[str] = []
|
||||
interval_job_ids: list[str] = []
|
||||
|
||||
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
|
||||
monkeypatch.setattr(
|
||||
maintenance_scheduler_module,
|
||||
"get_scheduler",
|
||||
lambda: SimpleNamespace(
|
||||
add_cron_job=lambda *, job_id, **kwargs: cron_job_ids.append(job_id),
|
||||
add_interval_job=lambda *, job_id, **kwargs: interval_job_ids.append(job_id),
|
||||
),
|
||||
)
|
||||
|
||||
await scheduler.start()
|
||||
|
||||
assert "stats_aggregation" not in cron_job_ids
|
||||
assert "audit_cleanup" not in cron_job_ids
|
||||
assert "candidate_cleanup" not in cron_job_ids
|
||||
assert "db_maintenance" not in cron_job_ids
|
||||
assert "stats_hourly_aggregation" not in cron_job_ids
|
||||
assert "pool_monitor" not in interval_job_ids
|
||||
assert "http_client_idle_cleanup" not in interval_job_ids
|
||||
assert "usage_cleanup" not in cron_job_ids
|
||||
assert "wallet_daily_usage_aggregation" not in cron_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID not in cron_job_ids
|
||||
assert "antigravity_ua_refresh" not in interval_job_ids
|
||||
assert "gemini_file_mapping_cleanup" not in interval_job_ids
|
||||
assert "pending_cleanup" not in interval_job_ids
|
||||
assert "stats_aggregation" not in scheduler._registered_job_ids
|
||||
assert "audit_cleanup" not in scheduler._registered_job_ids
|
||||
assert "candidate_cleanup" not in scheduler._registered_job_ids
|
||||
assert "db_maintenance" not in scheduler._registered_job_ids
|
||||
assert "stats_hourly_aggregation" not in scheduler._registered_job_ids
|
||||
assert "pool_monitor" not in scheduler._registered_job_ids
|
||||
assert "http_client_idle_cleanup" not in scheduler._registered_job_ids
|
||||
assert "usage_cleanup" not in scheduler._registered_job_ids
|
||||
assert "wallet_daily_usage_aggregation" not in scheduler._registered_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID not in scheduler._registered_job_ids
|
||||
assert "antigravity_ua_refresh" not in scheduler._registered_job_ids
|
||||
assert "gemini_file_mapping_cleanup" not in scheduler._registered_job_ids
|
||||
assert "pending_cleanup" not in scheduler._registered_job_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_start_registers_python_owned_maintenance_jobs_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "maintenance_startup_tasks_enabled", False)
|
||||
monkeypatch.setattr(config, "audit_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "db_maintenance_python_enabled", True)
|
||||
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "pool_monitor_python_enabled", True)
|
||||
monkeypatch.setattr(config, "provider_checkin_python_enabled", True)
|
||||
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "stats_aggregation_python_enabled", True)
|
||||
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", True)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", True)
|
||||
|
||||
scheduler = MaintenanceScheduler()
|
||||
cron_job_ids: list[str] = []
|
||||
interval_job_ids: list[str] = []
|
||||
|
||||
monkeypatch.setattr(scheduler, "_get_checkin_time", lambda: (1, 5))
|
||||
monkeypatch.setattr(
|
||||
maintenance_scheduler_module,
|
||||
"get_scheduler",
|
||||
lambda: SimpleNamespace(
|
||||
add_cron_job=lambda *, job_id, **kwargs: cron_job_ids.append(job_id),
|
||||
add_interval_job=lambda *, job_id, **kwargs: interval_job_ids.append(job_id),
|
||||
),
|
||||
)
|
||||
|
||||
await scheduler.start()
|
||||
|
||||
assert "stats_aggregation" in cron_job_ids
|
||||
assert "audit_cleanup" in cron_job_ids
|
||||
assert "candidate_cleanup" in cron_job_ids
|
||||
assert "db_maintenance" in cron_job_ids
|
||||
assert "stats_hourly_aggregation" in cron_job_ids
|
||||
assert "pool_monitor" in interval_job_ids
|
||||
assert "http_client_idle_cleanup" in interval_job_ids
|
||||
assert "usage_cleanup" in cron_job_ids
|
||||
assert "wallet_daily_usage_aggregation" in cron_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID in cron_job_ids
|
||||
assert "antigravity_ua_refresh" in interval_job_ids
|
||||
assert "gemini_file_mapping_cleanup" in interval_job_ids
|
||||
assert "pending_cleanup" in interval_job_ids
|
||||
assert "stats_aggregation" in scheduler._registered_job_ids
|
||||
assert "audit_cleanup" in scheduler._registered_job_ids
|
||||
assert "candidate_cleanup" in scheduler._registered_job_ids
|
||||
assert "db_maintenance" in scheduler._registered_job_ids
|
||||
assert "stats_hourly_aggregation" in scheduler._registered_job_ids
|
||||
assert "pool_monitor" in scheduler._registered_job_ids
|
||||
assert "http_client_idle_cleanup" in scheduler._registered_job_ids
|
||||
assert "usage_cleanup" in scheduler._registered_job_ids
|
||||
assert "wallet_daily_usage_aggregation" in scheduler._registered_job_ids
|
||||
assert scheduler.CHECKIN_JOB_ID in scheduler._registered_job_ids
|
||||
assert "antigravity_ua_refresh" in scheduler._registered_job_ids
|
||||
assert "gemini_file_mapping_cleanup" in scheduler._registered_job_ids
|
||||
assert "pending_cleanup" in scheduler._registered_job_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_startup_tasks_skip_python_pending_cleanup_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scheduler = MaintenanceScheduler()
|
||||
refresh_user_agent = AsyncMock()
|
||||
perform_pending_cleanup = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
monkeypatch.setattr(maintenance_scheduler_module.asyncio, "sleep", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.antigravity.client.refresh_user_agent",
|
||||
refresh_user_agent,
|
||||
)
|
||||
monkeypatch.setattr(scheduler, "_perform_pending_cleanup", perform_pending_cleanup)
|
||||
|
||||
await scheduler._run_startup_tasks()
|
||||
|
||||
refresh_user_agent.assert_not_awaited()
|
||||
perform_pending_cleanup.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maintenance_scheduler_startup_tasks_run_python_pending_cleanup_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
scheduler = MaintenanceScheduler()
|
||||
refresh_user_agent = AsyncMock()
|
||||
perform_pending_cleanup = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", True)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", True)
|
||||
monkeypatch.setattr(maintenance_scheduler_module.asyncio, "sleep", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.adapters.antigravity.client.refresh_user_agent",
|
||||
refresh_user_agent,
|
||||
)
|
||||
monkeypatch.setattr(scheduler, "_perform_pending_cleanup", perform_pending_cleanup)
|
||||
|
||||
await scheduler._run_startup_tasks()
|
||||
|
||||
refresh_user_agent.assert_awaited_once()
|
||||
perform_pending_cleanup.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_maintenance_scheduler_when_unused(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, _model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(monkeypatch)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "audit_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "antigravity_ua_refresh_python_enabled", False)
|
||||
monkeypatch.setattr(config, "db_maintenance_python_enabled", False)
|
||||
monkeypatch.setattr(config, "gemini_file_mapping_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pending_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "pool_monitor_python_enabled", False)
|
||||
monkeypatch.setattr(config, "provider_checkin_python_enabled", False)
|
||||
monkeypatch.setattr(config, "request_candidate_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "stats_hourly_aggregation_python_enabled", False)
|
||||
monkeypatch.setattr(config, "usage_cleanup_python_enabled", False)
|
||||
monkeypatch.setattr(config, "wallet_daily_usage_aggregation_python_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "maintenance_scheduler" not in coordinator.acquire_calls
|
||||
assert state.maintenance_scheduler is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_maintenance_scheduler_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, _model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"maintenance_scheduler": True},
|
||||
)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "http_client_idle_cleanup_python_enabled", True)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "maintenance_scheduler" in coordinator.acquire_calls
|
||||
assert "maintenance_scheduler" in coordinator.registered_callbacks
|
||||
assert state.maintenance_scheduler is not None
|
||||
|
||||
|
||||
def _patch_core_infrastructure_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config, "log_startup_warnings", lambda: None)
|
||||
monkeypatch.setattr(main_module, "init_db", lambda: None)
|
||||
monkeypatch.setattr(main_module, "initialize_providers", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.clients.redis_client.get_redis_client",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.rate_limit.concurrency_manager.get_concurrency_manager",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.rate_limit.user_rpm_limiter.get_user_rpm_limiter",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr("src.core.batch_committer.init_batch_committer", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.codex_quota_sync_dispatcher.init_codex_quota_sync_dispatcher",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
def _patch_python_host_shutdown_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.codex_quota_sync_dispatcher.shutdown_codex_quota_sync_dispatcher",
|
||||
AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr("src.core.batch_committer.shutdown_batch_committer", AsyncMock())
|
||||
monkeypatch.setattr("src.clients.redis_client.close_redis_client", AsyncMock())
|
||||
monkeypatch.setattr(main_module, "close_http_clients", AsyncMock())
|
||||
|
||||
|
||||
class _FakeStartupTaskCoordinator:
|
||||
def __init__(self, acquire_results: dict[str, bool] | None = None) -> None:
|
||||
self.acquire_results = acquire_results or {}
|
||||
self.acquire_calls: list[str] = []
|
||||
self.registered_callbacks: list[str] = []
|
||||
self.released: list[str] = []
|
||||
|
||||
async def acquire(self, name: str) -> bool:
|
||||
self.acquire_calls.append(name)
|
||||
return self.acquire_results.get(name, False)
|
||||
|
||||
async def release(self, name: str) -> None:
|
||||
self.released.append(name)
|
||||
|
||||
def register_lock_lost_callback(self, name: str, _callback: Any) -> None:
|
||||
self.registered_callbacks.append(name)
|
||||
|
||||
|
||||
def _patch_background_services_dependencies(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
acquire_results: dict[str, bool] | None = None,
|
||||
) -> tuple[_FakeStartupTaskCoordinator, Any, Any, Any]:
|
||||
coordinator = _FakeStartupTaskCoordinator(acquire_results)
|
||||
quota_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
maintenance_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
model_fetch_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
pool_quota_probe_scheduler = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
task_poller = SimpleNamespace(start=AsyncMock(), stop=AsyncMock())
|
||||
task_scheduler = SimpleNamespace(start=MagicMock())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.utils.task_coordinator.StartupTaskCoordinator",
|
||||
lambda _redis: coordinator,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.usage.quota_scheduler.get_quota_scheduler",
|
||||
lambda: quota_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.system.maintenance_scheduler.get_maintenance_scheduler",
|
||||
lambda: maintenance_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.model.fetch_scheduler.get_model_fetch_scheduler",
|
||||
lambda: model_fetch_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider_keys.pool_quota_probe_scheduler.get_pool_quota_probe_scheduler",
|
||||
lambda: pool_quota_probe_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.task.polling.task_poller.get_task_poller",
|
||||
lambda: task_poller,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.system.scheduler.get_scheduler",
|
||||
lambda: task_scheduler,
|
||||
)
|
||||
|
||||
return coordinator, quota_scheduler, task_poller, model_fetch_scheduler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_core_infrastructure_skips_python_usage_consumer_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_core_infrastructure_dependencies(monkeypatch)
|
||||
started = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "require_redis", False)
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", False)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.start_usage_queue_consumer", started)
|
||||
|
||||
await main_module._initialize_core_infrastructure(main_module.LifecycleState())
|
||||
|
||||
started.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_core_infrastructure_starts_python_usage_consumer_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_core_infrastructure_dependencies(monkeypatch)
|
||||
started = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "require_redis", False)
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", True)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.start_usage_queue_consumer", started)
|
||||
|
||||
await main_module._initialize_core_infrastructure(main_module.LifecycleState())
|
||||
|
||||
started.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_python_host_shutdown_skips_python_usage_consumer_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_python_host_shutdown_dependencies(monkeypatch)
|
||||
stopped = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", False)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.stop_usage_queue_consumer", stopped)
|
||||
|
||||
await main_module._run_python_host_shutdown(main_module.LifecycleState())
|
||||
|
||||
stopped.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_python_host_shutdown_stops_python_usage_consumer_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_python_host_shutdown_dependencies(monkeypatch)
|
||||
stopped = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(config, "usage_queue_enabled", True)
|
||||
monkeypatch.setattr(config, "usage_queue_python_consumer_enabled", True)
|
||||
monkeypatch.setattr("src.services.usage.consumer_streams.stop_usage_queue_consumer", stopped)
|
||||
|
||||
await main_module._run_python_host_shutdown(main_module.LifecycleState())
|
||||
|
||||
stopped.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_video_poller_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "task_poller:video" not in coordinator.acquire_calls
|
||||
task_poller.start.assert_not_awaited()
|
||||
assert state.task_poller is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_video_poller_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"task_poller:video": True},
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", True)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "task_poller:video" in coordinator.acquire_calls
|
||||
assert "task_poller:video" in coordinator.registered_callbacks
|
||||
task_poller.start.assert_awaited_once()
|
||||
assert state.task_poller is task_poller
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_quota_scheduler_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, quota_scheduler, _task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "quota_scheduler" not in coordinator.acquire_calls
|
||||
quota_scheduler.start.assert_not_awaited()
|
||||
assert state.quota_scheduler is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_quota_scheduler_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, quota_scheduler, _task_poller, _model_fetch_scheduler = _patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"quota_scheduler": True},
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", True)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "quota_scheduler" in coordinator.acquire_calls
|
||||
assert "quota_scheduler" in coordinator.registered_callbacks
|
||||
quota_scheduler.start.assert_awaited_once()
|
||||
assert state.quota_scheduler is quota_scheduler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_skips_python_model_fetch_scheduler_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(monkeypatch)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "model_fetch_scheduler" not in coordinator.acquire_calls
|
||||
model_fetch_scheduler.start.assert_not_awaited()
|
||||
assert state.model_fetch_scheduler is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_background_services_starts_python_model_fetch_scheduler_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
coordinator, _quota_scheduler, _task_poller, model_fetch_scheduler = (
|
||||
_patch_background_services_dependencies(
|
||||
monkeypatch,
|
||||
acquire_results={"model_fetch_scheduler": True},
|
||||
)
|
||||
)
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
monkeypatch.setattr(config, "quota_scheduler_python_enabled", False)
|
||||
monkeypatch.setattr(config, "model_fetch_scheduler_python_enabled", True)
|
||||
monkeypatch.setattr(config, "video_task_python_poller_enabled", False)
|
||||
|
||||
await main_module._start_background_services(state)
|
||||
|
||||
assert "model_fetch_scheduler" in coordinator.acquire_calls
|
||||
assert "model_fetch_scheduler" in coordinator.registered_callbacks
|
||||
model_fetch_scheduler.start.assert_awaited_once()
|
||||
assert state.model_fetch_scheduler is model_fetch_scheduler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_service_on_lock_lost_keeps_state_when_stop_fails() -> None:
|
||||
state = main_module.LifecycleState()
|
||||
|
||||
@@ -191,7 +191,7 @@ async def test_fetch_models_from_endpoints_uses_rust_for_gemini(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_from_endpoints_falls_back_to_python_when_rust_unavailable(
|
||||
async def test_fetch_models_from_endpoints_returns_rust_only_error_when_rust_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(fetcher_mod.config, "executor_backend", "rust")
|
||||
@@ -224,7 +224,7 @@ async def test_fetch_models_from_endpoints_falls_back_to_python_when_rust_unavai
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert errors == []
|
||||
assert models == [{"id": "fallback-model", "api_format": "openai:chat"}]
|
||||
python_fetch.assert_awaited_once()
|
||||
assert ok is False
|
||||
assert models == []
|
||||
assert errors == ["openai:chat: rust executor unavailable"]
|
||||
python_fetch.assert_not_awaited()
|
||||
|
||||
@@ -23,7 +23,7 @@ class _TimeoutAsyncClient:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vertex_auth_timeout_error_includes_readable_message(
|
||||
async def test_vertex_auth_is_rust_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = VertexAuthService(
|
||||
@@ -39,5 +39,5 @@ async def test_vertex_auth_timeout_error_includes_readable_message(
|
||||
monkeypatch.setattr(service, "_create_jwt", lambda: "signed-jwt")
|
||||
monkeypatch.setattr("src.core.vertex_auth.httpx.AsyncClient", _TimeoutAsyncClient)
|
||||
|
||||
with pytest.raises(VertexAuthError, match=r"request timed out after 30s"):
|
||||
with pytest.raises(VertexAuthError, match=r"仅支持 Rust executor"):
|
||||
await service.get_access_token(httpx_client_kwargs={"timeout": 30})
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import src.services.task.video.cancel as cancel_mod
|
||||
@@ -171,3 +172,122 @@ async def test_video_cancel_service_uses_rust_for_gemini_cancel(
|
||||
plan = execute_sync.await_args.args[0]
|
||||
assert plan.method == "POST"
|
||||
assert plan.body.json_body == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_cancel_service_returns_503_when_rust_backend_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep-1",
|
||||
provider_id="prov-1",
|
||||
api_family="openai",
|
||||
endpoint_kind="video",
|
||||
base_url="https://api.openai.com",
|
||||
)
|
||||
key = SimpleNamespace(id="key-1", api_key="encrypted")
|
||||
db = _FakeDB(endpoint, key)
|
||||
service = VideoTaskCancelService(db)
|
||||
task = SimpleNamespace(
|
||||
id="task-1",
|
||||
status=VideoStatus.PROCESSING.value,
|
||||
external_task_id="ext-1",
|
||||
endpoint_id="ep-1",
|
||||
key_id="key-1",
|
||||
request_id="req-1",
|
||||
model="sora-2",
|
||||
completed_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cancel_mod.config, "executor_backend", "python")
|
||||
monkeypatch.setattr(
|
||||
"src.core.crypto.crypto_service.decrypt",
|
||||
lambda value: "upstream-key",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.core.api_format.build_upstream_headers_for_endpoint",
|
||||
lambda *args, **kwargs: {"authorization": "Bearer upstream-key"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.transport.build_provider_url",
|
||||
lambda endpoint, is_stream=False, key=None: "https://api.openai.com/v1/videos",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_default_client_async",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
response = await service.cancel_task(
|
||||
task=task,
|
||||
task_id="task-1",
|
||||
original_headers={"x-test": "1"},
|
||||
)
|
||||
|
||||
assert isinstance(response, httpx.Response)
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": {"message": "Video 取消仅支持 Rust executor"}}
|
||||
assert db.committed is False
|
||||
assert task.status == VideoStatus.PROCESSING.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_cancel_service_returns_503_when_rust_executor_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep-1",
|
||||
provider_id="prov-1",
|
||||
api_family="gemini",
|
||||
endpoint_kind="video",
|
||||
base_url="https://generativelanguage.googleapis.com",
|
||||
)
|
||||
key = SimpleNamespace(id="key-1", api_key="encrypted")
|
||||
db = _FakeDB(endpoint, key)
|
||||
service = VideoTaskCancelService(db)
|
||||
task = SimpleNamespace(
|
||||
id="task-1",
|
||||
status=VideoStatus.PROCESSING.value,
|
||||
external_task_id="operations/ext-1",
|
||||
endpoint_id="ep-1",
|
||||
key_id="key-1",
|
||||
request_id="req-1",
|
||||
model="veo-3",
|
||||
completed_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cancel_mod.config, "executor_backend", "rust")
|
||||
monkeypatch.setattr(
|
||||
rust_client_mod.RustExecutorClient,
|
||||
"execute_sync_json",
|
||||
AsyncMock(side_effect=rust_client_mod.RustExecutorClientError("executor down")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.core.crypto.crypto_service.decrypt",
|
||||
lambda value: "upstream-key",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.core.api_format.build_upstream_headers_for_endpoint",
|
||||
lambda *args, **kwargs: {"x-goog-api-key": "upstream-key"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.provider.auth.get_provider_auth",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.clients.http_client.HTTPClientPool.get_default_client_async",
|
||||
AsyncMock(side_effect=AssertionError("python fallback should not run")),
|
||||
)
|
||||
|
||||
response = await service.cancel_task(
|
||||
task=task,
|
||||
task_id="task-1",
|
||||
original_headers={"x-test": "1"},
|
||||
)
|
||||
|
||||
assert isinstance(response, httpx.Response)
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": {"message": "执行器暂时不可用,请稍后重试"}}
|
||||
assert db.committed is False
|
||||
assert task.status == VideoStatus.PROCESSING.value
|
||||
|
||||
@@ -25,30 +25,26 @@ async def test_poll_task_status_routes_gemini_video_to_gemini(
|
||||
external_task_id="operations/123",
|
||||
),
|
||||
)
|
||||
endpoint = SimpleNamespace(id="e1", base_url="https://example.com", api_format="gemini:video")
|
||||
key = SimpleNamespace(id="k1", api_key="enc")
|
||||
|
||||
monkeypatch.setattr(adapter, "_get_endpoint", lambda _db, _id: endpoint)
|
||||
monkeypatch.setattr(adapter, "_get_key", lambda _db, _id: key)
|
||||
monkeypatch.setattr(
|
||||
"src.services.task.video.poller_adapter.crypto_service.decrypt", lambda _v: "decrypted"
|
||||
prepared_ctx = VideoPollContext(
|
||||
task_id="task-1",
|
||||
external_task_id="operations/123",
|
||||
provider_api_format="gemini:video",
|
||||
base_url="https://example.com",
|
||||
upstream_key="decrypted",
|
||||
headers={"authorization": "Bearer x"},
|
||||
poll_count=0,
|
||||
retry_count=0,
|
||||
poll_interval_seconds=15,
|
||||
max_poll_count=10,
|
||||
current_status=VideoStatus.PROCESSING.value,
|
||||
)
|
||||
|
||||
auth_info = SimpleNamespace(auth_header="authorization", auth_value="Bearer x")
|
||||
monkeypatch.setattr(
|
||||
"src.services.task.video.poller_adapter.get_provider_auth",
|
||||
AsyncMock(return_value=auth_info),
|
||||
)
|
||||
|
||||
poll_gemini = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
|
||||
poll_openai = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
|
||||
monkeypatch.setattr(adapter, "_poll_gemini", poll_gemini)
|
||||
monkeypatch.setattr(adapter, "_poll_openai", poll_openai)
|
||||
poll_http = AsyncMock(return_value=InternalVideoPollResult(status=VideoStatus.PROCESSING))
|
||||
monkeypatch.setattr(adapter, "prepare_poll_context", AsyncMock(return_value=prepared_ctx))
|
||||
monkeypatch.setattr(adapter, "poll_task_http", poll_http)
|
||||
|
||||
result = await adapter._poll_task_status(MagicMock(), task)
|
||||
assert result.status == VideoStatus.PROCESSING
|
||||
assert poll_gemini.await_count == 1
|
||||
assert poll_openai.await_count == 0
|
||||
poll_http.assert_awaited_once_with(prepared_ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -129,8 +125,6 @@ async def test_video_poller_try_rust_payload_passes_proxy_snapshot(
|
||||
async def test_video_poller_openai_poll_prefers_rust_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.task.video import poller_adapter as mod
|
||||
|
||||
adapter = VideoTaskPollerAdapter()
|
||||
|
||||
rust_poll = AsyncMock(return_value={"id": "vid_1", "status": "processing"})
|
||||
@@ -140,9 +134,6 @@ async def test_video_poller_openai_poll_prefers_rust_payload(
|
||||
normalizer = MagicMock(return_value=normalized)
|
||||
monkeypatch.setattr(adapter._openai_normalizer, "video_poll_to_internal", normalizer)
|
||||
|
||||
get_upstream_client = AsyncMock(side_effect=AssertionError("python upstream client should not be used"))
|
||||
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
|
||||
|
||||
ctx = VideoPollContext(
|
||||
task_id="task-1",
|
||||
external_task_id="vid_1",
|
||||
@@ -161,34 +152,17 @@ async def test_video_poller_openai_poll_prefers_rust_payload(
|
||||
|
||||
assert result is normalized
|
||||
normalizer.assert_called_once_with({"id": "vid_1", "status": "processing"})
|
||||
get_upstream_client.assert_not_awaited()
|
||||
rust_poll.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_poller_openai_poll_fallback_uses_transport_aware_client(
|
||||
async def test_video_poller_openai_poll_requires_rust_executor_when_payload_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.task.video import poller_adapter as mod
|
||||
|
||||
adapter = VideoTaskPollerAdapter()
|
||||
|
||||
monkeypatch.setattr(adapter, "_try_rust_poll_payload", AsyncMock(return_value=None))
|
||||
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {"id": "vid_2", "status": "processing"}
|
||||
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=response)
|
||||
|
||||
get_upstream_client = AsyncMock(return_value=client)
|
||||
monkeypatch.setattr(mod.HTTPClientPool, "get_upstream_client", get_upstream_client)
|
||||
|
||||
normalized = InternalVideoPollResult(status=VideoStatus.PROCESSING, progress_percent=7)
|
||||
normalizer = MagicMock(return_value=normalized)
|
||||
monkeypatch.setattr(adapter._openai_normalizer, "video_poll_to_internal", normalizer)
|
||||
|
||||
ctx = VideoPollContext(
|
||||
task_id="task-2",
|
||||
external_task_id="vid_2",
|
||||
@@ -205,14 +179,7 @@ async def test_video_poller_openai_poll_fallback_uses_transport_aware_client(
|
||||
delegate_config={"node_id": "node-1", "tunnel": True},
|
||||
)
|
||||
|
||||
result = await adapter._poll_openai_with_context(ctx)
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await adapter._poll_openai_with_context(ctx)
|
||||
|
||||
assert result is normalized
|
||||
get_upstream_client.assert_awaited_once_with(
|
||||
{"node_id": "node-1", "tunnel": True},
|
||||
proxy_config={"enabled": True, "url": "http://proxy.test:8080"},
|
||||
)
|
||||
client.get.assert_awaited_once_with(
|
||||
"https://api.openai.com/v1/videos/vid_2",
|
||||
headers={"authorization": "Bearer test"},
|
||||
)
|
||||
assert "Rust executor" in str(exc_info.value)
|
||||
|
||||
Reference in New Issue
Block a user