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>
290 lines
11 KiB
Python
290 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Integration tests for resource history API endpoints.
|
|
|
|
Tests API endpoints for proper response format, error handling,
|
|
and parameter validation.
|
|
"""
|
|
|
|
import unittest
|
|
from unittest.mock import patch, MagicMock
|
|
import json
|
|
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
|
|
import mes_dashboard.core.database as db
|
|
from mes_dashboard.app import create_app
|
|
|
|
|
|
class TestResourceHistoryOptionsAPI(unittest.TestCase):
|
|
"""Integration tests for /api/resource/history/options endpoint."""
|
|
|
|
def setUp(self):
|
|
"""Set up test client."""
|
|
db._ENGINE = None
|
|
self.app = create_app('testing')
|
|
self.app.config['TESTING'] = True
|
|
self.client = self.app.test_client()
|
|
|
|
@patch('mes_dashboard.routes.resource_history_routes.get_filter_options')
|
|
def test_options_success(self, mock_get_options):
|
|
"""Successful options request should return workcenter_groups and families."""
|
|
mock_get_options.return_value = {
|
|
'workcenter_groups': [
|
|
{'name': '焊接_DB', 'sequence': 1},
|
|
{'name': '成型', 'sequence': 4}
|
|
],
|
|
'families': ['FAM01', 'FAM02']
|
|
}
|
|
|
|
response = self.client.get('/api/resource/history/options')
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = json.loads(response.data)
|
|
self.assertTrue(data['success'])
|
|
self.assertIn('data', data)
|
|
self.assertEqual(len(data['data']['workcenter_groups']), 2)
|
|
self.assertEqual(data['data']['workcenter_groups'][0]['name'], '焊接_DB')
|
|
self.assertEqual(data['data']['families'], ['FAM01', 'FAM02'])
|
|
|
|
@patch('mes_dashboard.routes.resource_history_routes.get_filter_options')
|
|
def test_options_failure(self, mock_get_options):
|
|
"""Failed options request should return error."""
|
|
mock_get_options.return_value = None
|
|
|
|
response = self.client.get('/api/resource/history/options')
|
|
|
|
self.assertEqual(response.status_code, 500)
|
|
data = json.loads(response.data)
|
|
self.assertFalse(data['success'])
|
|
self.assertIn('error', data)
|
|
|
|
|
|
class TestResourceHistoryQueryAPI(unittest.TestCase):
|
|
"""Integration tests for POST /api/resource/history/query endpoint."""
|
|
|
|
def setUp(self):
|
|
"""Set up test client."""
|
|
db._ENGINE = None
|
|
self.app = create_app('testing')
|
|
self.app.config['TESTING'] = True
|
|
self.client = self.app.test_client()
|
|
|
|
def test_missing_start_date(self):
|
|
"""Missing start_date should return 400."""
|
|
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)
|
|
self.assertFalse(data['success'])
|
|
self.assertIn('start_date', data['error'])
|
|
|
|
def test_missing_end_date(self):
|
|
"""Missing end_date should return 400."""
|
|
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.execute_primary_query')
|
|
def test_successful_query(self, mock_query):
|
|
"""Successful query should return query_id, summary, and detail."""
|
|
mock_query.return_value = {
|
|
'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,
|
|
},
|
|
}
|
|
|
|
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('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.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.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)
|
|
mock_query.assert_called_once()
|
|
call_kwargs = mock_query.call_args[1]
|
|
self.assertEqual(call_kwargs['granularity'], 'week')
|
|
self.assertEqual(call_kwargs['workcenter_groups'], ['焊接_DB', '成型'])
|
|
self.assertEqual(call_kwargs['families'], ['FAM01', 'FAM02'])
|
|
self.assertTrue(call_kwargs['is_production'])
|
|
self.assertTrue(call_kwargs['is_key'])
|
|
|
|
|
|
class TestResourceHistoryViewAPI(unittest.TestCase):
|
|
"""Integration tests for GET /api/resource/history/view endpoint."""
|
|
|
|
def setUp(self):
|
|
"""Set up test client."""
|
|
db._ENGINE = None
|
|
self.app = create_app('testing')
|
|
self.app.config['TESTING'] = True
|
|
self.client = self.app.test_client()
|
|
|
|
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.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/view?query_id=abc123&granularity=week'
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
data = json.loads(response.data)
|
|
self.assertTrue(data['success'])
|
|
self.assertIn('summary', data)
|
|
self.assertIn('detail', data)
|
|
|
|
|
|
class TestResourceHistoryExportAPI(unittest.TestCase):
|
|
"""Integration tests for /api/resource/history/export endpoint."""
|
|
|
|
def setUp(self):
|
|
"""Set up test client."""
|
|
db._ENGINE = None
|
|
self.app = create_app('testing')
|
|
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/export')
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
data = json.loads(response.data)
|
|
self.assertFalse(data['success'])
|
|
|
|
@patch('mes_dashboard.routes.resource_history_routes.export_csv')
|
|
def test_successful_export(self, mock_export):
|
|
"""Successful export should return CSV with correct headers."""
|
|
mock_export.return_value = iter(['站點,型號,機台,OU%\n', 'WC01,FAM01,RES01,80%\n'])
|
|
|
|
response = self.client.get(
|
|
'/api/resource/history/export?start_date=2024-01-01&end_date=2024-01-07'
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn('text/csv', response.content_type)
|
|
self.assertIn('attachment', response.headers['Content-Disposition'])
|
|
self.assertIn('resource_history', response.headers['Content-Disposition'])
|
|
|
|
@patch('mes_dashboard.routes.resource_history_routes.export_csv')
|
|
def test_export_filename_includes_dates(self, mock_export):
|
|
"""Export filename should include date range."""
|
|
mock_export.return_value = iter(['header\n'])
|
|
|
|
response = self.client.get(
|
|
'/api/resource/history/export?start_date=2024-01-01&end_date=2024-01-07'
|
|
)
|
|
|
|
self.assertIn('2024-01-01', response.headers['Content-Disposition'])
|
|
self.assertIn('2024-01-07', response.headers['Content-Disposition'])
|
|
|
|
|
|
class TestAPIContentType(unittest.TestCase):
|
|
"""Test that APIs return proper content types."""
|
|
|
|
def setUp(self):
|
|
"""Set up test client."""
|
|
db._ENGINE = None
|
|
self.app = create_app('testing')
|
|
self.app.config['TESTING'] = True
|
|
self.client = self.app.test_client()
|
|
|
|
@patch('mes_dashboard.routes.resource_history_routes.get_filter_options')
|
|
def test_json_content_type(self, mock_get_options):
|
|
"""API endpoints should return application/json content type."""
|
|
mock_get_options.return_value = {'workcenter_groups': [], 'families': []}
|
|
|
|
response = self.client.get('/api/resource/history/options')
|
|
|
|
self.assertIn('application/json', response.content_type)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|