Files
DashBoard/tests/test_hold_history_routes.py
egg 71c8102de6 feat: dataset cache for hold/resource history + slow connection migration
Two changes combined:

1. historical-query-slow-connection: Migrate all historical query pages
   to read_sql_df_slow with semaphore concurrency control (max 3),
   raise DB slow timeout to 300s, gunicorn timeout to 360s, and
   unify frontend timeouts to 360s for all historical pages.

2. hold-resource-history-dataset-cache: Convert hold-history and
   resource-history from multi-query to single-query + dataset cache
   pattern (L1 ProcessLevelCache + L2 Redis parquet/base64, TTL=900s).
   Replace old GET endpoints with POST /query + GET /view two-phase
   API. Frontend auto-retries on 410 cache_expired.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:15:02 +08:00

255 lines
9.1 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for Hold History API routes (two-phase query/view pattern)."""
import json
import unittest
from unittest.mock import patch
from mes_dashboard.app import create_app
import mes_dashboard.core.database as db
class TestHoldHistoryRoutesBase(unittest.TestCase):
"""Base class for Hold History route tests."""
def setUp(self):
db._ENGINE = None
self.app = create_app('testing')
self.app.config['TESTING'] = True
self.client = self.app.test_client()
class TestHoldHistoryPageRoute(TestHoldHistoryRoutesBase):
"""Test GET /hold-history page route."""
@patch('mes_dashboard.routes.hold_history_routes.os.path.exists', return_value=False)
def test_hold_history_page_includes_vite_entry(self, _mock_exists):
response = self.client.get('/hold-history', follow_redirects=False)
self.assertEqual(response.status_code, 302)
self.assertTrue(response.location.endswith('/portal-shell/hold-history'))
@patch('mes_dashboard.routes.hold_history_routes.os.path.exists', return_value=False)
def test_hold_history_page_redirects_without_admin(self, _mock_exists):
response = self.client.get('/hold-history', follow_redirects=False)
self.assertEqual(response.status_code, 302)
self.assertTrue(response.location.endswith('/portal-shell/hold-history'))
class TestHoldHistoryQueryRoute(TestHoldHistoryRoutesBase):
"""Test POST /api/hold-history/query endpoint."""
def test_query_missing_dates_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026-02-01'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_query_invalid_date_format_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026/02/01', 'end_date': '2026-02-07'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_query_end_before_start_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026-02-07', 'end_date': '2026-02-01'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_query_invalid_record_type_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={
'start_date': '2026-02-01',
'end_date': '2026-02-07',
'record_type': 'invalid',
},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.execute_primary_query')
def test_query_success(self, mock_exec):
mock_exec.return_value = {
'query_id': 'abc123',
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
response = self.client.post(
'/api/hold-history/query',
json={
'start_date': '2026-02-01',
'end_date': '2026-02-07',
'hold_type': 'quality',
},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
self.assertIn('query_id', payload['data'])
@patch('mes_dashboard.routes.hold_history_routes.execute_primary_query')
def test_query_passes_params(self, mock_exec):
mock_exec.return_value = {'query_id': 'x', 'trend': {}, 'reason_pareto': {}, 'duration': {}, 'list': {}}
self.client.post(
'/api/hold-history/query',
json={
'start_date': '2026-02-01',
'end_date': '2026-02-07',
'hold_type': 'non-quality',
'record_type': 'on_hold',
},
)
mock_exec.assert_called_once_with(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='non-quality',
record_type='on_hold',
)
@patch('mes_dashboard.routes.hold_history_routes.execute_primary_query')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8))
def test_query_rate_limited_returns_429(self, _mock_limit, mock_exec):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026-02-01', 'end_date': '2026-02-07'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '8')
mock_exec.assert_not_called()
class TestHoldHistoryViewRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/view endpoint."""
def test_view_missing_query_id_returns_400(self):
response = self.client.get('/api/hold-history/view')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
self.assertIn('query_id', payload['error'])
def test_view_invalid_record_type_returns_400(self):
response = self.client.get(
'/api/hold-history/view?query_id=abc123&record_type=bogus'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_view_invalid_duration_range_returns_400(self):
response = self.client.get(
'/api/hold-history/view?query_id=abc123&duration_range=invalid'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_cache_expired_returns_410(self, mock_view):
mock_view.return_value = None
response = self.client.get('/api/hold-history/view?query_id=abc123')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 410)
self.assertFalse(payload['success'])
self.assertEqual(payload['error'], 'cache_expired')
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_success(self, mock_view):
mock_view.return_value = {
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
response = self.client.get(
'/api/hold-history/view?query_id=abc123&hold_type=non-quality&reason=品質確認&page=2&per_page=20'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
mock_view.assert_called_once_with(
query_id='abc123',
hold_type='non-quality',
reason='品質確認',
record_type='new',
duration_range=None,
page=2,
per_page=20,
)
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_caps_per_page(self, mock_view):
mock_view.return_value = {
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
self.client.get(
'/api/hold-history/view?query_id=abc123&page=0&per_page=500'
)
call_kwargs = mock_view.call_args[1]
self.assertEqual(call_kwargs['page'], 1)
self.assertEqual(call_kwargs['per_page'], 200)
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_passes_duration_range(self, mock_view):
mock_view.return_value = {
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
self.client.get(
'/api/hold-history/view?query_id=abc123&duration_range=<4h'
)
call_kwargs = mock_view.call_args[1]
self.assertEqual(call_kwargs['duration_range'], '<4h')
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
def test_view_rate_limited_returns_429(self, _mock_limit, mock_view):
response = self.client.get('/api/hold-history/view?query_id=abc123')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '5')
mock_view.assert_not_called()