test_fastapi_video_query_api.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import asyncio
  2. from fastapi.testclient import TestClient
  3. from api.base import BaseApi
  4. from api.chui_zhi.videos import VideoQueryApi
  5. from api.fastapi_app import API_PATH, create_app, print_api_routes
  6. class FakeLogger:
  7. def __init__(self):
  8. self.records = []
  9. def info(self, message):
  10. self.records.append(('info', message))
  11. def error(self, message):
  12. self.records.append(('error', message))
  13. def exception(self, message):
  14. self.records.append(('exception', message))
  15. class FakeAliyunLogger:
  16. def __init__(self):
  17. self.events = []
  18. def logging_batch(self, events):
  19. self.events.extend(events)
  20. def build_test_app(mysql):
  21. app = create_app(manage_resources=False)
  22. app.state.mysql = mysql
  23. app.state.query_semaphore = asyncio.Semaphore(10)
  24. app.state.logger = FakeLogger()
  25. app.state.aliyun_logger = FakeAliyunLogger()
  26. return app
  27. def test_fastapi_query_keeps_existing_contract_and_unified_logging():
  28. assert issubclass(VideoQueryApi, BaseApi)
  29. class FakeMySQL:
  30. async def fetch_all(self, sql, params):
  31. return [
  32. {'id': 1, 'platform': params[0], 'create_time': '2026-08-04 12:00:00'},
  33. {'id': 2, 'platform': params[0], 'create_time': '2026-08-04 11:00:00'},
  34. ]
  35. app = build_test_app(FakeMySQL())
  36. payload = {
  37. 'platforms': ['xiaoniangao'],
  38. 'filters': [
  39. {'field': 'create_time', 'operator': '>=', 'value': 1785772800000},
  40. {'field': 'create_time', 'operator': '<', 'value': 1785859200000},
  41. {'field': 'like_cnt', 'operator': '>', 'value': 3},
  42. ],
  43. 'limit': 1,
  44. }
  45. with TestClient(app, raise_server_exceptions=False) as client:
  46. response = client.post(
  47. API_PATH,
  48. json=payload,
  49. headers={'X-Request-ID': 'scheduler-request-1'},
  50. )
  51. body = response.json()
  52. assert response.status_code == 200
  53. assert response.headers['X-Request-ID'] == 'scheduler-request-1'
  54. assert response.headers['Cache-Control'] == 'no-store'
  55. assert body['code'] == 0
  56. assert body['data']['count'] == 1
  57. assert body['data']['has_more'] is True
  58. assert body['data']['next_cursor'] == {'id': 1}
  59. event = app.state.aliyun_logger.events[0]
  60. assert event['trace_id'] == 'scheduler-request-1'
  61. assert event['data']['request_params'] == payload
  62. assert event['data']['status_code'] == 200
  63. assert event['data']['query_result_count'] == 2
  64. def test_default_time_range_is_pinned_in_next_cursor():
  65. class FakeMySQL:
  66. async def fetch_all(self, sql, params):
  67. return [{'id': 2}, {'id': 1}]
  68. app = build_test_app(FakeMySQL())
  69. with TestClient(app, raise_server_exceptions=False) as client:
  70. first_page = client.post(API_PATH, json={'limit': 1}).json()['data']
  71. cursor = first_page['next_cursor']
  72. second_page = client.post(API_PATH, json={'limit': 1, 'cursor': cursor}).json()['data']
  73. assert cursor['id'] == 2
  74. assert isinstance(cursor['query_time'], int)
  75. assert second_page['next_cursor']['query_time'] == cursor['query_time']
  76. def test_fastapi_validation_and_business_errors_use_same_exit_contract():
  77. class FakeMySQL:
  78. async def fetch_all(self, sql, params):
  79. raise AssertionError('参数错误时不应查询数据库')
  80. app = build_test_app(FakeMySQL())
  81. with TestClient(app, raise_server_exceptions=False) as client:
  82. validation = client.post(API_PATH, json={'unknown_parameter': 1})
  83. business = client.post(
  84. API_PATH,
  85. json={'filters': [{'field': 'like_cnt', 'operator': '>', 'value': 'invalid'}]},
  86. )
  87. wrong_method = client.get(API_PATH)
  88. assert validation.status_code == 400
  89. assert '不支持的参数: unknown_parameter' in validation.json()['msg']
  90. assert business.status_code == 422
  91. assert '必须是数字' in business.json()['msg']
  92. assert wrong_method.status_code == 405
  93. assert wrong_method.json() == {'code': 405, 'msg': 'Method Not Allowed', 'data': None}
  94. assert len(app.state.aliyun_logger.events) == 3
  95. business_event = app.state.aliyun_logger.events[1]
  96. assert business_event['data']['failure_stage'] == 'business_validation'
  97. assert business_event['data']['error_type'] == 'BusinessValidationError'
  98. assert 'Traceback (most recent call last)' in business_event['data']['message']
  99. assert '必须是数字' in business_event['data']['message']
  100. assert all('X-Request-ID' in response.headers for response in (validation, business, wrong_method))
  101. def test_fastapi_health_readiness_and_documentation_routes():
  102. class FakeMySQL:
  103. async def fetch_one(self, sql):
  104. assert sql == 'SELECT 1 AS ok'
  105. return {'ok': 1}
  106. app = build_test_app(FakeMySQL())
  107. with TestClient(app, raise_server_exceptions=False) as client:
  108. health = client.get('/health')
  109. ready = client.get('/ready')
  110. docs = client.get('/openapi.json')
  111. assert health.json()['data']['status'] == 'ok'
  112. assert ready.json()['data']['status'] == 'ready'
  113. assert API_PATH in docs.json()['paths']
  114. def test_startup_route_list_contains_business_source_location(capsys):
  115. app = create_app(manage_resources=False)
  116. print_api_routes(app)
  117. output = capsys.readouterr().out
  118. assert 'api.chui_zhi.videos.VideoQueryApi.logic' in output
  119. assert 'api/chui_zhi/videos.py:' in output
  120. assert 'api.fastapi_app.health' in output
  121. assert 'api/fastapi_app.py:' in output
  122. def test_fastapi_sls_failure_does_not_change_business_response():
  123. class FakeMySQL:
  124. async def fetch_all(self, sql, params):
  125. return []
  126. class BrokenAliyunLogger:
  127. def logging_batch(self, events):
  128. raise RuntimeError('SLS unavailable')
  129. app = build_test_app(FakeMySQL())
  130. app.state.aliyun_logger = BrokenAliyunLogger()
  131. with TestClient(app, raise_server_exceptions=False) as client:
  132. response = client.post(API_PATH, json={})
  133. assert response.status_code == 200
  134. assert response.json()['code'] == 0
  135. assert any('阿里云API日志上报失败' in message for _, message in app.state.logger.records)