test_video_query_api.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  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. keywords=['养生'],
  96. filter_match_mode=2,
  97. filters=[
  98. FilterCondition(
  99. field='create_time',
  100. operator='between',
  101. value=['2026-08-04 00:00:00', '2026-08-05 00:00:00'],
  102. ),
  103. FilterCondition(field='like_cnt', operator='>=', value=100),
  104. FilterCondition(field='duration', operator='between', value=[60, 300]),
  105. ],
  106. limit=20,
  107. )
  108. sql, params = build_query(request)
  109. assert '`platform` IN (%s, %s)' in sql
  110. assert '`video_title` LIKE %s' in sql
  111. assert '`like_cnt` >= %s' in sql
  112. assert '`duration` BETWEEN %s AND %s' in sql
  113. assert 'SELECT MAX(`source`.`id`) AS `selected_id`' in sql
  114. assert "CASE WHEN `source`.`out_video_id` = '' THEN `source`.`id` ELSE 0 END" in sql
  115. assert '`dedup`.`selected_id` = `cv`.`id`' in sql
  116. assert 'ORDER BY `cv`.`id` DESC' in sql
  117. assert 'OFFSET' not in sql
  118. assert params[-1] == 21
  119. assert '%养生%' in params
  120. def test_deduplication_groups_ids_before_cursor_pagination():
  121. request = VideoQueryParams(
  122. platforms=['xiaoniangao'],
  123. filters=[
  124. FilterCondition(field='create_time', operator='>=', value='2026-08-04 00:00:00'),
  125. FilterCondition(field='like_cnt', operator='>', value=3),
  126. ],
  127. cursor={'id': 100},
  128. limit=50,
  129. )
  130. sql, params = build_query(request)
  131. # 筛选条件只出现一次;HAVING作用于MAX(id),防止旧重复记录跨页再次出现。
  132. assert sql.count('`source`.`like_cnt` > %s') == 1
  133. assert 'HAVING MAX(`source`.`id`) < %s' in sql
  134. assert params[-2:] == [100, 51]
  135. def test_query_limit_above_server_max_is_rejected():
  136. with pytest.raises(ValueError):
  137. VideoQueryParams(
  138. limit=settings.API_MAX_LIMIT + 1,
  139. )
  140. def test_id_cursor_builds_stable_group_pagination_and_fetches_one_extra_row():
  141. request = VideoQueryParams(
  142. filters=[FilterCondition(field='create_time', operator='>=', value='2026-08-04 00:00:00')],
  143. limit=100,
  144. cursor={'id': 123},
  145. )
  146. sql, params = build_query(request)
  147. assert 'HAVING MAX(`source`.`id`) < %s' in sql
  148. assert params[-2:] == [123, 101]
  149. assert 'OFFSET' not in sql
  150. def test_millisecond_timestamps_are_converted_to_china_time():
  151. china_timezone = timezone(timedelta(hours=8))
  152. query_time = datetime(2026, 8, 4, tzinfo=china_timezone)
  153. condition = FilterCondition(
  154. field='create_time',
  155. operator='>=',
  156. value=int(query_time.timestamp() * 1000),
  157. )
  158. sql, params = compile_filter_condition(condition)
  159. assert sql == '`create_time` >= %s'
  160. assert params == ['2026-08-04 00:00:00']
  161. def test_missing_time_defaults_to_latest_three_days():
  162. before = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
  163. request = VideoQueryParams()
  164. after = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
  165. assert before <= request._default_query_end <= after
  166. sql, sql_params = build_query(request)
  167. assert '`create_time` >= %s' in sql
  168. assert '`create_time` < %s' in sql
  169. assert request._default_query_end - timedelta(days=3) in sql_params
  170. assert request._default_query_end in sql_params
  171. def test_explicit_create_time_filter_disables_default_time_range():
  172. request = VideoQueryParams(filters=[
  173. FilterCondition(field='create_time', operator='>', value='2026-08-04 00:00:00'),
  174. ])
  175. sql, params = build_query(request)
  176. assert request._default_query_end is None
  177. assert '`source`.`create_time` > %s' in sql
  178. assert '`source`.`create_time` < %s' not in sql
  179. assert '2026-08-04 00:00:00' in params
  180. def test_same_field_bounds_are_and_grouped_in_or_mode():
  181. request = VideoQueryParams(
  182. filter_match_mode=1,
  183. filters=[
  184. FilterCondition(field='play_cnt', operator='>=', value=100),
  185. FilterCondition(field='play_cnt', operator='<=', value=1000),
  186. FilterCondition(field='like_cnt', operator='>', value=3),
  187. ],
  188. )
  189. sql, params = build_query(request)
  190. assert '(`source`.`play_cnt` >= %s AND `source`.`play_cnt` <= %s)' in sql
  191. assert ' OR (`source`.`like_cnt` > %s)' in sql
  192. assert 100 in params
  193. assert 1000 in params
  194. def test_rejects_unsupported_filter_field():
  195. with pytest.raises(ValueError):
  196. FilterCondition(field='unknown_field', operator='>', value=1)
  197. def test_rejects_unsupported_platform_and_unknown_filter_parameter():
  198. with pytest.raises(ValueError, match='不支持的平台'):
  199. VideoQueryParams(platforms=['douyin'])
  200. with pytest.raises(ValueError):
  201. FilterCondition(field='like_cnt', operator='>', value=1, unknown='value')
  202. @pytest.mark.parametrize(
  203. 'field',
  204. [
  205. 'like_cnt',
  206. 'collection_cnt',
  207. 'comment_cnt',
  208. 'share_cnt',
  209. 'play_cnt',
  210. 'duration',
  211. ],
  212. )
  213. def test_supported_numeric_filter_mapping(field):
  214. condition = FilterCondition(field=field, operator='>', value=10)
  215. sql, params = compile_filter_condition(condition)
  216. assert f'`{field}` > %s' == sql
  217. assert params == [10]
  218. def test_empty_filters_are_not_added_to_query():
  219. request = VideoQueryParams(filters=[])
  220. sql, _ = build_query(request)
  221. where_sql = sql.split('WHERE', 1)[1]
  222. assert '`like_cnt` >' not in where_sql
  223. def test_publish_time_filter_mapping():
  224. condition = FilterCondition(
  225. field='publish_time',
  226. operator='>=',
  227. value='2026-08-01 00:00:00',
  228. )
  229. sql, params = compile_filter_condition(condition)
  230. assert sql == '`publish_time` >= %s'
  231. assert params == ['2026-08-01 00:00:00']
  232. def test_filter_range_and_set_are_parameterized():
  233. range_condition = FilterCondition(field='duration', operator='between', value=[60, 300])
  234. set_condition = FilterCondition(field='play_cnt', operator='in', value=[10, 20])
  235. range_sql, range_params = compile_filter_condition(range_condition)
  236. set_sql, set_params = compile_filter_condition(set_condition)
  237. assert range_sql == '`duration` BETWEEN %s AND %s'
  238. assert range_params == [60, 300]
  239. assert set_sql == '`play_cnt` IN (%s, %s)'
  240. assert set_params == [10, 20]
  241. def test_rejects_invalid_filter_range_and_non_finite_number():
  242. with pytest.raises(BusinessValidationError, match='起始值不能大于结束值'):
  243. compile_filter_condition(
  244. FilterCondition(field='duration', operator='between', value=[300, 60])
  245. )
  246. with pytest.raises(BusinessValidationError, match='有限数字'):
  247. compile_filter_condition(
  248. FilterCondition(field='like_cnt', operator='>', value='NaN')
  249. )
  250. def test_rejects_raw_sql_in_filter_value():
  251. with pytest.raises(BusinessValidationError, match='必须是数字'):
  252. condition = FilterCondition(field='like_cnt', operator='>', value='0 OR 1=1')
  253. compile_filter_condition(condition)