import json import pytest import httpx 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 from app.routers.websites import preview_cleanup_invalid_accounts, execute_cleanup_invalid_accounts from app.services.website_client import Sub2ApiWebsiteClient @pytest.fixture() def db_session(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) 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) class MockResponse: def __init__(self, status_code, lines, raise_exc=None): self.status_code = status_code self._lines = lines self._raise_exc = raise_exc def __enter__(self): if self._raise_exc: raise self._raise_exc return self def __exit__(self, *args): pass def iter_lines(self): return self._lines def mock_stream(status_code, lines, raise_exc=None): def mock_fn(*args, **kwargs): return MockResponse(status_code, lines, raise_exc) return mock_fn def test_website_client_test_account_sse_scenarios(monkeypatch): """测试 Sub2ApiWebsiteClient.test_account 在各种 SSE 场景下的解析、处理、ID 转义和 API 日志记录。""" c = Sub2ApiWebsiteClient( base_url="http://w1", api_prefix="api/v1", auth_type="bearer", auth_config={"token": "t1"}, ) log_calls = [] def mock_log(**kwargs): log_calls.append(kwargs) monkeypatch.setattr("app.services.website_client.log_external_api_call", mock_log) # 1. success=true,包含带有斜杠的 ID 校验以测试 URL quote 转义 monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": true}"])) res = c.test_account("acc/1") assert res["status"] == "success" assert res["cleanup_allowed"] is False # 验证日志记录和 URL 转义:acc/1 应被转义为 acc%2F1 assert len(log_calls) == 1 assert log_calls[-1]["method"] == "POST" assert log_calls[-1]["path"] == "/api/v1/accounts/acc%2F1/test" assert log_calls[-1]["success"] is True # 2. success=false monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: test_complete", "data: {\"success\": false}"])) res = c.test_account("acc/1") assert res["status"] == "failed" assert res["cleanup_allowed"] is True assert len(log_calls) == 2 assert log_calls[-1]["success"] is True # 3. HTTP 404 monkeypatch.setattr(c._client, "stream", mock_stream(404, [])) res = c.test_account("acc/1") assert res["status"] == "404" assert res["cleanup_allowed"] is True assert len(log_calls) == 3 assert log_calls[-1]["status_code"] == 404 assert log_calls[-1]["success"] is True # 4. error event monkeypatch.setattr(c._client, "stream", mock_stream(200, ["event: error", "data: \"internal error\""])) res = c.test_account("acc/1") assert res["status"] == "error" assert res["cleanup_allowed"] is False assert len(log_calls) == 4 assert log_calls[-1]["success"] is False assert log_calls[-1]["error_type"] == "WebsiteError" # 5. 超时 monkeypatch.setattr(c._client, "stream", mock_stream(200, [], raise_exc=httpx.TimeoutException("Connection timed out"))) res = c.test_account("acc/1") assert res["status"] == "timeout" assert res["cleanup_allowed"] is False assert len(log_calls) == 5 assert log_calls[-1]["success"] is False assert log_calls[-1]["error_type"] == "TimeoutException" # 6. 非法 SSE (没有 test_complete) monkeypatch.setattr(c._client, "stream", mock_stream(200, ["data: hello world"])) res = c.test_account("acc/1") assert res["status"] == "invalid_sse" assert res["cleanup_allowed"] is False assert len(log_calls) == 6 assert log_calls[-1]["success"] is False assert log_calls[-1]["error_type"] == "WebsiteError" def test_cleanup_invalid_preview_and_execute(db_session, monkeypatch): """测试预览与执行清理的完整工作流。 - 包含本地 orphaned 键。 - 包含远端 SmartUp 备注孤立账号。 - 包含无 SmartUp 备注的手工账号(不参与候选)。 - 包含测试通过与失败的不同流程分支。 """ 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) # 1. 本地 orphaned 键 k1:对应的远端账号测试将失败 (可清理) k1 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G1", group_name="G1-Name", key_name="Key-G1", key_value="sk-g1", status="orphaned", imported_website_id=w.id, imported_account_id="acc-k1", error="original-error-k1" ) # 2. 本地 orphaned 键 k2:对应的远端账号测试将成功 (不可清理) k2 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G2", group_name="G2-Name", key_name="Key-G2", key_value="sk-g2", status="orphaned", imported_website_id=w.id, imported_account_id="acc-k2", error="original-error-k2" ) # 3. 本地 imported 正常键 k3(不进入候选) k3 = UpstreamGeneratedKey( upstream_id=u1.id, group_id="G3", group_name="G3-Name", key_name="Key-G3", key_value="sk-g3", status="imported", imported_website_id=w.id, imported_account_id="acc-k3" ) db_session.add_all([k1, k2, k3]) db_session.commit() deleted_accounts = [] test_calls = {} class MockClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *a): pass def list_accounts(self): # 远端列表: # acc-k1 (Condition A, k1) # acc-k2 (Condition A, k2) # acc-k3 (k3 正常) # acc-orphan-remote (Condition B, notes 带备注,本地不存在关联) # acc-manual (无备注手工账号,notes 没匹配上,不应进候选) return [ {"id": "acc-k1", "name": "Acc-K1", "notes": f"Imported by SmartUp from upstream key #{k1.id}"}, {"id": "acc-k2", "name": "Acc-K2", "notes": f"Imported by SmartUp from upstream key #{k2.id}"}, {"id": "acc-k3", "name": "Acc-K3", "notes": f"Imported by SmartUp from upstream key #{k3.id}"}, {"id": "acc-orphan-remote", "name": "Orphan-Remote", "notes": "Imported by SmartUp from upstream key #9999"}, {"id": "acc-manual", "name": "Manual-Acc", "notes": "Manually created account"}, ] def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def test_account(self, account_id): test_calls[account_id] = test_calls.get(account_id, 0) + 1 if account_id in ("acc-k1", "acc-orphan-remote"): # 测试失败 -> 可删除 return {"status": "failed", "cleanup_allowed": True, "message": "API check failed"} else: # 其他测试成功 return {"status": "success", "cleanup_allowed": False, "message": "API check OK"} def delete_account(self, account_id): deleted_accounts.append(account_id) return {"status": "deleted", "message": f"Account {account_id} has been deleted"} monkeypatch.setattr("app.routers.websites._client", lambda row: MockClient()) # ── 1. 测试预览 ── preview_res = preview_cleanup_invalid_accounts(wid=w.id, db=db_session) assert preview_res.success is True # 候选账号应为 3 个:acc-k1, acc-k2, acc-orphan-remote (acc-k3 正常, acc-manual 无备注,均不进入候选) items = preview_res.items assert len(items) == 3 item_k1 = next(i for i in items if i.account_id == "acc-k1") assert item_k1.cleanup_allowed is True assert item_k1.test_status == "failed" item_k2 = next(i for i in items if i.account_id == "acc-k2") assert item_k2.cleanup_allowed is False assert item_k2.test_status == "test_passed" item_orphan = next(i for i in items if i.account_id == "acc-orphan-remote") assert item_orphan.cleanup_allowed is True assert item_orphan.test_status == "failed" assert item_orphan.upstream_key_id == 9999 # 确认没有调用过删除 assert len(deleted_accounts) == 0 # ── 2. 测试执行 ── # 重置调用计数 test_calls.clear() execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session) assert execute_res.success is True assert "成功清理 2 个" in execute_res.message assert "跳过 1 个" in execute_res.message # 验证是否重新跑了测试 assert "acc-k1" in test_calls assert "acc-k2" in test_calls assert "acc-orphan-remote" in test_calls # 验证被删除的账号 (只有测试失败/可清理的才删:acc-k1 和 acc-orphan-remote) assert len(deleted_accounts) == 2 assert "acc-k1" in deleted_accounts assert "acc-orphan-remote" in deleted_accounts assert "acc-k2" not in deleted_accounts # 验证本地数据库的 DB key 清空逻辑: db_session.refresh(k1) db_session.refresh(k2) db_session.refresh(k3) # k1 应该被清空 imported 字段,但保留 status='orphaned' 及原始 error assert k1.imported_website_id is None assert k1.imported_account_id is None assert k1.status == "orphaned" assert k1.error == "original-error-k1" # k2 由于测试成功跳过删除,DB 不变 assert k2.imported_website_id == w.id assert k2.imported_account_id == "acc-k2" assert k2.status == "orphaned" def test_cleanup_list_accounts_failure(db_session, monkeypatch): """测试 list_accounts() 失败时,整个清理预览与执行直接阻断报错。""" w = Website( name="W1", site_type="sub2api", base_url="http://w1", enabled=True, auth_config_json="{}", timeout_seconds=30 ) db_session.add(w) db_session.commit() class FailClient: def __init__(self, **kwargs): pass def __enter__(self): return self def __exit__(self, *a): pass def list_accounts(self): return None # 模拟获取失败 monkeypatch.setattr("app.routers.websites._client", lambda row: FailClient()) preview_res = preview_cleanup_invalid_accounts(wid=w.id, db=db_session) assert preview_res.success is False assert "拉取远端账号列表失败" in preview_res.message assert len(preview_res.items) == 0 execute_res = execute_cleanup_invalid_accounts(wid=w.id, db=db_session) assert execute_res.success is False assert "拉取远端账号列表失败" in execute_res.message assert len(execute_res.items) == 0