feat: finalize portal no-iframe migration baseline and archive change
This commit is contained in:
@@ -23,26 +23,25 @@ class TestPortalPage:
|
||||
# Wait for page to load
|
||||
expect(page.locator('h1')).to_contain_text('MES 報表入口')
|
||||
|
||||
def test_portal_has_all_tabs(self, page: Page, app_server: str):
|
||||
"""Portal should have all navigation tabs."""
|
||||
def test_portal_has_all_sidebar_routes(self, page: Page, app_server: str):
|
||||
"""Portal should expose route-based sidebar entries."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Check released tabs exist
|
||||
expect(page.locator('.tab:has-text("WIP 即時概況")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("設備即時概況")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("設備歷史績效")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("設備維修查詢")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("批次追蹤工具")')).to_be_visible()
|
||||
|
||||
def test_portal_tab_switching(self, page: Page, app_server: str):
|
||||
"""Portal tabs should switch iframe content."""
|
||||
expect(page.locator('.sidebar-item:has-text("WIP 即時概況")')).to_be_visible()
|
||||
expect(page.locator('.sidebar-item:has-text("設備即時概況")')).to_be_visible()
|
||||
expect(page.locator('.sidebar-item:has-text("設備歷史績效")')).to_be_visible()
|
||||
expect(page.locator('.sidebar-item:has-text("設備維修查詢")')).to_be_visible()
|
||||
|
||||
def test_portal_sidebar_navigation_uses_direct_routes(self, page: Page, app_server: str):
|
||||
"""Sidebar click should navigate to direct route without iframe switching."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Click on a different tab
|
||||
page.locator('.tab:has-text("設備即時概況")').click()
|
||||
|
||||
# Verify the tab is active
|
||||
expect(page.locator('.tab:has-text("設備即時概況")')).to_have_class(re.compile(r'active'))
|
||||
first_route = page.locator('.sidebar-item[data-route]').first
|
||||
expect(first_route).to_be_visible()
|
||||
target_href = first_route.get_attribute('href')
|
||||
assert target_href and target_href.startswith('/'), "sidebar route href missing"
|
||||
first_route.click()
|
||||
expect(page).to_have_url(re.compile(f".*{re.escape(target_href)}$"))
|
||||
|
||||
def test_portal_health_popup_clickable(self, page: Page, app_server: str):
|
||||
"""Health status pill should toggle popup visibility on click."""
|
||||
|
||||
@@ -12,7 +12,6 @@ Run with: pytest tests/stress/test_frontend_stress.py -v -s
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import re
|
||||
import requests
|
||||
from urllib.parse import quote
|
||||
from playwright.sync_api import Page, expect
|
||||
@@ -258,51 +257,55 @@ class TestMesApiStress:
|
||||
assert total_resolved >= 5, f"Too many unresolved requests"
|
||||
|
||||
|
||||
@pytest.mark.stress
|
||||
@pytest.mark.stress
|
||||
class TestPageNavigationStress:
|
||||
"""Stress tests for rapid page navigation."""
|
||||
"""Stress tests for rapid route navigation."""
|
||||
|
||||
def test_rapid_tab_switching(self, page: Page, app_server: str):
|
||||
"""Test rapid tab switching in portal."""
|
||||
def test_rapid_route_switching(self, page: Page, app_server: str):
|
||||
"""Rapid direct-route switching should remain responsive."""
|
||||
page.goto(app_server, wait_until='domcontentloaded', timeout=30000)
|
||||
sidebar_items = page.locator('.sidebar-item[data-target]')
|
||||
sidebar_items = page.locator('.sidebar-item[data-route]')
|
||||
expect(sidebar_items.first).to_be_visible()
|
||||
item_count = sidebar_items.count()
|
||||
assert item_count >= 1, "No portal sidebar pages available for navigation stress test"
|
||||
assert item_count >= 1, "No portal sidebar routes available for stress test"
|
||||
|
||||
route_hrefs = []
|
||||
checked = min(item_count, 5)
|
||||
for idx in range(checked):
|
||||
href = sidebar_items.nth(idx).get_attribute('href')
|
||||
if href and href.startswith('/'):
|
||||
route_hrefs.append(href)
|
||||
|
||||
assert route_hrefs, "Unable to resolve route hrefs from sidebar"
|
||||
|
||||
js_errors = []
|
||||
page.on("pageerror", lambda error: js_errors.append(str(error)))
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Rapidly switch pages 20 times
|
||||
for i in range(20):
|
||||
item = sidebar_items.nth(i % item_count)
|
||||
item.click()
|
||||
page.wait_for_timeout(50)
|
||||
page.goto(f"{app_server}{route_hrefs[i % len(route_hrefs)]}", wait_until='domcontentloaded', timeout=60000)
|
||||
expect(page.locator('body')).to_be_visible()
|
||||
page.wait_for_timeout(80)
|
||||
|
||||
switch_time = time.time() - start_time
|
||||
print(f"\n 20 sidebar switches in {switch_time:.3f}s")
|
||||
print(f"\n 20 route switches in {switch_time:.3f}s")
|
||||
assert len(js_errors) == 0, f"JS errors detected during route switching: {js_errors[:3]}"
|
||||
|
||||
# Page should still be responsive
|
||||
expect(page.locator('h1')).to_contain_text('MES 報表入口')
|
||||
print(" Portal remained stable")
|
||||
|
||||
def test_portal_iframe_stress(self, page: Page, app_server: str):
|
||||
"""Test portal remains responsive with iframe loading."""
|
||||
def test_portal_navigation_contract_without_iframe(self, page: Page, app_server: str):
|
||||
"""Portal sidebar should expose route metadata and no iframe DOM."""
|
||||
page.goto(app_server, wait_until='domcontentloaded', timeout=30000)
|
||||
sidebar_items = page.locator('.sidebar-item[data-target]')
|
||||
sidebar_items = page.locator('.sidebar-item[data-route]')
|
||||
expect(sidebar_items.first).to_be_visible()
|
||||
item_count = sidebar_items.count()
|
||||
assert item_count >= 1, "No portal sidebar pages available for iframe stress test"
|
||||
assert sidebar_items.count() >= 1, "No route sidebar items found"
|
||||
|
||||
checked = min(item_count, 4)
|
||||
for idx in range(checked):
|
||||
item = sidebar_items.nth(idx)
|
||||
item.click()
|
||||
page.wait_for_timeout(200)
|
||||
iframe_count = page.locator('iframe').count()
|
||||
assert iframe_count == 0, "Portal should not render iframe after migration"
|
||||
|
||||
# Verify clicked item is active
|
||||
expect(item).to_have_class(re.compile(r'active'))
|
||||
for idx in range(min(sidebar_items.count(), 3)):
|
||||
href = sidebar_items.nth(idx).get_attribute('href')
|
||||
assert href and href.startswith('/'), f"Invalid sidebar href: {href}"
|
||||
|
||||
print(f"\n All {checked} sidebar pages clickable and responsive")
|
||||
print("\n Portal route sidebar contract verified without iframe")
|
||||
|
||||
|
||||
@pytest.mark.stress
|
||||
|
||||
@@ -49,6 +49,7 @@ class AppFactoryTests(unittest.TestCase):
|
||||
rules = {rule.rule for rule in app.url_map.iter_rules()}
|
||||
expected = {
|
||||
"/",
|
||||
"/portal-shell",
|
||||
"/tables",
|
||||
"/resource",
|
||||
"/wip-overview",
|
||||
@@ -69,6 +70,7 @@ class AppFactoryTests(unittest.TestCase):
|
||||
"/api/wip/meta/packages",
|
||||
"/api/resource/status/summary",
|
||||
"/api/dashboard/kpi",
|
||||
"/api/portal/navigation",
|
||||
"/api/excel-query/upload",
|
||||
"/api/query-tool/resolve",
|
||||
"/api/tmtt-defect/analysis",
|
||||
@@ -76,6 +78,54 @@ class AppFactoryTests(unittest.TestCase):
|
||||
missing = expected - rules
|
||||
self.assertFalse(missing, f"Missing routes: {sorted(missing)}")
|
||||
|
||||
def test_portal_spa_flag_default_enabled(self):
|
||||
old = os.environ.pop("PORTAL_SPA_ENABLED", None)
|
||||
try:
|
||||
app = create_app("testing")
|
||||
self.assertTrue(app.config.get("PORTAL_SPA_ENABLED"))
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/", follow_redirects=False)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers.get("Location"), "/portal-shell")
|
||||
finally:
|
||||
if old is not None:
|
||||
os.environ["PORTAL_SPA_ENABLED"] = old
|
||||
|
||||
def test_portal_spa_flag_disabled_via_env(self):
|
||||
old = os.environ.get("PORTAL_SPA_ENABLED")
|
||||
os.environ["PORTAL_SPA_ENABLED"] = "false"
|
||||
try:
|
||||
app = create_app("testing")
|
||||
self.assertFalse(app.config.get("PORTAL_SPA_ENABLED"))
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/")
|
||||
html = response.data.decode("utf-8")
|
||||
self.assertIn('data-portal-spa-enabled="false"', html)
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("PORTAL_SPA_ENABLED", None)
|
||||
else:
|
||||
os.environ["PORTAL_SPA_ENABLED"] = old
|
||||
|
||||
def test_portal_spa_flag_enabled_via_env(self):
|
||||
old = os.environ.get("PORTAL_SPA_ENABLED")
|
||||
os.environ["PORTAL_SPA_ENABLED"] = "true"
|
||||
try:
|
||||
app = create_app("testing")
|
||||
self.assertTrue(app.config.get("PORTAL_SPA_ENABLED"))
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/", follow_redirects=False)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers.get("Location"), "/portal-shell")
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("PORTAL_SPA_ENABLED", None)
|
||||
else:
|
||||
os.environ["PORTAL_SPA_ENABLED"] = old
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
130
tests/test_cutover_gates.py
Normal file
130
tests/test_cutover_gates.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cutover gate enforcement tests for portal no-iframe migration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASELINE_VISIBILITY_FILE = ROOT / "docs" / "migration" / "portal-no-iframe" / "baseline_drawer_visibility.json"
|
||||
BASELINE_API_FILE = ROOT / "docs" / "migration" / "portal-no-iframe" / "baseline_api_payload_contracts.json"
|
||||
ROLLBACK_RUNBOOK = ROOT / "docs" / "migration" / "portal-no-iframe" / "rollback_rehearsal_runbook.md"
|
||||
ROLLBACK_STRATEGY = ROOT / "docs" / "migration" / "portal-no-iframe" / "rollback_strategy_shell_and_wrappers.md"
|
||||
LEGACY_REWRITE_SMOKE_CHECKLIST = ROOT / "docs" / "migration" / "portal-no-iframe" / "legacy_rewrite_smoke_checklists.md"
|
||||
STRESS_SUITE = ROOT / "tests" / "stress" / "test_frontend_stress.py"
|
||||
|
||||
|
||||
def _login_as_admin(client) -> None:
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin"] = {"displayName": "Admin", "employeeNo": "A001"}
|
||||
|
||||
|
||||
def _route_set(drawers: list[dict]) -> set[str]:
|
||||
return {
|
||||
str(page.get("route"))
|
||||
for drawer in drawers
|
||||
for page in drawer.get("pages", [])
|
||||
if page.get("route")
|
||||
}
|
||||
|
||||
|
||||
def test_g1_route_availability_gate_p0_routes_are_2xx_or_3xx():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
client = app.test_client()
|
||||
|
||||
p0_routes = [
|
||||
"/",
|
||||
"/portal-shell",
|
||||
"/api/portal/navigation",
|
||||
"/wip-overview",
|
||||
"/resource",
|
||||
"/qc-gate",
|
||||
]
|
||||
|
||||
statuses = [client.get(route).status_code for route in p0_routes]
|
||||
assert all(200 <= status < 400 for status in statuses), statuses
|
||||
|
||||
|
||||
def test_g2_drawer_parity_gate_matches_baseline_for_admin_and_non_admin():
|
||||
baseline = json.loads(BASELINE_VISIBILITY_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
|
||||
non_admin_client = app.test_client()
|
||||
non_admin_payload = json.loads(non_admin_client.get("/api/portal/navigation").data.decode("utf-8"))
|
||||
|
||||
admin_client = app.test_client()
|
||||
_login_as_admin(admin_client)
|
||||
admin_payload = json.loads(admin_client.get("/api/portal/navigation").data.decode("utf-8"))
|
||||
|
||||
assert _route_set(non_admin_payload["drawers"]) == _route_set(baseline["non_admin"])
|
||||
assert _route_set(admin_payload["drawers"]) == _route_set(baseline["admin"])
|
||||
|
||||
|
||||
def test_g3_workflow_smoke_gate_critical_routes_reachable_for_admin():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
client = app.test_client()
|
||||
_login_as_admin(client)
|
||||
|
||||
smoke_routes = [
|
||||
"/",
|
||||
"/wip-overview",
|
||||
"/wip-detail?workcenter=TMTT&type=PJA3460&status=queue",
|
||||
"/hold-detail?reason=YieldLimit",
|
||||
"/hold-overview",
|
||||
"/hold-history",
|
||||
"/resource",
|
||||
"/resource-history?start_date=2026-01-01&end_date=2026-01-31",
|
||||
"/qc-gate",
|
||||
"/job-query",
|
||||
"/excel-query",
|
||||
"/query-tool",
|
||||
"/tmtt-defect",
|
||||
]
|
||||
|
||||
statuses = [client.get(route).status_code for route in smoke_routes]
|
||||
assert all(200 <= status < 400 for status in statuses), statuses
|
||||
|
||||
|
||||
def test_g4_client_stability_gate_assertion_present_in_stress_suite():
|
||||
content = STRESS_SUITE.read_text(encoding="utf-8")
|
||||
assert 'page.on("pageerror"' in content
|
||||
assert 'assert len(js_errors) == 0' in content
|
||||
|
||||
|
||||
def test_g5_data_contract_gate_baseline_keys_are_defined_for_registered_apis():
|
||||
baseline = json.loads(BASELINE_API_FILE.read_text(encoding="utf-8"))
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
registered_routes = {rule.rule for rule in app.url_map.iter_rules()}
|
||||
|
||||
for api_route, contract in baseline.get("apis", {}).items():
|
||||
assert api_route in registered_routes, f"Missing API route in app map: {api_route}"
|
||||
required_keys = contract.get("required_keys", [])
|
||||
assert required_keys, f"No required_keys defined for {api_route}"
|
||||
assert all(isinstance(key, str) and key for key in required_keys)
|
||||
|
||||
|
||||
def test_g7_rollback_readiness_gate_has_15_minute_slo_and_operator_steps():
|
||||
rehearsal = ROLLBACK_RUNBOOK.read_text(encoding="utf-8")
|
||||
strategy = ROLLBACK_STRATEGY.read_text(encoding="utf-8")
|
||||
|
||||
assert "15" in rehearsal
|
||||
assert "PORTAL_SPA_ENABLED=false" in strategy
|
||||
assert "/api/portal/navigation" in strategy
|
||||
|
||||
|
||||
def test_legacy_rewrite_smoke_checklist_covers_all_wrapped_pages():
|
||||
content = LEGACY_REWRITE_SMOKE_CHECKLIST.read_text(encoding="utf-8")
|
||||
|
||||
assert "tmtt-defect" in content
|
||||
assert "job-query" in content
|
||||
assert "excel-query" in content
|
||||
assert "query-tool" in content
|
||||
assert "SMOKE-01" in content
|
||||
@@ -114,3 +114,117 @@ def test_health_route_uses_internal_memoization(
|
||||
assert response1.status_code == 200
|
||||
assert response2.status_code == 200
|
||||
assert mock_db.call_count == 1
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.health_routes.get_portal_shell_asset_status')
|
||||
def test_frontend_shell_health_endpoint_healthy(mock_status):
|
||||
mock_status.return_value = {
|
||||
"status": "healthy",
|
||||
"route": "/portal-shell",
|
||||
"checks": {
|
||||
"portal_shell_html": {"exists": True},
|
||||
"portal_shell_js": {"exists": True},
|
||||
"portal_shell_css": {"exists": True},
|
||||
"tailwind_css": {"exists": True},
|
||||
"html_references": {
|
||||
"portal_shell_js": True,
|
||||
"portal_shell_css": True,
|
||||
"tailwind_css": True,
|
||||
},
|
||||
},
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"http_code": 200,
|
||||
}
|
||||
|
||||
response = _client().get('/health/frontend-shell')
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["status"] == "healthy"
|
||||
assert payload["checks"]["portal_shell_css"]["exists"] is True
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.health_routes.get_portal_shell_asset_status')
|
||||
def test_frontend_shell_health_endpoint_unhealthy(mock_status):
|
||||
mock_status.return_value = {
|
||||
"status": "unhealthy",
|
||||
"route": "/portal-shell",
|
||||
"checks": {
|
||||
"portal_shell_html": {"exists": False},
|
||||
"portal_shell_js": {"exists": False},
|
||||
"portal_shell_css": {"exists": False},
|
||||
"tailwind_css": {"exists": False},
|
||||
"html_references": {
|
||||
"portal_shell_js": False,
|
||||
"portal_shell_css": False,
|
||||
"tailwind_css": False,
|
||||
},
|
||||
},
|
||||
"errors": ["asset missing: static/dist/portal-shell.css"],
|
||||
"warnings": [],
|
||||
"http_code": 503,
|
||||
}
|
||||
|
||||
response = _client().get('/health/frontend-shell')
|
||||
assert response.status_code == 503
|
||||
payload = response.get_json()
|
||||
assert payload["status"] == "unhealthy"
|
||||
assert any("portal-shell.css" in error for error in payload.get("errors", []))
|
||||
|
||||
|
||||
def test_get_portal_shell_asset_status_reports_nested_html_as_healthy(tmp_path):
|
||||
from mes_dashboard.routes.health_routes import get_portal_shell_asset_status
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
dist_dir = static_dir / "dist"
|
||||
nested_dir = dist_dir / "src" / "portal-shell"
|
||||
nested_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(nested_dir / "index.html").write_text(
|
||||
"<html><head>"
|
||||
"<link rel='stylesheet' href='/static/dist/tailwind.css'>"
|
||||
"<link rel='stylesheet' href='/static/dist/portal-shell.css'>"
|
||||
"<script type='module' src='/static/dist/portal-shell.js'></script>"
|
||||
"</head><body><div id='app'></div></body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(dist_dir / "portal-shell.js").write_text("console.log('ok');", encoding="utf-8")
|
||||
(dist_dir / "portal-shell.css").write_text(".shell{}", encoding="utf-8")
|
||||
(dist_dir / "tailwind.css").write_text(".tw{}", encoding="utf-8")
|
||||
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
app.static_folder = str(static_dir)
|
||||
|
||||
with app.app_context():
|
||||
result = get_portal_shell_asset_status()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["checks"]["portal_shell_html"]["source"] == "nested"
|
||||
assert result["checks"]["html_references"]["portal_shell_css"] is True
|
||||
|
||||
|
||||
def test_get_portal_shell_asset_status_reports_missing_css_as_unhealthy(tmp_path):
|
||||
from mes_dashboard.routes.health_routes import get_portal_shell_asset_status
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
dist_dir = static_dir / "dist"
|
||||
dist_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(dist_dir / "portal-shell.html").write_text(
|
||||
"<html><head>"
|
||||
"<script type='module' src='/static/dist/portal-shell.js'></script>"
|
||||
"</head><body><div id='app'></div></body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(dist_dir / "portal-shell.js").write_text("console.log('ok');", encoding="utf-8")
|
||||
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
app.static_folder = str(static_dir)
|
||||
|
||||
with app.app_context():
|
||||
result = get_portal_shell_asset_status()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert any("portal-shell.css" in error for error in result["errors"])
|
||||
|
||||
31
tests/test_navigation_contract.py
Normal file
31
tests/test_navigation_contract.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for portal navigation migration contract helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mes_dashboard.services.navigation_contract import (
|
||||
compute_drawer_visibility,
|
||||
validate_drawer_page_contract,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PAGE_STATUS_FILE = ROOT / "data" / "page_status.json"
|
||||
BASELINE_VISIBILITY_FILE = ROOT / "docs" / "migration" / "portal-no-iframe" / "baseline_drawer_visibility.json"
|
||||
|
||||
|
||||
def test_current_page_status_contract_has_no_validation_errors():
|
||||
payload = json.loads(PAGE_STATUS_FILE.read_text(encoding="utf-8"))
|
||||
errors = validate_drawer_page_contract(payload)
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_baseline_visibility_matches_computed_current_state():
|
||||
payload = json.loads(PAGE_STATUS_FILE.read_text(encoding="utf-8"))
|
||||
baseline = json.loads(BASELINE_VISIBILITY_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
assert baseline["admin"] == compute_drawer_visibility(payload, is_admin=True)
|
||||
assert baseline["non_admin"] == compute_drawer_visibility(payload, is_admin=False)
|
||||
@@ -170,16 +170,16 @@ class TestNavigationConfig:
|
||||
|
||||
reports = next(drawer for drawer in nav if drawer["id"] == "reports")
|
||||
assert [page["route"] for page in reports["pages"]] == ["/wip-overview"]
|
||||
assert reports["pages"][0]["frame_id"] == "wipOverviewFrame"
|
||||
assert reports["pages"][0]["tool_src"] is None
|
||||
assert "frame_id" not in reports["pages"][0]
|
||||
assert "tool_src" not in reports["pages"][0]
|
||||
|
||||
queries = next(drawer for drawer in nav if drawer["id"] == "queries")
|
||||
assert queries["pages"][0]["route"] == "/tables"
|
||||
assert queries["pages"][-1]["route"] == "/dev-page"
|
||||
|
||||
dev_tools = next(drawer for drawer in nav if drawer["id"] == "dev-tools")
|
||||
assert all(page["frame_id"] == "toolFrame" for page in dev_tools["pages"])
|
||||
assert dev_tools["pages"][0]["tool_src"] == "/admin/pages"
|
||||
assert all("frame_id" not in page for page in dev_tools["pages"])
|
||||
assert all("tool_src" not in page for page in dev_tools["pages"])
|
||||
|
||||
|
||||
class TestIsApiPublic:
|
||||
|
||||
170
tests/test_portal_shell_routes.py
Normal file
170
tests/test_portal_shell_routes.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for portal shell routes and navigation API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
|
||||
|
||||
def _login_as_admin(client) -> None:
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin"] = {"displayName": "Admin", "employeeNo": "A001"}
|
||||
|
||||
|
||||
def test_portal_shell_fallback_html_served_when_dist_missing(monkeypatch):
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
|
||||
# Force fallback path by simulating missing dist file.
|
||||
monkeypatch.setattr("os.path.exists", lambda *_args, **_kwargs: False)
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/portal-shell")
|
||||
assert response.status_code == 200
|
||||
html = response.data.decode("utf-8")
|
||||
assert "/static/dist/portal-shell.js" in html
|
||||
assert "/static/dist/portal-shell.css" in html
|
||||
assert "/static/dist/tailwind.css" in html
|
||||
|
||||
|
||||
def test_portal_shell_uses_nested_dist_html_when_top_level_missing(monkeypatch):
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
|
||||
def fake_exists(path: str) -> bool:
|
||||
if path.endswith("/dist/portal-shell.html"):
|
||||
return False
|
||||
return path.endswith("/dist/src/portal-shell/index.html")
|
||||
|
||||
monkeypatch.setattr("os.path.exists", fake_exists)
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/portal-shell")
|
||||
assert response.status_code == 200
|
||||
html = response.data.decode("utf-8")
|
||||
assert "/static/dist/portal-shell.js" in html
|
||||
assert "/static/dist/portal-shell.css" in html
|
||||
assert "/static/dist/tailwind.css" in html
|
||||
|
||||
|
||||
def test_portal_navigation_non_admin_visibility_matches_release_only():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/api/portal/navigation")
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.data.decode("utf-8"))
|
||||
assert payload["is_admin"] is False
|
||||
assert payload["admin_user"] is None
|
||||
|
||||
all_routes = {
|
||||
page["route"]
|
||||
for drawer in payload["drawers"]
|
||||
for page in drawer["pages"]
|
||||
}
|
||||
|
||||
# Non-admin baseline from current config.
|
||||
assert "/wip-overview" in all_routes
|
||||
assert "/resource" in all_routes
|
||||
assert "/qc-gate" in all_routes
|
||||
assert "/resource-history" in all_routes
|
||||
assert "/job-query" in all_routes
|
||||
assert "/admin/pages" not in all_routes
|
||||
assert "/excel-query" not in all_routes
|
||||
|
||||
|
||||
def test_portal_navigation_admin_includes_admin_drawer_routes():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
client = app.test_client()
|
||||
_login_as_admin(client)
|
||||
|
||||
response = client.get("/api/portal/navigation")
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.data.decode("utf-8"))
|
||||
assert payload["is_admin"] is True
|
||||
assert payload["admin_user"]["displayName"] == "Admin"
|
||||
|
||||
all_routes = {
|
||||
page["route"]
|
||||
for drawer in payload["drawers"]
|
||||
for page in drawer["pages"]
|
||||
}
|
||||
assert "/admin/pages" in all_routes
|
||||
assert "/admin/performance" in all_routes
|
||||
assert "/excel-query" in all_routes
|
||||
|
||||
|
||||
def test_wrapper_telemetry_endpoint_removed_after_wrapper_decommission():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
client = app.test_client()
|
||||
|
||||
response = client.post(
|
||||
"/api/portal/wrapper-telemetry",
|
||||
json={
|
||||
"route": "/job-query",
|
||||
"event_type": "wrapper_loaded",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_navigation_drawer_and_page_order_deterministic_non_admin():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/api/portal/navigation")
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.data.decode("utf-8"))
|
||||
|
||||
drawer_ids = [drawer["id"] for drawer in payload["drawers"]]
|
||||
assert drawer_ids == ["reports", "drawer-2", "drawer"]
|
||||
|
||||
reports_routes = [page["route"] for page in payload["drawers"][0]["pages"]]
|
||||
assert reports_routes == ["/wip-overview", "/resource", "/qc-gate"]
|
||||
|
||||
|
||||
def test_navigation_mixed_release_dev_visibility_admin_vs_non_admin():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
|
||||
non_admin_client = app.test_client()
|
||||
non_admin_resp = non_admin_client.get("/api/portal/navigation")
|
||||
assert non_admin_resp.status_code == 200
|
||||
non_admin_payload = json.loads(non_admin_resp.data.decode("utf-8"))
|
||||
non_admin_routes = {
|
||||
page["route"]
|
||||
for drawer in non_admin_payload["drawers"]
|
||||
for page in drawer["pages"]
|
||||
}
|
||||
assert "/hold-overview" not in non_admin_routes
|
||||
assert "/hold-history" not in non_admin_routes
|
||||
|
||||
admin_client = app.test_client()
|
||||
_login_as_admin(admin_client)
|
||||
admin_resp = admin_client.get("/api/portal/navigation")
|
||||
assert admin_resp.status_code == 200
|
||||
admin_payload = json.loads(admin_resp.data.decode("utf-8"))
|
||||
admin_routes = {
|
||||
page["route"]
|
||||
for drawer in admin_payload["drawers"]
|
||||
for page in drawer["pages"]
|
||||
}
|
||||
assert "/hold-overview" in admin_routes
|
||||
assert "/hold-history" in admin_routes
|
||||
|
||||
|
||||
def test_legacy_wrapper_routes_are_reachable():
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
client = app.test_client()
|
||||
_login_as_admin(client)
|
||||
|
||||
for route in ["/job-query", "/excel-query", "/query-tool", "/tmtt-defect"]:
|
||||
response = client.get(route)
|
||||
assert response.status_code == 200, f"{route} should be reachable"
|
||||
@@ -6,6 +6,7 @@ required core JavaScript resources.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
@@ -21,12 +22,20 @@ class TestTemplateIntegration(unittest.TestCase):
|
||||
"""Test that all templates properly extend _base.html."""
|
||||
|
||||
def setUp(self):
|
||||
self._old_portal_spa = os.environ.get("PORTAL_SPA_ENABLED")
|
||||
os.environ["PORTAL_SPA_ENABLED"] = "false"
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
_login_as_admin(self.client)
|
||||
|
||||
def tearDown(self):
|
||||
if self._old_portal_spa is None:
|
||||
os.environ.pop("PORTAL_SPA_ENABLED", None)
|
||||
else:
|
||||
os.environ["PORTAL_SPA_ENABLED"] = self._old_portal_spa
|
||||
|
||||
def test_portal_includes_base_scripts(self):
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -113,13 +122,21 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
"""Test dynamic portal drawer rendering."""
|
||||
|
||||
def setUp(self):
|
||||
self._old_portal_spa = os.environ.get("PORTAL_SPA_ENABLED")
|
||||
os.environ["PORTAL_SPA_ENABLED"] = "false"
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
_login_as_admin(self.client)
|
||||
|
||||
def test_portal_uses_navigation_config_for_sidebar_and_iframes(self):
|
||||
def tearDown(self):
|
||||
if self._old_portal_spa is None:
|
||||
os.environ.pop("PORTAL_SPA_ENABLED", None)
|
||||
else:
|
||||
os.environ["PORTAL_SPA_ENABLED"] = self._old_portal_spa
|
||||
|
||||
def test_portal_uses_navigation_config_for_sidebar_links_without_iframe(self):
|
||||
drawers = [
|
||||
{
|
||||
"id": "custom",
|
||||
@@ -132,8 +149,6 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
"name": "自訂首頁",
|
||||
"status": "released",
|
||||
"order": 1,
|
||||
"frame_id": "customFrame",
|
||||
"tool_src": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -148,8 +163,6 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
"name": "頁面管理",
|
||||
"status": "dev",
|
||||
"order": 1,
|
||||
"frame_id": "toolFrame",
|
||||
"tool_src": "/admin/pages",
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -160,10 +173,11 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode("utf-8")
|
||||
self.assertIn("自訂分類", html)
|
||||
self.assertIn('data-target="customFrame"', html)
|
||||
self.assertIn('id="customFrame"', html)
|
||||
self.assertIn('data-tool-src="/admin/pages"', html)
|
||||
self.assertIn('id="toolFrame"', html)
|
||||
self.assertIn('href="/wip-overview"', html)
|
||||
self.assertIn('data-route="/wip-overview"', html)
|
||||
self.assertIn('href="/admin/pages"', html)
|
||||
self.assertIn('data-route="/admin/pages"', html)
|
||||
self.assertNotIn("<iframe", html)
|
||||
|
||||
def test_portal_hides_admin_only_drawer_for_non_admin(self):
|
||||
client = self.app.test_client()
|
||||
@@ -179,8 +193,6 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
"name": "自訂首頁",
|
||||
"status": "released",
|
||||
"order": 1,
|
||||
"frame_id": "customFrame",
|
||||
"tool_src": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -195,8 +207,6 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
"name": "頁面管理",
|
||||
"status": "dev",
|
||||
"order": 1,
|
||||
"frame_id": "toolFrame",
|
||||
"tool_src": "/admin/pages",
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -208,19 +218,28 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
html = response.data.decode("utf-8")
|
||||
self.assertIn("自訂分類", html)
|
||||
self.assertNotIn("開發工具", html)
|
||||
self.assertNotIn('data-tool-src="/admin/pages"', html)
|
||||
self.assertNotIn('href="/admin/pages"', html)
|
||||
self.assertNotIn("<iframe", html)
|
||||
|
||||
|
||||
class TestToastCSSIntegration(unittest.TestCase):
|
||||
"""Test that Toast CSS styles are included in pages."""
|
||||
|
||||
def setUp(self):
|
||||
self._old_portal_spa = os.environ.get("PORTAL_SPA_ENABLED")
|
||||
os.environ["PORTAL_SPA_ENABLED"] = "false"
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
_login_as_admin(self.client)
|
||||
|
||||
def tearDown(self):
|
||||
if self._old_portal_spa is None:
|
||||
os.environ.pop("PORTAL_SPA_ENABLED", None)
|
||||
else:
|
||||
os.environ["PORTAL_SPA_ENABLED"] = self._old_portal_spa
|
||||
|
||||
def test_portal_includes_toast_css(self):
|
||||
response = self.client.get('/')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
Reference in New Issue
Block a user