import json import sys from pathlib import Path import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool sys.path.insert(0, str(Path(__file__).resolve().parent)) from app.database import Base from app.models.upstream import Upstream from app.models.upstream_key import UpstreamGeneratedKey from app.models.website import Website from app.routers import websites as websites_router from app.schemas.website import ImportAccountsRequest @pytest.fixture() def db_session(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) db = TestingSessionLocal() try: yield db finally: db.close() Base.metadata.drop_all(bind=engine) def seed_account_import_rows(db_session): website = Website( name="My Sub2API", site_type="sub2api", base_url="http://sub2api.local", api_prefix="/api/v1/admin", auth_type="api_key", auth_config_json=json.dumps({"key": "admin-key", "header": "x-api-key"}), groups_endpoint="/groups", group_update_endpoint="/groups/{id}", ) upstream = Upstream( name="Packy", base_url="http://packy.local", api_prefix="/api/v1", auth_type="login_password", auth_config_json="{}", ) db_session.add_all([website, upstream]) db_session.commit() db_session.refresh(website) db_session.refresh(upstream) generated = UpstreamGeneratedKey( upstream_id=upstream.id, group_id="vip", group_name="VIP", key_id="up-key-id", key_name="SmartUp-VIP", key_value="sk-upstream-generated", masked_key="sk-u...ated", raw_json="{}", status="created", ) db_session.add(generated) db_session.commit() db_session.refresh(generated) return website, generated def test_import_upstream_key_creates_sub2api_account_management_apikey(monkeypatch, db_session): website, generated = seed_account_import_rows(db_session) account_bodies = [] class FakeWebsiteClient: def __init__(self, **kwargs): self.kwargs = kwargs def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "anthropic"}] def list_accounts(self): return [{"id": "101", "name": "Existing", "platform": "anthropic", "group_ids": [7]}] def create_account(self, body, endpoint="/accounts"): account_bodies.append((endpoint, body)) return {"id": 101, "name": body["name"]} def account_exists(self, account_id): return True @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) response = websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest( upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, account_name_prefix="SmartUp", default_platform="anthropic", ), db_session, object(), ) assert "新建 1" in response.message assert len(account_bodies) == 1 endpoint, body = account_bodies[0] assert endpoint == "/accounts" assert body["type"] == "apikey" assert body["platform"] == "anthropic" assert body["credentials"]["api_key"] == "sk-upstream-generated" assert body["credentials"]["base_url"] == "http://packy.local" assert body["group_ids"] == [7] assert body["concurrency"] == 100 assert body["priority"] == 1 assert body["credentials"]["base_url"] == "http://packy.local" db_session.refresh(generated) assert generated.status == "imported" assert generated.imported_website_id == website.id assert generated.imported_account_id == "101" def test_import_upstream_key_idempotent_skips_already_imported(monkeypatch, db_session): """已导入过的 Key 再次调用不会重复创建。""" website, generated = seed_account_import_rows(db_session) generated.imported_website_id = website.id generated.imported_account_id = "101" db_session.commit() create_called = False class FakeWebsiteClient: def __init__(self, **kwargs): self.kwargs = kwargs def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "anthropic"}] def list_accounts(self): return [{"id": "101", "name": "Existing", "platform": "anthropic", "group_ids": [7]}] def create_account(self, body, endpoint="/accounts"): nonlocal create_called create_called = True return {"id": 999, "name": body["name"]} def account_exists(self, account_id): return True # 模拟远端账号仍存在 @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) response = websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest( upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, account_name_prefix="SmartUp", default_platform="anthropic", ), db_session, object(), ) # create_account 不应被调用 assert not create_called, "create_account should NOT be called for already imported key" # 返回 exists assert len(response.items) == 1 assert response.items[0].status == "exists" assert response.items[0].account_id == "101" assert response.items[0].message == "已存在并已对齐目标分组" # success 应为 true(没有 failed) assert response.success def test_sync_imported_upstream_keys_efficient_and_correct(monkeypatch, db_session): """验证 sync_imported_upstream_keys 优化:只拉一次账号列表,处理 exists 和 stale_cleared。""" from app.routers import websites as websites_router from app.schemas.website import SyncImportStatusRequest website, generated1 = seed_account_import_rows(db_session) generated1.imported_website_id = website.id generated1.imported_account_id = "101" # 增加另一个已导入行以测试单次请求 & 多个状态 generated2 = UpstreamGeneratedKey( upstream_id=generated1.upstream_id, group_id="normal", group_name="normal", key_name="normal-key", key_value="sk-normal", imported_website_id=website.id, imported_account_id="102", status="imported", ) db_session.add(generated2) db_session.commit() list_accounts_calls = 0 class FakeClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *a): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "anthropic"}] def list_accounts(self): nonlocal list_accounts_calls list_accounts_calls += 1 # 远端只有 A1 (id: 101),没有 A2 (id: 102) return [ {"id": "101", "name": "SmartUp-A1", "platform": "anthropic", "group_ids": [7]} ] @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", lambda **kw: FakeClient(**kw)) response = websites_router.sync_imported_upstream_keys( website.id, SyncImportStatusRequest(upstream_id=generated1.upstream_id), db_session, object(), ) # 1. 验证 list_accounts() 只被调用了 1 次 assert list_accounts_calls == 1 # 2. 检查结果 assert len(response.items) == 2 item_101 = next(item for item in response.items if item.account_id == "101") item_102 = next(item for item in response.items if item.account_id == "102") # 101 仍存在 assert item_101.status == "exists" # 102 已被清除 assert item_102.status == "stale_cleared" # 3. 校验 DB 反映 db_session.refresh(generated1) db_session.refresh(generated2) assert generated1.imported_account_id == "101" assert generated2.imported_account_id is None def test_sync_imported_upstream_keys_check_failed_on_exception(monkeypatch, db_session): """验证 list_accounts 报错或返回 None 时返回 check_failed,且不清空本地导入标记。""" from app.routers import websites as websites_router from app.schemas.website import SyncImportStatusRequest website, generated = seed_account_import_rows(db_session) generated.imported_website_id = website.id generated.imported_account_id = "101" db_session.commit() class FakeClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *a): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "anthropic"}] def list_accounts(self): raise Exception("List accounts failure") @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", lambda **kw: FakeClient(**kw)) response = websites_router.sync_imported_upstream_keys( website.id, SyncImportStatusRequest(upstream_id=generated.upstream_id), db_session, object(), ) assert len(response.items) == 1 assert response.items[0].status == "check_failed" db_session.refresh(generated) assert generated.imported_account_id == "101" def test_import_rebuilds_when_remote_deleted(monkeypatch, db_session): """导入接口:远端账号已删除时自动清标记并重新创建。""" from app.routers import websites as websites_router from app.schemas.website import ImportAccountsRequest website, generated = seed_account_import_rows(db_session) generated.imported_website_id = website.id generated.imported_account_id = "101" db_session.commit() class FakeClient: def __init__(self, **kwargs): self.kwargs = kwargs def __enter__(self): return self def __exit__(self, *a): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "anthropic"}] def list_accounts(self): return [] def account_exists(self, account_id): return False def create_account(self, body, endpoint="/accounts"): return {"id": 202, "name": body["name"]} @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", lambda **kw: FakeClient(**kw)) response = websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest(upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, account_name_prefix="SmartUp", default_platform="openai"), db_session, object(), ) assert len(response.items) == 1 assert response.items[0].status == "created", f"expected created, got {response.items[0].status}" db_session.refresh(generated) assert generated.imported_account_id == "202" def test_import_skips_on_check_failed(monkeypatch, db_session): """导入接口:校验失败时保守跳过,不创建也不清除。""" from app.routers import websites as websites_router from app.schemas.website import ImportAccountsRequest website, generated = seed_account_import_rows(db_session) generated.imported_website_id = website.id generated.imported_account_id = "101" db_session.commit() class FakeClient: def __init__(self, **kwargs): self.kwargs = kwargs def __enter__(self): return self def __exit__(self, *a): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "openai"}] def list_accounts(self): return None def account_exists(self, account_id): return None def create_account(self, body, endpoint="/accounts"): raise RuntimeError("should not be called") @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", lambda **kw: FakeClient(**kw)) response = websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest(upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, account_name_prefix="SmartUp", default_platform="openai"), db_session, object(), ) assert len(response.items) == 1 assert response.items[0].status == "check_failed" db_session.refresh(generated) assert generated.imported_account_id == "101" # 未被清除 assert not response.success def test_import_upstream_key_with_custom_concurrency_and_priority(monkeypatch, db_session): website, generated = seed_account_import_rows(db_session) account_bodies = [] class FakeWebsiteClient: def __init__(self, **kwargs): self.kwargs = kwargs def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "openai"}] def create_account(self, body, endpoint="/accounts"): account_bodies.append((endpoint, body)) return {"id": 202, "name": body["name"]} def account_exists(self, account_id): return True @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest( upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, account_name_prefix="SmartUp", default_platform="openai", concurrency=20, priority=5, ), db_session, object(), ) assert len(account_bodies) == 1 _, body = account_bodies[0] assert body["concurrency"] == 20 assert body["priority"] == 5 assert body["credentials"]["base_url"] == "http://packy.local" def test_import_platform_from_snapshot(monkeypatch, db_session): """验证快照 platform=anthropic 且名称不含 Claude 时,创建账号平台必须为 anthropic""" from app.models.snapshot import UpstreamRateSnapshot website, generated = seed_account_import_rows(db_session) generated.key_name = "MyCustomKey" # 不含 Claude # 写入快照指定平台为 anthropic snapshot = UpstreamRateSnapshot( upstream_id=generated.upstream_id, snapshot_json=json.dumps({ "groups": { "vip": { "group_name": "VIP", "rate": 0.1, "platform": "anthropic" } } }) ) db_session.add(snapshot) db_session.commit() account_bodies = [] class FakeWebsiteClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *args): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "anthropic"}] def create_account(self, body, endpoint="/accounts"): account_bodies.append(body) return {"id": 101, "name": body["name"]} def account_exists(self, account_id): return True @staticmethod def extract_id(data): return "101" monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest( upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, default_platform="openai", platform_mode="auto", ), db_session, object(), ) assert len(account_bodies) == 1 assert account_bodies[0]["platform"] == "anthropic" def test_import_platform_fallback_to_name(monkeypatch, db_session): """验证快照无 platform 但名称含 Claude 时,仍兜底为 anthropic""" from app.models.snapshot import UpstreamRateSnapshot website, generated = seed_account_import_rows(db_session) generated.key_name = "Claude-Key" # 写入快照但不含 platform 字段 snapshot = UpstreamRateSnapshot( upstream_id=generated.upstream_id, snapshot_json=json.dumps({ "groups": { "vip": { "group_name": "VIP", "rate": 0.1, } } }) ) db_session.add(snapshot) db_session.commit() account_bodies = [] class FakeWebsiteClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *args): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "anthropic"}] def create_account(self, body, endpoint="/accounts"): account_bodies.append(body) return {"id": 101, "name": body["name"]} def account_exists(self, account_id): return True @staticmethod def extract_id(data): return "101" monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest( upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, default_platform="openai", platform_mode="auto", ), db_session, object(), ) assert len(account_bodies) == 1 assert account_bodies[0]["platform"] == "anthropic" def test_import_platform_manual_override(monkeypatch, db_session): """手动模式下不使用快照平台,仍按用户指定平台""" from app.models.snapshot import UpstreamRateSnapshot website, generated = seed_account_import_rows(db_session) # 快照虽然指定为 anthropic snapshot = UpstreamRateSnapshot( upstream_id=generated.upstream_id, snapshot_json=json.dumps({ "groups": { "vip": { "group_name": "VIP", "rate": 0.1, "platform": "anthropic" } } }) ) db_session.add(snapshot) db_session.commit() account_bodies = [] class FakeWebsiteClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *args): return False def get_groups(self, endpoint="/groups"): return [{"id": "7", "name": "Target", "platform": "gemini"}] def create_account(self, body, endpoint="/accounts"): account_bodies.append(body) return {"id": 101, "name": body["name"]} def account_exists(self, account_id): return True @staticmethod def extract_id(data): return "101" monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest( upstream_key_ids=[generated.id], target_group_map={"vip": "7"}, default_platform="gemini", platform_mode="manual", # 手动模式 ), db_session, object(), ) assert len(account_bodies) == 1 assert account_bodies[0]["platform"] == "gemini" def test_same_key_can_be_imported_into_two_platform_groups(monkeypatch, db_session): """同一 Key 的不同平台映射独立创建,迁移其中一平台不影响另一平台。""" website, generated = seed_account_import_rows(db_session) class FakeClient: accounts = {} next_id = 100 def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *args): return False def get_groups(self, endpoint="/groups"): return [ {"id": "7", "name": "OpenAI", "platform": "openai"}, {"id": "8", "name": "Claude", "platform": "anthropic"}, {"id": "9", "name": "OpenAI 2", "platform": "openai"}, ] def list_accounts(self): return list(self.accounts.values()) def create_account(self, body, endpoint="/accounts"): type(self).next_id += 1 account = {"id": str(type(self).next_id), **body} type(self).accounts[account["id"]] = account return account def update_account(self, account_id, body): type(self).accounts[str(account_id)].update(body) return type(self).accounts[str(account_id)] @staticmethod def extract_id(data): return str(data.get("id")) monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient) monkeypatch.setattr(websites_router, "reconcile_upstream_keys_full", lambda db, uid: None) def do_import(target_group_id): return websites_router.import_upstream_keys_as_accounts( website.id, ImportAccountsRequest( upstream_key_ids=[generated.id], target_group_map={"vip": target_group_id}, auto_priority_by_rate=False, ), db_session, object(), ) first = do_import("7") second = do_import("8") third = do_import("9") assert first.items[0].status == "created" assert second.items[0].status == "created" assert third.items[0].status == "exists" assert third.items[0].platform == "openai" links = db_session.query(websites_router.UpstreamKeyAccountLink).filter_by( upstream_key_id=generated.id, website_id=website.id, status="active", ).all() assert {(link.platform, link.target_group) for link in links} == { ("openai", "9"), ("anthropic", "8"), } assert len(FakeClient.accounts) == 2