879 lines
36 KiB
Python
879 lines
36 KiB
Python
"""
|
||
优先级同步测试套件 — 分组内竞争逻辑
|
||
|
||
核心规则(测试覆盖):
|
||
- 竞争分组键 = imported_target_group_id or group_id(老数据 fallback)
|
||
- 只有同一竞争分组内账号数 > 1 时才更新 priority / 发通知
|
||
- 不同分组各 1 个账号:不调用 update_account,不发通知
|
||
- 同一目标分组多账号:组内按倍率升序独立排序,priority 从 1 开始,每档间隔 10
|
||
- 两个目标分组各有多账号:彼此独立,每组内 priority 都从 1 开始,每档间隔 10
|
||
- 老数据 imported_target_group_id=NULL:fallback group_id,不报错
|
||
"""
|
||
import json
|
||
from datetime import datetime, timezone
|
||
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.website import Website, WebsiteSyncLog
|
||
from app.models.upstream_key import UpstreamGeneratedKey
|
||
from app.models.snapshot import UpstreamRateSnapshot
|
||
from app.services.website_sync import (
|
||
build_rate_priority_map,
|
||
sync_account_priorities_for_upstream,
|
||
)
|
||
from app.services.website_client import Sub2ApiWebsiteClient
|
||
|
||
|
||
@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 _make_snapshot(db, upstream_id: int, groups: dict):
|
||
"""插入快照 groups = {group_id: rate}。"""
|
||
snap = {
|
||
"groups": {
|
||
gid: {"group_name": gid, "rate": rate}
|
||
for gid, rate in groups.items()
|
||
}
|
||
}
|
||
db.add(UpstreamRateSnapshot(
|
||
upstream_id=upstream_id,
|
||
snapshot_json=json.dumps(snap),
|
||
captured_at=datetime.now(timezone.utc),
|
||
))
|
||
db.commit()
|
||
|
||
|
||
def _make_key(db, upstream_id, group_id, key_name, key_value,
|
||
website_id, account_id, imported_target_group_id=None):
|
||
k = UpstreamGeneratedKey(
|
||
upstream_id=upstream_id,
|
||
group_id=group_id,
|
||
group_name=group_id,
|
||
key_name=key_name,
|
||
key_value=key_value,
|
||
imported_website_id=website_id,
|
||
imported_account_id=account_id,
|
||
imported_target_group_id=imported_target_group_id,
|
||
)
|
||
db.add(k)
|
||
db.commit()
|
||
return k
|
||
|
||
|
||
class MockClient:
|
||
"""可注入的 update_account 记录器。"""
|
||
_calls: list # 由子类/工厂绑定
|
||
|
||
def __init__(self, **kwargs):
|
||
pass # calls 由工厂方法注入
|
||
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
|
||
def update_account(self, account_id, data):
|
||
type(self)._shared_calls.append((account_id, data))
|
||
|
||
|
||
def make_mock_client(calls: list):
|
||
"""返回一个与 calls 列表绑定的 MockClient 类。"""
|
||
class _Client(MockClient):
|
||
_shared_calls = calls
|
||
return _Client
|
||
|
||
|
||
# ── 用例 ─────────────────────────────────────────────────────────────────────
|
||
|
||
def test_no_update_when_different_groups_single_account_each(db_session, monkeypatch):
|
||
"""不同分组各 1 个账号 → 无竞争 → 不调用 update_account,不发通知。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u1 = Upstream(name="U1", base_url="http://u1")
|
||
u2 = Upstream(name="U2", base_url="http://u2")
|
||
db_session.add_all([w, u1, u2])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u1); db_session.refresh(u2)
|
||
|
||
_make_snapshot(db_session, u1.id, {"G1": 1.0})
|
||
_make_snapshot(db_session, u2.id, {"G2": 2.0})
|
||
|
||
# 不同分组,各 1 个账号,imported_target_group_id=None (fallback group_id)
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1")
|
||
_make_key(db_session, u2.id, "G2", "K2", "V2", w.id, "A2")
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
results = sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
# 无竞争分组 → 直接返回空列表,不调用 update_account
|
||
assert update_calls == [], f"不应有更新调用,实际:{update_calls}"
|
||
assert results == []
|
||
|
||
# 不写日志
|
||
logs = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == w.id).all()
|
||
assert logs == []
|
||
|
||
|
||
def test_same_target_group_two_accounts_updated(db_session, monkeypatch):
|
||
"""同一目标分组 2 个账号 → 有竞争 → 按倍率更新 priority。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u1 = Upstream(name="U1", base_url="http://u1")
|
||
u2 = Upstream(name="U2", base_url="http://u2")
|
||
db_session.add_all([w, u1, u2])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u1); db_session.refresh(u2)
|
||
|
||
_make_snapshot(db_session, u1.id, {"G1": 1.0})
|
||
_make_snapshot(db_session, u2.id, {"G2": 2.0})
|
||
|
||
# 两个账号都属于目标分组 "TG1"
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u2.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
results = sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
assert len(update_calls) == 2
|
||
priority_map = {aid: data["priority"] for aid, data in update_calls}
|
||
# G1 rate=1.0 → priority=1(低倍率优先);G2 rate=2.0 → priority=11
|
||
assert priority_map["A1"] == 1
|
||
assert priority_map["A2"] == 11
|
||
|
||
# 写了日志
|
||
log = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == w.id).first()
|
||
assert log is not None
|
||
assert log.algorithm == "priority_sync"
|
||
|
||
|
||
def test_two_target_groups_independent_priority(db_session, monkeypatch):
|
||
"""两个目标分组各有多账号 → 每组内独立从 1 开始排序,不互相影响。"""
|
||
w = Website(name="W1", 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)
|
||
|
||
_make_snapshot(db_session, u1.id, {
|
||
"G1": 1.0, # → TG1 中排 priority=1
|
||
"G2": 2.0, # → TG1 中排 priority=11
|
||
"G3": 0.5, # → TG2 中排 priority=1
|
||
"G4": 3.0, # → TG2 中排 priority=11
|
||
})
|
||
|
||
# TG1: G1(1.0), G2(2.0)
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
# TG2: G3(0.5), G4(3.0)
|
||
_make_key(db_session, u1.id, "G3", "K3", "V3", w.id, "A3", imported_target_group_id="TG2")
|
||
_make_key(db_session, u1.id, "G4", "K4", "V4", w.id, "A4", imported_target_group_id="TG2")
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
priority_map = {aid: data["priority"] for aid, data in update_calls}
|
||
assert len(update_calls) == 4
|
||
|
||
# TG1 内部:G1(1.0)→p1, G2(2.0)→p11
|
||
assert priority_map["A1"] == 1
|
||
assert priority_map["A2"] == 11
|
||
# TG2 内部:G3(0.5)→p1, G4(3.0)→p11(独立从 1 开始)
|
||
assert priority_map["A3"] == 1
|
||
assert priority_map["A4"] == 11
|
||
|
||
|
||
def test_old_data_null_target_group_fallback(db_session, monkeypatch):
|
||
"""老数据 imported_target_group_id=NULL → fallback group_id,不报错。
|
||
|
||
两个账号同 group_id(极端边界),视为同一竞争分组 → 有竞争 → 更新。
|
||
"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u1 = Upstream(name="U1", base_url="http://u1")
|
||
u2 = Upstream(name="U2", base_url="http://u2")
|
||
db_session.add_all([w, u1, u2])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u1); db_session.refresh(u2)
|
||
|
||
_make_snapshot(db_session, u1.id, {"GSHARED": 1.0})
|
||
_make_snapshot(db_session, u2.id, {"GSHARED": 2.0})
|
||
|
||
# 老数据:imported_target_group_id=None,两账号 group_id 相同
|
||
_make_key(db_session, u1.id, "GSHARED", "K1", "V1", w.id, "A1", imported_target_group_id=None)
|
||
_make_key(db_session, u2.id, "GSHARED", "K2", "V2", w.id, "A2", imported_target_group_id=None)
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
# 不应抛异常
|
||
results = sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
# 同 group_id="GSHARED" → 竞争分组,两账号都更新
|
||
assert len(update_calls) == 2
|
||
|
||
|
||
def test_null_target_group_backfills_from_remote_account_group(db_session, monkeypatch):
|
||
"""老导入数据 target_group_id=NULL,但远端账号有单一 group_ids → 回填后参与同组重排。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u1 = Upstream(name="U1", base_url="http://u1")
|
||
u2 = Upstream(name="U2", base_url="http://u2")
|
||
u3 = Upstream(name="U3", base_url="http://u3")
|
||
db_session.add_all([w, u1, u2, u3])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u1); db_session.refresh(u2); db_session.refresh(u3)
|
||
|
||
_make_snapshot(db_session, u1.id, {"G2": 0.06})
|
||
_make_snapshot(db_session, u2.id, {"G19": 0.07})
|
||
_make_snapshot(db_session, u3.id, {"G21": 0.05})
|
||
|
||
legacy = _make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id=None)
|
||
_make_key(db_session, u2.id, "G19", "K19", "V19", w.id, "A19", imported_target_group_id="TG1")
|
||
_make_key(db_session, u3.id, "G21", "K21", "V21", w.id, "A21", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
|
||
class AccountListMockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def list_accounts(self):
|
||
return [
|
||
{"id": "A2", "group_ids": ["TG1"]},
|
||
{"id": "A19", "group_ids": ["TG1"]},
|
||
{"id": "A21", "group_ids": ["TG1"]},
|
||
]
|
||
def update_account(self, account_id, data):
|
||
update_calls.append((account_id, data))
|
||
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", AccountListMockClient)
|
||
|
||
sync_account_priorities_for_upstream(db_session, u1.id, website_id=w.id)
|
||
|
||
updated = {account_id: payload["priority"] for account_id, payload in update_calls}
|
||
assert updated == {"A21": 1, "A2": 11, "A19": 21}
|
||
db_session.refresh(legacy)
|
||
assert legacy.imported_target_group_id == "TG1"
|
||
|
||
|
||
def test_single_account_in_mixed_website(db_session, monkeypatch):
|
||
"""同一网站:一个目标分组有 2 账号(竞争),另一个目标分组只有 1 账号(不参与)。
|
||
|
||
只有竞争分组的 2 个账号被更新,单账号分组不调用 update_account。
|
||
"""
|
||
w = Website(name="W1", 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)
|
||
|
||
_make_snapshot(db_session, u1.id, {"G1": 1.0, "G2": 2.0, "G3": 3.0})
|
||
|
||
# TG1: G1 + G2 → 竞争
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
# TG2: 只有 G3 → 单账号,跳过
|
||
_make_key(db_session, u1.id, "G3", "K3", "V3", w.id, "A3", imported_target_group_id="TG2")
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
updated_ids = {c[0] for c in update_calls}
|
||
assert "A3" not in updated_ids, "单账号分组不应被更新"
|
||
assert updated_ids == {"A1", "A2"}
|
||
|
||
|
||
def test_missing_rate_skips_entire_competitive_group(db_session, monkeypatch):
|
||
"""竞争分组内所有账号均无快照倍率 → 有效账号 < 2 → 整组跳过,不调用 update_account。"""
|
||
w = Website(name="W1", 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)
|
||
|
||
# 故意不插快照 → raw_rate_map 为空
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
results = sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
assert update_calls == [], "无快照倍率时不应调用 update_account"
|
||
assert results == []
|
||
|
||
|
||
def test_partial_missing_rate_sufficient_accounts_still_updates(db_session, monkeypatch):
|
||
"""竞争分组内 3 账号,1 个无快照倍率,剩余 2 个有倍率 → 仍有竞争,2 个有倍率的账号正常更新。"""
|
||
w = Website(name="W1", 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)
|
||
|
||
# G1、G2 有快照,G3 没有快照
|
||
_make_snapshot(db_session, u1.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G3", "K3", "V3", w.id, "A3", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
updated = {c[0]: c[1]["priority"] for c in update_calls}
|
||
# A3 无快照 → 不参与排序,不被更新
|
||
assert "A3" not in updated
|
||
# A1(G1, rate=1.0) → priority=1;A2(G2, rate=2.0) → priority=11
|
||
assert updated["A1"] == 1
|
||
assert updated["A2"] == 11
|
||
|
||
|
||
def test_priority_sync_log_structure(db_session, monkeypatch):
|
||
"""日志写入格式验证(需要竞争分组才会写日志)。"""
|
||
w = Website(name="W1", 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)
|
||
|
||
_make_snapshot(db_session, u1.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
# 同一目标分组 2 账号 → 有日志
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
class LoggingMockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def update_account(self, account_id, data): pass
|
||
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", LoggingMockClient)
|
||
|
||
sync_account_priorities_for_upstream(db_session, u1.id)
|
||
|
||
log = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == w.id).first()
|
||
assert log is not None
|
||
assert log.algorithm == "priority_sync"
|
||
|
||
data = json.loads(log.source_rates_json)
|
||
# 第一项是 priority_map 元数据
|
||
assert data[0]["_meta"] == "priority_map"
|
||
# 后续项是账号结果
|
||
account_ids = {item["account_id"] for item in data[1:] if "account_id" in item}
|
||
assert {"A1", "A2"} == account_ids
|
||
|
||
|
||
# ── 保留:build_rate_priority_map 单元测试(供初始导入使用)────────────────
|
||
|
||
def test_priority_sync_cross_upstream_group(db_session):
|
||
"""build_rate_priority_map:相同 group_id 不同上游不互相覆盖。"""
|
||
u1 = Upstream(name="U1", base_url="http://u1")
|
||
u2 = Upstream(name="U2", base_url="http://u2")
|
||
db_session.add_all([u1, u2])
|
||
db_session.commit()
|
||
db_session.refresh(u1); db_session.refresh(u2)
|
||
|
||
_make_snapshot(db_session, u1.id, {"VIP": 1.0})
|
||
_make_snapshot(db_session, u2.id, {"VIP": 2.0})
|
||
|
||
priority_map = build_rate_priority_map(db_session, {u1.id, u2.id})
|
||
|
||
assert priority_map[f"{u1.id}:VIP"] == 1
|
||
assert priority_map[f"{u2.id}:VIP"] == 11
|
||
assert len(priority_map) == 2
|
||
|
||
|
||
def test_import_auto_priority_by_rate(db_session, monkeypatch):
|
||
"""初始导入时 auto_priority_by_rate=True 按全局倍率分配 priority。"""
|
||
from app.routers.websites import import_upstream_keys_as_accounts
|
||
from app.schemas.website import ImportAccountsRequest
|
||
|
||
w = Website(name="W1", base_url="http://w1", enabled=True, auth_config_json="{}",
|
||
groups_endpoint="/groups", group_update_endpoint="/groups/{id}", timeout_seconds=30)
|
||
u1 = Upstream(name="U1", base_url="http://u1")
|
||
u2 = Upstream(name="U2", base_url="http://u2")
|
||
db_session.add_all([w, u1, u2])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u1); db_session.refresh(u2)
|
||
|
||
_make_snapshot(db_session, u1.id, {"G1": 2.0})
|
||
_make_snapshot(db_session, u2.id, {"G2": 1.0})
|
||
|
||
k1 = UpstreamGeneratedKey(upstream_id=u1.id, group_id="G1", group_name="G1",
|
||
key_name="K1", key_value="V1")
|
||
k2 = UpstreamGeneratedKey(upstream_id=u2.id, group_id="G2", group_name="G2",
|
||
key_name="K2", key_value="V2")
|
||
db_session.add_all([k1, k2])
|
||
db_session.commit()
|
||
|
||
created_accounts = []
|
||
|
||
class MockImportClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def create_account(self, body):
|
||
created_accounts.append(body)
|
||
return {"id": f"remote-{len(created_accounts)}", "name": body["name"]}
|
||
def extract_id(self, data): return data["id"]
|
||
def account_exists(self, aid): return False
|
||
|
||
monkeypatch.setattr("app.routers.websites._client", lambda website: MockImportClient())
|
||
monkeypatch.setattr("app.services.website_client.Sub2ApiWebsiteClient", MockImportClient)
|
||
|
||
req = ImportAccountsRequest(
|
||
upstream_key_ids=[k1.id, k2.id],
|
||
target_group_map={},
|
||
auto_priority_by_rate=True,
|
||
priority=10,
|
||
account_name_prefix="test",
|
||
default_platform="openai",
|
||
)
|
||
|
||
import_upstream_keys_as_accounts(w.id, req, db_session)
|
||
|
||
assert len(created_accounts) == 2
|
||
# G2 rate=1.0 → priority 1;G1 rate=2.0 → priority 11
|
||
p1 = next(a["priority"] for a in created_accounts if "G1" in a["name"])
|
||
p2 = next(a["priority"] for a in created_accounts if "G2" in a["name"])
|
||
assert p2 == 1
|
||
assert p1 == 11
|
||
|
||
|
||
def test_reorder_priority_endpoint_scopes_to_current_website(db_session, monkeypatch):
|
||
"""手动重排只影响当前网站,不误改同一上游导入到其他网站的账号。"""
|
||
from app.routers.websites import reorder_account_priorities
|
||
from app.schemas.website import ReorderPriorityRequest
|
||
|
||
w1 = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
w2 = Website(name="W2", base_url="http://w2", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u1 = Upstream(name="U1", base_url="http://u1")
|
||
db_session.add_all([w1, w2, u1])
|
||
db_session.commit()
|
||
db_session.refresh(w1); db_session.refresh(w2); db_session.refresh(u1)
|
||
|
||
_make_snapshot(db_session, u1.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
_make_key(db_session, u1.id, "G1", "K1", "V1", w1.id, "W1A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G2", "K2", "V2", w1.id, "W1A2", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G1", "K3", "V3", w2.id, "W2A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u1.id, "G2", "K4", "V4", w2.id, "W2A2", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
monkeypatch.setattr(
|
||
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||
make_mock_client(update_calls),
|
||
)
|
||
|
||
response = reorder_account_priorities(
|
||
w1.id,
|
||
ReorderPriorityRequest(upstream_id=u1.id),
|
||
db_session,
|
||
)
|
||
|
||
updated = {account_id: payload["priority"] for account_id, payload in update_calls}
|
||
assert response.success is True
|
||
assert updated == {"W1A1": 1, "W1A2": 11}
|
||
|
||
|
||
def test_priority_sync_notification_grouped_by_target_group():
|
||
"""测试 DingTalk Markdown 通知按目标分组正确聚合。"""
|
||
from app.utils.dingtalk import format_dingtalk_priority_changed
|
||
|
||
updates = [
|
||
{
|
||
"target_group_id": "TG1",
|
||
"target_group_name": "PRO智能分组",
|
||
"account_id": "A123",
|
||
"account_name": "SmartUp-PRO-xxx",
|
||
"source_group_id": "sg1",
|
||
"source_group_name": "28",
|
||
"source_rate": 0.8,
|
||
"old_priority": 11,
|
||
"new_priority": 1,
|
||
"status": "success",
|
||
"message": "success",
|
||
},
|
||
{
|
||
"target_group_id": "TG1",
|
||
"target_group_name": "PRO智能分组",
|
||
"account_id": "A456",
|
||
"account_name": "SmartUp-Claude-xxx",
|
||
"source_group_id": "sg2",
|
||
"source_group_name": "claude max渠道",
|
||
"source_rate": 1.2,
|
||
"old_priority": 21,
|
||
"new_priority": 11,
|
||
"status": "failed",
|
||
"message": "xxx",
|
||
},
|
||
{
|
||
"target_group_id": "TG2",
|
||
"target_group_name": "Basic智能分组",
|
||
"account_id": "A789",
|
||
"account_name": "SmartUp-Basic-xxx",
|
||
"source_group_id": "sg3",
|
||
"source_group_name": "basic渠道",
|
||
"source_rate": 2.0,
|
||
"old_priority": "未知",
|
||
"new_priority": 31,
|
||
"status": "success",
|
||
"message": "success",
|
||
}
|
||
]
|
||
|
||
msg = format_dingtalk_priority_changed("SmartSite", "Upstream1", "2026-07-01 09:00:00", updates)
|
||
text = msg["markdown"]["text"]
|
||
|
||
# 验证分组标题
|
||
assert "目标分组:PRO智能分组" in text
|
||
assert "目标分组:Basic智能分组" in text
|
||
|
||
# 验证账号明细
|
||
assert "- ✅ SmartUp-PRO-xxx(账号ID A123):old=11 → new=1,来源分组=28,倍率=0.8" in text
|
||
assert "- ❌ SmartUp-Claude-xxx(账号ID A456):old=21 → new=11,来源分组=claude max渠道,倍率=1.2,原因=xxx" in text
|
||
assert "- ✅ SmartUp-Basic-xxx(账号ID A789):old=未知 → new=31,来源分组=basic渠道,倍率=2.0" in text
|
||
|
||
|
||
def test_priority_sync_fetches_remote_accounts_and_backfills_info(db_session, monkeypatch):
|
||
"""验证优先级同步能获取并回填远端账号的名称和旧优先级。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u = Upstream(name="U1", base_url="http://u1")
|
||
db_session.add_all([w, u])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u)
|
||
|
||
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
_make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
|
||
class CustomMockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def list_accounts(self):
|
||
return [
|
||
{"id": "A1", "name": "SmartUp-A1-Name", "priority": 15},
|
||
{"id": "A2", "name": "SmartUp-A2-Name", "priority": 25},
|
||
]
|
||
def get_groups(self, *args, **kwargs):
|
||
return [
|
||
{"id": "TG1", "name": "TG1-Readable-Group-Name"}
|
||
]
|
||
def update_account(self, account_id, data):
|
||
update_calls.append((account_id, data))
|
||
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", CustomMockClient)
|
||
|
||
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
|
||
assert len(results) == 2
|
||
|
||
# 校验 site_results 中的丰富信息是否填充
|
||
res_a1 = next(r for r in results if r["account_id"] == "A1")
|
||
assert res_a1["account_name"] == "SmartUp-A1-Name"
|
||
assert res_a1["old_priority"] == 15
|
||
assert res_a1["new_priority"] == 1
|
||
assert res_a1["source_rate"] == 1.0
|
||
assert res_a1["target_group_id"] == "TG1"
|
||
assert res_a1["target_group_name"] == "TG1-Readable-Group-Name"
|
||
|
||
res_a2 = next(r for r in results if r["account_id"] == "A2")
|
||
assert res_a2["account_name"] == "SmartUp-A2-Name"
|
||
assert res_a2["old_priority"] == 25
|
||
assert res_a2["new_priority"] == 11
|
||
assert res_a2["source_rate"] == 2.0
|
||
|
||
# 验证本地 DB 中行的 imported_target_group_name 被正确更新写入
|
||
row1 = db_session.query(UpstreamGeneratedKey).filter_by(imported_account_id="A1").first()
|
||
assert row1.imported_target_group_name == "TG1-Readable-Group-Name"
|
||
|
||
|
||
def test_priority_sync_graceful_on_remote_list_failure(db_session, monkeypatch):
|
||
"""验证 list_accounts 抛异常时,优先级同步依然成功更新,且各项降级为账号 ID 和未知。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u = Upstream(name="U1", base_url="http://u1")
|
||
db_session.add_all([w, u])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u)
|
||
|
||
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
_make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
_make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
class CustomMockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def list_accounts(self):
|
||
raise Exception("Remote server error 500")
|
||
def update_account(self, account_id, data):
|
||
pass
|
||
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", CustomMockClient)
|
||
|
||
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
|
||
assert len(results) == 2
|
||
|
||
# 校验降级逻辑
|
||
res_a1 = next(r for r in results if r["account_id"] == "A1")
|
||
assert res_a1["account_name"] == "A1"
|
||
assert res_a1["old_priority"] is None
|
||
assert res_a1["new_priority"] == 1
|
||
assert res_a1["status"] == "success"
|
||
|
||
|
||
def test_priority_sync_stale_account_cleanup_on_list_success(db_session, monkeypatch):
|
||
"""验证 priority sync 时,若远端账号列表中缺失某个导入账号,能自愈清理本地标记并不影响其他竞争账号。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u = Upstream(name="U1", base_url="http://u1")
|
||
db_session.add_all([w, u])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u)
|
||
|
||
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0, "G3": 3.0})
|
||
|
||
# A1(K1), A2(K2) 与 A3(K3) 竞争
|
||
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
k3 = _make_key(db_session, u.id, "G3", "K3", "V3", w.id, "A3", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
|
||
class MockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def list_accounts(self):
|
||
# 只有 A1 和 A2,缺失 A3
|
||
return [
|
||
{"id": "A1", "name": "SmartUp-A1"},
|
||
{"id": "A2", "name": "SmartUp-A2"},
|
||
]
|
||
def get_groups(self, *a, **kw):
|
||
return [{"id": "TG1", "name": "TG1-Group"}]
|
||
def update_account(self, account_id, data):
|
||
update_calls.append((account_id, data))
|
||
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
|
||
|
||
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
|
||
|
||
# A1 和 A2 正常竞争更新,A3 被自愈清除不调用 update_account
|
||
assert len(update_calls) == 2
|
||
priority_map = {aid: data["priority"] for aid, data in update_calls}
|
||
assert priority_map["A1"] == 1
|
||
assert priority_map["A2"] == 11
|
||
|
||
res_a3 = next(r for r in results if r["account_id"] == "A3")
|
||
assert res_a3["status"] == "stale"
|
||
assert "目标账号已不存在,已清除导入标记" in res_a3["message"]
|
||
|
||
db_session.refresh(k3)
|
||
assert k3.imported_account_id is None
|
||
assert k3.imported_website_id is None
|
||
assert k3.status == "created"
|
||
|
||
|
||
def test_priority_sync_stale_account_single_remaining_skips_update(db_session, monkeypatch):
|
||
"""验证 priority sync 时,若清除失效账号后某分组只剩一个有效账号,该分组跳过更新不调用 update_account。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u = Upstream(name="U1", base_url="http://u1")
|
||
db_session.add_all([w, u])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u)
|
||
|
||
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
# A1 与 A2 竞争
|
||
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
|
||
class MockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def list_accounts(self):
|
||
# 只有 A1,缺失 A2
|
||
return [{"id": "A1", "name": "SmartUp-A1"}]
|
||
def get_groups(self, *a, **kw):
|
||
return [{"id": "TG1", "name": "TG1-Group"}]
|
||
def update_account(self, account_id, data):
|
||
update_calls.append((account_id, data))
|
||
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
|
||
|
||
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
|
||
|
||
# 竞争分组只剩 A1 一个有效账号 -> 跳过不更新,update_account 不应被调用
|
||
assert update_calls == []
|
||
res_a2 = next(r for r in results if r["account_id"] == "A2")
|
||
assert res_a2["status"] == "stale"
|
||
db_session.refresh(k2)
|
||
assert k2.imported_account_id is None
|
||
|
||
|
||
def test_priority_sync_no_cleanup_on_list_failure(db_session, monkeypatch):
|
||
"""验证 list_accounts 失败时,不清理本地导入标记,仍尝试调用 update_account。"""
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u = Upstream(name="U1", base_url="http://u1")
|
||
db_session.add_all([w, u])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u)
|
||
|
||
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
update_calls = []
|
||
|
||
class MockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def list_accounts(self):
|
||
raise Exception("Remote list error")
|
||
def update_account(self, account_id, data):
|
||
update_calls.append((account_id, data))
|
||
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
|
||
|
||
sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
|
||
|
||
# list_accounts 失败,不做清理,继续正常对 A1 和 A2 尝试更新
|
||
assert len(update_calls) == 2
|
||
db_session.refresh(k1)
|
||
db_session.refresh(k2)
|
||
assert k1.imported_account_id == "A1"
|
||
assert k2.imported_account_id == "A2"
|
||
|
||
|
||
def test_update_account_404_error_custom_msg_and_fallback_cleanup(db_session, monkeypatch):
|
||
"""验证 update_account 抛出 404 错误时转换为友好文案并触发自愈清除。"""
|
||
from app.services.website_client import WebsiteError
|
||
w = Website(name="W1", base_url="http://w1", enabled=True,
|
||
auth_config_json="{}", timeout_seconds=30)
|
||
u = Upstream(name="U1", base_url="http://u1")
|
||
db_session.add_all([w, u])
|
||
db_session.commit()
|
||
db_session.refresh(w); db_session.refresh(u)
|
||
|
||
_make_snapshot(db_session, u.id, {"G1": 1.0, "G2": 2.0})
|
||
|
||
k1 = _make_key(db_session, u.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
|
||
k2 = _make_key(db_session, u.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
|
||
|
||
import httpx
|
||
|
||
class MockClient:
|
||
def __init__(self, **kwargs): pass
|
||
def __enter__(self): return self
|
||
def __exit__(self, *a): pass
|
||
def list_accounts(self):
|
||
# 模拟 list 失败,以便依赖 update_account 兜底清理
|
||
raise Exception("list failed")
|
||
def update_account(self, account_id, data):
|
||
# A2 触发 404 错误
|
||
if account_id == "A2":
|
||
req = httpx.Request("PUT", f"http://w1/accounts/{account_id}")
|
||
resp = httpx.Response(404, request=req)
|
||
raise WebsiteError("目标网站接口不存在") from httpx.HTTPStatusError("404", request=req, response=resp)
|
||
|
||
def get_groups(self, *a, **kw):
|
||
return []
|
||
|
||
# 验证单独调用 update_account 404 时的定制错误文案
|
||
client = Sub2ApiWebsiteClient(base_url="http://w1", api_prefix="", auth_type="api_key", auth_config={})
|
||
# Mock _request in client
|
||
def mock_request(method, path, body=None):
|
||
req = httpx.Request(method, path)
|
||
resp = httpx.Response(404, request=req)
|
||
raise WebsiteError("目标网站接口不存在") from httpx.HTTPStatusError("404", request=req, response=resp)
|
||
monkeypatch.setattr(client, "_request", mock_request)
|
||
|
||
with pytest.raises(WebsiteError) as exc_info:
|
||
client.update_account("A2", {"priority": 1})
|
||
assert "目标账号 A2 不存在或已被删除" in str(exc_info.value)
|
||
|
||
# 验证同步流程中的 404 兜底清理
|
||
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", MockClient)
|
||
results = sync_account_priorities_for_upstream(db_session, u.id, website_id=w.id)
|
||
|
||
res_a2 = next(r for r in results if r["account_id"] == "A2")
|
||
assert res_a2["status"] == "stale"
|
||
assert "目标账号已不存在,已清除导入标记" in res_a2["message"]
|
||
|
||
db_session.refresh(k2)
|
||
assert k2.imported_account_id is None
|
||
assert k2.status == "created"
|