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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for Hold History API routes."""
|
||||
"""Unit tests for Hold History API routes (two-phase query/view pattern)."""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
@@ -35,215 +35,220 @@ class TestHoldHistoryPageRoute(TestHoldHistoryRoutesBase):
|
||||
self.assertTrue(response.location.endswith('/portal-shell/hold-history'))
|
||||
|
||||
|
||||
class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
|
||||
"""Test GET /api/hold-history/trend endpoint."""
|
||||
class TestHoldHistoryQueryRoute(TestHoldHistoryRoutesBase):
|
||||
"""Test POST /api/hold-history/query endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
|
||||
def test_trend_passes_date_range(self, mock_trend):
|
||||
mock_trend.return_value = {
|
||||
'days': [
|
||||
{
|
||||
'date': '2026-02-01',
|
||||
'quality': {'holdQty': 10, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 1},
|
||||
'non_quality': {'holdQty': 5, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
|
||||
'all': {'holdQty': 15, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(payload['success'])
|
||||
self.assertIn('days', payload['data'])
|
||||
mock_trend.assert_called_once_with('2026-02-01', '2026-02-07')
|
||||
|
||||
def test_trend_invalid_date_returns_400(self):
|
||||
response = self.client.get('/api/hold-history/trend?start_date=2026/02/01&end_date=2026-02-07')
|
||||
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'])
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
|
||||
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_trend_rate_limited_returns_429(self, _mock_limit, mock_service):
|
||||
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
|
||||
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_service.assert_not_called()
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
|
||||
class TestHoldHistoryReasonParetoRoute(TestHoldHistoryRoutesBase):
|
||||
"""Test GET /api/hold-history/reason-pareto endpoint."""
|
||||
class TestHoldHistoryViewRoute(TestHoldHistoryRoutesBase):
|
||||
"""Test GET /api/hold-history/view endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
|
||||
def test_reason_pareto_passes_hold_type_and_record_type(self, mock_service):
|
||||
mock_service.return_value = {'items': []}
|
||||
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/reason-pareto?start_date=2026-02-01&end_date=2026-02-07'
|
||||
'&hold_type=non-quality&record_type=on_hold'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'non-quality', 'on_hold')
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
|
||||
def test_reason_pareto_defaults_record_type_to_new(self, mock_service):
|
||||
mock_service.return_value = {'items': []}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&hold_type=quality'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'new')
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
|
||||
def test_reason_pareto_multi_record_type(self, mock_service):
|
||||
mock_service.return_value = {'items': []}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07'
|
||||
'&hold_type=quality&record_type=on_hold,released'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'on_hold,released')
|
||||
|
||||
def test_reason_pareto_invalid_record_type_returns_400(self):
|
||||
response = self.client.get(
|
||||
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=invalid'
|
||||
'/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_reason_pareto_partial_invalid_record_type_returns_400(self):
|
||||
def test_view_invalid_duration_range_returns_400(self):
|
||||
response = self.client.get(
|
||||
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=on_hold,bogus'
|
||||
'/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
|
||||
|
||||
class TestHoldHistoryDurationRoute(TestHoldHistoryRoutesBase):
|
||||
"""Test GET /api/hold-history/duration endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration')
|
||||
def test_duration_failure_returns_500(self, mock_service):
|
||||
mock_service.return_value = None
|
||||
|
||||
response = self.client.get('/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07')
|
||||
response = self.client.get('/api/hold-history/view?query_id=abc123')
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertEqual(response.status_code, 410)
|
||||
self.assertFalse(payload['success'])
|
||||
self.assertEqual(payload['error'], 'cache_expired')
|
||||
|
||||
def test_duration_invalid_hold_type(self):
|
||||
response = self.client.get(
|
||||
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&hold_type=invalid'
|
||||
)
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(payload['success'])
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration')
|
||||
def test_duration_passes_record_type(self, mock_service):
|
||||
mock_service.return_value = {'items': []}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=released'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'released')
|
||||
|
||||
def test_duration_invalid_record_type_returns_400(self):
|
||||
response = self.client.get(
|
||||
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=bogus'
|
||||
)
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(payload['success'])
|
||||
|
||||
|
||||
class TestHoldHistoryListRoute(TestHoldHistoryRoutesBase):
|
||||
"""Test GET /api/hold-history/list endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
|
||||
def test_list_caps_per_page_and_sets_page_floor(self, mock_service):
|
||||
mock_service.return_value = {
|
||||
'items': [],
|
||||
'pagination': {'page': 1, 'perPage': 200, 'total': 0, 'totalPages': 1},
|
||||
@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/list?start_date=2026-02-01&end_date=2026-02-07'
|
||||
'&hold_type=all&page=0&per_page=500&reason=品質確認'
|
||||
'/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)
|
||||
mock_service.assert_called_once_with(
|
||||
start_date='2026-02-01',
|
||||
end_date='2026-02-07',
|
||||
hold_type='all',
|
||||
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=1,
|
||||
per_page=200,
|
||||
page=2,
|
||||
per_page=20,
|
||||
)
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
|
||||
def test_list_passes_duration_range(self, mock_service):
|
||||
mock_service.return_value = {
|
||||
'items': [],
|
||||
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
|
||||
@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': {}},
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=<4h'
|
||||
self.client.get(
|
||||
'/api/hold-history/view?query_id=abc123&page=0&per_page=500'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_service.assert_called_once_with(
|
||||
start_date='2026-02-01',
|
||||
end_date='2026-02-07',
|
||||
hold_type='quality',
|
||||
reason=None,
|
||||
record_type='new',
|
||||
duration_range='<4h',
|
||||
page=1,
|
||||
per_page=50,
|
||||
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'
|
||||
)
|
||||
|
||||
def test_list_invalid_duration_range_returns_400(self):
|
||||
response = self.client.get(
|
||||
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=invalid'
|
||||
)
|
||||
payload = json.loads(response.data)
|
||||
call_kwargs = mock_view.call_args[1]
|
||||
self.assertEqual(call_kwargs['duration_range'], '<4h')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(payload['success'])
|
||||
|
||||
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
|
||||
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
|
||||
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
|
||||
def test_list_rate_limited_returns_429(self, _mock_limit, mock_service):
|
||||
response = self.client.get('/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07')
|
||||
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_service.assert_not_called()
|
||||
mock_view.assert_not_called()
|
||||
|
||||
@@ -61,8 +61,8 @@ class TestResourceHistoryOptionsAPI(unittest.TestCase):
|
||||
self.assertIn('error', data)
|
||||
|
||||
|
||||
class TestResourceHistorySummaryAPI(unittest.TestCase):
|
||||
"""Integration tests for /api/resource/history/summary endpoint."""
|
||||
class TestResourceHistoryQueryAPI(unittest.TestCase):
|
||||
"""Integration tests for POST /api/resource/history/query endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
@@ -73,7 +73,10 @@ class TestResourceHistorySummaryAPI(unittest.TestCase):
|
||||
|
||||
def test_missing_start_date(self):
|
||||
"""Missing start_date should return 400."""
|
||||
response = self.client.get('/api/resource/history/summary?end_date=2024-01-31')
|
||||
response = self.client.post(
|
||||
'/api/resource/history/query',
|
||||
json={'end_date': '2024-01-31'},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
@@ -82,74 +85,69 @@ class TestResourceHistorySummaryAPI(unittest.TestCase):
|
||||
|
||||
def test_missing_end_date(self):
|
||||
"""Missing end_date should return 400."""
|
||||
response = self.client.get('/api/resource/history/summary?start_date=2024-01-01')
|
||||
response = self.client.post(
|
||||
'/api/resource/history/query',
|
||||
json={'start_date': '2024-01-01'},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('end_date', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.resource_history_routes.query_summary')
|
||||
def test_date_range_exceeds_limit(self, mock_query):
|
||||
"""Date range exceeding 730 days should return error."""
|
||||
mock_query.return_value = {'error': '查詢範圍不可超過 730 天(兩年)'}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/resource/history/summary?start_date=2024-01-01&end_date=2026-01-02'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('730', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.resource_history_routes.query_summary')
|
||||
def test_successful_summary(self, mock_query):
|
||||
"""Successful summary request should return all data sections."""
|
||||
@patch('mes_dashboard.routes.resource_history_routes.execute_primary_query')
|
||||
def test_successful_query(self, mock_query):
|
||||
"""Successful query should return query_id, summary, and detail."""
|
||||
mock_query.return_value = {
|
||||
'kpi': {
|
||||
'ou_pct': 80.0,
|
||||
'prd_hours': 800,
|
||||
'sby_hours': 100,
|
||||
'udt_hours': 50,
|
||||
'sdt_hours': 30,
|
||||
'egt_hours': 20,
|
||||
'nst_hours': 100,
|
||||
'machine_count': 10
|
||||
'query_id': 'abc123',
|
||||
'summary': {
|
||||
'kpi': {'ou_pct': 80.0, 'machine_count': 10},
|
||||
'trend': [{'date': '2024-01-01', 'ou_pct': 80.0}],
|
||||
'heatmap': [{'workcenter': 'WC01', 'date': '2024-01-01', 'ou_pct': 80.0}],
|
||||
'workcenter_comparison': [{'workcenter': 'WC01', 'ou_pct': 80.0}],
|
||||
},
|
||||
'detail': {
|
||||
'data': [{'workcenter': 'WC01', 'ou_pct': 80.0}],
|
||||
'total': 1,
|
||||
'truncated': False,
|
||||
'max_records': None,
|
||||
},
|
||||
'trend': [{'date': '2024-01-01', 'ou_pct': 80.0}],
|
||||
'heatmap': [{'workcenter': 'WC01', 'date': '2024-01-01', 'ou_pct': 80.0}],
|
||||
'workcenter_comparison': [{'workcenter': 'WC01', 'ou_pct': 80.0}]
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/resource/history/summary?start_date=2024-01-01&end_date=2024-01-07'
|
||||
response = self.client.post(
|
||||
'/api/resource/history/query',
|
||||
json={'start_date': '2024-01-01', 'end_date': '2024-01-07'},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('kpi', data['data'])
|
||||
self.assertIn('trend', data['data'])
|
||||
self.assertIn('heatmap', data['data'])
|
||||
self.assertIn('workcenter_comparison', data['data'])
|
||||
self.assertIn('query_id', data)
|
||||
self.assertIn('summary', data)
|
||||
self.assertIn('detail', data)
|
||||
self.assertIn('kpi', data['summary'])
|
||||
self.assertIn('trend', data['summary'])
|
||||
|
||||
@patch('mes_dashboard.routes.resource_history_routes.query_summary')
|
||||
def test_summary_with_filters(self, mock_query):
|
||||
"""Summary with filters should pass them to service."""
|
||||
mock_query.return_value = {'kpi': {}, 'trend': [], 'heatmap': [], 'workcenter_comparison': []}
|
||||
@patch('mes_dashboard.routes.resource_history_routes.execute_primary_query')
|
||||
def test_query_with_filters(self, mock_query):
|
||||
"""Query with filters should pass them to service."""
|
||||
mock_query.return_value = {
|
||||
'query_id': 'abc123',
|
||||
'summary': {'kpi': {}, 'trend': [], 'heatmap': [], 'workcenter_comparison': []},
|
||||
'detail': {'data': [], 'total': 0, 'truncated': False, 'max_records': None},
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/resource/history/summary'
|
||||
'?start_date=2024-01-01'
|
||||
'&end_date=2024-01-07'
|
||||
'&granularity=week'
|
||||
'&workcenter_groups=焊接_DB'
|
||||
'&workcenter_groups=成型'
|
||||
'&families=FAM01'
|
||||
'&families=FAM02'
|
||||
'&is_production=1'
|
||||
'&is_key=1'
|
||||
response = self.client.post(
|
||||
'/api/resource/history/query',
|
||||
json={
|
||||
'start_date': '2024-01-01',
|
||||
'end_date': '2024-01-07',
|
||||
'granularity': 'week',
|
||||
'workcenter_groups': ['焊接_DB', '成型'],
|
||||
'families': ['FAM01', 'FAM02'],
|
||||
'is_production': True,
|
||||
'is_key': True,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -162,8 +160,8 @@ class TestResourceHistorySummaryAPI(unittest.TestCase):
|
||||
self.assertTrue(call_kwargs['is_key'])
|
||||
|
||||
|
||||
class TestResourceHistoryDetailAPI(unittest.TestCase):
|
||||
"""Integration tests for /api/resource/history/detail endpoint."""
|
||||
class TestResourceHistoryViewAPI(unittest.TestCase):
|
||||
"""Integration tests for GET /api/resource/history/view endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
@@ -172,60 +170,54 @@ class TestResourceHistoryDetailAPI(unittest.TestCase):
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_missing_dates(self):
|
||||
"""Missing dates should return 400."""
|
||||
response = self.client.get('/api/resource/history/detail')
|
||||
def test_missing_query_id(self):
|
||||
"""Missing query_id should return 400."""
|
||||
response = self.client.get('/api/resource/history/view')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('query_id', data['error'])
|
||||
|
||||
@patch('mes_dashboard.routes.resource_history_routes.query_detail')
|
||||
def test_successful_detail(self, mock_query):
|
||||
"""Successful detail request should return data with total and truncated flag."""
|
||||
mock_query.return_value = {
|
||||
'data': [
|
||||
{'workcenter': 'WC01', 'family': 'FAM01', 'resource': 'RES01', 'ou_pct': 80.0}
|
||||
],
|
||||
'total': 100,
|
||||
'truncated': False,
|
||||
'max_records': None
|
||||
@patch('mes_dashboard.routes.resource_history_routes.apply_view')
|
||||
def test_cache_expired(self, mock_view):
|
||||
"""Expired cache should return 410."""
|
||||
mock_view.return_value = None
|
||||
|
||||
response = self.client.get('/api/resource/history/view?query_id=abc123')
|
||||
|
||||
self.assertEqual(response.status_code, 410)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertEqual(data['error'], 'cache_expired')
|
||||
|
||||
@patch('mes_dashboard.routes.resource_history_routes.apply_view')
|
||||
def test_successful_view(self, mock_view):
|
||||
"""Successful view should return summary and detail."""
|
||||
mock_view.return_value = {
|
||||
'summary': {
|
||||
'kpi': {'ou_pct': 80.0},
|
||||
'trend': [],
|
||||
'heatmap': [],
|
||||
'workcenter_comparison': [],
|
||||
},
|
||||
'detail': {
|
||||
'data': [{'workcenter': 'WC01', 'ou_pct': 80.0}],
|
||||
'total': 1,
|
||||
'truncated': False,
|
||||
'max_records': None,
|
||||
},
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/resource/history/detail?start_date=2024-01-01&end_date=2024-01-07'
|
||||
'/api/resource/history/view?query_id=abc123&granularity=week'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('data', data)
|
||||
self.assertIn('total', data)
|
||||
self.assertIn('truncated', data)
|
||||
self.assertFalse(data['truncated'])
|
||||
|
||||
@patch('mes_dashboard.routes.resource_history_routes.query_detail')
|
||||
def test_detail_truncated_warning(self, mock_query):
|
||||
"""Detail with truncated data should return truncated flag and max_records."""
|
||||
mock_query.return_value = {
|
||||
'data': [{'workcenter': 'WC01', 'family': 'FAM01', 'resource': 'RES01', 'ou_pct': 80.0}],
|
||||
'total': 6000,
|
||||
'truncated': True,
|
||||
'max_records': 5000
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/resource/history/detail'
|
||||
'?start_date=2024-01-01'
|
||||
'&end_date=2024-01-07'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertTrue(data['truncated'])
|
||||
self.assertEqual(data['max_records'], 5000)
|
||||
self.assertEqual(data['total'], 6000)
|
||||
self.assertIn('summary', data)
|
||||
self.assertIn('detail', data)
|
||||
|
||||
|
||||
class TestResourceHistoryExportAPI(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user