Files
DashBoard/tests/test_material_trace_routes.py
egg 777751311c
Some checks failed
full-modernization-gates / frontend-route-governance (push) Has been cancelled
full-modernization-gates / backend-modernization-gates (push) Has been cancelled
released-pages-hardening-gates / released-pages-hardening (push) Has been cancelled
feat: add material trace page for bidirectional LOT/material query
Implement full-stack material trace feature enabling forward (LOT/工單 → 原物料)
and reverse (原物料 → LOT) queries with wildcard support, safeguards (memory guard,
IN-clause batching, Oracle slow-query channel), CSV export, and portal-shell integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:32:41 +08:00

277 lines
9.6 KiB
Python

# -*- coding: utf-8 -*-
"""Integration tests for Material Trace API routes.
Tests input validation, pagination structure, and CSV export.
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from mes_dashboard import create_app
from mes_dashboard.core.cache import NoOpCache
from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests
@pytest.fixture
def app():
"""Create test Flask application."""
app = create_app()
app.config["TESTING"] = True
app.extensions["cache"] = NoOpCache()
return app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
@pytest.fixture(autouse=True)
def _reset_rate_limits():
reset_rate_limits_for_tests()
yield
reset_rate_limits_for_tests()
# ============================================================
# 7.6 Input validation → HTTP 400
# ============================================================
class TestQueryValidation:
def test_missing_mode_returns_400(self, client):
response = client.post(
"/api/material-trace/query",
data=json.dumps({"values": ["LOT-A"]}),
content_type="application/json",
)
assert response.status_code == 400
payload = response.get_json()
assert payload["success"] is False
assert "無效的查詢模式" in payload["error"]
def test_invalid_mode_returns_400(self, client):
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "invalid", "values": ["LOT-A"]}),
content_type="application/json",
)
assert response.status_code == 400
assert "無效的查詢模式" in response.get_json()["error"]
def test_empty_values_returns_400(self, client):
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "lot", "values": []}),
content_type="application/json",
)
assert response.status_code == 400
assert "請輸入至少一筆" in response.get_json()["error"]
def test_blank_values_returns_400(self, client):
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "lot", "values": ["", " "]}),
content_type="application/json",
)
assert response.status_code == 400
assert "請輸入至少一筆" in response.get_json()["error"]
def test_forward_over_200_returns_400(self, client):
values = [f"LOT-{i}" for i in range(201)]
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "lot", "values": values}),
content_type="application/json",
)
assert response.status_code == 400
assert "正向查詢上限 200 筆" in response.get_json()["error"]
def test_workorder_over_200_returns_400(self, client):
values = [f"WO-{i}" for i in range(201)]
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "workorder", "values": values}),
content_type="application/json",
)
assert response.status_code == 400
assert "正向查詢上限 200 筆" in response.get_json()["error"]
def test_reverse_over_50_returns_400(self, client):
values = [f"MLOT-{i}" for i in range(51)]
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "material_lot", "values": values}),
content_type="application/json",
)
assert response.status_code == 400
assert "反向查詢上限 50 筆" in response.get_json()["error"]
def test_non_json_returns_415(self, client):
response = client.post(
"/api/material-trace/query",
data="plain text",
content_type="text/plain",
)
assert response.status_code == 415
def test_empty_body_returns_400(self, client):
response = client.post(
"/api/material-trace/query",
data=json.dumps({}),
content_type="application/json",
)
# Empty object triggers require_non_empty_object
assert response.status_code in (400, 415)
# ============================================================
# 7.7 Query endpoint — correct pagination structure
# ============================================================
class TestQueryPagination:
@patch("mes_dashboard.routes.material_trace_routes.forward_query")
def test_query_returns_pagination_structure(self, mock_fwd, client):
mock_fwd.return_value = {
"rows": [{"CONTAINERNAME": "LOT-1", "PJ_WORKORDER": "WO-1"}],
"pagination": {
"page": 1,
"per_page": 50,
"total": 100,
"total_pages": 2,
},
"meta": {},
}
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "workorder", "values": ["WO-001"]}),
content_type="application/json",
)
assert response.status_code == 200
payload = response.get_json()
assert payload["success"] is True
assert "pagination" in payload
pag = payload["pagination"]
assert pag["page"] == 1
assert pag["per_page"] == 50
assert pag["total"] == 100
assert pag["total_pages"] == 2
assert len(payload["rows"]) == 1
@patch("mes_dashboard.routes.material_trace_routes.forward_query")
def test_query_passes_page_param(self, mock_fwd, client):
mock_fwd.return_value = {
"rows": [],
"pagination": {"page": 3, "per_page": 50, "total": 200, "total_pages": 4},
"meta": {},
}
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "workorder", "values": ["WO-001"], "page": 3}),
content_type="application/json",
)
assert response.status_code == 200
mock_fwd.assert_called_once()
call_kwargs = mock_fwd.call_args
# page should be 3
assert call_kwargs[0][3] == 3 or call_kwargs.kwargs.get("page") == 3
@patch("mes_dashboard.routes.material_trace_routes.reverse_query")
def test_reverse_mode_dispatches_correctly(self, mock_rev, client):
mock_rev.return_value = {
"rows": [],
"pagination": {"page": 1, "per_page": 50, "total": 0, "total_pages": 0},
"meta": {},
}
response = client.post(
"/api/material-trace/query",
data=json.dumps({"mode": "material_lot", "values": ["MLOT-A"]}),
content_type="application/json",
)
assert response.status_code == 200
mock_rev.assert_called_once()
# ============================================================
# 7.8 Export endpoint — CSV content-type and UTF-8 BOM
# ============================================================
class TestExportEndpoint:
@patch("mes_dashboard.routes.material_trace_routes.export_csv")
def test_export_returns_csv_content_type(self, mock_export, client):
csv_content = b"\xef\xbb\xbfLOT ID,\xe5\xb7\xa5\xe5\x96\xae\n"
mock_export.return_value = (csv_content, {})
response = client.post(
"/api/material-trace/export",
data=json.dumps({"mode": "workorder", "values": ["WO-001"]}),
content_type="application/json",
)
assert response.status_code == 200
assert "text/csv" in response.content_type
# Check UTF-8 BOM
assert response.data[:3] == b"\xef\xbb\xbf"
@patch("mes_dashboard.routes.material_trace_routes.export_csv")
def test_export_truncated_sets_header(self, mock_export, client):
csv_content = b"\xef\xbb\xbfheader\nrow\n"
mock_export.return_value = (csv_content, {"truncated": True, "export_max_rows": 50000})
response = client.post(
"/api/material-trace/export",
data=json.dumps({"mode": "workorder", "values": ["WO-001"]}),
content_type="application/json",
)
assert response.status_code == 200
assert response.headers.get("X-Truncated") == "true"
def test_export_validation_same_as_query(self, client):
"""Export should reject invalid mode same as query."""
response = client.post(
"/api/material-trace/export",
data=json.dumps({"mode": "invalid", "values": ["X"]}),
content_type="application/json",
)
assert response.status_code == 400
# ============================================================
# Filter options endpoint
# ============================================================
class TestFilterOptions:
@patch("mes_dashboard.routes.material_trace_routes.get_workcenter_groups")
def test_filter_options_returns_groups(self, mock_groups, client):
mock_groups.return_value = [
{"name": "焊接_DB", "sequence": 1},
{"name": "焊線_WB", "sequence": 2},
]
response = client.get("/api/material-trace/filter-options")
assert response.status_code == 200
payload = response.get_json()
assert payload["success"] is True
assert payload["data"]["workcenter_groups"] == ["焊接_DB", "焊線_WB"]
@patch("mes_dashboard.routes.material_trace_routes.get_workcenter_groups")
def test_filter_options_unavailable_returns_503(self, mock_groups, client):
mock_groups.return_value = None
response = client.get("/api/material-trace/filter-options")
assert response.status_code == 503