test_directory.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. from __future__ import annotations
  2. from datetime import timedelta
  3. from types import SimpleNamespace
  4. import pytest
  5. from aiohttp import CookieJar
  6. from aiohttp.test_utils import TestClient, TestServer
  7. from pyrogram.enums import ChatMemberStatus
  8. async def _approved_teacher(directory_db, *, user_id: int = 200):
  9. await directory_db.upsert_directory_identity(
  10. user_id=user_id,
  11. username=f"teacher{user_id}",
  12. first_name="王",
  13. last_name="技师",
  14. )
  15. await directory_db.save_directory_location(
  16. user_id=user_id,
  17. longitude=116.4074,
  18. latitude=39.9042,
  19. source="test",
  20. actor_id=user_id,
  21. actor_name="王技师",
  22. )
  23. _, created = await directory_db.submit_teacher_application(
  24. user_id=user_id,
  25. source="test",
  26. bot_id="primary",
  27. chat_id=-100200,
  28. chat_title="测试群",
  29. )
  30. assert created is True
  31. await directory_db.decide_teacher_application(
  32. user_id=user_id,
  33. action="approve",
  34. actor_id="admin",
  35. actor_name="admin",
  36. source="test",
  37. )
  38. await directory_db.set_teacher_state(
  39. user_id=user_id,
  40. action="list",
  41. actor_id=user_id,
  42. actor_name="王技师",
  43. source="test",
  44. )
  45. return await directory_db.get_directory_profile(user_id)
  46. async def test_teacher_application_distance_listing_and_presence(app_modules):
  47. directory_db = app_modules.load("wbb.utils.dbdirectory")
  48. directory_service = app_modules.load("wbb.services.directory")
  49. await directory_db.ensure_directory_indexes()
  50. approved = await _approved_teacher(directory_db)
  51. assert approved["application_source_chat_id"] == -100200
  52. assert approved["application_source_chat_title"] == "测试群"
  53. profile, applied = await directory_db.set_teacher_state(
  54. user_id=200,
  55. action="online",
  56. actor_id=200,
  57. actor_name="王技师",
  58. source="test",
  59. idempotency_key="presence:one",
  60. )
  61. assert applied is True
  62. assert directory_db.teacher_is_online(profile) is True
  63. duplicate, applied = await directory_db.set_teacher_state(
  64. user_id=200,
  65. action="online",
  66. actor_id=200,
  67. actor_name="王技师",
  68. source="test",
  69. idempotency_key="presence:one",
  70. )
  71. assert applied is False
  72. assert directory_db.teacher_is_online(duplicate) is True
  73. items, total = await directory_db.list_directory_teachers(
  74. longitude=116.4174,
  75. latitude=39.9042,
  76. page=1,
  77. page_size=10,
  78. )
  79. assert total == 1
  80. public = directory_service.public_teacher(items[0])
  81. assert public["display_name"] == "王 技师"
  82. assert 800 < public["distance_meters"] < 1000
  83. assert "longitude" not in public
  84. assert "latitude" not in public
  85. await app_modules.wbb.control_db.directory_profiles.update_one(
  86. {"user_id": 200},
  87. {
  88. "$set": {
  89. "online_until": directory_db.utc_now() - timedelta(seconds=1),
  90. }
  91. },
  92. )
  93. assert await directory_db.expire_teacher_presence() == 1
  94. expired = await directory_db.get_directory_profile(200)
  95. assert directory_db.teacher_is_online(expired) is False
  96. assert expired["listed"] is True
  97. async def test_location_only_keeps_coordinates_and_source(app_modules):
  98. directory_db = app_modules.load("wbb.utils.dbdirectory")
  99. await directory_db.upsert_directory_identity(
  100. user_id=300,
  101. username="member300",
  102. first_name="李",
  103. last_name="同学",
  104. )
  105. location = await directory_db.save_directory_location(
  106. user_id=300,
  107. longitude=121.4737,
  108. latitude=31.2304,
  109. source="test",
  110. actor_id=300,
  111. actor_name="李同学",
  112. )
  113. assert location["source"] == "test"
  114. assert location["longitude"] == pytest.approx(121.4737)
  115. assert location["latitude"] == pytest.approx(31.2304)
  116. assert "region" not in location
  117. assert "geocode_consent" not in location
  118. assert "geocode_status" not in location
  119. assert "coordinate_key" not in location
  120. await app_modules.wbb.control_db.directory_locations.update_one(
  121. {"user_id": 300},
  122. {
  123. "$set": {
  124. "region": {"label": "旧区域"},
  125. "geocode_consent": True,
  126. "geocode_status": "completed",
  127. "coordinate_key": "legacy",
  128. }
  129. },
  130. )
  131. updated = await directory_db.save_directory_location(
  132. user_id=300,
  133. longitude=121.48,
  134. latitude=31.24,
  135. source="test",
  136. actor_id=300,
  137. actor_name="李同学",
  138. )
  139. assert "region" not in updated
  140. assert "geocode_consent" not in updated
  141. assert "geocode_status" not in updated
  142. assert "coordinate_key" not in updated
  143. async def test_cross_bot_membership_verification_uses_live_status(app_modules):
  144. directory_db = app_modules.load("wbb.utils.dbdirectory")
  145. directory_service = app_modules.load("wbb.services.directory")
  146. user = SimpleNamespace(
  147. id=400,
  148. username="member400",
  149. first_name="测试",
  150. last_name="成员",
  151. is_bot=False,
  152. )
  153. app_modules.app.members[(-100123, 400)] = SimpleNamespace(
  154. status=ChatMemberStatus.ADMINISTRATOR,
  155. user=user,
  156. )
  157. await directory_db.upsert_directory_membership(
  158. bot_id="primary",
  159. chat_id=-100123,
  160. user_id=400,
  161. status="member",
  162. active=True,
  163. )
  164. member = await directory_service.verify_platform_membership(user_id=400)
  165. assert member["allowed"] is True
  166. assert member["chat_id"] == -100123
  167. admin = await directory_service.verify_platform_membership(
  168. user_id=400,
  169. require_admin=True,
  170. )
  171. assert admin["allowed"] is True
  172. app_modules.app.members[(-100123, 400)] = SimpleNamespace(
  173. status=ChatMemberStatus.LEFT,
  174. user=user,
  175. )
  176. denied = await directory_service.verify_platform_membership(user_id=400)
  177. assert denied["allowed"] is False
  178. async def test_directory_admin_api_requires_role_and_manages_global_data(app_modules):
  179. admin_api = app_modules.load("wbb.admin.api")
  180. directory_db = app_modules.load("wbb.utils.dbdirectory")
  181. app_modules.wbb.BOT_PERMISSIONS = {"teacher_directory.manage"}
  182. application = admin_api.build_admin_application()
  183. await application["admin_api"].initialize()
  184. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  185. await client.start_server()
  186. try:
  187. login = await client.post(
  188. "/api/admin/v1/auth/login",
  189. json={"username": "admin", "password": "qwe0.123456"},
  190. )
  191. login_data = (await login.json())["data"]
  192. changed = await client.put(
  193. "/api/admin/v1/auth/password",
  194. headers={"X-CSRF-Token": login_data["csrf_token"]},
  195. json={
  196. "current_password": "qwe0.123456",
  197. "new_password": "changed-pass-123",
  198. },
  199. )
  200. csrf = (await changed.json())["data"]["csrf_token"]
  201. headers = {"X-CSRF-Token": csrf}
  202. await directory_db.upsert_directory_identity(
  203. user_id=500,
  204. username="student500",
  205. first_name="周",
  206. last_name="同学",
  207. )
  208. updated = await client.put(
  209. "/api/admin/v1/directory/locations/500",
  210. headers=headers,
  211. json={
  212. "longitude": 113.2644,
  213. "latitude": 23.1291,
  214. "reason": "修正测试位置",
  215. "confirm": True,
  216. },
  217. )
  218. assert updated.status == 200
  219. payload = (await updated.json())["data"]
  220. assert payload["longitude"] == pytest.approx(113.2644)
  221. assert payload["source"] == "web"
  222. assert "region" not in payload
  223. assert "geocode_status" not in payload
  224. listed = await client.get("/api/admin/v1/directory/locations")
  225. assert listed.status == 200
  226. item = (await listed.json())["data"]["items"][0]
  227. assert item["latitude"] == pytest.approx(23.1291)
  228. assert "point" not in item
  229. assert "coordinate_key" not in item
  230. assert "region" not in item
  231. assert "geocode_consent" not in item
  232. settings = await client.get("/api/admin/v1/directory/settings")
  233. assert settings.status == 200
  234. settings_data = (await settings.json())["data"]
  235. assert settings_data["nearby_radius_km"] == 20
  236. assert "nominatim_enabled" not in settings_data
  237. unconfirmed = await client.delete(
  238. "/api/admin/v1/directory/locations/500",
  239. headers=headers,
  240. json={"reason": "测试"},
  241. )
  242. assert unconfirmed.status == 409
  243. deleted = await client.delete(
  244. "/api/admin/v1/directory/locations/500",
  245. headers=headers,
  246. json={"reason": "删除测试位置", "confirm": True},
  247. )
  248. assert deleted.status == 200
  249. assert (await deleted.json())["data"]["deleted"] is True
  250. app_modules.wbb.BOT_PERMISSIONS = set()
  251. denied = await client.get("/api/admin/v1/directory/settings")
  252. assert denied.status == 403
  253. assert (await denied.json())["error"]["details"]["required_permission"] == (
  254. "teacher_directory.manage"
  255. )
  256. finally:
  257. await client.close()
  258. async def test_role_specific_bottom_menus_are_chinese(app_modules):
  259. module = app_modules.load("wbb.modules.teacher_directory")
  260. directory_service = app_modules.load("wbb.services.directory")
  261. try:
  262. student = module._directory_keyboard(
  263. {"application_status": "none"},
  264. is_admin=False,
  265. )
  266. student_labels = [
  267. button.text for row in student.keyboard for button in row
  268. ]
  269. assert "Telegram 定位" in student_labels
  270. assert "手动选择位置" in student_labels
  271. assert "附近技师" in student_labels
  272. assert "申请成为技师" in student_labels
  273. assert "我的状态" not in student_labels
  274. assert "上线" not in student_labels
  275. assert "下线" not in student_labels
  276. assert "技师审批" not in student_labels
  277. teacher_admin = module._directory_keyboard(
  278. {
  279. "application_status": "approved",
  280. "listed": True,
  281. "online": True,
  282. },
  283. is_admin=True,
  284. )
  285. teacher_labels = [
  286. button.text for row in teacher_admin.keyboard for button in row
  287. ]
  288. assert "下榜" in teacher_labels
  289. assert "🔴 下线" in teacher_labels
  290. assert "技师审批" in teacher_labels
  291. group_student = module._group_directory_keyboard(
  292. {"application_status": "none"},
  293. chat_id=-100123,
  294. is_admin=False,
  295. )
  296. group_student_labels = [
  297. button.text for row in group_student.inline_keyboard for button in row
  298. ]
  299. assert "申请成为技师" in group_student_labels
  300. assert "我的状态" not in group_student_labels
  301. teacher_rows = module._teacher_result_markup(
  302. [
  303. {
  304. "user_id": index,
  305. "username": f"teacher{index}",
  306. "display_name": f"技师 {index}",
  307. "online": index % 2 == 0,
  308. }
  309. for index in range(1, 5)
  310. ],
  311. radius_key="all",
  312. page=1,
  313. total=4,
  314. ).inline_keyboard
  315. assert [len(row) for row in teacher_rows[:2]] == [3, 1]
  316. assert teacher_rows[0][0].text.startswith("🔴")
  317. assert teacher_rows[0][1].text.startswith("🟢")
  318. finally:
  319. await directory_service.stop_presence_sweeper()
  320. async def test_internal_membership_rpc_requires_loopback_token(app_modules):
  321. admin_api = app_modules.load("wbb.admin.api")
  322. app_modules.wbb.INTERNAL_TOKEN = "internal-test-token"
  323. application = admin_api.build_admin_application()
  324. await application["admin_api"].initialize()
  325. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  326. await client.start_server()
  327. user = SimpleNamespace(
  328. id=600,
  329. username="admin600",
  330. first_name="目录",
  331. last_name="管理员",
  332. is_bot=False,
  333. )
  334. app_modules.app.members[(-100600, 600)] = SimpleNamespace(
  335. status=ChatMemberStatus.ADMINISTRATOR,
  336. user=user,
  337. )
  338. try:
  339. denied = await client.post(
  340. "/api/internal/v1/directory/verify-local",
  341. json={
  342. "user_id": "600",
  343. "chat_ids": ["-100600"],
  344. "require_admin": True,
  345. },
  346. )
  347. assert denied.status == 403
  348. allowed = await client.post(
  349. "/api/internal/v1/directory/verify-local",
  350. headers={"Authorization": "Bearer internal-test-token"},
  351. json={
  352. "user_id": "600",
  353. "chat_ids": ["-100600"],
  354. "require_admin": True,
  355. },
  356. )
  357. assert allowed.status == 200
  358. data = (await allowed.json())["data"]
  359. assert data["allowed"] is True
  360. assert data["chat_id"] == "-100600"
  361. finally:
  362. await client.close()