File size: 7,011 Bytes
4ebb74e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""端到端集成验证:真实 FastAPI 应用 + 假 Gemini 后端,不联网。"""

import asyncio
import os
import sys
import tempfile
import time
from types import SimpleNamespace

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

TMP = tempfile.mkdtemp(prefix="gs-e2e-")
os.environ["SECURE_1PSID"] = "PSID-E2E"
os.environ["SECURE_1PSIDTS"] = "ENV-TS-E2E"
os.environ["GEMINI_COOKIE_PATH"] = TMP
os.environ["SESSION_HEALTH_INTERVAL"] = "30"
os.environ["AUTO_DELETE_CHAT"] = "true"

import gemini_session as gs  # noqa: E402

PASS, FAIL = [], []


def check(name, cond, extra=""):
	(PASS if cond else FAIL).append(name)
	print(("[OK]   " if cond else "[FAIL] ") + name + (f"  {extra}" if extra and not cond else ""))


class FakeCookies:
	def __init__(self, values):
		self.jar = [SimpleNamespace(name=k, value=v) for k, v in values.items()]

	def get(self, name, default=None):
		for c in self.jar:
			if c.name == name:
				return c.value
		return default


class FakeResponse:
	def __init__(self, text, cid, delta=None):
		self.text = text
		self.text_delta = delta
		self.thoughts = None
		self.images = []
		self.metadata = [cid]


class FakeClient:
	def __init__(self, psid=None, psidts=None, **kwargs):
		self.psid = psid
		self.psidts = psidts
		self._running = False
		self.client = None
		self.auto_refresh = False
		self.refresh_task = None
		self.activity_task = None
		self.account_status = SimpleNamespace(name="AVAILABLE")
		self.cookies = FakeCookies({"__Secure-1PSID": psid, "__Secure-1PSIDTS": psidts})
		self.deleted = []
		self.fail_next_generate = False

	async def init(self, **kwargs):
		self._running = True
		self.client = object()
		self.auto_refresh = kwargs.get("auto_refresh", False)
		# 对齐真实 GeminiClient.init():开启 auto_refresh 时立即拉起后台任务
		if self.auto_refresh:
			self.refresh_task = asyncio.create_task(self.start_auto_refresh())
			self.activity_task = asyncio.create_task(self.start_activity_watchdog())

	async def generate_content(self, prompt, **kwargs):
		if self.fail_next_generate:
			self.fail_next_generate = False
			self.account_status = SimpleNamespace(name="UNAUTHENTICATED")
			raise gs_gemini_error()
		return FakeResponse("OK", "cid-generate")

	async def generate_content_stream(self, prompt, **kwargs):
		yield FakeResponse("Hel", "cid-stream-1", delta="Hel")
		yield FakeResponse("lo", "cid-stream-2", delta="lo")

	async def fetch_latest_chat_response(self, cid):
		return FakeResponse("OK", cid)

	async def delete_chat(self, cid):
		self.deleted.append(cid)

	async def start_auto_refresh(self):
		while self._running:
			await asyncio.sleep(3600)

	async def start_activity_watchdog(self):
		while self._running:
			await asyncio.sleep(3600)

	async def close(self):
		self._closed_marker = True
		self._running = False
		self.client = None


def gs_gemini_error():
	from gemini_webapi import GeminiError

	return GeminiError("Failed to generate contents. No output data found in response.")


def main():
	gs.GeminiClient = FakeClient

	import main as app_main  # 导入时才读取环境变量
	from fastapi.testclient import TestClient

	with TestClient(app_main.app) as client:
		# ---------------------------------------------------------- 生命周期
		check("E1 lifespan 启动了监督协程", app_main.session_manager.status()["supervisor_alive"] is True)

		# 初始化是后台任务,等它跑完再断言
		deadline = time.time() + 10
		while time.time() < deadline and not app_main.session_manager.status()["initialized"]:
			time.sleep(0.1)
		check("E1b 后台初始化已完成", app_main.session_manager.status()["initialized"] is True)

		# ---------------------------------------------------------- /api/session
		r = client.get("/api/session")
		check("E2 /api/session 返回 200", r.status_code == 200, str(r.status_code))
		data = r.json()
		check("E3 基于 Cookie 初始化成功", data["initialized"] is True)
		check("E4 凭据来源为 environment", data["credential_source"] == "environment")
		check("E5 轮换任务存活", data["rotation_task_alive"] is True)
		check("E6 1PSIDTS 已脱敏", "*" in data["live_1psidts_masked"] and data["live_1psidts_masked"] != "ENV-TS-E2E")
		check("E7 缓存已落盘", data["cached_1psidts_masked"] != "")

		# ---------------------------------------------------------- 非流式对话
		payload = {"model": "gemini-2.5-flash", "messages": [{"role": "user", "content": "hi"}]}
		r = client.post("/v1/chat/completions", json=payload)
		check("E8 非流式请求 200", r.status_code == 200, f"{r.status_code} {r.text[:200]}")
		body = r.json()
		check("E9 返回内容正确", body["choices"][0]["message"]["content"].strip() == "OK", str(body)[:200])

		# ---------------------------------------------------------- 流式对话
		r = client.post("/v1/chat/completions", json={**payload, "stream": True})
		check("E10 流式请求 200", r.status_code == 200, str(r.status_code))
		check("E11 流式返回 SSE 数据", "data: [DONE]" in r.text and "Hel" in r.text, r.text[:200])

		# ---------------------------------------------------------- 失效自愈(非流式)
		bad = app_main.session_manager.client
		bad.fail_next_generate = True
		r = client.post("/v1/chat/completions", json=payload)
		check("E12 会话失效后请求仍成功(自动重建重试)", r.status_code == 200, f"{r.status_code} {r.text[:200]}")
		check("E13 确实重建了客户端", app_main.session_manager.client is not bad)
		check("E14 recycle_count 递增", app_main.session_manager.status()["recycle_count"] >= 1)

		# ---------------------------------------------------------- 轮换 -> 缓存 -> 状态
		live = app_main.session_manager.client
		live.cookies = FakeCookies({"__Secure-1PSID": "PSID-E2E", "__Secure-1PSIDTS": "ROTATED-E2E"})
		app_main.session_manager._observe_rotation(live)
		r = client.get("/api/session")
		data = r.json()
		check("E15 轮换被记录", data["rotation_count"] >= 1)
		check("E16 轮换值已落盘", data["cached_1psidts_masked"].startswith("ROTA"))
		check("E17 换值年龄已更新", data["last_1psidts_change_age_sec"] is not None and data["last_1psidts_change_age_sec"] < 60)

		# ---------------------------------------------------------- 管理面板状态
		# 没有 API_KEY 时管理面板不可用,这里直接调用函数绕过 token
		import admin

		status = asyncio.run(admin.get_status(token=""))
		check("E18 管理面板状态含 session 块", "session" in status and "rotation_count" in status)
		check("E19 管理面板透出实时 1PSIDTS", status["live_1psidts_masked"] != "")

		# ---------------------------------------------------------- 关闭
	check("E20 退出 lifespan 后监督协程停止", app_main.session_manager.status()["supervisor_alive"] is False)

	print(f"\n通过 {len(PASS)} / {len(PASS) + len(FAIL)}")
	if FAIL:
		print("失败项:")
		for name in FAIL:
			print("  -", name)
	return 1 if FAIL else 0


if __name__ == "__main__":
	sys.exit(main())