test_video_query_api.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. from datetime import datetime, timedelta, timezone
  2. import pytest
  3. from api.chui_zhi.videos import (
  4. BusinessValidationError,
  5. FilterCondition,
  6. VideoQueryParams,
  7. build_query,
  8. compile_filter_condition,
  9. )
  10. from config import settings
  11. from core.utils.log.aliyun_log import AliyunLogger
  12. from core.utils.log.logger_manager import LoggerManager
  13. def test_api_aliyun_logger_uses_independent_destination():
  14. logger = LoggerManager.get_aliyun_logger(
  15. platform='chui_zhi',
  16. mode='api',
  17. env='prod',
  18. project='crawler-log-prod',
  19. logstore='crawler-api-access',
  20. endpoint='cn-hangzhou.log.aliyuncs.com',
  21. )
  22. assert isinstance(logger, AliyunLogger)
  23. assert logger._resolve_destination() == (
  24. 'crawler-log-prod',
  25. 'crawler-api-access',
  26. 'cn-hangzhou.log.aliyuncs.com',
  27. )
  28. def test_default_crawler_aliyun_destination_is_unchanged():
  29. logger = AliyunLogger(platform='dou_yin', mode='crawler', env='prod')
  30. assert logger._resolve_destination() == (
  31. 'crawler-log-prod',
  32. 'crawler-fetch',
  33. 'cn-hangzhou.log.aliyuncs.com',
  34. )
  35. def test_api_sls_log_is_flat_and_omits_empty_fields():
  36. logger = AliyunLogger(platform='chui_zhi', mode='api')
  37. contents = dict(logger._build_log_contents({
  38. 'code': '2000',
  39. 'message': 'API请求成功',
  40. 'trace_id': 'request-1',
  41. 'account': None,
  42. 'data': {
  43. 'request_id': 'request-1',
  44. 'path': '/api/v1/crawler/videos/query',
  45. 'method': 'POST',
  46. 'request_params': {'keywords': ['早'], 'filters': []},
  47. 'platforms': ['xiaoniangao', 'xiaoniangaotuijianliu'],
  48. 'status_code': 200,
  49. 'success': True,
  50. 'request_duration_ms': 4096.24,
  51. 'failure_stage': '',
  52. 'error_type': '',
  53. 'query_result_count': 140,
  54. 'query_duration_ms': 4091.31,
  55. 'query_success': True,
  56. },
  57. }))
  58. assert contents['TraceId'] == 'request-1'
  59. assert contents['success'] == 'true'
  60. assert contents['query_success'] == 'true'
  61. assert contents['request_params'] == '{"keywords":["早"],"filters":[]}'
  62. assert contents['platforms'] == '["xiaoniangao","xiaoniangaotuijianliu"]'
  63. assert 'data' not in contents
  64. assert 'account' not in contents
  65. assert 'timestamp' not in contents
  66. assert 'code' not in contents
  67. assert 'failure_stage' not in contents
  68. assert 'error_type' not in contents
  69. def test_existing_crawler_sls_format_is_unchanged():
  70. logger = AliyunLogger(platform='dou_yin', mode='crawler')
  71. contents = dict(logger._build_log_contents({
  72. 'code': '2000',
  73. 'message': '抓取成功',
  74. 'trace_id': 'crawler-trace-1',
  75. 'account': None,
  76. 'data': {
  77. 'url': 'https://example.com/video/1',
  78. 'status_code': 200,
  79. 'success': True,
  80. },
  81. }))
  82. assert contents['TraceId'] == 'crawler-trace-1'
  83. assert contents['code'] == '2000'
  84. assert contents['data'] == (
  85. '{"url": "https://example.com/video/1", "status_code": 200, "success": true}'
  86. )
  87. assert contents['account'] == 'None'
  88. assert contents['success'] == 'True'
  89. assert 'timestamp' in contents
  90. # 旧格式不会把普通data中的url提升为顶层字段。
  91. assert 'url' not in contents
  92. def test_build_query_contains_parameterized_filters():
  93. request = VideoQueryParams(
  94. platforms=['xiaoniangao', 'xiaoniangaotuijianliu'],
  95. start_time=datetime(2026, 8, 4),
  96. end_time=datetime(2026, 8, 5),
  97. keywords=['养生'],
  98. filter_match_mode=2,
  99. filters=[
  100. FilterCondition(field='like_cnt', operator='>=', value=100),
  101. FilterCondition(field='duration', operator='between', value=[60, 300]),
  102. ],
  103. limit=20,
  104. )
  105. sql, params = build_query(request)
  106. assert '`platform` IN (%s, %s)' in sql
  107. assert '`video_title` LIKE %s' in sql
  108. assert '`like_cnt` >= %s' in sql
  109. assert '`duration` BETWEEN %s AND %s' in sql
  110. assert 'SELECT MAX(`source`.`id`) AS `selected_id`' in sql
  111. assert "CASE WHEN `source`.`out_video_id` = '' THEN `source`.`id` ELSE 0 END" in sql
  112. assert '`dedup`.`selected_id` = `cv`.`id`' in sql
  113. assert 'ORDER BY `cv`.`id` DESC' in sql
  114. assert 'OFFSET' not in sql
  115. assert params[-1] == 21
  116. assert '%养生%' in params
  117. def test_deduplication_groups_ids_before_cursor_pagination():
  118. request = VideoQueryParams(
  119. platforms=['xiaoniangao'],
  120. start_time=datetime(2026, 8, 4),
  121. end_time=datetime(2026, 8, 5),
  122. filters=[FilterCondition(field='like_cnt', operator='>', value=3)],
  123. cursor={'id': 100},
  124. limit=50,
  125. )
  126. sql, params = build_query(request)
  127. # 筛选条件只出现一次;HAVING作用于MAX(id),防止旧重复记录跨页再次出现。
  128. assert sql.count('`source`.`like_cnt` > %s') == 1
  129. assert 'HAVING MAX(`source`.`id`) < %s' in sql
  130. assert params[-2:] == [100, 51]
  131. def test_query_limit_above_server_max_is_rejected():
  132. with pytest.raises(ValueError):
  133. VideoQueryParams(
  134. start_time=datetime(2026, 8, 4),
  135. end_time=datetime(2026, 8, 5),
  136. limit=settings.API_MAX_LIMIT + 1,
  137. )
  138. def test_id_cursor_builds_stable_group_pagination_and_fetches_one_extra_row():
  139. request = VideoQueryParams(
  140. start_time=datetime(2026, 8, 4),
  141. end_time=datetime(2026, 8, 5),
  142. limit=100,
  143. cursor={'id': 123},
  144. )
  145. sql, params = build_query(request)
  146. assert 'HAVING MAX(`source`.`id`) < %s' in sql
  147. assert params[-2:] == [123, 101]
  148. assert 'OFFSET' not in sql
  149. def test_millisecond_timestamps_are_converted_to_china_time():
  150. china_timezone = timezone(timedelta(hours=8))
  151. start_time = datetime(2026, 8, 4, tzinfo=china_timezone)
  152. end_time = datetime(2026, 8, 5, tzinfo=china_timezone)
  153. request = VideoQueryParams(
  154. start_time=int(start_time.timestamp() * 1000),
  155. end_time=int(end_time.timestamp() * 1000),
  156. )
  157. assert request.start_time == datetime(2026, 8, 4)
  158. assert request.end_time == datetime(2026, 8, 5)
  159. def test_missing_time_defaults_to_latest_three_days():
  160. before = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
  161. request = VideoQueryParams()
  162. after = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
  163. assert before <= request.end_time <= after
  164. assert request.end_time - request.start_time == timedelta(days=3)
  165. sql, sql_params = build_query(request)
  166. assert '`create_time` >= %s' in sql
  167. assert '`create_time` < %s' in sql
  168. assert request.start_time in sql_params
  169. assert request.end_time in sql_params
  170. def test_rejects_unsupported_filter_field():
  171. with pytest.raises(ValueError):
  172. FilterCondition(field='unknown_field', operator='>', value=1)
  173. def test_rejects_unsupported_platform_and_unknown_filter_parameter():
  174. with pytest.raises(ValueError, match='不支持的平台'):
  175. VideoQueryParams(platforms=['douyin'])
  176. with pytest.raises(ValueError):
  177. FilterCondition(field='like_cnt', operator='>', value=1, unknown='value')
  178. @pytest.mark.parametrize(
  179. 'field',
  180. [
  181. 'like_cnt',
  182. 'collection_cnt',
  183. 'comment_cnt',
  184. 'share_cnt',
  185. 'play_cnt',
  186. 'duration',
  187. ],
  188. )
  189. def test_supported_numeric_filter_mapping(field):
  190. condition = FilterCondition(field=field, operator='>', value=10)
  191. sql, params = compile_filter_condition(condition)
  192. assert f'`{field}` > %s' == sql
  193. assert params == [10]
  194. def test_empty_filters_are_not_added_to_query():
  195. request = VideoQueryParams(filters=[])
  196. sql, _ = build_query(request)
  197. where_sql = sql.split('WHERE', 1)[1]
  198. assert '`like_cnt` >' not in where_sql
  199. def test_publish_time_filter_mapping():
  200. condition = FilterCondition(
  201. field='publish_time',
  202. operator='>=',
  203. value='2026-08-01 00:00:00',
  204. )
  205. sql, params = compile_filter_condition(condition)
  206. assert sql == '`publish_time` >= %s'
  207. assert params == ['2026-08-01 00:00:00']
  208. def test_filter_range_and_set_are_parameterized():
  209. range_condition = FilterCondition(field='duration', operator='between', value=[60, 300])
  210. set_condition = FilterCondition(field='play_cnt', operator='in', value=[10, 20])
  211. range_sql, range_params = compile_filter_condition(range_condition)
  212. set_sql, set_params = compile_filter_condition(set_condition)
  213. assert range_sql == '`duration` BETWEEN %s AND %s'
  214. assert range_params == [60, 300]
  215. assert set_sql == '`play_cnt` IN (%s, %s)'
  216. assert set_params == [10, 20]
  217. def test_rejects_invalid_filter_range_and_non_finite_number():
  218. with pytest.raises(BusinessValidationError, match='起始值不能大于结束值'):
  219. compile_filter_condition(
  220. FilterCondition(field='duration', operator='between', value=[300, 60])
  221. )
  222. with pytest.raises(BusinessValidationError, match='有限数字'):
  223. compile_filter_condition(
  224. FilterCondition(field='like_cnt', operator='>', value='NaN')
  225. )
  226. def test_rejects_raw_sql_in_filter_value():
  227. with pytest.raises(BusinessValidationError, match='必须是数字'):
  228. condition = FilterCondition(field='like_cnt', operator='>', value='0 OR 1=1')
  229. compile_filter_condition(condition)