feat(mid-section-defect): harden with distributed lock, rate limit, filter separation, abort, SQL classification and tests

Address 6 code review findings (P0-P3): add Redis distributed lock to prevent
duplicate Oracle pipeline on cold cache, apply rate limiting to 3 high-cost
routes, separate UI filter state from committed query state, add AbortController
for request cancellation, push workcenter group classification into Oracle SQL
CASE WHEN, and add 18 route+service tests. Also add workcenter group selection
to job-query equipment selector and rename button to "查詢".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-10 09:32:14 +08:00
parent 8b1b8da59b
commit af59031f95
16 changed files with 1461 additions and 601 deletions

View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
"""Route tests for mid-section defect APIs."""
from __future__ import annotations
from unittest.mock import patch
import mes_dashboard.core.database as db
from mes_dashboard.app import create_app
from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests
def _client():
db._ENGINE = None
app = create_app('testing')
app.config['TESTING'] = True
return app.test_client()
def setup_function():
reset_rate_limits_for_tests()
def teardown_function():
reset_rate_limits_for_tests()
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
def test_analysis_success(mock_query_analysis):
mock_query_analysis.return_value = {
'kpi': {'total_input': 100},
'charts': {'by_station': []},
'daily_trend': [],
'available_loss_reasons': ['A'],
'genealogy_status': 'ready',
'detail': [{}, {}],
}
client = _client()
response = client.get(
'/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31&loss_reasons=A,B'
)
assert response.status_code == 200
payload = response.get_json()
assert payload['success'] is True
assert payload['data']['detail_total_count'] == 2
assert payload['data']['kpi']['total_input'] == 100
mock_query_analysis.assert_called_once_with('2025-01-01', '2025-01-31', ['A', 'B'])
def test_analysis_missing_dates_returns_400():
client = _client()
response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01')
assert response.status_code == 400
payload = response.get_json()
assert payload['success'] is False
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
def test_analysis_service_failure_returns_500(mock_query_analysis):
mock_query_analysis.return_value = None
client = _client()
response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31')
assert response.status_code == 500
payload = response.get_json()
assert payload['success'] is False
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7))
def test_analysis_rate_limited_returns_429(_mock_rate_limit, mock_query_analysis):
client = _client()
response = client.get('/api/mid-section-defect/analysis?start_date=2025-01-01&end_date=2025-01-31')
assert response.status_code == 429
assert response.headers.get('Retry-After') == '7'
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
mock_query_analysis.assert_not_called()
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis_detail')
def test_detail_success(mock_query_detail):
mock_query_detail.return_value = {
'detail': [{'CONTAINERNAME': 'LOT-1'}],
'pagination': {'page': 2, 'page_size': 200, 'total_count': 350, 'total_pages': 2},
}
client = _client()
response = client.get(
'/api/mid-section-defect/analysis/detail?start_date=2025-01-01&end_date=2025-01-31&page=2&page_size=200'
)
assert response.status_code == 200
payload = response.get_json()
assert payload['success'] is True
assert payload['data']['pagination']['page'] == 2
mock_query_detail.assert_called_once_with(
'2025-01-01',
'2025-01-31',
None,
page=2,
page_size=200,
)
def test_detail_missing_dates_returns_400():
client = _client()
response = client.get('/api/mid-section-defect/analysis/detail?end_date=2025-01-31')
assert response.status_code == 400
payload = response.get_json()
assert payload['success'] is False
@patch('mes_dashboard.routes.mid_section_defect_routes.query_analysis_detail')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
def test_detail_rate_limited_returns_429(_mock_rate_limit, mock_query_detail):
client = _client()
response = client.get('/api/mid-section-defect/analysis/detail?start_date=2025-01-01&end_date=2025-01-31')
assert response.status_code == 429
assert response.headers.get('Retry-After') == '5'
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
mock_query_detail.assert_not_called()
@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv')
def test_export_success(mock_export_csv):
mock_export_csv.return_value = iter([
'\ufeff',
'LOT ID,TYPE\r\n',
'A001,T1\r\n',
])
client = _client()
response = client.get(
'/api/mid-section-defect/export?start_date=2025-01-01&end_date=2025-01-31&loss_reasons=A,B'
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'attachment;' in response.headers.get('Content-Disposition', '')
mock_export_csv.assert_called_once_with('2025-01-01', '2025-01-31', ['A', 'B'])
@patch('mes_dashboard.routes.mid_section_defect_routes.export_csv')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 9))
def test_export_rate_limited_returns_429(_mock_rate_limit, mock_export_csv):
client = _client()
response = client.get('/api/mid-section-defect/export?start_date=2025-01-01&end_date=2025-01-31')
assert response.status_code == 429
assert response.headers.get('Retry-After') == '9'
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
mock_export_csv.assert_not_called()

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Service tests for mid-section defect analysis."""
from __future__ import annotations
from unittest.mock import patch
import pandas as pd
from mes_dashboard.services.mid_section_defect_service import (
query_analysis,
query_analysis_detail,
query_all_loss_reasons,
)
def test_query_analysis_invalid_date_format_returns_error():
result = query_analysis('2025/01/01', '2025-01-31')
assert 'error' in result
assert 'YYYY-MM-DD' in result['error']
def test_query_analysis_start_after_end_returns_error():
result = query_analysis('2025-02-01', '2025-01-31')
assert 'error' in result
assert '起始日期不能晚於結束日期' in result['error']
def test_query_analysis_exceeds_max_days_returns_error():
result = query_analysis('2025-01-01', '2025-12-31')
assert 'error' in result
assert '180' in result['error']
@patch('mes_dashboard.services.mid_section_defect_service.query_analysis')
def test_query_analysis_detail_returns_sorted_first_page(mock_query_analysis):
mock_query_analysis.return_value = {
'detail': [
{'CONTAINERNAME': 'C', 'DEFECT_RATE': 0.3},
{'CONTAINERNAME': 'A', 'DEFECT_RATE': 5.2},
{'CONTAINERNAME': 'B', 'DEFECT_RATE': 3.1},
]
}
result = query_analysis_detail('2025-01-01', '2025-01-31', page=1, page_size=2)
assert [row['CONTAINERNAME'] for row in result['detail']] == ['A', 'B']
assert result['pagination'] == {
'page': 1,
'page_size': 2,
'total_count': 3,
'total_pages': 2,
}
@patch('mes_dashboard.services.mid_section_defect_service.query_analysis')
def test_query_analysis_detail_clamps_page_to_last_page(mock_query_analysis):
mock_query_analysis.return_value = {
'detail': [
{'CONTAINERNAME': 'A', 'DEFECT_RATE': 9.9},
{'CONTAINERNAME': 'B', 'DEFECT_RATE': 8.8},
{'CONTAINERNAME': 'C', 'DEFECT_RATE': 7.7},
]
}
result = query_analysis_detail('2025-01-01', '2025-01-31', page=10, page_size=2)
assert result['pagination']['page'] == 2
assert result['pagination']['total_pages'] == 2
assert len(result['detail']) == 1
assert result['detail'][0]['CONTAINERNAME'] == 'C'
@patch('mes_dashboard.services.mid_section_defect_service.query_analysis')
def test_query_analysis_detail_returns_error_passthrough(mock_query_analysis):
mock_query_analysis.return_value = {'error': '日期格式無效'}
result = query_analysis_detail('2025-01-01', '2025-01-31', page=1, page_size=200)
assert result == {'error': '日期格式無效'}
@patch('mes_dashboard.services.mid_section_defect_service.query_analysis')
def test_query_analysis_detail_returns_none_on_service_failure(mock_query_analysis):
mock_query_analysis.return_value = None
result = query_analysis_detail('2025-01-01', '2025-01-31', page=1, page_size=200)
assert result is None
@patch('mes_dashboard.services.mid_section_defect_service.cache_get')
@patch('mes_dashboard.services.mid_section_defect_service.read_sql_df')
def test_query_all_loss_reasons_cache_hit_skips_query(mock_read_sql_df, mock_cache_get):
mock_cache_get.return_value = {'loss_reasons': ['Cached_A', 'Cached_B']}
result = query_all_loss_reasons()
assert result == {'loss_reasons': ['Cached_A', 'Cached_B']}
mock_read_sql_df.assert_not_called()
@patch('mes_dashboard.services.mid_section_defect_service.cache_get', return_value=None)
@patch('mes_dashboard.services.mid_section_defect_service.cache_set')
@patch('mes_dashboard.services.mid_section_defect_service.read_sql_df')
@patch('mes_dashboard.services.mid_section_defect_service.SQLLoader.load')
def test_query_all_loss_reasons_cache_miss_queries_and_caches_sorted_values(
mock_sql_load,
mock_read_sql_df,
mock_cache_set,
_mock_cache_get,
):
mock_sql_load.return_value = 'SELECT ...'
mock_read_sql_df.return_value = pd.DataFrame(
{'LOSSREASONNAME': ['B_REASON', None, 'A_REASON', 'B_REASON']}
)
result = query_all_loss_reasons()
assert result == {'loss_reasons': ['A_REASON', 'B_REASON']}
mock_cache_set.assert_called_once_with(
'mid_section_loss_reasons:None:',
{'loss_reasons': ['A_REASON', 'B_REASON']},
ttl=86400,
)