Files
DashBoard/tests/test_hold_history_routes.py
egg 7cb0985b12 feat(modernization): full architecture blueprint with hardening follow-up
Implement phased modernization infrastructure for transitioning from
multi-page legacy routing to SPA portal-shell architecture, plus
post-delivery hardening fixes for policy loading, fallback consistency,
and governance drift detection.

Key changes:
- Add route contract enrichment with scope/visibility/compatibility policies
- Canonical 302 redirects from legacy direct-entry to /portal-shell/ routes
- Asset readiness enforcement and runtime fallback retirement for in-scope routes
- Shared feature-flag helpers (env > config > default) replacing duplicated _to_bool
- Defensive copy for lru_cached policy payloads preventing mutation corruption
- Unified retired-fallback response helper across app and blueprint routes
- Frontend/backend route-contract cross-validation in governance gates
- Shell CSS token fallback values for routes rendered outside shell scope
- Local-safe .env.example defaults with production recommendation comments
- Legacy contract fallback warning logging and single-hop redirect optimization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:26:02 +08:00

250 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for Hold History API routes."""
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 TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/trend 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')
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')
@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')
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()
class TestHoldHistoryReasonParetoRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/reason-pareto 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': []}
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'
)
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):
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=on_hold,bogus'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
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')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
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},
}
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=品質確認'
)
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',
reason='品質確認',
record_type='new',
duration_range=None,
page=1,
per_page=200,
)
@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},
}
response = self.client.get(
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=<4h'
)
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,
)
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)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
@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')
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()