chore: finalize vite migration hardening and archive openspec changes

This commit is contained in:
beabigegg
2026-02-08 20:03:36 +08:00
parent b56e80381b
commit c8e225101e
119 changed files with 6547 additions and 1301 deletions

View File

@@ -0,0 +1,9 @@
{
"rows": 30000,
"query_count": 400,
"seed": 42,
"thresholds": {
"max_p95_ratio_indexed_vs_baseline": 1.25,
"max_memory_amplification_ratio": 1.8
}
}

View File

@@ -1,4 +1,5 @@
import unittest
import os
from mes_dashboard.app import create_app
import mes_dashboard.core.database as db
@@ -18,9 +19,17 @@ class AppFactoryTests(unittest.TestCase):
self.assertEqual(cache.get("app_factory_probe"), {"ok": True})
def test_create_app_production_config(self):
app = create_app("production")
self.assertFalse(app.config.get("DEBUG"))
self.assertEqual(app.config.get("ENV"), "production")
old_secret = os.environ.get("SECRET_KEY")
try:
os.environ["SECRET_KEY"] = "test-production-secret-key"
app = create_app("production")
self.assertFalse(app.config.get("DEBUG"))
self.assertEqual(app.config.get("ENV"), "production")
finally:
if old_secret is None:
os.environ.pop("SECRET_KEY", None)
else:
os.environ["SECRET_KEY"] = old_secret
def test_create_app_independent_instances(self):
app1 = create_app()

View File

@@ -12,12 +12,18 @@ 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
from mes_dashboard.services import page_registry
@pytest.fixture
def temp_page_status(tmp_path):
from mes_dashboard.app import create_app
from mes_dashboard.services import page_registry
@pytest.fixture(autouse=True)
def _ldap_defaults(monkeypatch):
monkeypatch.setattr("mes_dashboard.services.auth_service.LDAP_API_BASE", "https://ldap.panjit.example")
monkeypatch.setattr("mes_dashboard.services.auth_service.LDAP_CONFIG_ERROR", None)
@pytest.fixture
def temp_page_status(tmp_path):
"""Create temporary page status file."""
data_file = tmp_path / "page_status.json"
initial_data = {
@@ -69,9 +75,10 @@ class TestLoginRoute:
assert response.status_code == 200
assert "管理員登入" in response.data.decode("utf-8") or "login" in response.data.decode("utf-8").lower()
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_login_success(self, mock_post, client):
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_login_success(self, mock_post, _mock_is_admin, client):
"""Test successful login via LDAP."""
# Mock LDAP response
mock_response = MagicMock()

View File

@@ -1,159 +1,212 @@
# -*- coding: utf-8 -*-
"""Unit tests for auth_service module."""
import pytest
from unittest.mock import patch, MagicMock
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from mes_dashboard.services import auth_service
class TestAuthenticate:
"""Tests for authenticate function via LDAP."""
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_success(self, mock_post):
"""Test successful authentication via LDAP."""
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"user": {
"username": "92367",
"displayName": "Test User",
"mail": "test@panjit.com.tw",
"department": "Test Dept"
}
}
mock_post.return_value = mock_response
result = auth_service.authenticate("92367", "password123")
assert result is not None
assert result["username"] == "92367"
assert result["mail"] == "test@panjit.com.tw"
mock_post.assert_called_once()
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_invalid_credentials(self, mock_post):
"""Test authentication with invalid credentials via LDAP."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": False}
mock_post.return_value = mock_response
result = auth_service.authenticate("wrong", "wrong")
assert result is None
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_timeout(self, mock_post):
"""Test authentication timeout handling."""
import requests
mock_post.side_effect = requests.Timeout()
result = auth_service.authenticate("user", "pass")
assert result is None
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_connection_error(self, mock_post):
"""Test authentication connection error handling."""
import requests
mock_post.side_effect = requests.ConnectionError()
result = auth_service.authenticate("user", "pass")
assert result is None
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_invalid_json(self, mock_post):
"""Test authentication with invalid JSON response."""
mock_response = MagicMock()
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_post.return_value = mock_response
result = auth_service.authenticate("user", "pass")
assert result is None
class TestLocalAuthenticate:
"""Tests for local authentication."""
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
def test_local_auth_success(self):
"""Test successful local authentication."""
result = auth_service.authenticate("testuser", "testpass")
assert result is not None
assert result["username"] == "testuser"
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
def test_local_auth_wrong_password(self):
"""Test local authentication with wrong password."""
result = auth_service.authenticate("testuser", "wrongpass")
assert result is None
class TestIsAdmin:
"""Tests for is_admin function."""
def test_is_admin_with_admin_email(self):
"""Test admin check with admin email."""
# Save original ADMIN_EMAILS
original = auth_service.ADMIN_EMAILS
try:
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
user = {"mail": "admin@panjit.com.tw"}
assert auth_service.is_admin(user) is True
finally:
auth_service.ADMIN_EMAILS = original
def test_is_admin_with_non_admin_email(self):
"""Test admin check with non-admin email."""
original = auth_service.ADMIN_EMAILS
try:
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
user = {"mail": "user@panjit.com.tw"}
assert auth_service.is_admin(user) is False
finally:
auth_service.ADMIN_EMAILS = original
def test_is_admin_case_insensitive(self):
"""Test admin check is case insensitive."""
original = auth_service.ADMIN_EMAILS
try:
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
user = {"mail": "ADMIN@PANJIT.COM.TW"}
assert auth_service.is_admin(user) is True
finally:
auth_service.ADMIN_EMAILS = original
def test_is_admin_with_missing_mail(self):
"""Test admin check with missing mail field."""
user = {}
assert auth_service.is_admin(user) is False
def test_is_admin_with_empty_mail(self):
"""Test admin check with empty mail field."""
user = {"mail": ""}
assert auth_service.is_admin(user) is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])
# -*- coding: utf-8 -*-
"""Unit tests for auth_service module."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from mes_dashboard.services import auth_service
@pytest.fixture(autouse=True)
def _ldap_defaults(monkeypatch):
"""Keep LDAP auth tests deterministic regardless of host env vars."""
monkeypatch.setattr(auth_service, "LDAP_API_BASE", "https://ldap.panjit.example")
monkeypatch.setattr(auth_service, "LDAP_CONFIG_ERROR", None)
class TestAuthenticate:
"""Tests for authenticate function via LDAP."""
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_success(self, mock_post):
"""Test successful authentication via LDAP."""
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"user": {
"username": "92367",
"displayName": "Test User",
"mail": "test@panjit.com.tw",
"department": "Test Dept"
}
}
mock_post.return_value = mock_response
result = auth_service.authenticate("92367", "password123")
assert result is not None
assert result["username"] == "92367"
assert result["mail"] == "test@panjit.com.tw"
mock_post.assert_called_once()
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_invalid_credentials(self, mock_post):
"""Test authentication with invalid credentials via LDAP."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": False}
mock_post.return_value = mock_response
result = auth_service.authenticate("wrong", "wrong")
assert result is None
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_timeout(self, mock_post):
"""Test authentication timeout handling."""
import requests
mock_post.side_effect = requests.Timeout()
result = auth_service.authenticate("user", "pass")
assert result is None
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_connection_error(self, mock_post):
"""Test authentication connection error handling."""
import requests
mock_post.side_effect = requests.ConnectionError()
result = auth_service.authenticate("user", "pass")
assert result is None
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_invalid_json(self, mock_post):
"""Test authentication with invalid JSON response."""
mock_response = MagicMock()
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_post.return_value = mock_response
result = auth_service.authenticate("user", "pass")
assert result is None
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
@patch('mes_dashboard.services.auth_service.requests.post')
def test_authenticate_rejects_invalid_ldap_config_without_outbound_call(self, mock_post):
"""Unsafe LDAP config should block auth and skip outbound request."""
with patch.object(auth_service, "LDAP_CONFIG_ERROR", "invalid LDAP API URL"):
result = auth_service.authenticate("user", "pass")
assert result is None
mock_post.assert_not_called()
class TestLdapConfigValidation:
"""Validate LDAP base URL hardening rules."""
def test_accepts_https_allowlisted_host(self):
api_base, error = auth_service._validate_ldap_api_url(
"https://ldap.panjit.example",
("ldap.panjit.example",),
)
assert error is None
assert api_base == "https://ldap.panjit.example"
def test_rejects_non_https_url(self):
api_base, error = auth_service._validate_ldap_api_url(
"http://ldap.panjit.example",
("ldap.panjit.example",),
)
assert api_base is None
assert error is not None
assert "HTTPS" in error
def test_rejects_non_allowlisted_host(self):
api_base, error = auth_service._validate_ldap_api_url(
"https://evil.example",
("ldap.panjit.example",),
)
assert api_base is None
assert error is not None
assert "allowlisted" in error
class TestLocalAuthenticate:
"""Tests for local authentication."""
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
def test_local_auth_success(self):
"""Test successful local authentication."""
result = auth_service.authenticate("testuser", "testpass")
assert result is not None
assert result["username"] == "testuser"
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
def test_local_auth_wrong_password(self):
"""Test local authentication with wrong password."""
result = auth_service.authenticate("testuser", "wrongpass")
assert result is None
class TestIsAdmin:
"""Tests for is_admin function."""
def test_is_admin_with_admin_email(self):
"""Test admin check with admin email."""
original = auth_service.ADMIN_EMAILS
try:
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
user = {"mail": "admin@panjit.com.tw"}
assert auth_service.is_admin(user) is True
finally:
auth_service.ADMIN_EMAILS = original
def test_is_admin_with_non_admin_email(self):
"""Test admin check with non-admin email."""
original = auth_service.ADMIN_EMAILS
try:
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
user = {"mail": "user@panjit.com.tw"}
assert auth_service.is_admin(user) is False
finally:
auth_service.ADMIN_EMAILS = original
def test_is_admin_case_insensitive(self):
"""Test admin check is case insensitive."""
original = auth_service.ADMIN_EMAILS
try:
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
user = {"mail": "ADMIN@PANJIT.COM.TW"}
assert auth_service.is_admin(user) is True
finally:
auth_service.ADMIN_EMAILS = original
def test_is_admin_with_missing_mail(self):
"""Test admin check with missing mail field."""
user = {}
assert auth_service.is_admin(user) is False
def test_is_admin_with_empty_mail(self):
"""Test admin check with empty mail field."""
user = {"mail": ""}
assert auth_service.is_admin(user) is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -279,7 +279,7 @@ class TestLayeredCacheTelemetry:
assert telemetry['reads_total'] >= 2
class TestIsCacheAvailable:
class TestIsCacheAvailable:
"""Test is_cache_available function."""
def test_returns_false_when_disabled(self):
@@ -306,8 +306,35 @@ class TestIsCacheAvailable:
mock_client = MagicMock()
mock_client.exists.return_value = 1
with patch.object(cache, 'REDIS_ENABLED', True):
with patch.object(cache, 'get_redis_client', return_value=mock_client):
with patch.object(cache, 'get_key', return_value='mes_wip:data'):
result = cache.is_cache_available()
assert result is True
with patch.object(cache, 'REDIS_ENABLED', True):
with patch.object(cache, 'get_redis_client', return_value=mock_client):
with patch.object(cache, 'get_key', return_value='mes_wip:data'):
result = cache.is_cache_available()
assert result is True
class TestProcessLevelCache:
"""Test bounded process-level cache behavior."""
def test_lru_eviction_prefers_recent_keys(self):
from mes_dashboard.core.cache import ProcessLevelCache
cache = ProcessLevelCache(ttl_seconds=60, max_size=2)
df1 = pd.DataFrame([{"LOTID": "A"}])
df2 = pd.DataFrame([{"LOTID": "B"}])
df3 = pd.DataFrame([{"LOTID": "C"}])
cache.set("a", df1)
cache.set("b", df2)
assert cache.get("a") is not None # refresh recency for "a"
cache.set("c", df3) # should evict "b"
assert cache.get("b") is None
assert cache.get("a") is not None
assert cache.get("c") is not None
def test_wip_process_cache_uses_bounded_config(self):
import mes_dashboard.core.cache as cache
assert cache.WIP_PROCESS_CACHE_MAX_SIZE >= 1
assert cache._wip_df_cache.max_size == cache.WIP_PROCESS_CACHE_MAX_SIZE

View File

@@ -152,11 +152,11 @@ class TestLoadFullTable:
class TestUpdateRedisCache:
"""Test Redis cache update logic."""
def test_update_redis_cache_success(self):
"""Test _update_redis_cache updates cache correctly."""
import mes_dashboard.core.cache_updater as cu
mock_client = MagicMock()
def test_update_redis_cache_success(self):
"""Test _update_redis_cache updates cache correctly."""
import mes_dashboard.core.cache_updater as cu
mock_client = MagicMock()
mock_pipeline = MagicMock()
mock_client.pipeline.return_value = mock_pipeline
@@ -167,22 +167,44 @@ class TestUpdateRedisCache:
with patch.object(cu, 'get_redis_client', return_value=mock_client):
with patch.object(cu, 'get_key', side_effect=lambda k: f'mes_wip:{k}'):
updater = cu.CacheUpdater()
result = updater._update_redis_cache(test_df, '2024-01-15 10:30:00')
updater = cu.CacheUpdater()
result = updater._update_redis_cache(test_df, '2024-01-15 10:30:00')
assert result is True
mock_pipeline.rename.assert_called_once()
mock_pipeline.execute.assert_called_once()
assert result is True
mock_pipeline.execute.assert_called_once()
def test_update_redis_cache_no_client(self):
"""Test _update_redis_cache handles no client."""
import mes_dashboard.core.cache_updater as cu
def test_update_redis_cache_no_client(self):
"""Test _update_redis_cache handles no client."""
import mes_dashboard.core.cache_updater as cu
test_df = pd.DataFrame({'LOTID': ['LOT001']})
with patch.object(cu, 'get_redis_client', return_value=None):
updater = cu.CacheUpdater()
result = updater._update_redis_cache(test_df, '2024-01-15')
assert result is False
with patch.object(cu, 'get_redis_client', return_value=None):
updater = cu.CacheUpdater()
result = updater._update_redis_cache(test_df, '2024-01-15')
assert result is False
def test_update_redis_cache_cleans_staging_key_on_failure(self):
"""Failed publish should clean staged key and keep function safe."""
import mes_dashboard.core.cache_updater as cu
mock_client = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.execute.side_effect = RuntimeError("pipeline failed")
mock_client.pipeline.return_value = mock_pipeline
test_df = pd.DataFrame({'LOTID': ['LOT001'], 'QTY': [100]})
with patch.object(cu, 'get_redis_client', return_value=mock_client):
with patch.object(cu, 'get_key', side_effect=lambda k: f'mes_wip:{k}'):
updater = cu.CacheUpdater()
result = updater._update_redis_cache(test_df, '2024-01-15 10:30:00')
assert result is False
mock_client.delete.assert_called_once()
staged_key = mock_client.delete.call_args.args[0]
assert "staging" in staged_key
class TestCacheUpdateFlow:

View File

@@ -221,3 +221,45 @@ class TestCircuitBreakerDisabled:
# Should still allow (disabled)
assert cb.allow_request() is True
class TestCircuitBreakerLogging:
"""Verify transition logs are emitted without lock-held I/O."""
def test_transition_emits_open_log_and_preserves_state(self):
cb = CircuitBreaker(
"test",
failure_threshold=2,
failure_rate_threshold=0.5,
window_size=4,
)
with patch.object(cb, "_emit_transition_log") as mock_emit:
for _ in range(4):
cb.record_failure()
assert cb.state == CircuitState.OPEN
mock_emit.assert_called_once()
level, message = mock_emit.call_args.args
assert level is not None
assert "OPENED" in message
def test_transition_logging_executes_outside_lock(self):
cb = CircuitBreaker(
"test",
failure_threshold=2,
failure_rate_threshold=0.5,
window_size=4,
)
lock_states: list[bool] = []
def _capture(_level, _message):
lock_states.append(cb._lock.locked())
with patch.object(cb, "_emit_transition_log", side_effect=_capture):
for _ in range(4):
cb.record_failure()
assert lock_states
assert all(not state for state in lock_states)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""Tests for DB secret redaction logging filter."""
from __future__ import annotations
import logging
from mes_dashboard.core.database import (
SecretRedactionFilter,
install_log_redaction_filter,
redact_connection_secrets,
)
def test_redact_connection_secrets_masks_oracle_url_password():
raw = "connect failed: oracle+oracledb://user:super-secret@db-host:1521/service"
masked = redact_connection_secrets(raw)
assert "super-secret" not in masked
assert "user:***@" in masked
def test_redact_connection_secrets_masks_db_password_env_pattern():
raw = "Runtime config error DB_PASSWORD=myPassword123"
masked = redact_connection_secrets(raw)
assert "myPassword123" not in masked
assert "DB_PASSWORD=***" in masked
def test_install_log_redaction_filter_attaches_to_logger_handlers():
logger = logging.getLogger("mes_dashboard.test_redaction")
logger.handlers = []
logger.filters = []
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
logger.addHandler(handler)
install_log_redaction_filter(logger)
assert any(isinstance(f, SecretRedactionFilter) for f in handler.filters)

View File

@@ -13,6 +13,8 @@ def _client():
db._ENGINE = None
app = create_app('testing')
app.config['TESTING'] = True
from mes_dashboard.routes.health_routes import _reset_health_memo_for_tests
_reset_health_memo_for_tests()
return app.test_client()
@@ -78,3 +80,37 @@ def test_deep_health_exposes_route_cache_telemetry(
assert route_cache['degraded'] is False
assert payload['resilience']['recovery_recommendation']['action'] == 'none'
assert payload['resilience']['thresholds']['pool_saturation_warning'] >= 0.5
def test_health_memo_store_and_expire(monkeypatch):
import mes_dashboard.routes.health_routes as hr
hr._reset_health_memo_for_tests()
monkeypatch.setattr(hr, "_health_memo_enabled", lambda: True)
hr._set_health_memo("health", {"status": "healthy"}, 200)
cached = hr._get_health_memo("health")
assert cached == ({"status": "healthy"}, 200)
with hr._HEALTH_MEMO_LOCK:
hr._HEALTH_MEMO["health"] = {"ts": 0.0, "payload": {"status": "old"}, "status": 200}
with patch("mes_dashboard.routes.health_routes.time.time", return_value=100.0):
assert hr._get_health_memo("health") is None
@patch('mes_dashboard.routes.health_routes._health_memo_enabled', return_value=True)
@patch('mes_dashboard.routes.health_routes.check_database', return_value=('ok', None))
@patch('mes_dashboard.routes.health_routes.check_redis', return_value=('ok', None))
def test_health_route_uses_internal_memoization(
_mock_redis,
mock_db,
_mock_enabled,
):
client = _client()
response1 = client.get('/health')
response2 = client.get('/health')
assert response1.status_code == 200
assert response2.status_code == 200
assert mock_db.call_count == 1

View File

@@ -46,6 +46,7 @@ class TestAPIResponseFormat:
assert response.status_code == 200
data = json.loads(response.data)
assert data["success"] is True
assert "single_port_bind" in data["data"]
assert "data" in data
def test_unauthenticated_redirect(self, client):
@@ -249,8 +250,17 @@ class TestWorkerControlAPI:
os.unlink(temp_flag) # Remove so we can test creation
with patch('mes_dashboard.routes.admin_routes.RESTART_FLAG_PATH', temp_flag):
with patch('mes_dashboard.routes.admin_routes._check_restart_cooldown', return_value=(False, 0)):
response = admin_client.post('/admin/api/worker/restart')
with patch(
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
return_value={
"state": "allowed",
"allowed": True,
"cooldown": False,
"blocked": False,
"cooldown_remaining_seconds": 0,
},
):
response = admin_client.post('/admin/api/worker/restart', json={})
assert response.status_code == 200
data = json.loads(response.data)
@@ -264,14 +274,79 @@ class TestWorkerControlAPI:
def test_worker_restart_cooldown(self, admin_client):
"""Worker restart respects cooldown."""
with patch('mes_dashboard.routes.admin_routes._check_restart_cooldown', return_value=(True, 45)):
response = admin_client.post('/admin/api/worker/restart')
with patch(
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
return_value={
"state": "cooldown",
"allowed": False,
"cooldown": True,
"blocked": False,
"cooldown_remaining_seconds": 45,
},
):
response = admin_client.post('/admin/api/worker/restart', json={})
assert response.status_code == 429
data = json.loads(response.data)
assert data["success"] is False
assert "cooldown" in data["error"]["message"].lower()
def test_worker_restart_blocked_requires_override(self, admin_client):
with patch(
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
return_value={
"state": "blocked",
"allowed": False,
"cooldown": False,
"blocked": True,
"cooldown_remaining_seconds": 0,
},
):
response = admin_client.post('/admin/api/worker/restart', json={})
assert response.status_code == 409
data = json.loads(response.data)
assert data["success"] is False
assert "guarded mode" in data["error"]["message"].lower()
def test_worker_restart_manual_override_allowed_when_blocked(self, admin_client):
fd, temp_flag = tempfile.mkstemp()
os.close(fd)
os.unlink(temp_flag)
with (
patch('mes_dashboard.routes.admin_routes.RESTART_FLAG_PATH', temp_flag),
patch(
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
return_value={
"state": "blocked",
"allowed": False,
"cooldown": False,
"blocked": True,
"cooldown_remaining_seconds": 0,
},
),
):
response = admin_client.post(
'/admin/api/worker/restart',
json={
"manual_override": True,
"override_acknowledged": True,
"override_reason": "incident mitigation",
},
)
assert response.status_code == 200
data = json.loads(response.data)
assert data["success"] is True
assert data["data"]["decision"]["decision"] == "manual_override"
assert "single_port_bind" in data["data"]
try:
os.unlink(temp_flag)
except OSError:
pass
class TestCircuitBreakerIntegration:
"""Test circuit breaker integration with database layer."""
@@ -286,6 +361,12 @@ class TestCircuitBreakerIntegration:
assert "state" in cb_status
assert "enabled" in cb_status
def test_system_status_reports_single_port_bind(self, admin_client):
response = admin_client.get('/admin/api/system-status')
assert response.status_code == 200
data = json.loads(response.data)
assert data["data"]["single_port_bind"]
class TestPerformancePage:
"""Test performance monitoring page."""

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""Route-level rate limit behavior tests."""
from __future__ import annotations
from unittest.mock import patch
import mes_dashboard.core.database as db
from mes_dashboard.app import create_app
def _client():
db._ENGINE = None
app = create_app('testing')
app.config['TESTING'] = True
return app.test_client()
@patch('mes_dashboard.services.resource_service.get_merged_resource_status')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
def test_resource_status_rate_limit_returns_429(_mock_limit, mock_service):
client = _client()
response = client.get('/api/resource/status')
assert response.status_code == 429
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '5'
mock_service.assert_not_called()
@patch('mes_dashboard.services.wip_service.get_hold_detail_lots')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 4))
def test_hold_detail_lots_rate_limit_returns_429(_mock_limit, mock_service):
client = _client()
response = client.get('/api/wip/hold-detail/lots?reason=YieldLimit')
assert response.status_code == 429
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '4'
mock_service.assert_not_called()

View File

@@ -4,9 +4,10 @@
Tests aggregation, status classification, and cache query functionality.
"""
import pytest
from unittest.mock import patch, MagicMock
import json
import pytest
from unittest.mock import patch, MagicMock
import json
import pandas as pd
class TestClassifyStatus:
@@ -297,12 +298,17 @@ class TestGetEquipmentStatusById:
"""Test get_equipment_status_by_id function."""
@pytest.fixture(autouse=True)
def reset_modules(self):
"""Reset module state before each test."""
import mes_dashboard.core.redis_client as rc
rc._REDIS_CLIENT = None
yield
rc._REDIS_CLIENT = None
def reset_modules(self):
"""Reset module state before each test."""
import mes_dashboard.core.redis_client as rc
import mes_dashboard.services.realtime_equipment_cache as eq
rc._REDIS_CLIENT = None
eq._equipment_status_cache.invalidate("equipment_status_all")
eq._invalidate_equipment_status_lookup()
yield
rc._REDIS_CLIENT = None
eq._equipment_status_cache.invalidate("equipment_status_all")
eq._invalidate_equipment_status_lookup()
def test_returns_none_when_redis_unavailable(self):
"""Test returns None when Redis client unavailable."""
@@ -350,12 +356,17 @@ class TestGetEquipmentStatusByIds:
"""Test get_equipment_status_by_ids function."""
@pytest.fixture(autouse=True)
def reset_modules(self):
"""Reset module state before each test."""
import mes_dashboard.core.redis_client as rc
rc._REDIS_CLIENT = None
yield
rc._REDIS_CLIENT = None
def reset_modules(self):
"""Reset module state before each test."""
import mes_dashboard.core.redis_client as rc
import mes_dashboard.services.realtime_equipment_cache as eq
rc._REDIS_CLIENT = None
eq._equipment_status_cache.invalidate("equipment_status_all")
eq._invalidate_equipment_status_lookup()
yield
rc._REDIS_CLIENT = None
eq._equipment_status_cache.invalidate("equipment_status_all")
eq._invalidate_equipment_status_lookup()
def test_returns_empty_for_empty_input(self):
"""Test returns empty list for empty input."""
@@ -401,12 +412,17 @@ class TestGetAllEquipmentStatus:
"""Test get_all_equipment_status function."""
@pytest.fixture(autouse=True)
def reset_modules(self):
"""Reset module state before each test."""
import mes_dashboard.core.redis_client as rc
rc._REDIS_CLIENT = None
yield
rc._REDIS_CLIENT = None
def reset_modules(self):
"""Reset module state before each test."""
import mes_dashboard.core.redis_client as rc
import mes_dashboard.services.realtime_equipment_cache as eq
rc._REDIS_CLIENT = None
eq._equipment_status_cache.invalidate("equipment_status_all")
eq._invalidate_equipment_status_lookup()
yield
rc._REDIS_CLIENT = None
eq._equipment_status_cache.invalidate("equipment_status_all")
eq._invalidate_equipment_status_lookup()
def test_returns_empty_when_redis_unavailable(self):
"""Test returns empty list when Redis unavailable."""
@@ -449,7 +465,7 @@ class TestGetAllEquipmentStatus:
assert result[1]['RESOURCEID'] == 'R002'
class TestGetEquipmentStatusCacheStatus:
class TestGetEquipmentStatusCacheStatus:
"""Test get_equipment_status_cache_status function."""
@pytest.fixture
@@ -489,6 +505,44 @@ class TestGetEquipmentStatusCacheStatus:
from mes_dashboard.services.realtime_equipment_cache import get_equipment_status_cache_status
result = get_equipment_status_cache_status()
assert result['enabled'] is True
assert result['loaded'] is True
assert result['count'] == 1000
assert result['enabled'] is True
assert result['loaded'] is True
assert result['count'] == 1000
class TestEquipmentProcessLevelCache:
"""Test bounded process-level cache behavior for equipment status."""
def test_lru_eviction_prefers_recent_keys(self):
import mes_dashboard.services.realtime_equipment_cache as eq
cache = eq._ProcessLevelCache(ttl_seconds=60, max_size=2)
cache.set("a", [{"RESOURCEID": "R001"}])
cache.set("b", [{"RESOURCEID": "R002"}])
assert cache.get("a") is not None # refresh recency
cache.set("c", [{"RESOURCEID": "R003"}]) # should evict "b"
assert cache.get("b") is None
assert cache.get("a") is not None
assert cache.get("c") is not None
def test_global_equipment_cache_uses_bounded_config(self):
import mes_dashboard.services.realtime_equipment_cache as eq
assert eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE >= 1
assert eq._equipment_status_cache.max_size == eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE
class TestSharedQueryFragments:
"""Test shared SQL fragment governance for equipment cache."""
def test_equipment_load_uses_shared_sql_fragment(self):
import mes_dashboard.services.realtime_equipment_cache as eq
from mes_dashboard.services.sql_fragments import EQUIPMENT_STATUS_SELECT_SQL
mock_df = pd.DataFrame([{"RESOURCEID": "R001", "EQUIPMENTID": "EQ-01"}])
with patch.object(eq, "read_sql_df", return_value=mock_df) as mock_read:
eq._load_equipment_status_from_oracle()
sql = mock_read.call_args[0][0]
assert sql.strip() == EQUIPMENT_STATUS_SELECT_SQL.strip()

View File

@@ -483,7 +483,7 @@ class TestBuildFilterBuilder:
# Parameterized query should have bind variables
assert len(params) > 0
def test_includes_asset_status_filter(self):
def test_includes_asset_status_filter(self):
"""Test includes asset status exclusion filter with parameterization."""
import mes_dashboard.services.resource_cache as rc
@@ -496,6 +496,17 @@ class TestBuildFilterBuilder:
# Parameterized query should have bind variables
assert len(params) > 0
def test_resource_load_uses_shared_sql_fragment_template(self):
"""Test resource load path uses shared SQL fragment template."""
import mes_dashboard.services.resource_cache as rc
from mes_dashboard.services.sql_fragments import RESOURCE_TABLE
with patch.object(rc, "read_sql_df", return_value=pd.DataFrame()) as mock_read:
rc._load_from_oracle()
sql = mock_read.call_args[0][0]
assert RESOURCE_TABLE in sql
class TestResourceDerivedIndex:
"""Test derived resource index and telemetry behavior."""
@@ -512,9 +523,12 @@ class TestResourceDerivedIndex:
def test_get_resource_by_id_uses_index_snapshot(self):
import mes_dashboard.services.resource_cache as rc
cache_df = pd.DataFrame([{"RESOURCEID": "R001", "RESOURCENAME": "Machine1"}])
rc._resource_df_cache.set(rc.RESOURCE_DF_CACHE_KEY, cache_df)
snapshot = {
"records": [{"RESOURCEID": "R001", "RESOURCENAME": "Machine1"}],
"by_resource_id": {"R001": {"RESOURCEID": "R001", "RESOURCENAME": "Machine1"}},
"ready": True,
"all_positions": [0],
"by_resource_id": {"R001": 0},
}
with patch.object(rc, "get_resource_index_snapshot", return_value=snapshot):
row = rc.get_resource_by_id("R001")
@@ -561,8 +575,8 @@ class TestResourceDerivedIndex:
"built_at": "2026-02-07T10:00:05",
"version_checked_at": 0.0,
"count": 1,
"records": [{"RESOURCEID": "OLD"}],
"by_resource_id": {"OLD": {"RESOURCEID": "OLD"}},
"all_positions": [0],
"by_resource_id": {"OLD": 0},
}
rebuilt_df = pd.DataFrame([
@@ -576,4 +590,48 @@ class TestResourceDerivedIndex:
snapshot = rc.get_resource_index_snapshot()
assert snapshot["version"] == "v2"
assert snapshot["count"] == 1
assert snapshot["by_resource_id"]["R002"]["RESOURCENAME"] == "Machine2"
assert snapshot["by_resource_id"]["R002"] == 0
records = rc.get_all_resources()
assert records[0]["RESOURCENAME"] == "Machine2"
def test_normalized_index_does_not_store_full_records_copy(self):
import mes_dashboard.services.resource_cache as rc
df = pd.DataFrame([
{"RESOURCEID": "R001", "WORKCENTERNAME": "WC1", "PJ_ISPRODUCTION": 1},
{"RESOURCEID": "R002", "WORKCENTERNAME": "WC2", "PJ_ISPRODUCTION": 0},
])
index = rc._build_resource_index(df, source="redis", version="v1", updated_at="2026-02-08T00:00:00")
assert "records" not in index
assert index["by_resource_id"]["R001"] == 0
assert index["by_resource_id"]["R002"] == 1
assert index["memory"]["index_bytes"] > 0
assert index["memory"]["records_json_bytes"] == 0
class TestResourceProcessLevelCache:
"""Test bounded process-level cache for resource data."""
def test_lru_eviction_prefers_recent_keys(self):
import mes_dashboard.services.resource_cache as rc
cache = rc._ProcessLevelCache(ttl_seconds=60, max_size=2)
df1 = pd.DataFrame([{"RESOURCEID": "R001"}])
df2 = pd.DataFrame([{"RESOURCEID": "R002"}])
df3 = pd.DataFrame([{"RESOURCEID": "R003"}])
cache.set("a", df1)
cache.set("b", df2)
assert cache.get("a") is not None # refresh recency for "a"
cache.set("c", df3) # should evict "b"
assert cache.get("b") is None
assert cache.get("a") is not None
assert cache.get("c") is not None
def test_resource_process_cache_uses_bounded_config(self):
import mes_dashboard.services.resource_cache as rc
assert rc.RESOURCE_PROCESS_CACHE_MAX_SIZE >= 1
assert rc._resource_df_cache.max_size == rc.RESOURCE_PROCESS_CACHE_MAX_SIZE

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""Tests for resource route helpers and safeguards."""
from __future__ import annotations
def test_clean_nan_values_handles_deep_nesting_without_recursion_error():
from mes_dashboard.routes.resource_routes import _clean_nan_values
payload = current = {}
for _ in range(2500):
nxt = {}
current["next"] = nxt
current = nxt
current["value"] = float("nan")
cleaned = _clean_nan_values(payload)
cursor = cleaned
for _ in range(2500):
cursor = cursor["next"]
assert cursor["value"] is None
def test_clean_nan_values_breaks_cycles_safely():
from mes_dashboard.routes.resource_routes import _clean_nan_values
payload = {"name": "root"}
payload["self"] = payload
cleaned = _clean_nan_values(payload)
assert cleaned["name"] == "root"
assert cleaned["self"] is None

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""Tests for runtime contract loading and drift validation."""
from __future__ import annotations
from pathlib import Path
from mes_dashboard.core.runtime_contract import (
load_runtime_contract,
validate_runtime_contract,
)
def test_runtime_contract_resolves_relative_watchdog_paths(tmp_path, monkeypatch):
monkeypatch.setenv("MES_DASHBOARD_ROOT", str(tmp_path))
monkeypatch.setenv("WATCHDOG_RUNTIME_DIR", "./tmp")
monkeypatch.setenv("WATCHDOG_RESTART_FLAG", "./tmp/restart.flag")
monkeypatch.setenv("WATCHDOG_PID_FILE", "./tmp/gunicorn.pid")
monkeypatch.setenv("WATCHDOG_STATE_FILE", "./tmp/restart_state.json")
contract = load_runtime_contract()
assert Path(contract["watchdog_runtime_dir"]) == (tmp_path / "tmp").resolve()
assert Path(contract["watchdog_restart_flag"]) == (tmp_path / "tmp" / "restart.flag").resolve()
assert Path(contract["watchdog_pid_file"]) == (tmp_path / "tmp" / "gunicorn.pid").resolve()
def test_runtime_contract_detects_flag_pid_drift():
contract = {
"watchdog_runtime_dir": "/opt/runtime",
"watchdog_restart_flag": "/tmp/restart.flag",
"watchdog_pid_file": "/tmp/gunicorn.pid",
"watchdog_state_file": "/tmp/restart_state.json",
"gunicorn_bind": "0.0.0.0:8080",
"conda_bin": "",
"conda_env_name": "mes-dashboard",
}
errors = validate_runtime_contract(contract, strict=False)
assert any("WATCHDOG_RESTART_FLAG" in err for err in errors)
assert any("WATCHDOG_PID_FILE" in err for err in errors)

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""Runtime hardening tests for startup security, CSRF, and shutdown lifecycle."""
from __future__ import annotations
from unittest.mock import patch
import pytest
import mes_dashboard.core.database as db
from mes_dashboard.app import create_app
from mes_dashboard.routes.health_routes import check_database
@pytest.fixture
def testing_app_factory(monkeypatch):
def _factory(*, csrf_enabled: bool = False):
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
db._ENGINE = None
db._HEALTH_ENGINE = None
app = create_app("testing")
app.config["TESTING"] = True
app.config["CSRF_ENABLED"] = csrf_enabled
return app
return _factory
def _shutdown(app):
shutdown = app.extensions.get("runtime_shutdown")
if callable(shutdown):
shutdown()
def test_production_requires_secret_key(monkeypatch):
monkeypatch.delenv("SECRET_KEY", raising=False)
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
db._ENGINE = None
db._HEALTH_ENGINE = None
with pytest.raises(RuntimeError, match="SECRET_KEY"):
create_app("production")
def test_login_post_rejects_missing_csrf(testing_app_factory):
app = testing_app_factory(csrf_enabled=True)
client = app.test_client()
response = client.post("/admin/login", data={"username": "user", "password": "pw"})
assert response.status_code == 403
assert "CSRF" in response.get_data(as_text=True)
_shutdown(app)
def test_login_success_rotates_session_and_clears_legacy_state(testing_app_factory):
app = testing_app_factory(csrf_enabled=True)
client = app.test_client()
client.get("/admin/login")
with client.session_transaction() as sess:
sess["_csrf_token"] = "seed-token"
sess["legacy"] = "keep-me-out"
with (
patch("mes_dashboard.routes.auth_routes.authenticate") as mock_auth,
patch("mes_dashboard.routes.auth_routes.is_admin", return_value=True),
):
mock_auth.return_value = {
"username": "admin",
"displayName": "Admin",
"mail": "admin@example.com",
"department": "IT",
}
response = client.post(
"/admin/login?next=/",
data={
"username": "admin",
"password": "secret",
"csrf_token": "seed-token",
},
follow_redirects=False,
)
assert response.status_code == 302
with client.session_transaction() as sess:
assert "legacy" not in sess
assert "admin" in sess
assert sess.get("_csrf_token")
assert sess.get("_csrf_token") != "seed-token"
_shutdown(app)
def test_runtime_shutdown_hook_invokes_all_cleanup_handlers(testing_app_factory):
app = testing_app_factory(csrf_enabled=False)
with (
patch("mes_dashboard.app.stop_cache_updater") as mock_cache_stop,
patch("mes_dashboard.app.stop_equipment_status_sync_worker") as mock_sync_stop,
patch("mes_dashboard.app.close_redis") as mock_close_redis,
patch("mes_dashboard.app.dispose_engine") as mock_dispose_engine,
):
app.extensions["runtime_shutdown"]()
mock_cache_stop.assert_called_once()
mock_sync_stop.assert_called_once()
mock_close_redis.assert_called_once()
mock_dispose_engine.assert_called_once()
_shutdown(app)
def test_health_check_uses_dedicated_health_engine():
with patch("mes_dashboard.routes.health_routes.get_health_engine") as mock_engine:
conn_ctx = mock_engine.return_value.connect.return_value
status, error = check_database()
assert status == "ok"
assert error is None
mock_engine.assert_called_once()
conn_ctx.__enter__.assert_called_once()
@patch("mes_dashboard.routes.health_routes.check_database", return_value=("ok", None))
@patch("mes_dashboard.routes.health_routes.check_redis", return_value=("ok", None))
@patch("mes_dashboard.routes.health_routes.get_route_cache_status", return_value={"mode": "l1+l2", "degraded": False})
@patch("mes_dashboard.routes.health_routes.get_pool_status", return_value={"saturation": 1.0})
@patch("mes_dashboard.core.circuit_breaker.get_circuit_breaker_status", return_value={"state": "CLOSED"})
def test_health_reports_pool_saturation_degraded_reason(
_mock_circuit,
_mock_pool_status,
_mock_route_cache,
_mock_redis,
_mock_db,
testing_app_factory,
):
app = testing_app_factory(csrf_enabled=False)
response = app.test_client().get("/health")
assert response.status_code == 200
payload = response.get_json()
assert payload["degraded_reason"] == "db_pool_saturated"
assert payload["resilience"]["recovery_recommendation"]["action"] == "consider_controlled_worker_restart"
_shutdown(app)
def test_security_headers_applied_globally(testing_app_factory):
app = testing_app_factory(csrf_enabled=False)
response = app.test_client().get("/")
assert response.status_code == 200
assert "Content-Security-Policy" in response.headers
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert "Referrer-Policy" in response.headers
_shutdown(app)
def test_hsts_header_enabled_in_production(monkeypatch):
monkeypatch.setenv("SECRET_KEY", "test-production-secret-key")
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
monkeypatch.setenv("RUNTIME_CONTRACT_ENFORCE", "false")
db._ENGINE = None
db._HEALTH_ENGINE = None
app = create_app("production")
app.config["TESTING"] = True
response = app.test_client().get("/")
assert response.status_code == 200
assert "Strict-Transport-Security" in response.headers
_shutdown(app)

38
tests/test_utils.py Normal file
View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""Unit tests for shared utility helpers."""
from __future__ import annotations
from pathlib import Path
from mes_dashboard.core.utils import parse_bool_query
def test_parse_bool_query_truthy_values():
assert parse_bool_query("true") is True
assert parse_bool_query("1") is True
assert parse_bool_query("YES") is True
def test_parse_bool_query_falsy_values():
assert parse_bool_query("false") is False
assert parse_bool_query("0") is False
assert parse_bool_query("no") is False
def test_parse_bool_query_unknown_uses_default():
assert parse_bool_query("", default=True) is True
assert parse_bool_query("not-a-bool", default=False) is False
def test_route_modules_do_not_define_duplicate_parse_bool_helpers():
routes_dir = Path(__file__).resolve().parents[1] / "src" / "mes_dashboard" / "routes"
route_files = sorted(routes_dir.glob("*_routes.py"))
duplicates = []
for route_file in route_files:
text = route_file.read_text(encoding="utf-8")
if "def _parse_bool" in text:
duplicates.append(route_file.name)
assert duplicates == []

View File

@@ -129,7 +129,7 @@ class TestOverviewHoldRoute(TestWipRoutesBase):
self.assertFalse(data['success'])
class TestDetailRoute(TestWipRoutesBase):
class TestDetailRoute(TestWipRoutesBase):
"""Test GET /api/wip/detail/<workcenter> endpoint."""
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
@@ -217,10 +217,10 @@ class TestDetailRoute(TestWipRoutesBase):
self.assertEqual(call_args.kwargs['page_size'], 500)
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
def test_handles_page_less_than_one(self, mock_get_detail):
"""Page number less than 1 should be set to 1."""
mock_get_detail.return_value = {
'workcenter': '切割',
def test_handles_page_less_than_one(self, mock_get_detail):
"""Page number less than 1 should be set to 1."""
mock_get_detail.return_value = {
'workcenter': '切割',
'summary': {'total_lots': 0, 'on_equipment_lots': 0,
'waiting_lots': 0, 'hold_lots': 0},
'specs': [],
@@ -232,19 +232,50 @@ class TestDetailRoute(TestWipRoutesBase):
response = self.client.get('/api/wip/detail/切割?page=0')
call_args = mock_get_detail.call_args
self.assertEqual(call_args.kwargs['page'], 1)
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
def test_returns_error_on_failure(self, mock_get_detail):
"""Should return success=False and 500 on failure."""
mock_get_detail.return_value = None
response = self.client.get('/api/wip/detail/不存在的工站')
data = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(data['success'])
call_args = mock_get_detail.call_args
self.assertEqual(call_args.kwargs['page'], 1)
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
def test_handles_page_size_less_than_one(self, mock_get_detail):
"""Page size less than 1 should be set to 1."""
mock_get_detail.return_value = {
'workcenter': '切割',
'summary': {'total_lots': 0, 'on_equipment_lots': 0,
'waiting_lots': 0, 'hold_lots': 0},
'specs': [],
'lots': [],
'pagination': {'page': 1, 'page_size': 1,
'total_count': 0, 'total_pages': 1},
'sys_date': None
}
self.client.get('/api/wip/detail/切割?page_size=0')
call_args = mock_get_detail.call_args
self.assertEqual(call_args.kwargs['page_size'], 1)
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
def test_returns_error_on_failure(self, mock_get_detail):
"""Should return success=False and 500 on failure."""
mock_get_detail.return_value = None
response = self.client.get('/api/wip/detail/不存在的工站')
data = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(data['success'])
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7))
def test_detail_rate_limited_returns_429(self, _mock_limit, mock_get_detail):
"""Rate-limited detail requests should return 429."""
response = self.client.get('/api/wip/detail/焊接_DB')
data = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertFalse(data['success'])
self.assertEqual(data['error']['code'], 'TOO_MANY_REQUESTS')
mock_get_detail.assert_not_called()
class TestMetaWorkcentersRoute(TestWipRoutesBase):

View File

@@ -4,7 +4,8 @@
Tests workcenter group lookup and mapping functionality.
"""
import pytest
import importlib
import pytest
from unittest.mock import patch, MagicMock
import pandas as pd
@@ -347,3 +348,17 @@ class TestGetCacheStatus:
assert result['last_refresh'] is not None
assert result['workcenter_groups_count'] == 1
assert result['workcenter_mapping_count'] == 1
class TestFilterCacheConfig:
"""Test environment-based filter-cache source configuration."""
def test_filter_cache_views_are_env_configurable(self, monkeypatch):
monkeypatch.setenv("FILTER_CACHE_WIP_VIEW", "CUSTOM.WIP_VIEW")
monkeypatch.setenv("FILTER_CACHE_SPEC_WORKCENTER_VIEW", "CUSTOM.SPEC_VIEW")
import mes_dashboard.services.filter_cache as fc
reloaded = importlib.reload(fc)
assert reloaded.WIP_VIEW == "CUSTOM.WIP_VIEW"
assert reloaded.SPEC_WORKCENTER_VIEW == "CUSTOM.SPEC_VIEW"

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Unit tests for worker recovery policy guards."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from mes_dashboard.core.worker_recovery_policy import (
decide_restart_request,
evaluate_worker_recovery_state,
)
def test_policy_enters_blocked_state_when_attempts_exceed_threshold(monkeypatch):
monkeypatch.setenv("WORKER_RESTART_RETRY_BUDGET", "2")
monkeypatch.setenv("WORKER_RESTART_CHURN_THRESHOLD", "2")
monkeypatch.setenv("WORKER_RESTART_WINDOW_SECONDS", "120")
monkeypatch.setenv("WORKER_RESTART_COOLDOWN", "10")
now = datetime(2026, 2, 8, 12, 0, tzinfo=timezone.utc)
history = [
{"requested_at": (now - timedelta(seconds=30)).isoformat()},
{"requested_at": (now - timedelta(seconds=60)).isoformat()},
]
state = evaluate_worker_recovery_state(history, now=now)
assert state["blocked"] is True
assert state["state"] == "blocked"
assert state["allowed"] is False
def test_policy_reports_cooldown_when_recent_request_exists(monkeypatch):
monkeypatch.setenv("WORKER_RESTART_RETRY_BUDGET", "5")
monkeypatch.setenv("WORKER_RESTART_CHURN_THRESHOLD", "5")
monkeypatch.setenv("WORKER_RESTART_WINDOW_SECONDS", "300")
monkeypatch.setenv("WORKER_RESTART_COOLDOWN", "60")
now = datetime(2026, 2, 8, 12, 0, tzinfo=timezone.utc)
last_requested = (now - timedelta(seconds=20)).isoformat()
state = evaluate_worker_recovery_state([], last_requested_at=last_requested, now=now)
assert state["cooldown"] is True
assert state["state"] == "cooldown"
assert state["cooldown_remaining_seconds"] > 0
def test_manual_override_decision_requires_acknowledgement():
blocked_state = {
"blocked": True,
"cooldown": False,
}
denied = decide_restart_request(blocked_state, source="manual")
assert denied["allowed"] is False
assert denied["requires_acknowledgement"] is True
allowed = decide_restart_request(
blocked_state,
source="manual",
manual_override=True,
override_acknowledged=True,
)
assert allowed["allowed"] is True
assert allowed["decision"] == "manual_override"