import json import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.database import Base from app.models.upstream import Upstream from app.models.upstream_key import UpstreamGeneratedKey from app.models.website import Website, WebsiteGroupBinding from app.routers.websites import organize_website_groups @pytest.fixture() def db_session(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) # Ensure all models are created from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key 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 test_organize_groups_full_scenarios(db_session, monkeypatch): """测试一键整理分组的各种核心场景: 1. 已有 Key 未导入时,创建目标账号并绑定目标分组 2. 已导入且目标账号存在时,不重复创建(若未绑定目标分组则补齐绑定) 3. 已导入但目标账号不存在时,清理旧标记并重建 4. 绑定关系存在但没有对应 Key 时,返回 missing_key 5. 多个目标分组、多上游分组绑定时,正确分类 """ # 1. 建立测试 Website 和 Upstream w = Website( name="W1", site_type="sub2api", base_url="http://w1", enabled=True, auth_config_json="{}", timeout_seconds=30 ) u1 = Upstream(name="U1", base_url="http://u1") db_session.add_all([w, u1]) db_session.commit() db_session.refresh(w) db_session.refresh(u1) # 2. 建立绑定关系 # 绑定 1:目标分组 TG1 ↔ 上游分组 G1(有 Key 未导入),G2(没有 Key — 对应 missing_key) b1 = WebsiteGroupBinding( website_id=w.id, target_group_id="TG1", target_group_name="TG1-Group", source_groups_json=json.dumps([ {"upstream_id": u1.id, "group_id": "G1"}, {"upstream_id": u1.id, "group_id": "G2"}, ]), enabled=True ) # 绑定 2:目标分组 TG2 ↔ 上游分组 G3(已导入且账号仍存在),G4(已导入但账号已删除) b2 = WebsiteGroupBinding( website_id=w.id, target_group_id="TG2", target_group_name="TG2-Group", source_groups_json=json.dumps([ {"upstream_id": u1.id, "group_id": "G3"}, {"upstream_id": u1.id, "group_id": "G4"}, ]), enabled=True ) db_session.add_all([b1, b2]) db_session.commit() # 3. 建立上游 Key 记录 # G1: 未导入 Key k1 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G1", group_name="G1-Name", key_name="Key-G1", key_value="sk-g1-secret", status="created", ) # G3: 已导入且账号存在 k3 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G3", group_name="G3-Name", key_name="Key-G3", key_value="sk-g3-secret", status="imported", imported_website_id=w.id, imported_account_id="ACC-G3", imported_target_group_id="TG2", ) # G4: 已导入但账号已删除 k4 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G4", group_name="G4-Name", key_name="Key-G4", key_value="sk-g4-secret", status="imported", imported_website_id=w.id, imported_account_id="ACC-G4-DELETED", imported_target_group_id="TG2", ) db_session.add_all([k1, k3, k4]) db_session.commit() # 4. Mock Sub2ApiWebsiteClient 交互 created_accounts = [] updated_accounts = [] class MockClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *a): pass def get_groups(self, *a, **kw): return [ {"id": "TG1", "name": "TG1-Group"}, {"id": "TG2", "name": "TG2-Group"}, ] def list_accounts(self): # ACC-G3 仍存在,ACC-G4-DELETED 不在此列表中代表已被删除 # 另外,我们假设 ACC-G3 目前绑定的 group_ids 是 [999],即不包含 TG2 (或整型数值) return [ { "id": "ACC-G3", "name": "SmartUp-G3-ACC", "group_ids": [999], } ] def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def create_account(self, body): # 模拟创建账号 acc_id = f"NEW-{body['name']}" new_acc = {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"]} created_accounts.append(new_acc) return new_acc def update_account(self, account_id, body): # 模拟更新账号的分组绑定 updated_accounts.append((account_id, body)) return {"id": account_id, "group_ids": body.get("group_ids")} monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient) monkeypatch.setattr("app.routers.websites.sync_account_priorities_for_website", lambda db, wid: []) # 5. 执行一键整理 response = organize_website_groups(wid=w.id, db=db_session) # 6. 断言结果 assert response.success is True # 整理完成:创建账号 2 (k1创建 + k4重建) / 已存在 1 (k3已存在并补齐) / 缺少 Key 1 (G2缺少) assert "创建账号 2" in response.message assert "已存在 1" in response.message assert "缺少 Key 1" in response.message items = response.items assert len(items) == 4 # 按 target_group_id 分类校验 # TG1 分组下的 G1 (已创建) item_g1 = next(item for item in items if item.target_group_id == "TG1" and item.source_group_id == "G1") assert item_g1.status == "created" assert item_g1.key_name == "Key-G1" assert item_g1.account_id.startswith("NEW-U1-") # TG1 分组下的 G2 (缺少 Key) item_g2 = next(item for item in items if item.target_group_id == "TG1" and item.source_group_id == "G2") assert item_g2.status == "missing_key" assert item_g2.message == "请先生成上游 Key" # TG2 分组下的 G3 (已存在并补齐) item_g3 = next(item for item in items if item.target_group_id == "TG2" and item.source_group_id == "G3") assert item_g3.status == "exists" assert "已补齐目标分组绑定" in item_g3.message # 校验是否触发了 update_account 补齐了 TG2 assert len(updated_accounts) == 1 assert updated_accounts[0][0] == "ACC-G3" # TG2 (或者其数值) 应该在更新后的 group_ids 中 assert "TG2" in updated_accounts[0][1]["group_ids"] or 999 in updated_accounts[0][1]["group_ids"] # TG2 分组下的 G4 (清理后重建) item_g4 = next(item for item in items if item.target_group_id == "TG2" and item.source_group_id == "G4") assert item_g4.status == "recreated" assert item_g4.message == "清理后重建账号" assert item_g4.account_id.startswith("NEW-U1-") # 7. 检查数据库中 Key 记录的状态变化 db_session.refresh(k1) db_session.refresh(k3) db_session.refresh(k4) assert k1.imported_website_id == w.id assert k1.imported_account_id is not None assert k1.imported_target_group_id == "TG1" assert k1.status == "imported" assert k3.imported_target_group_id == "TG2" assert k4.imported_website_id == w.id assert k4.imported_account_id is not None assert k4.imported_target_group_id == "TG2" assert k4.status == "imported" def test_organize_groups_list_accounts_none_conservatively_skips(db_session, monkeypatch): """测试一键整理分组时,若远端账号列表拉取失败返回 None: - 已导入的账号不得被清除或重建,而应该保守跳过(状态记为 failed,提示无法校验)。 - 未导入的账号仍能正常导入创建。 """ w = Website( name="W1", site_type="sub2api", base_url="http://w1", enabled=True, auth_config_json="{}", timeout_seconds=30 ) u1 = Upstream(name="U1", base_url="http://u1") db_session.add_all([w, u1]) db_session.commit() db_session.refresh(w) db_session.refresh(u1) # 绑定关系:目标分组 TG1 ↔ 上游分组 G1(未导入),G3(已导入) b1 = WebsiteGroupBinding( website_id=w.id, target_group_id="TG1", target_group_name="TG1-Group", source_groups_json=json.dumps([ {"upstream_id": u1.id, "group_id": "G1", "group_name": "G1-SourceGroup"}, {"upstream_id": u1.id, "group_id": "G3", "group_name": "G3-SourceGroup"}, ]), enabled=True ) db_session.add_all([b1]) db_session.commit() # k1: 未导入 Key k1 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G1", group_name="G1-SourceGroup", key_name="Key-G1", key_value="sk-g1-secret", status="created", ) # k3: 已导入 Key k3 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G3", group_name="G3-SourceGroup", key_name="Key-G3", key_value="sk-g3-secret", status="imported", imported_website_id=w.id, imported_account_id="ACC-G3-EXISTING", imported_target_group_id="TG1", ) db_session.add_all([k1, k3]) db_session.commit() created_accounts = [] class MockClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *a): pass def get_groups(self, *a, **kw): return [{"id": "TG1", "name": "TG1-Group"}] def list_accounts(self): # 模拟获取失败返回 None! return None def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def create_account(self, body): acc_id = f"NEW-{body['name']}" new_acc = {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"]} created_accounts.append(new_acc) return new_acc monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient) monkeypatch.setattr("app.routers.websites.sync_account_priorities_for_website", lambda db, wid: []) # 执行一键整理 response = organize_website_groups(wid=w.id, db=db_session) # 断言结果:由于其中有 1 个已导入账号校验失败,response.success 应为 False(有失败项) assert response.success is False # 统计信息中应该有“创建账号 1”与“失败 1” assert "创建账号 1" in response.message assert "失败 1" in response.message items = response.items assert len(items) == 2 # G1 (未导入) 仍应该成功创建账号 item_g1 = next(item for item in items if item.source_group_id == "G1") assert item_g1.status == "created" assert item_g1.account_id.startswith("NEW-U1-") assert item_g1.source_group_name == "G1-SourceGroup" # 校验 group_name 优先从绑定中获取! # G3 (已导入) 应该保守跳过 item_g3 = next(item for item in items if item.source_group_id == "G3") assert item_g3.status == "failed" assert item_g3.message == "无法校验目标账号状态,已保守跳过" # 校验 DB 中的 k3 导入标记和状态绝对不能被清除/变动! db_session.refresh(k3) assert k3.imported_website_id == w.id assert k3.imported_account_id == "ACC-G3-EXISTING" assert k3.status == "imported"