fix(sync): 优先级同步前置过滤远端失效账号防排序污染,移除 locals 隐患,并将自愈结果纳入本地日志与摘要统计
This commit is contained in:
@@ -685,3 +685,194 @@ def test_priority_sync_graceful_on_remote_list_failure(db_session, monkeypatch):
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user