test_directory.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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_clearing_legacy_coordinates_keeps_public_teacher_state(app_modules):
  144. directory_db = app_modules.load("wbb.utils.dbdirectory")
  145. await directory_db.upsert_directory_identity(
  146. user_id=350,
  147. username="teacher350",
  148. first_name="公开",
  149. last_name="技师",
  150. )
  151. await directory_db.save_directory_location(
  152. user_id=350,
  153. longitude=121.48,
  154. latitude=31.24,
  155. source="test",
  156. actor_id=350,
  157. actor_name="公开技师",
  158. )
  159. await app_modules.wbb.control_db.directory_profiles.update_one(
  160. {"user_id": 350},
  161. {
  162. "$set": {
  163. "listed": True,
  164. "presence_status": "online",
  165. "online_until": directory_db.utc_now() + timedelta(hours=1),
  166. }
  167. },
  168. )
  169. assert await directory_db.clear_directory_location(
  170. user_id=350,
  171. actor_id=350,
  172. actor_name="公开技师",
  173. source="test",
  174. reason="不再保存精确坐标",
  175. )
  176. profile = await directory_db.get_directory_profile(350)
  177. assert profile["listed"] is True
  178. assert profile["presence_status"] == "online"
  179. assert profile["online_until"] is not None
  180. async def test_cross_bot_membership_verification_uses_live_status(app_modules):
  181. directory_db = app_modules.load("wbb.utils.dbdirectory")
  182. directory_service = app_modules.load("wbb.services.directory")
  183. user = SimpleNamespace(
  184. id=400,
  185. username="member400",
  186. first_name="测试",
  187. last_name="成员",
  188. is_bot=False,
  189. )
  190. app_modules.app.members[(-100123, 400)] = SimpleNamespace(
  191. status=ChatMemberStatus.ADMINISTRATOR,
  192. user=user,
  193. )
  194. await directory_db.upsert_directory_membership(
  195. bot_id="primary",
  196. chat_id=-100123,
  197. user_id=400,
  198. status="member",
  199. active=True,
  200. )
  201. member = await directory_service.verify_platform_membership(user_id=400)
  202. assert member["allowed"] is True
  203. assert member["chat_id"] == -100123
  204. admin = await directory_service.verify_platform_membership(
  205. user_id=400,
  206. require_admin=True,
  207. )
  208. assert admin["allowed"] is True
  209. app_modules.app.members[(-100123, 400)] = SimpleNamespace(
  210. status=ChatMemberStatus.LEFT,
  211. user=user,
  212. )
  213. denied = await directory_service.verify_platform_membership(user_id=400)
  214. assert denied["allowed"] is False
  215. async def test_directory_admin_api_requires_role_and_manages_global_data(app_modules):
  216. admin_api = app_modules.load("wbb.admin.api")
  217. directory_db = app_modules.load("wbb.utils.dbdirectory")
  218. app_modules.wbb.BOT_PERMISSIONS = {"teacher_directory.manage"}
  219. application = admin_api.build_admin_application()
  220. await application["admin_api"].initialize()
  221. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  222. await client.start_server()
  223. try:
  224. login = await client.post(
  225. "/api/admin/v1/auth/login",
  226. json={"username": "admin", "password": "qwe0.123456"},
  227. )
  228. login_data = (await login.json())["data"]
  229. changed = await client.put(
  230. "/api/admin/v1/auth/password",
  231. headers={"X-CSRF-Token": login_data["csrf_token"]},
  232. json={
  233. "current_password": "qwe0.123456",
  234. "new_password": "changed-pass-123",
  235. },
  236. )
  237. csrf = (await changed.json())["data"]["csrf_token"]
  238. headers = {"X-CSRF-Token": csrf}
  239. await directory_db.upsert_directory_identity(
  240. user_id=500,
  241. username="student500",
  242. first_name="周",
  243. last_name="同学",
  244. )
  245. updated = await client.put(
  246. "/api/admin/v1/directory/locations/500",
  247. headers=headers,
  248. json={
  249. "longitude": 113.2644,
  250. "latitude": 23.1291,
  251. "reason": "修正测试位置",
  252. "confirm": True,
  253. },
  254. )
  255. assert updated.status == 200
  256. payload = (await updated.json())["data"]
  257. assert payload["longitude"] == pytest.approx(113.2644)
  258. assert payload["source"] == "web"
  259. assert "region" not in payload
  260. assert "geocode_status" not in payload
  261. listed = await client.get("/api/admin/v1/directory/locations")
  262. assert listed.status == 200
  263. item = (await listed.json())["data"]["items"][0]
  264. assert item["latitude"] == pytest.approx(23.1291)
  265. assert "point" not in item
  266. assert "coordinate_key" not in item
  267. assert "region" not in item
  268. assert "geocode_consent" not in item
  269. settings = await client.get("/api/admin/v1/directory/settings")
  270. assert settings.status == 200
  271. settings_data = (await settings.json())["data"]
  272. assert settings_data["nearby_radius_km"] == 20
  273. assert "nominatim_enabled" not in settings_data
  274. unconfirmed = await client.delete(
  275. "/api/admin/v1/directory/locations/500",
  276. headers=headers,
  277. json={"reason": "测试"},
  278. )
  279. assert unconfirmed.status == 409
  280. deleted = await client.delete(
  281. "/api/admin/v1/directory/locations/500",
  282. headers=headers,
  283. json={"reason": "删除测试位置", "confirm": True},
  284. )
  285. assert deleted.status == 200
  286. assert (await deleted.json())["data"]["deleted"] is True
  287. app_modules.wbb.BOT_PERMISSIONS = set()
  288. denied = await client.get("/api/admin/v1/directory/settings")
  289. assert denied.status == 403
  290. assert (await denied.json())["error"]["details"]["required_permission"] == (
  291. "teacher_directory.manage"
  292. )
  293. finally:
  294. await client.close()
  295. async def test_role_specific_bottom_menus_are_chinese(app_modules):
  296. module = app_modules.load("wbb.modules.teacher_directory")
  297. directory_service = app_modules.load("wbb.services.directory")
  298. try:
  299. student = module._directory_keyboard(
  300. {"application_status": "none"},
  301. is_admin=False,
  302. )
  303. student_labels = [
  304. button.text for row in student.inline_keyboard for button in row
  305. ]
  306. assert "浏览技师" in student_labels
  307. assert "Telegram 定位" not in student_labels
  308. assert "手动选择位置" not in student_labels
  309. assert "清除位置" not in student_labels
  310. assert "申请成为技师" in student_labels
  311. assert "我的状态" not in student_labels
  312. assert "上线" not in student_labels
  313. assert "下线" not in student_labels
  314. assert "技师审批" not in student_labels
  315. teacher_admin = module._directory_keyboard(
  316. {
  317. "application_status": "approved",
  318. "listed": True,
  319. "online": True,
  320. },
  321. is_admin=True,
  322. )
  323. teacher_labels = [
  324. button.text for row in teacher_admin.inline_keyboard for button in row
  325. ]
  326. assert "下榜" in teacher_labels
  327. assert "🔴 下线" in teacher_labels
  328. assert "技师审批" in teacher_labels
  329. group_student = module._group_directory_keyboard(
  330. {"application_status": "none"},
  331. chat_id=-100123,
  332. is_admin=False,
  333. )
  334. group_student_labels = [
  335. button.text for row in group_student.inline_keyboard for button in row
  336. ]
  337. assert "浏览技师" in group_student_labels
  338. assert "申请成为技师" in group_student_labels
  339. assert "我的状态" not in group_student_labels
  340. teacher_rows = module._teacher_result_markup(
  341. [
  342. {
  343. "user_id": index,
  344. "username": f"teacher{index}",
  345. "display_name": f"技师 {index}",
  346. "online": index % 2 == 0,
  347. }
  348. for index in range(1, 5)
  349. ],
  350. radius_key="all",
  351. page=1,
  352. total=4,
  353. ).inline_keyboard
  354. assert [len(row) for row in teacher_rows[:2]] == [3, 1]
  355. assert teacher_rows[0][0].text.startswith("🔴")
  356. assert teacher_rows[0][1].text.startswith("🟢")
  357. finally:
  358. await directory_service.stop_presence_sweeper()
  359. async def test_internal_membership_rpc_requires_loopback_token(app_modules):
  360. admin_api = app_modules.load("wbb.admin.api")
  361. app_modules.wbb.INTERNAL_TOKEN = "internal-test-token"
  362. application = admin_api.build_admin_application()
  363. await application["admin_api"].initialize()
  364. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  365. await client.start_server()
  366. user = SimpleNamespace(
  367. id=600,
  368. username="admin600",
  369. first_name="目录",
  370. last_name="管理员",
  371. is_bot=False,
  372. )
  373. app_modules.app.members[(-100600, 600)] = SimpleNamespace(
  374. status=ChatMemberStatus.ADMINISTRATOR,
  375. user=user,
  376. )
  377. try:
  378. denied = await client.post(
  379. "/api/internal/v1/directory/verify-local",
  380. json={
  381. "user_id": "600",
  382. "chat_ids": ["-100600"],
  383. "require_admin": True,
  384. },
  385. )
  386. assert denied.status == 403
  387. allowed = await client.post(
  388. "/api/internal/v1/directory/verify-local",
  389. headers={"Authorization": "Bearer internal-test-token"},
  390. json={
  391. "user_id": "600",
  392. "chat_ids": ["-100600"],
  393. "require_admin": True,
  394. },
  395. )
  396. assert allowed.status == 200
  397. data = (await allowed.json())["data"]
  398. assert data["allowed"] is True
  399. assert data["chat_id"] == "-100600"
  400. finally:
  401. await client.close()