test_video_query_api.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import asyncio
  2. from datetime import datetime, timedelta, timezone
  3. import pytest
  4. from aiohttp.test_utils import TestClient, TestServer
  5. from api.app import ALIYUN_LOGGER_KEY, API_PATH, LOGGER_KEY, create_app
  6. from api.chui_zhi.videos import (
  7. BusinessValidationError,
  8. MYSQL_KEY,
  9. QUERY_SEMAPHORE_KEY,
  10. FilterCondition,
  11. VideoQueryRequest,
  12. build_query,
  13. compile_filter_condition,
  14. )
  15. from config import settings
  16. def test_build_query_contains_parameterized_filters():
  17. request = VideoQueryRequest(
  18. platforms=['xiaoniangao', 'xiaoniangaotuijianliu'],
  19. start_time=datetime(2026, 8, 4),
  20. end_time=datetime(2026, 8, 5),
  21. keywords=['养生'],
  22. filter_match_mode=2,
  23. filters=[
  24. FilterCondition(field='like_cnt', operator='>=', value=100),
  25. FilterCondition(field='duration', operator='between', value=[60, 300]),
  26. ],
  27. limit=20,
  28. )
  29. sql, params = build_query(request)
  30. assert '`platform` IN (%s, %s)' in sql
  31. assert '`video_title` LIKE %s' in sql
  32. assert '`like_cnt` >= %s' in sql
  33. assert '`duration` BETWEEN %s AND %s' in sql
  34. assert 'SELECT MAX(`source`.`id`) AS `selected_id`' in sql
  35. assert "CASE WHEN `source`.`out_video_id` = '' THEN `source`.`id` ELSE 0 END" in sql
  36. assert '`dedup`.`selected_id` = `cv`.`id`' in sql
  37. assert 'ORDER BY `cv`.`id` DESC' in sql
  38. assert 'OFFSET' not in sql
  39. assert params[-1] == 21
  40. assert '%养生%' in params
  41. def test_deduplication_groups_ids_before_cursor_pagination():
  42. request = VideoQueryRequest(
  43. platforms=['xiaoniangao'],
  44. start_time=datetime(2026, 8, 4),
  45. end_time=datetime(2026, 8, 5),
  46. filters=[FilterCondition(field='like_cnt', operator='>', value=3)],
  47. cursor={'id': 100},
  48. limit=50,
  49. )
  50. sql, params = build_query(request)
  51. # 筛选条件只出现一次;HAVING作用于MAX(id),防止旧重复记录跨页再次出现。
  52. assert sql.count('`source`.`like_cnt` > %s') == 1
  53. assert 'HAVING MAX(`source`.`id`) < %s' in sql
  54. assert params[-2:] == [100, 51]
  55. def test_query_limit_above_server_max_is_rejected():
  56. with pytest.raises(ValueError):
  57. VideoQueryRequest(
  58. start_time=datetime(2026, 8, 4),
  59. end_time=datetime(2026, 8, 5),
  60. limit=settings.API_MAX_LIMIT + 1,
  61. )
  62. def test_id_cursor_builds_stable_group_pagination_and_fetches_one_extra_row():
  63. request = VideoQueryRequest(
  64. start_time=datetime(2026, 8, 4),
  65. end_time=datetime(2026, 8, 5),
  66. limit=100,
  67. cursor={'id': 123},
  68. )
  69. sql, params = build_query(request)
  70. assert 'HAVING MAX(`source`.`id`) < %s' in sql
  71. assert params[-2:] == [123, 101]
  72. assert 'OFFSET' not in sql
  73. def test_millisecond_timestamps_are_converted_to_china_time():
  74. china_timezone = timezone(timedelta(hours=8))
  75. start_time = datetime(2026, 8, 4, tzinfo=china_timezone)
  76. end_time = datetime(2026, 8, 5, tzinfo=china_timezone)
  77. request = VideoQueryRequest(
  78. start_time=int(start_time.timestamp() * 1000),
  79. end_time=int(end_time.timestamp() * 1000),
  80. )
  81. assert request.start_time == datetime(2026, 8, 4)
  82. assert request.end_time == datetime(2026, 8, 5)
  83. def test_missing_time_defaults_to_latest_three_days():
  84. before = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
  85. request = VideoQueryRequest()
  86. after = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
  87. assert before <= request.end_time <= after
  88. assert request.end_time - request.start_time == timedelta(days=3)
  89. sql, sql_params = build_query(request)
  90. assert '`create_time` >= %s' in sql
  91. assert '`create_time` < %s' in sql
  92. assert request.start_time in sql_params
  93. assert request.end_time in sql_params
  94. def test_rejects_unsupported_filter_field():
  95. with pytest.raises(ValueError):
  96. FilterCondition(field='unknown_field', operator='>', value=1)
  97. def test_rejects_unsupported_platform_and_unknown_filter_parameter():
  98. with pytest.raises(ValueError, match='不支持的平台'):
  99. VideoQueryRequest(platforms=['douyin'])
  100. with pytest.raises(ValueError):
  101. FilterCondition(field='like_cnt', operator='>', value=1, unknown='value')
  102. @pytest.mark.parametrize(
  103. 'field',
  104. [
  105. 'like_cnt',
  106. 'collection_cnt',
  107. 'comment_cnt',
  108. 'share_cnt',
  109. 'play_cnt',
  110. 'duration',
  111. ],
  112. )
  113. def test_supported_numeric_filter_mapping(field):
  114. condition = FilterCondition(field=field, operator='>', value=10)
  115. sql, params = compile_filter_condition(condition)
  116. assert f'`{field}` > %s' == sql
  117. assert params == [10]
  118. def test_empty_filters_are_not_added_to_query():
  119. request = VideoQueryRequest(filters=[])
  120. sql, _ = build_query(request)
  121. where_sql = sql.split('WHERE', 1)[1]
  122. assert '`like_cnt` >' not in where_sql
  123. def test_publish_time_filter_mapping():
  124. condition = FilterCondition(
  125. field='publish_time',
  126. operator='>=',
  127. value='2026-08-01 00:00:00',
  128. )
  129. sql, params = compile_filter_condition(condition)
  130. assert sql == '`publish_time` >= %s'
  131. assert params == ['2026-08-01 00:00:00']
  132. def test_filter_range_and_set_are_parameterized():
  133. range_condition = FilterCondition(field='duration', operator='between', value=[60, 300])
  134. set_condition = FilterCondition(field='play_cnt', operator='in', value=[10, 20])
  135. range_sql, range_params = compile_filter_condition(range_condition)
  136. set_sql, set_params = compile_filter_condition(set_condition)
  137. assert range_sql == '`duration` BETWEEN %s AND %s'
  138. assert range_params == [60, 300]
  139. assert set_sql == '`play_cnt` IN (%s, %s)'
  140. assert set_params == [10, 20]
  141. def test_rejects_invalid_filter_range_and_non_finite_number():
  142. with pytest.raises(BusinessValidationError, match='起始值不能大于结束值'):
  143. compile_filter_condition(
  144. FilterCondition(field='duration', operator='between', value=[300, 60])
  145. )
  146. with pytest.raises(BusinessValidationError, match='有限数字'):
  147. compile_filter_condition(
  148. FilterCondition(field='like_cnt', operator='>', value='NaN')
  149. )
  150. def test_rejects_raw_sql_in_filter_value():
  151. with pytest.raises(BusinessValidationError, match='必须是数字'):
  152. condition = FilterCondition(field='like_cnt', operator='>', value='0 OR 1=1')
  153. compile_filter_condition(condition)
  154. @pytest.mark.asyncio
  155. async def test_api_requires_token_and_returns_expected_contract(monkeypatch):
  156. local_logs = []
  157. cloud_logs = []
  158. class FakeMySQL:
  159. async def fetch_all(self, sql, params):
  160. return [
  161. {'id': 1, 'platform': params[0], 'create_time': '2026-08-04 12:00:00'},
  162. {'id': 2, 'platform': params[0], 'create_time': '2026-08-04 11:00:00'},
  163. ]
  164. class FakeLogger:
  165. def info(self, message):
  166. local_logs.append(('info', message))
  167. def error(self, message):
  168. local_logs.append(('error', message))
  169. def exception(self, message):
  170. raise AssertionError(message)
  171. class FakeAliyunLogger:
  172. def logging_batch(self, events):
  173. cloud_logs.extend(events)
  174. monkeypatch.setattr(settings, 'CHUI_ZHI_API_TOKEN', 'test-token')
  175. app = create_app()
  176. app.cleanup_ctx.clear()
  177. app[MYSQL_KEY] = FakeMySQL()
  178. app[QUERY_SEMAPHORE_KEY] = asyncio.Semaphore(10)
  179. app[LOGGER_KEY] = FakeLogger()
  180. app[ALIYUN_LOGGER_KEY] = FakeAliyunLogger()
  181. client = TestClient(TestServer(app))
  182. await client.start_server()
  183. payload = {
  184. 'platforms': ['xiaoniangao'],
  185. 'start_time': 1785772800000,
  186. 'end_time': 1785859200000,
  187. 'filters': [
  188. {'field': 'like_cnt', 'operator': '>', 'value': 3},
  189. ],
  190. 'limit': 1,
  191. }
  192. try:
  193. unauthorized = await client.post(API_PATH, json=payload)
  194. assert unauthorized.status == 401
  195. authorized = await client.post(
  196. API_PATH,
  197. json=payload,
  198. headers={'X-API-Key': 'test-token', 'X-Request-ID': 'scheduler-request-1'},
  199. )
  200. body = await authorized.json()
  201. assert authorized.status == 200
  202. assert body['code'] == 0
  203. assert body['data']['count'] == 1
  204. assert body['data']['has_more'] is True
  205. assert body['data']['next_cursor'] == {'id': 1}
  206. assert body['data']['start_time'] == payload['start_time']
  207. assert body['data']['end_time'] == payload['end_time']
  208. assert authorized.headers['X-Request-ID'] == 'scheduler-request-1'
  209. invalid_payload = {
  210. **payload,
  211. 'filters': [{'field': 'like_cnt', 'operator': '>', 'value': 'invalid'}],
  212. }
  213. failed = await client.post(
  214. API_PATH,
  215. json=invalid_payload,
  216. headers={'X-API-Key': 'test-token'},
  217. )
  218. assert failed.status == 422
  219. assert len(cloud_logs) == 3
  220. assert all('event_type' not in log['data'] for log in cloud_logs)
  221. assert all('request_count' not in log['data'] for log in cloud_logs)
  222. assert all(log['data']['path'] == API_PATH for log in cloud_logs)
  223. assert all(log['data']['request_duration_ms'] >= 0 for log in cloud_logs)
  224. assert cloud_logs[0]['data']['status_code'] == 401
  225. assert cloud_logs[0]['data']['request_params']['body'] is None
  226. assert cloud_logs[0]['data']['message'] == 'unauthorized'
  227. assert cloud_logs[0]['data']['failure_stage'] == 'authentication'
  228. assert cloud_logs[0]['data']['error_type'] == 'AuthenticationError'
  229. assert cloud_logs[1]['data']['status_code'] == 200
  230. assert cloud_logs[1]['trace_id'] == 'scheduler-request-1'
  231. assert cloud_logs[1]['data']['request_id'] == 'scheduler-request-1'
  232. assert cloud_logs[1]['data']['request_params']['body'] == payload
  233. assert 'response' not in cloud_logs[1]['data']
  234. assert cloud_logs[1]['data']['success'] is True
  235. assert cloud_logs[1]['data']['query_result_count'] == 2
  236. assert cloud_logs[1]['data']['query_duration_ms'] >= 0
  237. assert cloud_logs[1]['data']['query_success'] is True
  238. assert cloud_logs[2]['data']['status_code'] == 422
  239. assert cloud_logs[2]['data']['success'] is False
  240. assert cloud_logs[2]['data']['query_result_count'] is None
  241. assert cloud_logs[2]['data']['query_duration_ms'] is None
  242. assert cloud_logs[2]['data']['failure_stage'] == 'business_validation'
  243. assert cloud_logs[2]['data']['error_type'] == 'BusinessValidationError'
  244. assert 'Traceback (most recent call last)' in cloud_logs[2]['data']['message']
  245. assert '必须是数字' in cloud_logs[2]['data']['message']
  246. assert cloud_logs[2]['message'] == cloud_logs[2]['data']['message']
  247. assert any(level == 'error' and 'status=401' in message for level, message in local_logs)
  248. assert any(level == 'info' and 'status=200' in message for level, message in local_logs)
  249. assert all('duration_ms=' in message for _, message in local_logs)
  250. assert any(level == 'error' and '必须是数字' in message for level, message in local_logs)
  251. assert all('response=' not in message for _, message in local_logs)
  252. finally:
  253. await client.close()
  254. @pytest.mark.asyncio
  255. async def test_cloud_log_failure_does_not_change_api_response(monkeypatch):
  256. exception_logs = []
  257. class FakeMySQL:
  258. async def fetch_all(self, sql, params):
  259. return []
  260. class FakeLogger:
  261. def info(self, message):
  262. pass
  263. def error(self, message):
  264. pass
  265. def exception(self, message):
  266. exception_logs.append(message)
  267. class BrokenAliyunLogger:
  268. def logging_batch(self, events):
  269. raise RuntimeError('SLS unavailable')
  270. monkeypatch.setattr(settings, 'CHUI_ZHI_API_TOKEN', 'test-token')
  271. app = create_app()
  272. app.cleanup_ctx.clear()
  273. app[MYSQL_KEY] = FakeMySQL()
  274. app[QUERY_SEMAPHORE_KEY] = asyncio.Semaphore(10)
  275. app[LOGGER_KEY] = FakeLogger()
  276. app[ALIYUN_LOGGER_KEY] = BrokenAliyunLogger()
  277. client = TestClient(TestServer(app))
  278. await client.start_server()
  279. try:
  280. response = await client.post(
  281. API_PATH,
  282. json={},
  283. headers={'X-API-Key': 'test-token'},
  284. )
  285. assert response.status == 200
  286. assert (await response.json())['code'] == 0
  287. assert any('阿里云API日志上报失败' in message for message in exception_logs)
  288. finally:
  289. await client.close()
  290. @pytest.mark.asyncio
  291. async def test_unsupported_parameters_return_specific_error_message(monkeypatch):
  292. class FakeMySQL:
  293. async def fetch_all(self, sql, params):
  294. raise AssertionError('参数校验失败时不应查询数据库')
  295. class FakeLogger:
  296. def info(self, message):
  297. pass
  298. def error(self, message):
  299. pass
  300. def exception(self, message):
  301. pass
  302. class FakeAliyunLogger:
  303. def logging_batch(self, events):
  304. pass
  305. monkeypatch.setattr(settings, 'CHUI_ZHI_API_TOKEN', 'test-token')
  306. app = create_app()
  307. app.cleanup_ctx.clear()
  308. app[MYSQL_KEY] = FakeMySQL()
  309. app[QUERY_SEMAPHORE_KEY] = asyncio.Semaphore(10)
  310. app[LOGGER_KEY] = FakeLogger()
  311. app[ALIYUN_LOGGER_KEY] = FakeAliyunLogger()
  312. client = TestClient(TestServer(app))
  313. await client.start_server()
  314. try:
  315. unknown_parameter = await client.post(
  316. API_PATH,
  317. json={'unknown_parameter': 1},
  318. headers={'X-API-Key': 'test-token'},
  319. )
  320. unknown_body = await unknown_parameter.json()
  321. assert unknown_parameter.status == 400
  322. assert '不支持的参数: unknown_parameter' in unknown_body['msg']
  323. unsupported_filter = await client.post(
  324. API_PATH,
  325. json={'filters': [{'field': 'unknown_field', 'operator': '>', 'value': 1}]},
  326. headers={'X-API-Key': 'test-token'},
  327. )
  328. filter_body = await unsupported_filter.json()
  329. assert unsupported_filter.status == 400
  330. assert 'filters.0.field不支持值 unknown_field' in filter_body['msg']
  331. unknown_filter_key = await client.post(
  332. API_PATH,
  333. json={
  334. 'filters': [
  335. {
  336. 'field': 'like_cnt',
  337. 'operator': '>',
  338. 'value': 1,
  339. 'column': 'like_cnt',
  340. }
  341. ]
  342. },
  343. headers={'X-API-Key': 'test-token'},
  344. )
  345. unknown_key_body = await unknown_filter_key.json()
  346. assert unknown_filter_key.status == 400
  347. assert '不支持的参数: filters.0.column' in unknown_key_body['msg']
  348. missing_filter_key = await client.post(
  349. API_PATH,
  350. json={'filters': [{'field': 'like_cnt', 'value': 1}]},
  351. headers={'X-API-Key': 'test-token'},
  352. )
  353. missing_key_body = await missing_filter_key.json()
  354. assert missing_filter_key.status == 400
  355. assert 'filters.0.operator不能为空' in missing_key_body['msg']
  356. finally:
  357. await client.close()
  358. def test_api_routes_are_registered():
  359. app = create_app()
  360. route_names = {route.name for route in app.router.routes()}
  361. assert 'chui_zhi_videos' in route_names
  362. assert {'health', 'ready'} <= route_names
  363. @pytest.mark.asyncio
  364. async def test_health_and_readiness_do_not_require_business_token():
  365. cloud_logs = []
  366. class FakeMySQL:
  367. async def fetch_one(self, sql):
  368. assert sql == 'SELECT 1 AS ok'
  369. return {'ok': 1}
  370. class FakeLogger:
  371. def info(self, message):
  372. pass
  373. def error(self, message):
  374. pass
  375. def exception(self, message):
  376. raise AssertionError(message)
  377. class FakeAliyunLogger:
  378. def logging_batch(self, events):
  379. cloud_logs.extend(events)
  380. app = create_app()
  381. app.cleanup_ctx.clear()
  382. app[MYSQL_KEY] = FakeMySQL()
  383. app[LOGGER_KEY] = FakeLogger()
  384. app[ALIYUN_LOGGER_KEY] = FakeAliyunLogger()
  385. client = TestClient(TestServer(app))
  386. await client.start_server()
  387. try:
  388. health_response = await client.get('/health')
  389. ready_response = await client.get('/ready')
  390. assert health_response.status == 200
  391. assert ready_response.status == 200
  392. assert (await health_response.json())['data']['status'] == 'ok'
  393. assert (await ready_response.json())['data']['status'] == 'ready'
  394. assert len(cloud_logs) == 2
  395. finally:
  396. await client.close()