From 79baa455acac63e45c55b921ba99c885d474021f Mon Sep 17 00:00:00 2001 From: Han Date: Mon, 1 Jun 2026 23:06:34 +0800 Subject: [PATCH 1/6] fix: Removed the config page --- frontend/components/Sidebar.tsx | 11 ++--------- frontend/components/__tests__/Sidebar.test.tsx | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 8de20c9..7bd6fe5 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -167,18 +167,11 @@ const nav: NavSection[] = [ label: "帳號管理", roles: ["system_admin"], }, - { - id: "config", - href: "/config", - icon: "🛠️", - label: "系統設定", - roles: ["system_admin"], - }, { id: "others", href: "/others", - icon: "", - label: "替代", + icon: "🛠️", + label: "系統工具", roles: ["system_admin"], }, ], diff --git a/frontend/components/__tests__/Sidebar.test.tsx b/frontend/components/__tests__/Sidebar.test.tsx index 3ee4afe..567f012 100644 --- a/frontend/components/__tests__/Sidebar.test.tsx +++ b/frontend/components/__tests__/Sidebar.test.tsx @@ -108,7 +108,7 @@ describe("Sidebar", () => { expect(screen.getByText("系統")).toBeInTheDocument(); expect(screen.getByText("帳號管理")).toBeInTheDocument(); - expect(screen.getByText("系統設定")).toBeInTheDocument(); + expect(screen.getByText("系統工具")).toBeInTheDocument(); expect(screen.getByText("簽核管理")).toBeInTheDocument(); }); From 96621a58be88979df8d8e96bef2586e1629f93f7 Mon Sep 17 00:00:00 2001 From: Han Date: Tue, 2 Jun 2026 02:04:50 +0800 Subject: [PATCH 2/6] fix: c_tests/a_tests/d_tests + error_handlers + errors fix --- backend/app/common/errors.py | 2 +- backend/tests/a_tests/__init__.py | 0 backend/tests/a_tests/test_orders.py | 634 ++++++++++++++++++ backend/tests/a_tests/test_others.py | 124 ++++ backend/tests/d_tests/__init__.py | 0 backend/tests/d_tests/test_experiment_runs.py | 588 ++++++++++++++++ backend/tests/d_tests/test_reports.py | 486 ++++++++++++++ 7 files changed, 1833 insertions(+), 1 deletion(-) create mode 100644 backend/tests/a_tests/__init__.py create mode 100644 backend/tests/a_tests/test_orders.py create mode 100644 backend/tests/a_tests/test_others.py create mode 100644 backend/tests/d_tests/__init__.py create mode 100644 backend/tests/d_tests/test_experiment_runs.py create mode 100644 backend/tests/d_tests/test_reports.py diff --git a/backend/app/common/errors.py b/backend/app/common/errors.py index 1e21b60..74a42c7 100644 --- a/backend/app/common/errors.py +++ b/backend/app/common/errors.py @@ -20,7 +20,7 @@ def __init__(self, message: str | None = None, status_code: int | None = None) - class ValidationError(AppError): code = "VALIDATION_ERROR" - default_status = status.HTTP_422_UNPROCESSABLE_ENTITY + default_status = status.HTTP_422_UNPROCESSABLE_CONTENT class NotFoundError(AppError): diff --git a/backend/tests/a_tests/__init__.py b/backend/tests/a_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/a_tests/test_orders.py b/backend/tests/a_tests/test_orders.py new file mode 100644 index 0000000..537cb38 --- /dev/null +++ b/backend/tests/a_tests/test_orders.py @@ -0,0 +1,634 @@ +"""Endpoint-level (integration) tests for /api/orders. Owned by 組員 A. + +================================================================================ +Structure mirrors the canonical template ``tests/c_tests/test_machines.py``: +one test = one behaviour, named ``test___``; exercise +the real HTTP surface through the shared httpx ``AsyncClient`` fixtures in +``tests/conftest.py``; assert on BOTH the status code AND the body; cover the +breadth (list / get / create / update / delete / a domain state-transition / +permission gating); create OWN rows with UNIQUE ids so reruns can't collide with +the accumulating session DB; never assert exact collection counts. + +WHERE THE ORDERS SURFACE DIFFERS FROM THE MACHINES TEMPLATE (verified against the +source AND a live run — these are deliberate, load-bearing deviations): + + * ENVELOPES ARE NON-STANDARD on the SUCCESS path. The orders router + (``app/routes/orders.py``) does NOT use the project ``items/page/pageSize/ + total`` list envelope or the ``{data, message}`` success envelope. It returns + its OWN ``app.schemas.order.ApiResponse`` (``{success, data, message}``) and a + bespoke list shape: + list GET /api/orders -> {"success", "data": [...], + "pagination": {total, page, limit, + totalPages}} + create POST /api/orders -> 201 + {"success", "data": {id, orderNo, + status, priority, message}} + get GET /api/orders/{id} -> {"success", "data": , + "message": null} + update PATCH /api/orders/{id} -> {"success", "data": , + "message": "委託單已更新"} + delete DELETE /api/orders/{id} -> {"success", "data": {"id": }, + "message": "委託單已刪除"} + action POST .../{id}/actions -> {"success", "data": {id, action, status, + quotaOverride}, "message": } + history GET .../{id}/history -> {"success", "data": [...]} + applicant GET .../applicant/{} -> {"success", "data": [...]} + => We assert these REAL shapes. The ERROR envelope, by contrast, IS the + project-standard nested ``{"error": {"code", "message", "details"}}`` because + every error funnels through the global handlers (``app/core/error_handlers.py`` + + the ``AppError`` handler in ``app/main.py``). + + * CREATE returns 201 (``status_code=status.HTTP_201_CREATED`` on the route), not + 200 like machines. + + * ROLE GATING lives in the REPOSITORY, not in a route ``Depends``. The orders + routes only ``Depends(get_current_user)`` (authentication), so the 401 case is + the only gate enforced at the route boundary. Authorization (plant_user can + create/update/delete/submit/cancel; lab_supervisor approves; etc.) is enforced + inside ``OrderRepository`` via ``require_role`` (``app/core/order_security.py``), + which raises a RAW ``HTTPException(403)`` -> normalized to FORBIDDEN. Note + ``ROLE_ALIASES`` makes ``system_admin`` count as ``plant_user`` AND + ``lab_supervisor``, so the cross-lab ``admin_client`` can both create and + approve — we lean on that for the valid-approve path (see the lab-id bug note + below). + + * ILLEGAL TRANSITIONS are 400 BAD_REQUEST, not 409. The repo raises + ``bad_request(...)`` (a raw 400 HTTPException) for an action that isn't legal + from the current status; the handler maps 400 -> "BAD_REQUEST". (Contrast the + machines template's note about 409/ILLEGAL_STATE — that does NOT apply here.) + + * VALIDATION 422s all surface as nested ``error.code == "VALIDATION_ERROR"``: + framework body-validation (missing departmentId, empty items) via the custom + RequestValidationError handler, AND ``OrderActionRequest`` / ``OrderUpdate`` + pydantic ``model_validator`` failures (e.g. return-without-reason, empty PATCH) + which are ALSO request-validation errors (they raise during body parsing). + +SEED / ROLE MAP this file is pinned to (scripts/seed_dev.py): + * ``plant_user_client`` = requester@example.com (plant_user, DEPT-RD) — the + APPLICANT. Creates / updates / deletes / submits / cancels its own orders. + * ``admin_client`` = admin@example.com (system_admin, cross-lab). Aliased + to plant_user AND lab_supervisor, so it can create AND approve any order. + * ``supervisor_a_client``= supervisor@example.com (lab_supervisor, LAB-A). + * ``engineer_a_client`` = engineer@example.com (lab_engineer, LAB-A) — NOT a + plant_user, so it cannot create orders (403). + +MASTER-DATA NOTE: ``OrderRepository._validate_order_master_data`` accepts +``departmentId`` and ``labId`` by CODE (``DEPT-RD`` / ``LAB-A``) OR UUID, but +``experimentId`` ONLY by the ``lab_capabilities.id`` UUID, and the capability must +belong to the item's lab. So every valid create payload needs a real LAB-A +capability UUID, which we fetch once via ``db_session`` in ``_lab_a_capability``. + +KNOWN BUG surfaced while writing these tests (asserted as the CURRENT behaviour so +this file regression-locks it; see the report): a LAB-A ``lab_supervisor`` CANNOT +approve a LAB-A order created with ``labId: "LAB-A"``. Order items persist +``lab_id`` exactly as submitted (the CODE ``"LAB-A"``), but the approval lab-gate +compares it against the supervisor's lab UUID (``require_role`` returns +``labIds=[str(user.lab_id)]``), so ``"LAB-A" in {}`` is always False and the +repo raises 403 "No approvable order items for this manager". Only ``all_labs`` +roles (system_admin / general_supervisor) bypass the lab-gate, which is why the +valid-approve test uses ``admin_client``. +""" + +from __future__ import annotations + +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Lab, LabCapability + +# NOTE: no ``pytestmark = pytest.mark.asyncio`` — ``asyncio_mode = "auto"`` already +# auto-collects every ``async def test_*`` as an asyncio test. + + +def _uid(prefix: str) -> str: + """A collision-proof sample id for mutating tests. + + The session DB is seeded once and accumulates every order/item mutating tests + create; a uuid4 suffix keeps each created sampleId unique per run so reruns + don't entangle with rows an earlier run inserted. + """ + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +async def _lab_a_capability(db: AsyncSession) -> tuple[str, str]: + """Return ``("LAB-A", )`` for a real LAB-A capability. + + ``experimentId`` must be a ``lab_capabilities.id`` UUID whose lab matches the + item's ``labId`` — see the MASTER-DATA NOTE in the module docstring. + """ + lab = (await db.execute(select(Lab).where(Lab.code == "LAB-A"))).scalar_one() + capability = ( + (await db.execute(select(LabCapability).where(LabCapability.lab_id == lab.id))) + .scalars() + .first() + ) + assert capability is not None, "expected a seeded LAB-A capability (SEM/FIB/EDX)" + return "LAB-A", str(capability.id) + + +def _order_payload( + *, + lab_code: str, + capability_id: str, + sample_id: str, + department_id: str = "DEPT-RD", + priority: str = "normal", + **overrides: object, +) -> dict[str, object]: + """A reusable, valid create payload (camelCase to match the schema aliases).""" + payload: dict[str, object] = { + "departmentId": department_id, + "priority": priority, + "items": [ + { + "sampleId": sample_id, + "sampleName": "測試樣品", + "labId": lab_code, + "experimentId": capability_id, + "targetGroup": "G1", + "target": 1, + } + ], + } + payload.update(overrides) + return payload + + +async def _create_order(client: AsyncClient, db: AsyncSession, sample_id: str) -> int: + """Create a fresh DRAFT order through the API and return its integer id.""" + lab_code, capability_id = await _lab_a_capability(db) + res = await client.post( + "/api/orders", + json=_order_payload(lab_code=lab_code, capability_id=capability_id, sample_id=sample_id), + ) + assert res.status_code == 201, res.text + return int(res.json()["data"]["id"]) + + +# --------------------------------------------------------------------------- +# LIST — GET /api/orders +# Verifies the happy path AND the bespoke list envelope this router uses +# (success / data / pagination{total,page,limit,totalPages}) — NOT the project +# items/page/pageSize/total shape. +# --------------------------------------------------------------------------- +async def test_list_orders_returns_pagination_envelope( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + # Arrange: guarantee at least one visible order exists this session. + await _create_order(plant_user_client, db_session, _uid("LIST")) + + res = await plant_user_client.get("/api/orders") + assert res.status_code == 200, res.text + + body = res.json() + assert body["success"] is True + assert isinstance(body["data"], list) + pagination = body["pagination"] + # Bespoke list envelope: keys differ from the standard PageResponse. + assert set(pagination.keys()) == {"total", "page", "limit", "totalPages"} + assert pagination["page"] == 1 + assert pagination["limit"] == 10 + # Lower bound only — the session DB accumulates rows across tests / reruns. + assert pagination["total"] >= 1 + + +async def test_list_orders_respects_limit_query( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """``limit`` caps the page size; the slice never exceeds it.""" + # Two distinct orders so a limit=1 page is provably a partial slice. + await _create_order(plant_user_client, db_session, _uid("PAGE-A")) + await _create_order(plant_user_client, db_session, _uid("PAGE-B")) + + res = await plant_user_client.get("/api/orders", params={"limit": 1, "page": 1}) + assert res.status_code == 200, res.text + body = res.json() + assert len(body["data"]) <= 1 + assert body["pagination"]["limit"] == 1 + assert body["pagination"]["total"] >= 2 + + +# --------------------------------------------------------------------------- +# GET ONE — GET /api/orders/{order_id} +# Happy path returns the full serialized order under ``data``; a missing id is a +# nested NOT_FOUND. +# --------------------------------------------------------------------------- +async def test_get_order_happy_path( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + order_id = await _create_order(plant_user_client, db_session, _uid("GET")) + + res = await plant_user_client.get(f"/api/orders/{order_id}") + assert res.status_code == 200, res.text + + data = res.json()["data"] + assert data["id"] == order_id + assert data["status"] == "draft" # service default for a fresh order + assert data["orderNo"].startswith("ORD-") + # Items are serialized with camelCase aliases. + assert isinstance(data["items"], list) and len(data["items"]) == 1 + assert set(data["items"][0].keys()) >= {"sampleId", "labId", "experimentId", "status"} + + +async def test_get_missing_order_is_404(plant_user_client: AsyncClient) -> None: + res = await plant_user_client.get("/api/orders/99999999") + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +# --------------------------------------------------------------------------- +# CREATE — POST /api/orders +# Happy path returns 201 + the bespoke create payload. Plus validation cases that +# all normalize to the nested VALIDATION_ERROR envelope, and a master-data 400. +# --------------------------------------------------------------------------- +async def test_create_order_success( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + lab_code, capability_id = await _lab_a_capability(db_session) + sample_id = _uid("CREATE") + res = await plant_user_client.post( + "/api/orders", + json=_order_payload(lab_code=lab_code, capability_id=capability_id, sample_id=sample_id), + ) + assert res.status_code == 201, res.text + + data = res.json()["data"] + assert isinstance(data["id"], int) + assert data["orderNo"].startswith("ORD-") + assert data["status"] == "draft" # new orders start in draft + assert data["priority"] == "normal" + assert data["message"] # human-facing 委託單已建立 + + +async def test_create_order_missing_department_is_422( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Omitting required ``departmentId`` is a FRAMEWORK 422, normalized into the + project nested envelope with the offending field named in ``error.message``.""" + lab_code, capability_id = await _lab_a_capability(db_session) + payload = _order_payload( + lab_code=lab_code, capability_id=capability_id, sample_id=_uid("NO-DEPT") + ) + del payload["departmentId"] + + res = await plant_user_client.post("/api/orders", json=payload) + assert res.status_code == 422, res.text + body = res.json() + assert "detail" not in body, res.text # NOT the raw FastAPI {"detail": [...]} + assert body["error"]["code"] == "VALIDATION_ERROR", res.text + assert "departmentId" in body["error"]["message"], res.text + + +async def test_create_order_empty_items_is_422(plant_user_client: AsyncClient) -> None: + """``items`` has ``min_length=1`` — an empty list is rejected by Pydantic.""" + res = await plant_user_client.post("/api/orders", json={"departmentId": "DEPT-RD", "items": []}) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +async def test_create_order_unknown_experiment_is_400( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A well-formed body whose ``experimentId`` isn't a real capability passes + Pydantic but fails the repo's master-data validation -> ``bad_request`` (400 / + BAD_REQUEST), proving the service-layer validation path (distinct from the + framework 422 above).""" + res = await plant_user_client.post( + "/api/orders", + json=_order_payload( + lab_code="LAB-A", + capability_id="00000000-0000-0000-0000-000000000000", + sample_id=_uid("BAD-EXP"), + ), + ) + assert res.status_code == 400, res.text + assert res.json()["error"]["code"] == "BAD_REQUEST", res.text + + +# --------------------------------------------------------------------------- +# UPDATE — PATCH /api/orders/{order_id} +# Happy path on a draft order + 404 + a model-validator 422 (empty patch). +# --------------------------------------------------------------------------- +async def test_update_order_success( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + order_id = await _create_order(plant_user_client, db_session, _uid("UPDATE")) + + res = await plant_user_client.patch(f"/api/orders/{order_id}", json={"priority": "urgent"}) + assert res.status_code == 200, res.text + + body = res.json() + assert body["message"] == "委託單已更新" + assert body["data"]["priority"] == "urgent" + + +async def test_update_missing_order_is_404(plant_user_client: AsyncClient) -> None: + res = await plant_user_client.patch("/api/orders/99999999", json={"priority": "urgent"}) + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +async def test_update_order_no_fields_is_422( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """An empty PATCH trips the ``OrderUpdate`` ``model_validator`` ("at least one + update field is required"), which surfaces as a nested VALIDATION_ERROR.""" + order_id = await _create_order(plant_user_client, db_session, _uid("EMPTY-PATCH")) + + res = await plant_user_client.patch(f"/api/orders/{order_id}", json={}) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +# --------------------------------------------------------------------------- +# DELETE — DELETE /api/orders/{order_id} +# A real soft-delete endpoint exists (is_deleted=True). Verify 200 + the +# follow-up GET 404 (deleted orders are filtered out of every read). +# --------------------------------------------------------------------------- +async def test_delete_draft_order_then_get_is_404( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + order_id = await _create_order(plant_user_client, db_session, _uid("DELETE")) + + res = await plant_user_client.delete(f"/api/orders/{order_id}") + assert res.status_code == 200, res.text + assert res.json()["data"]["id"] == order_id + assert res.json()["message"] == "委託單已刪除" + + # Verify persistence of the soft-delete: the order is no longer readable. + follow_up = await plant_user_client.get(f"/api/orders/{order_id}") + assert follow_up.status_code == 404, follow_up.text + assert follow_up.json()["error"]["code"] == "NOT_FOUND", follow_up.text + + +async def test_delete_missing_order_is_404(plant_user_client: AsyncClient) -> None: + res = await plant_user_client.delete("/api/orders/99999999") + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +# --------------------------------------------------------------------------- +# ACTIONS — POST /api/orders/{order_id}/actions (the Order state machine) +# docs/flow.md state machine, encoded in app/core/order_constants.TRANSITIONS: +# draft/returned --submit--> pending_approval --approve--> approved ... +# Valid transitions succeed; an action illegal from the current status is 400. +# --------------------------------------------------------------------------- +async def test_submit_draft_order_succeeds( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """submit: draft -> pending_approval (applicant-driven, the happy transition).""" + order_id = await _create_order(plant_user_client, db_session, _uid("SUBMIT")) + + res = await plant_user_client.post(f"/api/orders/{order_id}/actions", json={"action": "submit"}) + assert res.status_code == 200, res.text + + body = res.json() + assert body["data"]["action"] == "submit" + assert body["data"]["status"] == "pending_approval" + assert body["message"] == "委託單已送出簽核" + + +async def test_cancel_draft_order_succeeds( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """cancel: draft -> cancelled (applicant-driven).""" + order_id = await _create_order(plant_user_client, db_session, _uid("CANCEL")) + + res = await plant_user_client.post(f"/api/orders/{order_id}/actions", json={"action": "cancel"}) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "cancelled" + + +async def test_illegal_transition_is_400( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """An action not legal from the current status returns BAD_REQUEST (400), + NOT 409. ``approve`` is not legal from ``draft`` (it requires pending_approval), + and the status guard runs in the repo BEFORE any role check, so even the + applicant's own attempt is rejected on the transition rule.""" + order_id = await _create_order(plant_user_client, db_session, _uid("ILLEGAL")) + + res = await plant_user_client.post( + f"/api/orders/{order_id}/actions", json={"action": "approve"} + ) + assert res.status_code == 400, res.text + assert res.json()["error"]["code"] == "BAD_REQUEST", res.text + + +async def test_action_on_missing_order_is_404(plant_user_client: AsyncClient) -> None: + res = await plant_user_client.post("/api/orders/99999999/actions", json={"action": "submit"}) + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +async def test_return_action_without_reason_is_422(plant_user_client: AsyncClient) -> None: + """``OrderActionRequest`` requires a non-empty ``reason`` for return/reject; + omitting it is a model-validator failure -> nested VALIDATION_ERROR (no order + needs to exist — the body is rejected before the route logic runs).""" + res = await plant_user_client.post("/api/orders/1/actions", json={"action": "return"}) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +# --------------------------------------------------------------------------- +# APPROVE — the supervisor side of the state machine. +# Valid approve uses ``admin_client`` (all_labs bypasses the lab-gate); the +# lab-scoped supervisor and the plant_user both hit 403 — for DIFFERENT reasons, +# which we pin separately so a future fix to either is caught. +# --------------------------------------------------------------------------- +async def test_admin_can_approve_submitted_order( + plant_user_client: AsyncClient, + admin_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """approve: pending_approval -> approved. The cross-lab admin (all_labs) is the + only role that clears BOTH the lab-gate and the role-gate in one client.""" + order_id = await _create_order(plant_user_client, db_session, _uid("APPROVE")) + await plant_user_client.post(f"/api/orders/{order_id}/actions", json={"action": "submit"}) + + res = await admin_client.post(f"/api/orders/{order_id}/actions", json={"action": "approve"}) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "approved" + + +async def test_plant_user_cannot_approve_is_403( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """The applicant lacks the lab_supervisor role, so ``require_role`` in the repo + rejects an approve with 403 / FORBIDDEN (authorization, not a transition rule: + the order IS in pending_approval, a legal-from state).""" + order_id = await _create_order(plant_user_client, db_session, _uid("PU-APPROVE")) + await plant_user_client.post(f"/api/orders/{order_id}/actions", json={"action": "submit"}) + + res = await plant_user_client.post( + f"/api/orders/{order_id}/actions", json={"action": "approve"} + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +@pytest.mark.xfail( + reason=( + "KNOWN BUG: order items persist lab_id as the submitted CODE ('LAB-A') but " + "the approval lab-gate compares against the supervisor's lab UUID, so a " + "LAB-A supervisor cannot approve a LAB-A order. This xfail documents the " + "INTENDED behaviour (a same-lab supervisor SHOULD be able to approve); it " + "will XPASS and flag the moment the lab-id mismatch is fixed." + ), + strict=True, +) +async def test_lab_supervisor_can_approve_own_lab_order( + plant_user_client: AsyncClient, + supervisor_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + order_id = await _create_order(plant_user_client, db_session, _uid("SUP-APPROVE")) + await plant_user_client.post(f"/api/orders/{order_id}/actions", json={"action": "submit"}) + + res = await supervisor_a_client.post( + f"/api/orders/{order_id}/actions", json={"action": "approve"} + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "approved" + + +# --------------------------------------------------------------------------- +# APPLICANT — GET /api/orders/applicant/{applicant_id} +# Returns the bespoke ApiResponse with ``data`` as a list of full orders for that +# applicant. The applicant id is the user's UUID (== /api/me data.id). +# --------------------------------------------------------------------------- +async def test_list_orders_by_applicant_returns_own_orders( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + me = (await plant_user_client.get("/api/me")).json()["data"] + applicant_id = str(me["id"]) + order_id = await _create_order(plant_user_client, db_session, _uid("APPLICANT")) + + res = await plant_user_client.get(f"/api/orders/applicant/{applicant_id}") + assert res.status_code == 200, res.text + + body = res.json() + assert body["success"] is True + assert isinstance(body["data"], list) + ids = {row["id"] for row in body["data"]} + assert order_id in ids, "the applicant's freshly created order must be listed" + # Every returned order belongs to this applicant. + assert all(row["applicantId"] == applicant_id for row in body["data"]) + + +# --------------------------------------------------------------------------- +# HISTORY — GET /api/orders/{order_id}/history +# Each lifecycle action appends a history row; we assert the create + submit rows +# show up in chronological order. +# --------------------------------------------------------------------------- +async def test_order_history_records_lifecycle( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + order_id = await _create_order(plant_user_client, db_session, _uid("HISTORY")) + await plant_user_client.post(f"/api/orders/{order_id}/actions", json={"action": "submit"}) + + res = await plant_user_client.get(f"/api/orders/{order_id}/history") + assert res.status_code == 200, res.text + + body = res.json() + assert body["success"] is True + actions = [row["action"] for row in body["data"]] + # create is appended on creation; submit on the action. Order is ascending. + assert actions[0] == "create" + assert "submit" in actions + last = body["data"][-1] + assert last["toStatus"] == "pending_approval" + + +# --------------------------------------------------------------------------- +# PERMISSION / AUTH GATING. +# The ONLY gate at the route boundary is authentication (get_current_user); every +# orders route depends on it, so an unauthenticated request 401s with the nested +# UNAUTHORIZED envelope. Authorization (role) is enforced deeper, in the repo. +# --------------------------------------------------------------------------- +async def test_list_orders_unauthenticated_is_401(client: AsyncClient) -> None: + res = await client.get("/api/orders") + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_create_order_unauthenticated_is_401(client: AsyncClient) -> None: + res = await client.post( + "/api/orders", + json={ + "departmentId": "DEPT-RD", + "items": [{"sampleId": "X", "labId": "LAB-A", "experimentId": "X"}], + }, + ) + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_get_one_order_is_not_auth_gated( + plant_user_client: AsyncClient, + client: AsyncClient, + db_session: AsyncSession, +) -> None: + """KNOWN BUG, locked in: ``GET /api/orders/{order_id}`` has NO + ``Depends(get_current_user)`` (only ``get_order_history`` and this route omit + it; every OTHER orders route is auth-gated). So an UNAUTHENTICATED client can + read any order by integer id — it returns 200, not 401. We assert the actual + behaviour so a future addition of the auth dependency flips this test and is + reviewed deliberately. Contrast ``test_list_orders_unauthenticated_is_401``, + which IS gated.""" + order_id = await _create_order(plant_user_client, db_session, _uid("NOAUTH-GET")) + + res = await client.get(f"/api/orders/{order_id}") + assert res.status_code == 200, res.text + assert res.json()["data"]["id"] == order_id + + +async def test_get_order_history_is_not_auth_gated( + plant_user_client: AsyncClient, + client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Same missing-auth gap as the get-one route: ``GET /api/orders/{id}/history`` + omits ``Depends(get_current_user)``, so an unauthenticated client reads it.""" + order_id = await _create_order(plant_user_client, db_session, _uid("NOAUTH-HIST")) + + res = await client.get(f"/api/orders/{order_id}/history") + assert res.status_code == 200, res.text + assert res.json()["data"][0]["action"] == "create" + + +async def test_engineer_cannot_create_order_is_403( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A lab_engineer is authenticated but is NOT a plant_user, so the repo's + ``require_role(..., {"plant_user"})`` rejects creation with 403 / FORBIDDEN. + This is the authorization gate that lives BELOW the route (the route itself + only checks authentication).""" + lab_code, capability_id = await _lab_a_capability(db_session) + res = await engineer_a_client.post( + "/api/orders", + json=_order_payload( + lab_code=lab_code, capability_id=capability_id, sample_id=_uid("ENG-CREATE") + ), + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text diff --git a/backend/tests/a_tests/test_others.py b/backend/tests/a_tests/test_others.py new file mode 100644 index 0000000..7c00359 --- /dev/null +++ b/backend/tests/a_tests/test_others.py @@ -0,0 +1,124 @@ +"""Endpoint-level tests for the order/sample/WIP aggregate routes in +``app/routes/others.py`` (組員 A/B legacy surface). + +PURPOSE: lock in the ERROR-ENVELOPE behaviour of the raw ``HTTPException`` paths in +``others.py`` now that the global handlers (``app/core/error_handlers.py``) +normalize EVERY error into the nested ``{"error": {"code", "message", "details"}}`` +shape. These tests double as regression coverage for that harmonization. + +TEST-ENV REALITY (verified against a live run AND the schema) — this drives which +``others.py`` 404 paths are actually reachable here: + + * The test DB is built from ``Base.metadata.create_all`` (conftest), NOT Alembic. + ``samples`` is a MIGRATION-ONLY table (組員 B ships it via migration; there is + no ORM model — see ``app/db/models/wips.py``), so it DOES NOT EXIST in the test + DB. ``wips`` and ``orders`` DO (they have ORM models). Confirmed: + SELECT to_regclass('public.samples') -> NULL + SELECT to_regclass('public.wips') -> wips + SELECT to_regclass('public.orders') -> orders + => Any ``others.py`` route that touches ``samples`` raises a ``SQLAlchemyError`` + (missing relation), which the global ``SQLAlchemyError`` handler turns into + 500 / ``DATABASE_ERROR`` — BEFORE the route's own raw ``HTTPException(404)`` can + run. So the sample-dependent raw-404s are MASKED in this environment; we assert + the 500/DATABASE_ERROR they actually produce and call it out, rather than + pretending the 404 fires. + + * ``GET /api/orders/{order_no}`` in ``others.py`` is SHADOWED by + ``app/routes/orders.py``'s ``GET /api/orders/{order_id:int}`` (registered with + an ``int`` path param + ``get_current_user``). A numeric path hits the orders + router (auth-gated, NOT_FOUND for an unknown id); a non-numeric path fails int + parsing -> 422 VALIDATION_ERROR. Either way the ``others.py`` handler's raw 404 + ("Order not found") is UNREACHABLE for this route. Likewise ``others.py``'s + ``GET /api/labs`` / ``GET /api/master-data`` / ``GET /api/storage-locations`` + are shadowed by the real labs / master_data routers (they return the project + ``{items,total}`` / ``{data,message}`` envelopes and are AUTH-GATED), so the + bare-dict ``others.py`` versions are dead code. + +REACHABLE raw-404 paths in ``others.py`` (asserted below): + * POST /api/others/wips/{wip_id}/complete -> 404 NOT_FOUND (wips exists) + * POST /api/others/orders/{order_id}/confirm-delivery-> 404 NOT_FOUND (get_real_order + on the existing ``orders`` table returns None before any samples query) + +NOTE: ``others.py`` routes have NO ``get_current_user`` dependency — they resolve +the user from the request cookie via ``resolve_current_user`` WITHOUT raising on +absence. So these are not 401-gated; an authed client is used merely to exercise +the cookie-resolution path. +""" + +from __future__ import annotations + +import uuid + +from httpx import AsyncClient + + +def _missing_uuid() -> str: + """A syntactically valid UUID that is guaranteed absent from the DB.""" + return str(uuid.uuid4()) + + +# --------------------------------------------------------------------------- +# REACHABLE raw-404 paths -> nested NOT_FOUND envelope (the behaviour we lock in). +# --------------------------------------------------------------------------- +async def test_complete_missing_wip_is_nested_404(plant_user_client: AsyncClient) -> None: + """``POST /api/others/wips/{id}/complete`` for an absent WIP raises a raw + ``HTTPException(404, "WIP not found")`` which the global handler renders as the + nested ``{"error": {"code": "NOT_FOUND", ...}}`` envelope. ``wips`` exists in + the test DB, so this raw-404 path is genuinely reached.""" + res = await plant_user_client.post(f"/api/others/wips/{_missing_uuid()}/complete") + assert res.status_code == 404, res.text + body = res.json() + assert "detail" not in body, res.text # NOT the raw FastAPI {"detail": "..."} + assert body["error"]["code"] == "NOT_FOUND", res.text + assert body["error"]["message"] == "WIP not found", res.text + + +async def test_confirm_delivery_missing_order_is_nested_404( + plant_user_client: AsyncClient, +) -> None: + """``POST /api/others/orders/{id}/confirm-delivery`` for an unknown order hits + the raw ``HTTPException(404, "Order not found")`` (``get_real_order`` returns + None) BEFORE any ``samples`` query, so the 404 is reachable and normalized to + the nested envelope.""" + res = await plant_user_client.post(f"/api/others/orders/{_missing_uuid()}/confirm-delivery") + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +# --------------------------------------------------------------------------- +# SHADOWED / MASKED paths — assert the CURRENT (buggy) behaviour so a fix flags. +# --------------------------------------------------------------------------- +async def test_get_order_by_no_route_is_shadowed_by_int_route( + plant_user_client: AsyncClient, +) -> None: + """``GET /api/orders/{order_no}`` (others.py, str param) is shadowed by the + orders router's ``int``-typed ``GET /api/orders/{order_id}``. A non-numeric + order_no fails int coercion -> 422 VALIDATION_ERROR; the others.py raw-404 for + a missing order_no never runs. (Documents the route-shadowing inconsistency.)""" + res = await plant_user_client.get("/api/orders/NOT-A-NUMERIC-ORDER-NO") + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +async def test_numeric_unknown_order_is_handled_by_orders_router( + plant_user_client: AsyncClient, +) -> None: + """A numeric path is served by the orders router (NOT others.py): unknown id + -> nested NOT_FOUND, proving which router actually owns this URL.""" + res = await plant_user_client.get("/api/orders/424242") + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +async def test_generate_wips_missing_sample_table_is_500_database_error( + plant_user_client: AsyncClient, +) -> None: + """``POST /api/others/samples/{id}/generate-wips`` queries the ``samples`` + table, which is migration-only and ABSENT from the ``create_all`` test DB. The + resulting ``SQLAlchemyError`` is normalized to 500 / DATABASE_ERROR by the + global handler, BEFORE the route's own raw 404 ("Sample not found") can fire. + We assert the masked behaviour so a future fix (real samples table in tests, or + a guarded query) is caught.""" + res = await plant_user_client.post(f"/api/others/samples/{_missing_uuid()}/generate-wips") + assert res.status_code == 500, res.text + assert res.json()["error"]["code"] == "DATABASE_ERROR", res.text diff --git a/backend/tests/d_tests/__init__.py b/backend/tests/d_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/d_tests/test_experiment_runs.py b/backend/tests/d_tests/test_experiment_runs.py new file mode 100644 index 0000000..0d175cd --- /dev/null +++ b/backend/tests/d_tests/test_experiment_runs.py @@ -0,0 +1,588 @@ +"""Endpoint-level (integration) tests for /api/experiment-runs. Owned by 組員 D. + +================================================================================ +Structure mirrors the canonical templates ``tests/c_tests/test_machines.py`` and +``tests/a_tests/test_orders.py``: one test = one behaviour, named +``test___``; drive the real HTTP surface via the shared +httpx ``AsyncClient`` fixtures in ``tests/conftest.py``; assert on BOTH the status +code AND the body; create OWN rows with UNIQUE ids so reruns can't collide with +the accumulating session DB; never assert exact collection counts. + +CONTRACT FACTS verified against the source AND a live run (load-bearing): + + * ENVELOPES ARE THE PROJECT-STANDARD shape (unlike orders). The router wraps + every response in the shared ``ApiResponse`` / ``PageResponse``: + list GET "" -> {"items": [...], "page", "pageSize", "total"} + get GET /{wip_id} -> {"data": , "message": null} + action POST/PATCH /{wip_id}/* -> {"data": , "message": } + signal POST /{wip_id}/machine-signal -> 202 + {"data": {...}, "message": } + The ERROR envelope is the standard nested ``{"error": {"code", "message", + "details"}}`` (global handlers). + + * AUTH / PERMISSIONS (verified against each route's ``Depends``): + GET "" , GET /{wip_id} , POST /{wip_id}/machine-signal -> get_current_user + GET /{wip_id}/operators , check-in/out , progress , result , verify , + confirm , abort-request -> "experiments:operate" + abort-review -> "experiments:review" + ROLE MAP (scripts/seed_dev.py): ``experiments:operate`` is a LAB_ENGINEER perm + (so engineers AND supervisors have it); ``experiments:review`` is a + SUPERVISOR-ONLY extra (lab_engineer does NOT have it). Therefore: + engineer_a_client = 李大明, lab_engineer LAB-A -> operate, NOT review. + supervisor_a_client = 譚曉蓉, lab_supervisor LAB-A -> operate AND review. + plant_user_client = requester, plant_user -> neither -> 403. + LAB SCOPE: D's ``wips.lab_name`` stores the DISPLAY NAME (LAB-A = 材料分析實驗室). + A non-admin engineer/supervisor can only touch WIPs whose ``lab_name`` matches + their lab's display name; a cross-lab WIP raises 403 FORBIDDEN (the service's + ``_require_wip`` does the scope check AFTER the existence check, so it's 403, + NOT a 404 — contrast machines, which hides existence with a 404). + + * STATE MACHINE (exec_status, canonical English ``WipStatus`` in wip_execution): + check_in : wips.status=="dispatched" AND exec waiting_load -> running + check_out : running -> unloaded + progress : running (else 409) + result : running|unloaded -> waiting_confirm (sets data_verified flag) + verify : waiting_confirm AND not verified -> sets data_verified=True + confirm : waiting_confirm AND data_verified -> completed + abort-req : not-ended, no pending -> abort_status=待主管判定 + abort-rev : has pending -> terminated (approve) / running (reject) + ILLEGAL TRANSITIONS raise ``ConflictError`` -> 409 CONFLICT (NOT 400 like + orders, NOT 422). The ONE 422 path is ``confirm`` on an UNVERIFIED waiting_confirm + WIP: the service raises ``ValidationError`` -> 422 VALIDATION_ERROR (nested). + + * SEED HAS NO WIPs / WipExecution ROWS, so every test arranges its own via + ``db_session`` (raw ORM insert). ``wips.sample_id`` is NOT-NULL with a + DB-level FK to ``samples`` in B's migration, but the test DB is built from + ``Base.metadata.create_all`` (no ``samples`` table, no FK), so an arbitrary + uuid4 ``sample_id`` inserts cleanly. We set ``sample_id=None`` is NOT possible + (NOT NULL), so we use a throwaway uuid4 — and the completion sample-flow + advance is wrapped best-effort in the service so the absent ``samples`` table + never fails ``confirm``. +""" + +from __future__ import annotations + +import uuid + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.enums import WipStatus +from app.db.models import Wip, WipExecution + +# NOTE: no ``pytestmark = pytest.mark.asyncio`` — ``asyncio_mode = "auto"`` already +# auto-collects every ``async def test_*`` as an asyncio test. + +# LAB-A's display name as persisted in ``wips.lab_name`` (scripts/seed_dev.py). +# engineer_a_client / supervisor_a_client are both LAB-A, so this is the lab +# whose WIPs they may operate on. +LAB_A_NAME = "材料分析實驗室" +LAB_B_NAME = "電性測試實驗室" +# The seeded LAB-A engineer's display name — the service stamps history "by" with +# CurrentUser.name, and the operator picker keys on the lab's user names. +ENGINEER_A_NAME = "李大明" + + +def _uid(prefix: str) -> str: + """A collision-proof WIP business code for mutating tests. + + The session DB is seeded once and accumulates every row tests create; a uuid4 + suffix keeps each created ``wip_no`` unique per run so reruns don't entangle + with rows an earlier run inserted. + """ + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +async def _make_wip( + db: AsyncSession, + *, + lab_name: str = LAB_A_NAME, + b_status: str = "dispatched", + exec_status: str | None = None, + experiment_item: str = "EDX", + order_no: str | None = None, + data_verified: bool = False, +) -> str: + """Arrange a WIP (+ optional WipExecution side row) directly in the DB. + + Returns the WIP business code (``wip_no``). ``exec_status=None`` means NO + exec row is created (the WIP sits at B's coarse ``b_status`` only) — this is + the genuine "待上機" pre-check-in state when ``b_status='dispatched'``. + A non-None ``exec_status`` creates the side row in that fine-grained state so + a test can start partway through the machine. + """ + wip_no = _uid("WIP") + db.add( + Wip( + wip_no=wip_no, + sample_id=uuid.uuid4(), # FK to samples not enforced in the test schema + order_no=order_no or _uid("ORD"), + lab_name=lab_name, + experiment_item=experiment_item, + status=b_status, + progress=0, + ) + ) + if exec_status is not None: + db.add( + WipExecution( + wip_no=wip_no, + exec_status=exec_status, + operator=ENGINEER_A_NAME, + machine_id="SEM-A-001", + recipe="R1", + data_verified=data_verified, + ) + ) + await db.commit() + return wip_no + + +# --------------------------------------------------------------------------- +# LIST — GET /api/experiment-runs +# Happy path + the project PageResponse envelope + the status query filter. +# --------------------------------------------------------------------------- +async def test_list_experiment_runs_returns_page_envelope( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + # Arrange: guarantee at least one LAB-A WIP this engineer can see. + wip_no = await _make_wip(db_session, b_status="dispatched") + + res = await engineer_a_client.get("/api/experiment-runs") + assert res.status_code == 200, res.text + + body = res.json() + # Project-standard PageResponse, NOT the orders bespoke shape. + assert set(body.keys()) >= {"items", "page", "pageSize", "total"} + assert isinstance(body["items"], list) + assert body["page"] == 1 + ids = {row["wipId"] for row in body["items"]} + assert wip_no in ids, "the engineer's own-lab WIP must be listable" + # Every visible row is the engineer's lab (lab scope) — proven via the dict. + assert all("status" in row for row in body["items"]) + + +async def test_list_experiment_runs_is_lab_scoped( + engineer_a_client: AsyncClient, + admin_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A LAB-A engineer must NOT see a LAB-B WIP; the cross-lab admin must. + + Proves EXCLUSION concretely (not vacuously) by pinning a known LAB-B WIP and + confirming the admin can see it while the engineer cannot. + """ + lab_b_wip = await _make_wip(db_session, lab_name=LAB_B_NAME, b_status="dispatched") + + eng_ids = { + r["wipId"] for r in (await engineer_a_client.get("/api/experiment-runs")).json()["items"] + } + assert lab_b_wip not in eng_ids + + admin_ids = { + r["wipId"] for r in (await admin_client.get("/api/experiment-runs")).json()["items"] + } + assert lab_b_wip in admin_ids + + +async def test_list_experiment_runs_status_filter( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """``?status=`` filters on the serialized (Chinese) status. A running WIP is + "執行中"; filtering on a non-matching status excludes it.""" + running_wip = await _make_wip( + db_session, b_status="running", exec_status=WipStatus.RUNNING.value + ) + + res = await engineer_a_client.get("/api/experiment-runs", params={"status": "執行中"}) + assert res.status_code == 200, res.text + statuses = {row["status"] for row in res.json()["items"]} + assert statuses <= {"執行中"}, res.text + ids = {row["wipId"] for row in res.json()["items"]} + assert running_wip in ids + + +# --------------------------------------------------------------------------- +# GET ONE — GET /api/experiment-runs/{wip_id} +# Happy path returns the wip dict under ``data``; a missing id is nested NOT_FOUND. +# --------------------------------------------------------------------------- +async def test_get_experiment_run_happy_path( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + wip_no = await _make_wip(db_session, b_status="dispatched") + + res = await engineer_a_client.get(f"/api/experiment-runs/{wip_no}") + assert res.status_code == 200, res.text + + data = res.json()["data"] + assert data["wipId"] == wip_no + assert data["experimentItem"] == "EDX" + # No exec row yet -> serialized from B's coarse status: dispatched -> 待上機. + assert data["status"] == "待上機" + + +async def test_get_missing_experiment_run_is_404(engineer_a_client: AsyncClient) -> None: + res = await engineer_a_client.get(f"/api/experiment-runs/{_uid('NOPE')}") + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +# --------------------------------------------------------------------------- +# OPERATORS — GET /api/experiment-runs/{wip_id}/operators +# Permission-gated (experiments:operate); returns the WIP's lab members. +# --------------------------------------------------------------------------- +async def test_list_operators_includes_lab_members( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + wip_no = await _make_wip(db_session, b_status="dispatched") + + res = await engineer_a_client.get(f"/api/experiment-runs/{wip_no}/operators") + assert res.status_code == 200, res.text + names = {row["name"] for row in res.json()["data"]} + # The seeded LAB-A engineer/supervisor are members of 材料分析實驗室. + assert ENGINEER_A_NAME in names, res.text + + +# --------------------------------------------------------------------------- +# STATE MACHINE — the happy transition sequence, driven through the API: +# check-in -> upload result -> verify -> confirm +# Each step asserts the serialized status advances correctly. +# --------------------------------------------------------------------------- +async def test_full_happy_transition_sequence( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + wip_no = await _make_wip(db_session, b_status="dispatched") + + # check-in: dispatched/waiting_load -> running (執行中) + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/check-in", + json={"operator": ENGINEER_A_NAME, "machineId": "SEM-A-001", "recipe": "R1"}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "執行中" + assert res.json()["message"] # non-empty success message + + # upload result: running -> waiting_confirm (待確認). data_verified=False here. + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/result", + json={"note": "完成", "rawDataUrl": "/data/x.csv", "dataVerified": False}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "待確認" + assert res.json()["data"]["progress"] == 100 + + # verify: waiting_confirm & unverified -> data_verified=True (status stays 待確認) + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/verify", + json={"operator": ENGINEER_A_NAME}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["dataVerified"] is True + + # confirm: waiting_confirm & verified -> completed (已完成) + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/confirm", + json={"operator": ENGINEER_A_NAME}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "已完成" + + +async def test_check_out_after_check_in( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """check-out: running -> unloaded (已下機). Exercises the dedicated route.""" + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/check-out", + json={"operator": ENGINEER_A_NAME, "note": "結束"}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "已下機" + + +async def test_update_progress_while_running( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """PATCH /progress on a running WIP updates the percentage.""" + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + + res = await engineer_a_client.patch( + f"/api/experiment-runs/{wip_no}/progress", + json={"progress": 42}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["progress"] == 42 + + +# --------------------------------------------------------------------------- +# ILLEGAL TRANSITIONS. +# * an action illegal from the current state -> 409 CONFLICT +# * confirm on an UNVERIFIED waiting_confirm -> 422 VALIDATION_ERROR (the one +# 422 service path: ValidationError, not ConflictError) +# --------------------------------------------------------------------------- +async def test_check_in_when_not_dispatched_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """check-in requires wips.status=='dispatched' (待上機). A WIP still in B's + 'created' state can't be checked in -> ConflictError -> 409 CONFLICT.""" + wip_no = await _make_wip(db_session, b_status="created") + + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/check-in", + json={"operator": ENGINEER_A_NAME, "machineId": "SEM-A-001", "recipe": "R1"}, + ) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_update_progress_when_not_running_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """progress is only legal while running; a 待上機 WIP (no exec row) -> 409.""" + wip_no = await _make_wip(db_session, b_status="dispatched") + + res = await engineer_a_client.patch( + f"/api/experiment-runs/{wip_no}/progress", + json={"progress": 10}, + ) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_confirm_unverified_data_is_422( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """confirm on a waiting_confirm WIP whose data is NOT yet verified raises the + service ``ValidationError`` -> nested 422 VALIDATION_ERROR. This is the ONLY + 422 path in this module (every other illegal state is a 409 ConflictError).""" + wip_no = await _make_wip( + db_session, + b_status="running", + exec_status=WipStatus.WAITING_CONFIRM.value, + data_verified=False, + ) + + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/confirm", + json={"operator": ENGINEER_A_NAME}, + ) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +async def test_verify_already_verified_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Re-verifying an already-verified waiting_confirm WIP -> 409 CONFLICT.""" + wip_no = await _make_wip( + db_session, + b_status="running", + exec_status=WipStatus.WAITING_CONFIRM.value, + data_verified=True, + ) + + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/verify", + json={"operator": ENGINEER_A_NAME}, + ) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +# --------------------------------------------------------------------------- +# ABORT — request (operate) + review (review). Exercises BOTH role sides. +# request_abort : operate role, not-ended WIP -> abort pending +# review_abort : review role; approve -> terminated, reject -> running +# --------------------------------------------------------------------------- +async def test_abort_request_then_approve( + engineer_a_client: AsyncClient, + supervisor_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """operate side requests the abort; the SUPERVISOR (review role) approves it + -> WIP terminated (已終止). Two clients, separate calls — no cookie clobber.""" + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + + # operate: engineer files the abort request. + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-request", + json={"reason": "機台異常"}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["abort"]["status"] == "待主管判定" + + # review: supervisor approves -> terminated. + res = await supervisor_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-review", + json={"approve": True, "note": "同意終止"}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "已終止" + + +async def test_abort_review_reject_resumes_running( + engineer_a_client: AsyncClient, + supervisor_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A rejected abort returns the WIP to running (執行中).""" + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-request", + json={"reason": "誤判"}, + ) + + res = await supervisor_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-review", + json={"approve": False, "note": "繼續實驗"}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "執行中" + + +async def test_abort_review_without_pending_is_409( + supervisor_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Reviewing a WIP that has no pending abort request -> 409 CONFLICT.""" + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + + res = await supervisor_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-review", + json={"approve": True, "note": "x"}, + ) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_abort_request_twice_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A second abort request while one is pending -> 409 CONFLICT.""" + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-request", json={"reason": "first"} + ) + + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-request", json={"reason": "second"} + ) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +# --------------------------------------------------------------------------- +# MACHINE SIGNAL — POST /{wip_id}/machine-signal (get_current_user, returns 202). +# In tests there's no Celery broker, so the route falls back to SYNCHRONOUS +# completion (apply_machine_completion). It still returns 202 with the data +# envelope; the WIP moves to 待確認. +# --------------------------------------------------------------------------- +async def test_machine_signal_returns_202( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + + res = await engineer_a_client.post(f"/api/experiment-runs/{wip_no}/machine-signal") + assert res.status_code == 202, res.text + assert res.json()["data"]["wipId"] == wip_no + assert res.json()["message"] # non-empty + + +async def test_machine_signal_missing_wip_is_404(engineer_a_client: AsyncClient) -> None: + """The route calls ``service.get_wip`` first, so a missing id 404s before any + Celery dispatch.""" + res = await engineer_a_client.post(f"/api/experiment-runs/{_uid('NOPE')}/machine-signal") + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +# --------------------------------------------------------------------------- +# PERMISSION / AUTH GATING. +# * unauthenticated -> 401 UNAUTHORIZED +# * authenticated but lacking experiments:operate -> 403 FORBIDDEN +# * lab_engineer lacking experiments:review -> 403 FORBIDDEN +# * cross-lab WIP (non-admin) -> 403 FORBIDDEN (not 404) +# --------------------------------------------------------------------------- +async def test_list_unauthenticated_is_401(client: AsyncClient) -> None: + res = await client.get("/api/experiment-runs") + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_check_in_without_operate_permission_is_403( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """plant_user is authenticated but lacks experiments:operate -> 403 FORBIDDEN + (the require_permission gate rejects before any service logic).""" + wip_no = await _make_wip(db_session, b_status="dispatched") + + res = await plant_user_client.post( + f"/api/experiment-runs/{wip_no}/check-in", + json={"operator": "x", "machineId": "M", "recipe": "R"}, + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +async def test_abort_review_without_review_permission_is_403( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A lab_engineer HAS experiments:operate but NOT experiments:review, so the + review route rejects with 403 FORBIDDEN — proving the operate/review split.""" + wip_no = await _make_wip(db_session, b_status="running", exec_status=WipStatus.RUNNING.value) + + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/abort-review", + json={"approve": True, "note": "x"}, + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +async def test_operate_cross_lab_wip_is_403( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A LAB-A engineer operating on a LAB-B WIP: the WIP EXISTS but is out of + scope, so ``_require_wip`` raises ForbiddenError -> 403 FORBIDDEN (existence is + NOT hidden behind a 404 here — contrast machines, which 404s cross-lab).""" + lab_b_wip = await _make_wip(db_session, lab_name=LAB_B_NAME, b_status="dispatched") + + res = await engineer_a_client.get(f"/api/experiment-runs/{lab_b_wip}") + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +async def test_check_in_missing_required_field_is_422( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Omitting a required body field (recipe) is a FRAMEWORK 422, normalized into + the nested envelope with code VALIDATION_ERROR (custom RequestValidationError + handler) — NOT the raw FastAPI {"detail": [...]}.""" + wip_no = await _make_wip(db_session, b_status="dispatched") + + res = await engineer_a_client.post( + f"/api/experiment-runs/{wip_no}/check-in", + json={"operator": ENGINEER_A_NAME, "machineId": "SEM-A-001"}, # no recipe + ) + assert res.status_code == 422, res.text + body = res.json() + assert "detail" not in body, res.text + assert body["error"]["code"] == "VALIDATION_ERROR", res.text diff --git a/backend/tests/d_tests/test_reports.py b/backend/tests/d_tests/test_reports.py new file mode 100644 index 0000000..07ebb29 --- /dev/null +++ b/backend/tests/d_tests/test_reports.py @@ -0,0 +1,486 @@ +"""Endpoint-level (integration) tests for /api/reports. Owned by 組員 D. + +================================================================================ +Structure mirrors the canonical templates ``tests/c_tests/test_machines.py`` and +``tests/a_tests/test_orders.py`` (see those for the shared conventions). + +CONTRACT FACTS verified against the source AND a live run (load-bearing): + + * ENVELOPES ARE THE PROJECT-STANDARD shape (shared ApiResponse / PageResponse): + list GET "" -> {"items": [...], "page", "pageSize", "total"} + templates GET /templates -> {"items": [...], "page", "pageSize", "total"} + get GET /{report_id} -> {"data": , "message": null} + create/edit/submit/review/publish/save-template -> {"data": ..., "message": } + ERROR envelope is the standard nested ``{"error": {"code", "message", "details"}}``. + + * REPORT STATUS IS STORED AS A CHINESE DISPLAY STRING (REPORT_ZH in + app/common/enums/role_d_zh.py), NOT the canonical English enum value the rest + of the system uses: + draft 草稿 -> pending_review 待審核 -> confirmed 已確認 -> returned 已回傳 + (review-reject -> revised 已改版). + So the serialized ``status`` field carries Chinese — we assert on the Chinese + strings the API actually returns. + + * AUTH / PERMISSIONS (verified against each route's ``Depends``): + GET "" , GET /templates , GET /{report_id} -> get_current_user + POST /templates , POST "" , PATCH /{id} , + POST /{id}/submit , POST /{id}/publish -> "reports:operate" + POST /{id}/review -> "reports:review" + ROLE MAP (scripts/seed_dev.py): ``reports:operate`` is a LAB_ENGINEER perm; + ``reports:review`` is a SUPERVISOR-ONLY extra (lab_engineer lacks it). So: + engineer_a_client -> operate (create/edit/submit/publish), NOT review. + supervisor_a_client -> operate AND review. + plant_user_client -> neither -> 403. + LAB SCOPE: a report's lab is its source WIP's lab (reports.wip_id == wips.wip_no, + wips.lab_name = display name). A non-admin who can't access that lab -> 403 + FORBIDDEN on get/edit/submit/review/publish (existence not hidden). + + * STATE MACHINE (ReportService), illegal transition -> ``ConflictError`` -> 409: + create : source WIP must be waiting_confirm|completed (else 409) + edit : only 草稿|已改版 (else 409) + submit : only 草稿|已改版 -> 待審核 (else 409) + review : only 待審核 -> 已確認 (approve) | 已改版 (reject) (else 409) + publish : only 已確認 -> 已回傳 (else 409) + There is NO 422-from-service path here; the only 422s are FRAMEWORK body + validation (e.g. missing required ``name`` / ``wipId``). + + * SEED HAS NO WIPs / WipExecution / reports. Each test arranges a WIP (+ exec + row in waiting_confirm/completed) via ``db_session`` so create_report passes + the source-state gate, then drives the report machine through the API. +""" + +from __future__ import annotations + +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.enums import WipStatus +from app.db.models import Wip, WipExecution + +LAB_A_NAME = "材料分析實驗室" +LAB_B_NAME = "電性測試實驗室" +ENGINEER_A_NAME = "李大明" + + +def _uid(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +async def _make_source_wip( + db: AsyncSession, + *, + lab_name: str = LAB_A_NAME, + exec_status: str = WipStatus.WAITING_CONFIRM.value, + experiment_item: str = "EDX", + raw_data_url: str | None = "/data/auto.csv", +) -> str: + """Arrange a WIP + WipExecution in a report-eligible state (waiting_confirm or + completed), so ``create_report`` clears its source-state gate. Returns wip_no. + + NOTE on ``raw_data_url``: we set it by DEFAULT so the created Report gets a + ``ReportAttachment`` appended in ``create_report``. That is a WORKAROUND for a + real bug (pinned separately in ``test_create_report_no_attachment_is_500``): + ``create_report`` returns ``report_dict(rpt)`` right after ``commit()``, and + the serializer lazy-accesses ``rpt.attachments`` / ``rpt.versions``. When NO + attachment was appended (i.e. the source exec row had no ``raw_data_url``), + the empty ``attachments`` collection is unloaded post-commit and the lazy + load raises ``MissingGreenlet`` on the async session -> 500. Giving the source + WIP a ``raw_data_url`` forces an attachment to be appended (so that collection + is in-memory/loaded), letting the create response serialize. This lets the + downstream machine tests (which need a real created report) run against the + other routes, which all eager-load via ``repo.get_report`` and are unaffected. + """ + wip_no = _uid("WIP") + order_no = _uid("ORD") + db.add( + Wip( + wip_no=wip_no, + sample_id=uuid.uuid4(), + order_no=order_no, + lab_name=lab_name, + experiment_item=experiment_item, + status="running", + progress=100, + ) + ) + db.add( + WipExecution( + wip_no=wip_no, + exec_status=exec_status, + operator=ENGINEER_A_NAME, + machine_id="SEM-A-001", + recipe="R1", + data_verified=True, + result_note="ok", + raw_data_url=raw_data_url, + ) + ) + await db.commit() + return wip_no + + +async def _create_report(client: AsyncClient, db: AsyncSession, *, submit: bool = False) -> str: + """Create a report through the API and return its ``report_id``.""" + wip_no = await _make_source_wip(db) + res = await client.post("/api/reports", json={"wipId": wip_no, "submit": submit}) + assert res.status_code == 200, res.text + return res.json()["data"]["reportId"] + + +# --------------------------------------------------------------------------- +# LIST — GET /api/reports + GET /api/reports/templates +# --------------------------------------------------------------------------- +async def test_list_reports_returns_page_envelope( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + report_id = await _create_report(engineer_a_client, db_session) + + res = await engineer_a_client.get("/api/reports") + assert res.status_code == 200, res.text + body = res.json() + assert set(body.keys()) >= {"items", "page", "pageSize", "total"} + ids = {row["reportId"] for row in body["items"]} + assert report_id in ids, "the engineer's own-lab report must be listable" + + +async def test_list_reports_status_filter( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """``?status=`` filters on the stored Chinese status. A fresh report is 草稿.""" + report_id = await _create_report(engineer_a_client, db_session, submit=False) + + res = await engineer_a_client.get("/api/reports", params={"status": "草稿"}) + assert res.status_code == 200, res.text + statuses = {row["status"] for row in res.json()["items"]} + assert statuses <= {"草稿"}, res.text + assert report_id in {row["reportId"] for row in res.json()["items"]} + + +async def test_list_reports_is_lab_scoped( + engineer_a_client: AsyncClient, + admin_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A LAB-A engineer must NOT see a LAB-B report; the cross-lab admin must.""" + lab_b_wip = await _make_source_wip(db_session, lab_name=LAB_B_NAME) + res = await admin_client.post("/api/reports", json={"wipId": lab_b_wip}) + assert res.status_code == 200, res.text + lab_b_report = res.json()["data"]["reportId"] + + eng_ids = {r["reportId"] for r in (await engineer_a_client.get("/api/reports")).json()["items"]} + assert lab_b_report not in eng_ids + + admin_ids = {r["reportId"] for r in (await admin_client.get("/api/reports")).json()["items"]} + assert lab_b_report in admin_ids + + +async def test_list_templates_returns_page_envelope( + engineer_a_client: AsyncClient, +) -> None: + res = await engineer_a_client.get("/api/reports/templates") + assert res.status_code == 200, res.text + assert set(res.json().keys()) >= {"items", "page", "pageSize", "total"} + + +# --------------------------------------------------------------------------- +# GET ONE — GET /api/reports/{report_id} +# --------------------------------------------------------------------------- +async def test_get_report_happy_path( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + report_id = await _create_report(engineer_a_client, db_session) + + res = await engineer_a_client.get(f"/api/reports/{report_id}") + assert res.status_code == 200, res.text + data = res.json()["data"] + assert data["reportId"] == report_id + assert data["status"] == "草稿" + # versions are eager-loaded; a fresh report has its v1 entry. + assert isinstance(data["versions"], list) and len(data["versions"]) >= 1 + + +async def test_get_missing_report_is_404(engineer_a_client: AsyncClient) -> None: + res = await engineer_a_client.get(f"/api/reports/{_uid('NOPE')}") + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +# --------------------------------------------------------------------------- +# CREATE — POST /api/reports +# Happy path (draft + create-and-submit) + the source-state 409 + 404 missing WIP. +# --------------------------------------------------------------------------- +async def test_create_report_draft_success( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + wip_no = await _make_source_wip(db_session) + res = await engineer_a_client.post("/api/reports", json={"wipId": wip_no}) + assert res.status_code == 200, res.text + + data = res.json()["data"] + assert data["reportId"].startswith("RPT-") + assert data["status"] == "草稿" + assert data["wipId"] == wip_no + assert res.json()["message"] # 已建立報告草稿 + + +async def test_create_report_with_submit_goes_pending_review( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """``submit: true`` creates AND sends to review in one call -> 待審核.""" + wip_no = await _make_source_wip(db_session) + res = await engineer_a_client.post("/api/reports", json={"wipId": wip_no, "submit": True}) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "待審核" + + +@pytest.mark.xfail( + reason=( + "KNOWN BUG: ReportService.create_report returns report_dict(rpt) right after " + "commit(); the serializer lazy-accesses rpt.attachments. When the source WIP's " + "exec row has NO raw_data_url, no ReportAttachment is appended, so the empty " + "attachments collection is unloaded after commit and the lazy load raises " + "MissingGreenlet on the async session -> 500 DATABASE_ERROR. The report IS " + "persisted (a follow-up GET succeeds via the eager-loaded repo path); only the " + "create RESPONSE serialization 500s. INTENDED behaviour: create returns 200. " + "This strict xfail XPASSes the moment create_report eager-loads/refreshes the " + "relationships (or the serializer stops touching unloaded collections)." + ), + strict=True, +) +async def test_create_report_no_attachment_is_500( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Regression anchor for the create-response lazy-load bug. We assert the + INTENDED 200; today it returns 500, so this xfails (strict).""" + wip_no = await _make_source_wip(db_session, raw_data_url=None) + res = await engineer_a_client.post("/api/reports", json={"wipId": wip_no}) + assert res.status_code == 200, res.text + + +async def test_create_report_wip_not_ready_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A WIP still running (not waiting_confirm/completed) can't have a report + created -> ConflictError -> 409 CONFLICT.""" + wip_no = await _make_source_wip(db_session, exec_status=WipStatus.RUNNING.value) + + res = await engineer_a_client.post("/api/reports", json={"wipId": wip_no}) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_create_report_missing_wip_is_404( + engineer_a_client: AsyncClient, +) -> None: + res = await engineer_a_client.post("/api/reports", json={"wipId": _uid("NOPE")}) + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +async def test_create_report_missing_wip_id_is_422( + engineer_a_client: AsyncClient, +) -> None: + """Omitting required ``wipId`` is a FRAMEWORK 422, normalized to the nested + envelope (code VALIDATION_ERROR), NOT the raw FastAPI {"detail": [...]}.""" + res = await engineer_a_client.post("/api/reports", json={}) + assert res.status_code == 422, res.text + body = res.json() + assert "detail" not in body, res.text + assert body["error"]["code"] == "VALIDATION_ERROR", res.text + + +# --------------------------------------------------------------------------- +# EDIT — PATCH /api/reports/{report_id} +# --------------------------------------------------------------------------- +async def test_edit_draft_report_success( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + report_id = await _create_report(engineer_a_client, db_session) + + res = await engineer_a_client.patch( + f"/api/reports/{report_id}", + json={"summary": "改寫摘要", "conclusion": "新結論"}, + ) + assert res.status_code == 200, res.text + data = res.json()["data"] + assert data["summary"] == "改寫摘要" + assert data["conclusion"] == "新結論" + + +# --------------------------------------------------------------------------- +# STATE MACHINE — submit -> review(approve) -> publish, the happy chain. +# Two roles: engineer (operate) submits/publishes; supervisor (review) approves. +# --------------------------------------------------------------------------- +async def test_full_report_lifecycle( + engineer_a_client: AsyncClient, + supervisor_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + report_id = await _create_report(engineer_a_client, db_session) + + # submit (operate): 草稿 -> 待審核 + res = await engineer_a_client.post(f"/api/reports/{report_id}/submit") + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "待審核" + + # review/approve (review role = supervisor): 待審核 -> 已確認 + res = await supervisor_a_client.post( + f"/api/reports/{report_id}/review", json={"approve": True, "comment": "OK"} + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "已確認" + + # publish (operate): 已確認 -> 已回傳 + res = await engineer_a_client.post(f"/api/reports/{report_id}/publish") + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "已回傳" + + +async def test_review_reject_sets_revised( + engineer_a_client: AsyncClient, + supervisor_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A rejected review moves the report to 已改版 (revisable again).""" + report_id = await _create_report(engineer_a_client, db_session, submit=True) + + res = await supervisor_a_client.post( + f"/api/reports/{report_id}/review", + json={"approve": False, "comment": "請補充"}, + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == "已改版" + + +# --------------------------------------------------------------------------- +# ILLEGAL TRANSITIONS — all 409 CONFLICT. +# --------------------------------------------------------------------------- +async def test_submit_already_submitted_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Submitting a report that's already in 待審核 -> 409 CONFLICT.""" + report_id = await _create_report(engineer_a_client, db_session, submit=True) + + res = await engineer_a_client.post(f"/api/reports/{report_id}/submit") + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_review_draft_report_is_409( + engineer_a_client: AsyncClient, + supervisor_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Reviewing a report still in 草稿 (not 待審核) -> 409 CONFLICT.""" + report_id = await _create_report(engineer_a_client, db_session, submit=False) + + res = await supervisor_a_client.post(f"/api/reports/{report_id}/review", json={"approve": True}) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_publish_unconfirmed_report_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Publishing a report that is not yet 已確認 (here still 草稿) -> 409.""" + report_id = await _create_report(engineer_a_client, db_session, submit=False) + + res = await engineer_a_client.post(f"/api/reports/{report_id}/publish") + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_edit_submitted_report_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Editing a report once it's in 待審核 (only 草稿/已改版 are editable) -> 409.""" + report_id = await _create_report(engineer_a_client, db_session, submit=True) + + res = await engineer_a_client.patch(f"/api/reports/{report_id}", json={"summary": "x"}) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +# --------------------------------------------------------------------------- +# TEMPLATES — POST /api/reports/templates (operate-gated). +# --------------------------------------------------------------------------- +async def test_save_template_success(engineer_a_client: AsyncClient) -> None: + name = _uid("TMPL") + res = await engineer_a_client.post( + "/api/reports/templates", + json={"name": name, "summary": "範本摘要", "conclusion": "範本結論"}, + ) + assert res.status_code == 200, res.text + data = res.json()["data"] + assert data["name"] == name + assert isinstance(data["id"], int) + + +async def test_save_template_missing_name_is_422(engineer_a_client: AsyncClient) -> None: + res = await engineer_a_client.post("/api/reports/templates", json={"summary": "x"}) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +# --------------------------------------------------------------------------- +# PERMISSION / AUTH GATING. +# --------------------------------------------------------------------------- +async def test_list_reports_unauthenticated_is_401(client: AsyncClient) -> None: + res = await client.get("/api/reports") + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_create_report_without_operate_permission_is_403( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """plant_user lacks reports:operate -> 403 FORBIDDEN before any service logic.""" + wip_no = await _make_source_wip(db_session) + res = await plant_user_client.post("/api/reports", json={"wipId": wip_no}) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +async def test_review_without_review_permission_is_403( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A lab_engineer HAS reports:operate but NOT reports:review, so the review + route rejects with 403 FORBIDDEN — proving the operate/review split.""" + report_id = await _create_report(engineer_a_client, db_session, submit=True) + + res = await engineer_a_client.post(f"/api/reports/{report_id}/review", json={"approve": True}) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +async def test_get_cross_lab_report_is_403( + engineer_a_client: AsyncClient, + admin_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A LAB-A engineer reading a LAB-B report: the report exists but is out of + scope -> 403 FORBIDDEN (existence not hidden behind a 404).""" + lab_b_wip = await _make_source_wip(db_session, lab_name=LAB_B_NAME) + lab_b_report = (await admin_client.post("/api/reports", json={"wipId": lab_b_wip})).json()[ + "data" + ]["reportId"] + + res = await engineer_a_client.get(f"/api/reports/{lab_b_report}") + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text From d3f48468a7b9fb95ad4ab021177ce8029c6c8c39 Mon Sep 17 00:00:00 2001 From: Han Date: Tue, 2 Jun 2026 02:21:10 +0800 Subject: [PATCH 3/6] fix: c_tests/a_tests/d_tests + error_handlers + errors fix --- backend/tests/c_tests/test_dispatches.py | 611 +++++++++++++++++++++++ backend/tests/c_tests/test_recipes.py | 352 +++++++++++++ 2 files changed, 963 insertions(+) create mode 100644 backend/tests/c_tests/test_dispatches.py create mode 100644 backend/tests/c_tests/test_recipes.py diff --git a/backend/tests/c_tests/test_dispatches.py b/backend/tests/c_tests/test_dispatches.py new file mode 100644 index 0000000..8d8620a --- /dev/null +++ b/backend/tests/c_tests/test_dispatches.py @@ -0,0 +1,611 @@ +"""Endpoint-level (integration) tests for /api/dispatches. Owned by 組員 C. + +================================================================================ +Structure mirrors the canonical template ``tests/c_tests/test_machines.py`` (see +it for the shared conventions): one test = one behaviour, named +``test___``; exercise the real HTTP surface through the +shared httpx ``AsyncClient`` fixtures; assert on BOTH the status code AND the body; +create OWN rows with UNIQUE ids so reruns can't collide with the accumulating +session DB; never assert exact collection counts. + +CONTRACT FACTS verified against the source (app/routes/dispatches.py + +app/modules/dispatches/{service,repository,schemas,serializers}.py) AND a live run: + + * ENVELOPES are the PROJECT-STANDARD shared shapes: + list GET "" -> PageResponse {"items", "page", "pageSize", "total"} + create POST "" -> 200 + ApiResponse {"data": , "message"} + suggest POST /suggest -> 200 + ApiResponse {"data": [...], "message"} + replan POST /replan -> 200 + ApiResponse {"data": [...], "message"} + assign POST /{id}/assign -> 200 + ApiResponse {"data": , "message"} + The list router has NO real pagination: pageSize == total == len(items), page == 1. + ERROR envelope is the standard nested ``{"error": {"code", "message", "details"}}``. + + * AUTH / PERMISSIONS (verified against each route's ``Depends``): + GET "" -> get_current_user (auth ONLY) + POST "" , POST /suggest , POST /replan , + POST /{id}/assign -> require_permission("dispatches:manage") + READ IS NOT PERMISSION-GATED: ``DISPATCHES_READ = "dispatches:read"`` is declared + but DEAD CODE (never wired to a Depends), like machines' dead ``machines:read``. + + * ROLE MAP — dispatches:manage is held by LAB_ENGINEER and above (scripts/seed_dev.py + ``LAB_ENGINEER_PERMS`` line ~129), so this module mirrors MACHINES, NOT recipes: + engineer_a_client (lab_engineer, LAB-A) -> CAN manage dispatches. + supervisor_a_client (lab_supervisor) -> CAN manage (superset of engineer). + admin_client (system_admin, cross-lab) -> CAN manage (wildcard ``*``). + plant_user_client -> NO dispatches perms -> 403. + + * LAB SCOPE: dispatches.lab stores a short CODE (LAB-A), derived on create from the + source WIP's ``lab_name`` (Chinese display string) via the labs table. A non-admin + sees only own-lab dispatches; create rejects an out-of-lab WIP with ForbiddenError + (403); ``_require`` hides a cross-lab dispatch as 404 (existence not leaked). + + * STATE MACHINE (待排程 -> 待派工 -> 待上機), stored verbatim in Chinese + (service.py STATUS_*): + create -> 待排程 (STATUS_PENDING), and drives source WIP -> waiting_schedule + suggest -> 待派工 (STATUS_SCHEDULING), WIP -> scheduled (matches a machine) + replan -> 待派工 (re-runs over 待排程 + 待派工) + assign -> 待上機 (STATUS_WAITING_LOAD), WIP -> dispatched (strict chain check) + ILLEGAL assign (dispatch not in 待派工) -> ConflictError -> 409 CONFLICT (NOT 400). + Invalid strategy on suggest/replan -> ValidationError -> 422 VALIDATION_ERROR. + Machine/Recipe item mismatch on assign -> ValidationError -> 422. + Missing WIP/dispatch/machine/recipe -> NotFoundError -> 404 NOT_FOUND. + + * SEED has NO dispatches / WIPs — both tables start empty. Each test arranges its + own WIP (and, for assign, a Recipe) via ``db_session`` because the dispatch + create/assign paths read B's ``wips`` and C's ``recipes`` rows directly. Seeded + LAB-A machines we assign to: SEM-A-001 (SEM). LAB-B machine: IV-B-001 (IV). +""" + +from __future__ import annotations + +import uuid + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Recipe, Wip + +# NOTE: no ``pytestmark = pytest.mark.asyncio`` — ``asyncio_mode = "auto"`` already +# auto-collects every ``async def test_*`` as an asyncio test. + +LAB_A_NAME = "材料分析實驗室" # labs.name for LAB-A (WIP stores the display name) +LAB_B_NAME = "電性測試實驗室" # labs.name for LAB-B +LAB_A_MACHINE = "SEM-A-001" # seeded LAB-A machine, supports SEM +LAB_B_MACHINE = "IV-B-001" # seeded LAB-B machine, supports IV +ENGINEER_A_NAME = "李大明" + +STATUS_PENDING = "待排程" +STATUS_SCHEDULING = "待派工" +STATUS_WAITING_LOAD = "待上機" + + +def _uid(prefix: str) -> str: + """A collision-proof id for mutating tests (dispatchId / wipId / recipeId). + + The session DB is seeded once and accumulates every row mutating tests create; + a uuid4 suffix keeps each created id unique per run so reruns don't entangle + with rows an earlier run inserted. + """ + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +async def _make_source_wip( + db: AsyncSession, + *, + lab_name: str = LAB_A_NAME, + status: str = "created", +) -> str: + """Arrange a B-owned WIP whose lab resolves to ``lab_name``. Returns wip_no. + + ``dispatches.wip_id`` == ``wips.wip_no``. ``create`` derives the dispatch lab + from this WIP's ``lab_name`` and rejects the create if the caller can't access + that lab. Status ``created`` keeps the WIP inside the 派工排程 chain so the + create/suggest/assign WIP-sync steps (which forward-advance the WIP) run. + """ + wip_no = _uid("WIP") + db.add( + Wip( + wip_no=wip_no, + sample_id=uuid.uuid4(), + order_no=_uid("ORD"), + lab_name=lab_name, + experiment_item="SEM", + status=status, + progress=0, + ) + ) + await db.commit() + return wip_no + + +async def _make_recipe( + db: AsyncSession, + *, + experiment_item: str = "SEM", + machine_ids: list[str] | None = None, +) -> str: + """Arrange a Recipe row directly (the recipes manage API is supervisor-only; + arranging via the session keeps this fixture role-agnostic). Returns recipe_id. + + For an assign to pass the service's matching rules, the recipe's + ``experiment_item`` must equal the dispatch item AND the target machine must be + in the recipe's ``machine_ids``.""" + recipe_id = _uid("RCP") + db.add( + Recipe( + recipe_id=recipe_id, + name="派工配方", + version="v1", + experiment_item=experiment_item, + machine_ids=[LAB_A_MACHINE] if machine_ids is None else machine_ids, + method="", + parameters={}, + updated_by=ENGINEER_A_NAME, + ) + ) + await db.commit() + return recipe_id + + +def _create_payload( + dispatch_id: str, + wip_id: str, + *, + experiment_item: str = "SEM", + priority: str = "中", + **overrides: object, +) -> dict[str, object]: + """A reusable, valid create payload (camelCase to match CreateDispatchPayload).""" + payload: dict[str, object] = { + "dispatchId": dispatch_id, + "wipId": wip_id, + "orderId": _uid("ORD"), + "experimentItem": experiment_item, + "priority": priority, + "dueAt": "2026-12-31", + } + payload.update(overrides) + return payload + + +async def _create_dispatch( + client: AsyncClient, + db: AsyncSession, + dispatch_id: str, + *, + experiment_item: str = "SEM", +) -> str: + """Create a 待排程 dispatch through the API (arranging its source WIP). Returns + the wip_no it's tied to (callers usually only need the dispatch_id they passed).""" + wip_no = await _make_source_wip(db) + res = await client.post( + "/api/dispatches", + json=_create_payload(dispatch_id, wip_no, experiment_item=experiment_item), + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["status"] == STATUS_PENDING + return wip_no + + +# --------------------------------------------------------------------------- +# LIST — GET /api/dispatches +# Happy path + the PageResponse envelope (no real pagination: pageSize==total). +# --------------------------------------------------------------------------- +async def test_list_dispatches_returns_page_envelope( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + dispatch_id = _uid("DSP-LIST") + await _create_dispatch(engineer_a_client, db_session, dispatch_id) + + res = await engineer_a_client.get("/api/dispatches") + assert res.status_code == 200, res.text + + body = res.json() + assert set(body.keys()) >= {"items", "page", "pageSize", "total"} + assert isinstance(body["items"], list) + assert body["page"] == 1 + assert body["pageSize"] == body["total"] == len(body["items"]) + ids = {row["dispatchId"] for row in body["items"]} + assert dispatch_id in ids, "the engineer's own-lab dispatch must be listable" + # Each row uses the camelCase serializer (serializers.dispatch_dict). + sample = next(d for d in body["items"] if d["dispatchId"] == dispatch_id) + assert set(sample.keys()) == { + "dispatchId", + "wipId", + "orderId", + "experimentItem", + "priority", + "lab", + "dueAt", + "status", + "suggestedMachineId", + "assignedMachineId", + "assignedRecipeId", + "scheduledStart", + "scheduledEnd", + "createdBy", + "assignedBy", + "strategy", + "replanReason", + } + assert sample["lab"] == "LAB-A" # derived from the LAB-A WIP on create + + +async def test_list_dispatches_is_lab_scoped_for_engineer( + engineer_a_client: AsyncClient, + admin_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A LAB-A engineer must NOT see a LAB-B dispatch; the cross-lab admin must. + + Only the admin can create a LAB-B dispatch here (the LAB-A engineer would be + 403'd by the create lab-gate), so we arrange the LAB-B dispatch via admin and + confirm the engineer can't see it while the admin can. + """ + lab_b_wip = await _make_source_wip(db_session, lab_name=LAB_B_NAME) + lab_b_dispatch = _uid("DSP-LABB") + created = await admin_client.post( + "/api/dispatches", + json=_create_payload(lab_b_dispatch, lab_b_wip, experiment_item="IV"), + ) + assert created.status_code == 200, created.text + assert created.json()["data"]["lab"] == "LAB-B" + + eng_ids = { + d["dispatchId"] for d in (await engineer_a_client.get("/api/dispatches")).json()["items"] + } + assert lab_b_dispatch not in eng_ids + + admin_ids = { + d["dispatchId"] for d in (await admin_client.get("/api/dispatches")).json()["items"] + } + assert lab_b_dispatch in admin_ids + + +# --------------------------------------------------------------------------- +# CREATE — POST /api/dispatches +# Happy path (200 + {data, message}, status 待排程) + duplicate 409 + missing-WIP +# 404 + framework 422 + an out-of-lab WIP 403. +# --------------------------------------------------------------------------- +async def test_create_dispatch_success( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + wip_no = await _make_source_wip(db_session) + dispatch_id = _uid("DSP-CREATE") + res = await engineer_a_client.post( + "/api/dispatches", + json=_create_payload(dispatch_id, wip_no), + ) + assert res.status_code == 200, res.text + + body = res.json() + assert body["message"] # 派工單已建立 + data = body["data"] + assert data["dispatchId"] == dispatch_id + assert data["wipId"] == wip_no + assert data["status"] == STATUS_PENDING # new dispatches start 待排程 + assert data["lab"] == "LAB-A" # derived from the LAB-A WIP + assert data["createdBy"] == ENGINEER_A_NAME + + +async def test_create_dispatch_duplicate_id_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Re-using a dispatch id is a conflict. We create our own row first (unique + id), then POST it again so the collision is deterministic (seed has none).""" + dispatch_id = _uid("DSP-DUP") + await _create_dispatch(engineer_a_client, db_session, dispatch_id) + + # Second create reuses the dispatch_id (a fresh WIP, but the id collides first). + wip_no = await _make_source_wip(db_session) + dup = await engineer_a_client.post( + "/api/dispatches", + json=_create_payload(dispatch_id, wip_no), + ) + assert dup.status_code == 409, dup.text + assert dup.json()["error"]["code"] == "CONFLICT", dup.text + + +async def test_create_dispatch_missing_wip_is_404( + engineer_a_client: AsyncClient, +) -> None: + """A well-formed body whose ``wipId`` isn't a real WIP -> NotFoundError (404).""" + res = await engineer_a_client.post( + "/api/dispatches", + json=_create_payload(_uid("DSP-NOWIP"), _uid("WIP-MISSING")), + ) + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +async def test_create_dispatch_missing_required_field_is_422( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Omitting required ``orderId`` is a FRAMEWORK 422, normalized into the nested + envelope (code VALIDATION_ERROR), NOT the raw FastAPI ``{"detail": [...]}``.""" + wip_no = await _make_source_wip(db_session) + bad = _create_payload(_uid("DSP-422"), wip_no) + del bad["orderId"] + res = await engineer_a_client.post("/api/dispatches", json=bad) + assert res.status_code == 422, res.text + + body = res.json() + assert "detail" not in body, res.text + assert body["error"]["code"] == "VALIDATION_ERROR", res.text + assert "orderId" in body["error"]["message"], res.text + + +async def test_create_dispatch_out_of_lab_wip_is_403( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """A LAB-A engineer creating a dispatch for a LAB-B WIP trips the create + lab-gate (``can_access_lab`` false) -> ForbiddenError (403 FORBIDDEN). This is a + DOMAIN authorization error raised AFTER the route permission check passed (the + engineer HAS dispatches:manage) — distinct from the no-permission 403.""" + lab_b_wip = await _make_source_wip(db_session, lab_name=LAB_B_NAME) + res = await engineer_a_client.post( + "/api/dispatches", + json=_create_payload(_uid("DSP-XLAB"), lab_b_wip, experiment_item="IV"), + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +# --------------------------------------------------------------------------- +# SUGGEST — POST /api/dispatches/suggest +# Runs the strategy over all 待排程 dispatches: sets a suggested machine + moves +# them to 待派工. Happy path + an invalid-strategy 422. +# --------------------------------------------------------------------------- +async def test_suggest_moves_pending_to_scheduling( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """suggest matches a supporting machine and advances 待排程 -> 待派工. + + Our SEM dispatch should get SEM-A-001 (the seeded LAB-A SEM machine) suggested. + Other pending dispatches in LAB-A may also be swept up — we assert on OURS only, + found by dispatchId, never on the collection size. + """ + dispatch_id = _uid("DSP-SUGGEST") + await _create_dispatch(engineer_a_client, db_session, dispatch_id, experiment_item="SEM") + + res = await engineer_a_client.post("/api/dispatches/suggest", json={"strategy": "FIFO"}) + assert res.status_code == 200, res.text + + body = res.json() + assert body["message"] # 已產生排程建議 + assert isinstance(body["data"], list) + mine = next((d for d in body["data"] if d["dispatchId"] == dispatch_id), None) + assert mine is not None, "our pending dispatch must be in the suggestion batch" + assert mine["status"] == STATUS_SCHEDULING + assert mine["strategy"] == "FIFO" + assert mine["suggestedMachineId"] == LAB_A_MACHINE # SEM-A-001 supports SEM + + +async def test_suggest_invalid_strategy_is_422( + engineer_a_client: AsyncClient, +) -> None: + """An unknown strategy is a domain ValidationError raised by the service + (``_validate_strategy``) -> nested VALIDATION_ERROR (422). ``strategy`` is a + free ``str`` in the schema, so the bad value passes Pydantic and the service + rejects it (NOT a framework 422).""" + res = await engineer_a_client.post("/api/dispatches/suggest", json={"strategy": "亂猜"}) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +# --------------------------------------------------------------------------- +# REPLAN — POST /api/dispatches/replan +# Re-runs suggestion over 待排程 + 待派工 with a new strategy + a reason. +# --------------------------------------------------------------------------- +async def test_replan_records_reason_and_strategy( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """replan re-suggests and records strategy + replanReason on affected rows. + + We create a dispatch, suggest it to 待派工, then replan with a new strategy and + assert OUR row carries the new strategy + reason and stays in 待派工. + """ + dispatch_id = _uid("DSP-REPLAN") + await _create_dispatch(engineer_a_client, db_session, dispatch_id, experiment_item="SEM") + await engineer_a_client.post("/api/dispatches/suggest", json={"strategy": "FIFO"}) + + res = await engineer_a_client.post( + "/api/dispatches/replan", + json={"reason": "機台保養", "strategy": "Priority First"}, + ) + assert res.status_code == 200, res.text + + body = res.json() + assert body["message"] # 已重新排程 + mine = next((d for d in body["data"] if d["dispatchId"] == dispatch_id), None) + assert mine is not None, "our 待派工 dispatch must be re-planned" + assert mine["status"] == STATUS_SCHEDULING + assert mine["strategy"] == "Priority First" + assert mine["replanReason"] == "機台保養" + + +async def test_replan_invalid_strategy_is_422(engineer_a_client: AsyncClient) -> None: + res = await engineer_a_client.post( + "/api/dispatches/replan", + json={"reason": "x", "strategy": "不存在的策略"}, + ) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +# --------------------------------------------------------------------------- +# ASSIGN — POST /api/dispatches/{id}/assign (待派工 -> 待上機) +# Happy path (needs a 待派工 dispatch + a matching machine + a matching recipe) + +# illegal-state 409 + 404 missing dispatch + 422 recipe/item mismatch. +# --------------------------------------------------------------------------- +async def _suggest_dispatch( + client: AsyncClient, db: AsyncSession, dispatch_id: str, *, experiment_item: str = "SEM" +) -> None: + """Create a dispatch and run suggest so it lands in 待派工 (assign-ready).""" + await _create_dispatch(client, db, dispatch_id, experiment_item=experiment_item) + res = await client.post("/api/dispatches/suggest", json={"strategy": "FIFO"}) + assert res.status_code == 200, res.text + + +async def test_assign_machine_moves_to_waiting_load( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """assign on a 待派工 dispatch with a supporting machine + a matching recipe + advances it to 待上機 and records the assignment.""" + dispatch_id = _uid("DSP-ASSIGN") + await _suggest_dispatch(engineer_a_client, db_session, dispatch_id) + recipe_id = await _make_recipe(db_session, experiment_item="SEM", machine_ids=[LAB_A_MACHINE]) + + res = await engineer_a_client.post( + f"/api/dispatches/{dispatch_id}/assign", + json={ + "machineId": LAB_A_MACHINE, + "recipeId": recipe_id, + "scheduledStart": "2026-12-01 09:00:00", + "scheduledEnd": "2026-12-01 12:00:00", + }, + ) + assert res.status_code == 200, res.text + + data = res.json()["data"] + assert data["status"] == STATUS_WAITING_LOAD + assert data["assignedMachineId"] == LAB_A_MACHINE + assert data["assignedRecipeId"] == recipe_id + assert data["assignedBy"] == ENGINEER_A_NAME + + +async def test_assign_on_pending_dispatch_is_409( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Assigning a dispatch still in 待排程 (not yet suggested to 待派工) is an + illegal state transition -> ConflictError -> 409 CONFLICT (NOT 400). The status + guard runs in the service BEFORE the machine/recipe lookups.""" + dispatch_id = _uid("DSP-EARLY") + await _create_dispatch(engineer_a_client, db_session, dispatch_id) # stays 待排程 + recipe_id = await _make_recipe(db_session, experiment_item="SEM") + + res = await engineer_a_client.post( + f"/api/dispatches/{dispatch_id}/assign", + json={ + "machineId": LAB_A_MACHINE, + "recipeId": recipe_id, + "scheduledStart": "2026-12-01 09:00:00", + "scheduledEnd": "2026-12-01 12:00:00", + }, + ) + assert res.status_code == 409, res.text + assert res.json()["error"]["code"] == "CONFLICT", res.text + + +async def test_assign_missing_dispatch_is_404( + engineer_a_client: AsyncClient, +) -> None: + res = await engineer_a_client.post( + f"/api/dispatches/{_uid('DSP-NOPE')}/assign", + json={ + "machineId": LAB_A_MACHINE, + "recipeId": _uid("RCP-X"), + "scheduledStart": "2026-12-01 09:00:00", + "scheduledEnd": "2026-12-01 12:00:00", + }, + ) + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +async def test_assign_recipe_item_mismatch_is_422( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """The machine supports the dispatch item, but the recipe's experiment_item + differs -> ValidationError (422). Proves the service-layer matching rule + distinct from the not-found / conflict paths.""" + dispatch_id = _uid("DSP-MISMATCH") + await _suggest_dispatch(engineer_a_client, db_session, dispatch_id, experiment_item="SEM") + # Recipe is for a DIFFERENT item (FIB) though bound to the SEM machine. + recipe_id = await _make_recipe(db_session, experiment_item="FIB", machine_ids=[LAB_A_MACHINE]) + + res = await engineer_a_client.post( + f"/api/dispatches/{dispatch_id}/assign", + json={ + "machineId": LAB_A_MACHINE, # supports SEM (the dispatch item) + "recipeId": recipe_id, # but recipe is for FIB -> mismatch + "scheduledStart": "2026-12-01 09:00:00", + "scheduledEnd": "2026-12-01 12:00:00", + }, + ) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +async def test_assign_unsupported_machine_is_422( + engineer_a_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """assign with a same-lab machine that does NOT support the dispatch item -> + ValidationError (422). FIB-A-002 (LAB-A, supports FIB) can't run a SEM dispatch. + This guard runs before the recipe lookup.""" + dispatch_id = _uid("DSP-UNSUP") + await _suggest_dispatch(engineer_a_client, db_session, dispatch_id, experiment_item="SEM") + recipe_id = await _make_recipe(db_session, experiment_item="SEM") + + res = await engineer_a_client.post( + f"/api/dispatches/{dispatch_id}/assign", + json={ + "machineId": "FIB-A-002", # LAB-A, supports FIB not SEM + "recipeId": recipe_id, + "scheduledStart": "2026-12-01 09:00:00", + "scheduledEnd": "2026-12-01 12:00:00", + }, + ) + assert res.status_code == 422, res.text + assert res.json()["error"]["code"] == "VALIDATION_ERROR", res.text + + +# --------------------------------------------------------------------------- +# PERMISSION / AUTH GATING. +# * unauthenticated request -> 401 UNAUTHORIZED +# * authenticated plant_user (no perms) -> 403 FORBIDDEN +# (engineer_a HAS dispatches:manage — proven by every happy-path test above — +# so this module mirrors machines, NOT recipes.) +# --------------------------------------------------------------------------- +async def test_list_dispatches_unauthenticated_is_401(client: AsyncClient) -> None: + """Even the un-gated read endpoint requires a logged-in user (get_current_user + -> UnauthorizedError), so it 401s. The gate is AUTHENTICATION, not the dead + ``dispatches:read`` permission.""" + res = await client.get("/api/dispatches") + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_create_dispatch_unauthenticated_is_401(client: AsyncClient) -> None: + res = await client.post( + "/api/dispatches", + json=_create_payload(_uid("DSP-401"), _uid("WIP-X")), + ) + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_plant_user_cannot_manage_dispatch_is_403( + plant_user_client: AsyncClient, + db_session: AsyncSession, +) -> None: + """plant_user is authenticated but lacks ``dispatches:manage`` -> 403 FORBIDDEN + before any service logic (require_permission rejects at the route boundary).""" + wip_no = await _make_source_wip(db_session) + res = await plant_user_client.post( + "/api/dispatches", + json=_create_payload(_uid("DSP-403"), wip_no), + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text diff --git a/backend/tests/c_tests/test_recipes.py b/backend/tests/c_tests/test_recipes.py new file mode 100644 index 0000000..129ab9f --- /dev/null +++ b/backend/tests/c_tests/test_recipes.py @@ -0,0 +1,352 @@ +"""Endpoint-level (integration) tests for /api/recipes. Owned by 組員 C. + +================================================================================ +Structure mirrors the canonical template ``tests/c_tests/test_machines.py`` (see +it for the shared conventions): one test = one behaviour, named +``test___``; exercise the real HTTP surface through the +shared httpx ``AsyncClient`` fixtures in ``tests/conftest.py``; assert on BOTH the +status code AND the body; create OWN rows with UNIQUE ids so reruns can't collide +with the accumulating session DB; never assert exact collection counts. + +CONTRACT FACTS verified against the source (app/routes/recipes.py + +app/modules/recipes/{service,repository,schemas,serializers}.py) AND a live run: + + * ENVELOPES are the PROJECT-STANDARD shared shapes: + list GET "" -> PageResponse {"items", "page", "pageSize", "total"} + create POST "" -> 200 + ApiResponse {"data": , "message"} + update PATCH /{recipe_id} -> 200 + ApiResponse {"data": , "message"} + The router builds the page envelope from the FULL list (pageSize == len(items), + page == 1) — there is NO real pagination; ``total == pageSize == len(items)``. + ERROR envelope is the standard nested ``{"error": {"code", "message", "details"}}`` + (global handlers in app/core/error_handlers.py + the AppError handler in app/main.py). + + * AUTH / PERMISSIONS (verified against each route's ``Depends``): + GET "" -> get_current_user (authentication ONLY) + POST "" , PATCH /{recipe_id} -> require_permission("recipes:manage") + READ IS NOT PERMISSION-GATED: ``RECIPES_READ = "recipes:read"`` is declared in + the router but is DEAD CODE — never passed to ``require_permission`` / any + ``Depends`` (exactly like machines' dead ``machines:read``). Any logged-in user + can list; the result is merely lab-scoped. + + * ROLE MAP — recipes:manage is SUPERVISOR-ONLY (scripts/seed_dev.py): it lives in + ``LAB_SUPERVISOR_EXTRA_PERMS`` (line ~152), NOT in ``LAB_ENGINEER_PERMS`` (which + holds only ``recipes:read``). This is the OPPOSITE of machines, where the + engineer can manage. So: + supervisor_a_client (lab_supervisor, LAB-A) -> CAN manage recipes. + engineer_a_client (lab_engineer, LAB-A) -> has recipes:read only -> 403. + admin_client (system_admin, cross-lab)-> CAN manage (wildcard ``*``). + plant_user_client -> neither -> 403. + + * LAB SCOPE (app/modules/recipes/service.py): a recipe has no ``lab`` column; it + "belongs to a lab" iff any of its ``machine_ids`` is a machine in that lab. + ``system_admin`` sees/mutates everything. A non-admin manager (supervisor_a) + may only create/update recipes ALL of whose machine_ids are in their OWN lab — + ``_ensure_machines_in_scope`` raises ForbiddenError (403) otherwise, and also + rejects an EMPTY machine_ids list (would create an invisible orphan). Reads are + filtered to recipes overlapping the caller's lab machines; a missing/out-of-scope + recipe is hidden as 404 (existence not leaked). + + * SEED has NO recipes — the recipes table starts EMPTY. Every listable recipe in + these tests is one a test created. Seeded LAB-A machines we bind to: + SEM-A-001 (SEM) / EDX-A-001 (EDX); LAB-B machine IV-B-001 (IV). +""" + +from __future__ import annotations + +import uuid + +import pytest +from httpx import AsyncClient + +# NOTE: no ``pytestmark = pytest.mark.asyncio`` — ``asyncio_mode = "auto"`` +# (pyproject.toml) already auto-collects every ``async def test_*`` as an +# asyncio test. + +LAB_A_MACHINE = "SEM-A-001" # seeded LAB-A machine, supports SEM +LAB_B_MACHINE = "IV-B-001" # seeded LAB-B machine, supports IV + + +def _uid(prefix: str) -> str: + """A collision-proof recipe id for mutating tests. + + The session DB is seeded once and accumulates every recipe mutating tests + create; a uuid4 suffix keeps each created id unique per run so reruns can't + 409 against a row an earlier run inserted. + """ + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +def _recipe_payload( + recipe_id: str, + *, + machine_ids: list[str] | None = None, + experiment_item: str = "SEM", + **overrides: object, +) -> dict[str, object]: + """A reusable, valid create payload (camelCase to match the RecipePayload + aliases). Defaults bind to the seeded LAB-A machine so a LAB-A manager passes + the ``_ensure_machines_in_scope`` lab gate.""" + payload: dict[str, object] = { + "recipeId": recipe_id, + "name": "測試配方", + "version": "v1", + "experimentItem": experiment_item, + "machineIds": [LAB_A_MACHINE] if machine_ids is None else machine_ids, + "method": "標準流程", + "parameters": {"voltage": "5kV"}, + "updatedBy": "譚曉蓉", + } + payload.update(overrides) + return payload + + +# --------------------------------------------------------------------------- +# LIST — GET /api/recipes +# Happy path + the PageResponse envelope. The router has no real pagination: +# pageSize == total == len(items), page == 1. +# --------------------------------------------------------------------------- +async def test_list_recipes_returns_page_envelope( + supervisor_a_client: AsyncClient, +) -> None: + # Arrange: guarantee at least one recipe visible to this LAB-A manager. + recipe_id = _uid("RCP-LIST") + created = await supervisor_a_client.post("/api/recipes", json=_recipe_payload(recipe_id)) + assert created.status_code == 200, created.text + + res = await supervisor_a_client.get("/api/recipes") + assert res.status_code == 200, res.text + + body = res.json() + # Envelope contract: a PageResponse, not a bare list. + assert set(body.keys()) >= {"items", "page", "pageSize", "total"} + assert isinstance(body["items"], list) + assert body["page"] == 1 + # The router builds the page from the full list, so these three agree. + assert body["pageSize"] == body["total"] == len(body["items"]) + ids = {row["recipeId"] for row in body["items"]} + assert recipe_id in ids, "the manager's own-lab recipe must be listable" + # Each row uses the camelCase serializer (serializers.recipe_dict). + sample = next(r for r in body["items"] if r["recipeId"] == recipe_id) + assert set(sample.keys()) == { + "recipeId", + "name", + "version", + "experimentItem", + "machineIds", + "method", + "parameters", + "updatedBy", + "updatedAt", + } + + +async def test_list_recipes_is_lab_scoped_for_engineer( + engineer_a_client: AsyncClient, + admin_client: AsyncClient, +) -> None: + """A non-admin only sees recipes whose machines are in their lab. + + A recipe bound ONLY to a LAB-B machine must NOT appear for the LAB-A engineer + (who CAN list — reads are not gated — but is lab-scoped), while the cross-lab + admin DOES see it. We prove EXCLUSION concretely by pinning a specific LAB-B + recipe id and confirming the admin sees it but the engineer doesn't. + """ + lab_b_recipe = _uid("RCP-LABB") + # Only system_admin can create a LAB-B-only recipe (a LAB-A manager would be + # blocked by _ensure_machines_in_scope); admin's wildcard bypasses lab scope. + created = await admin_client.post( + "/api/recipes", + json=_recipe_payload(lab_b_recipe, machine_ids=[LAB_B_MACHINE], experiment_item="IV"), + ) + assert created.status_code == 200, created.text + + eng_ids = {r["recipeId"] for r in (await engineer_a_client.get("/api/recipes")).json()["items"]} + assert lab_b_recipe not in eng_ids + + admin_ids = {r["recipeId"] for r in (await admin_client.get("/api/recipes")).json()["items"]} + assert lab_b_recipe in admin_ids + + +# --------------------------------------------------------------------------- +# CREATE — POST /api/recipes +# Happy path (200 + {data, message}) + a duplicate-id 409 + a framework 422 + +# the lab-scope ForbiddenError (403) for an out-of-lab machine. +# --------------------------------------------------------------------------- +async def test_create_recipe_success(supervisor_a_client: AsyncClient) -> None: + recipe_id = _uid("RCP-CREATE") + res = await supervisor_a_client.post("/api/recipes", json=_recipe_payload(recipe_id)) + assert res.status_code == 200, res.text + + body = res.json() + assert body["message"] # non-empty success message (Recipe 已建立) + data = body["data"] + assert data["recipeId"] == recipe_id + assert data["experimentItem"] == "SEM" + assert data["machineIds"] == [LAB_A_MACHINE] + assert data["parameters"] == {"voltage": "5kV"} + + +async def test_admin_can_create_recipe(admin_client: AsyncClient) -> None: + """The cross-lab system_admin also holds recipes:manage (wildcard ``*``) and + bypasses the lab-scope machine gate, so it can create against any lab's + machine. Proves the admin manage path independent of the supervisor's lab.""" + recipe_id = _uid("RCP-ADMIN") + res = await admin_client.post( + "/api/recipes", + json=_recipe_payload(recipe_id, machine_ids=[LAB_B_MACHINE], experiment_item="IV"), + ) + assert res.status_code == 200, res.text + assert res.json()["data"]["recipeId"] == recipe_id + + +async def test_create_recipe_duplicate_id_is_409(supervisor_a_client: AsyncClient) -> None: + """Re-using an existing recipe id is a conflict, not a silent overwrite. + + We create our OWN row first (unique id), then POST it again so the collision + is deterministic without depending on any seeded recipe (there are none).""" + recipe_id = _uid("RCP-DUP") + first = await supervisor_a_client.post("/api/recipes", json=_recipe_payload(recipe_id)) + assert first.status_code == 200, first.text + + dup = await supervisor_a_client.post("/api/recipes", json=_recipe_payload(recipe_id)) + assert dup.status_code == 409, dup.text + assert dup.json()["error"]["code"] == "CONFLICT", dup.text + + +async def test_create_recipe_missing_required_field_is_422( + supervisor_a_client: AsyncClient, +) -> None: + """Omitting a required field (name) is rejected by Pydantic before the service + runs — a FRAMEWORK 422. The custom RequestValidationError handler normalizes it + into the project's nested envelope (code VALIDATION_ERROR), NOT the raw FastAPI + ``{"detail": [...]}``; the offending field is summarized into error.message.""" + bad = _recipe_payload(_uid("RCP-422")) + del bad["name"] + res = await supervisor_a_client.post("/api/recipes", json=bad) + assert res.status_code == 422, res.text + + body = res.json() + assert "detail" not in body, res.text + assert body["error"]["code"] == "VALIDATION_ERROR", res.text + assert "name" in body["error"]["message"], res.text + + +async def test_create_recipe_out_of_lab_machine_is_403( + supervisor_a_client: AsyncClient, +) -> None: + """A LAB-A manager binding a recipe to a LAB-B machine trips the service's + ``_ensure_machines_in_scope`` -> ForbiddenError (403 FORBIDDEN). This is a + DOMAIN authorization error raised AFTER the route's permission check passed + (the supervisor HAS recipes:manage) — distinct from the no-permission 403.""" + res = await supervisor_a_client.post( + "/api/recipes", + json=_recipe_payload(_uid("RCP-XLAB"), machine_ids=[LAB_B_MACHINE], experiment_item="IV"), + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +async def test_create_recipe_empty_machines_is_403( + supervisor_a_client: AsyncClient, +) -> None: + """A non-admin manager declaring NO machines would create an orphan recipe + nobody in their lab could see; ``_ensure_machines_in_scope`` rejects the empty + list with ForbiddenError (403). (machineIds defaults to [] in the schema, so an + empty list passes Pydantic and reaches the service.)""" + res = await supervisor_a_client.post( + "/api/recipes", + json=_recipe_payload(_uid("RCP-EMPTY"), machine_ids=[]), + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +# --------------------------------------------------------------------------- +# UPDATE — PATCH /api/recipes/{recipe_id} +# Happy path + 404. We create our own row first so we never mutate a shared one. +# --------------------------------------------------------------------------- +@pytest.mark.xfail( + reason=( + "KNOWN BUG: RecipeService.update returns recipe_dict(recipe) right after " + "commit(); the serializer reads recipe.updated_at, which TimestampMixin " + "mutates with a SERVER-SIDE onupdate=func.now(). After commit the new " + "server-generated value is unloaded on the instance (even with " + "expire_on_commit=False, a server-side onupdate column is expired), so the " + "lazy refresh fires await IO on the async session -> MissingGreenlet -> 500 " + "DATABASE_ERROR. The recipe IS persisted; only the update RESPONSE " + "serialization 500s. (create_recipe is NOT affected: on INSERT updated_at " + "comes from server_default and is populated without a post-commit refresh.) " + "INTENDED behaviour: update returns 200 with the changed fields. This strict " + "xfail XPASSes the moment update awaits repo.refresh(recipe) / eager-loads " + "updated_at (or the serializer stops touching the expired column). Mirrors " + "D's create_report lazy-load-after-commit bug." + ), + strict=True, +) +async def test_update_recipe_success(supervisor_a_client: AsyncClient) -> None: + """Regression anchor for the update-response post-commit lazy-load bug. We + assert the INTENDED 200; today it returns 500, so this xfails (strict).""" + recipe_id = _uid("RCP-UPDATE") + await supervisor_a_client.post("/api/recipes", json=_recipe_payload(recipe_id)) + + updated = _recipe_payload(recipe_id, name="改名後的配方", version="v2") + res = await supervisor_a_client.patch(f"/api/recipes/{recipe_id}", json=updated) + assert res.status_code == 200, res.text + + data = res.json()["data"] + assert data["name"] == "改名後的配方" + assert data["version"] == "v2" + + +async def test_update_missing_recipe_is_404(supervisor_a_client: AsyncClient) -> None: + missing = _uid("RCP-NOPE") + res = await supervisor_a_client.patch(f"/api/recipes/{missing}", json=_recipe_payload(missing)) + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND", res.text + + +# --------------------------------------------------------------------------- +# PERMISSION / AUTH GATING. +# * unauthenticated request -> 401 UNAUTHORIZED +# * authenticated lab_engineer (read-only on this -> 403 FORBIDDEN +# module: has recipes:read, NOT recipes:manage) +# * authenticated plant_user (no recipe perms) -> 403 FORBIDDEN +# --------------------------------------------------------------------------- +async def test_list_recipes_unauthenticated_is_401(client: AsyncClient) -> None: + """``client`` has no auth cookie. Even the un-gated read endpoint requires a + logged-in user (get_current_user raises UnauthorizedError -> nested + UNAUTHORIZED), so it 401s. The gate is AUTHENTICATION, not the dead + ``recipes:read`` permission.""" + res = await client.get("/api/recipes") + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_create_recipe_unauthenticated_is_401(client: AsyncClient) -> None: + res = await client.post("/api/recipes", json=_recipe_payload(_uid("RCP-401"))) + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + +async def test_engineer_cannot_manage_recipe_is_403( + engineer_a_client: AsyncClient, +) -> None: + """A lab_engineer is authenticated and CAN list recipes (read isn't gated), + but holds only ``recipes:read`` — NOT ``recipes:manage`` — so create is + rejected by require_permission with 403 / FORBIDDEN. This is the OPPOSITE of + machines (where the engineer CAN manage); recipes:manage is supervisor-only.""" + res = await engineer_a_client.post("/api/recipes", json=_recipe_payload(_uid("RCP-ENG"))) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text + + +async def test_plant_user_cannot_manage_recipe_is_403( + plant_user_client: AsyncClient, +) -> None: + """plant_user has no recipe permissions at all -> 403 on the manage route.""" + res = await plant_user_client.patch( + f"/api/recipes/{_uid('RCP-PU')}", + json=_recipe_payload(_uid("RCP-PU")), + ) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == "FORBIDDEN", res.text From 0713ad28f49d23b664151e89d6ecb7de373cbed0 Mon Sep 17 00:00:00 2001 From: hank Date: Tue, 2 Jun 2026 12:30:13 +0800 Subject: [PATCH 4/6] test: improve frontend and backend coverage --- .../tests/e_tests/test_dispatches_service.py | 308 ++++++++++++++++++ .../e_tests/test_experiment_runs_service.py | 240 ++++++++++++++ .../tests/e_tests/test_machines_service.py | 130 ++++++++ backend/tests/e_tests/test_recipes_service.py | 122 +++++++ backend/tests/e_tests/test_reports_service.py | 281 ++++++++++++++++ .../tests/e_tests/test_workflow_helpers.py | 181 ++++++++++ .../__tests__/ReportModals.test.tsx | 195 +++++++++++ .../components/__tests__/ReportTable.test.tsx | 161 +++++++++ .../src/lib/__tests__/displayNames.test.ts | 47 +++ .../src/lib/__tests__/errorMessage.test.ts | 24 ++ .../notifications-issues-api.test.ts | 87 +++++ .../services/__tests__/reports-api.test.ts | 91 ++++++ .../src/services/__tests__/role-d-api.test.ts | 192 +++++++++++ 13 files changed, 2059 insertions(+) create mode 100644 backend/tests/e_tests/test_dispatches_service.py create mode 100644 backend/tests/e_tests/test_experiment_runs_service.py create mode 100644 backend/tests/e_tests/test_machines_service.py create mode 100644 backend/tests/e_tests/test_recipes_service.py create mode 100644 backend/tests/e_tests/test_reports_service.py create mode 100644 backend/tests/e_tests/test_workflow_helpers.py create mode 100644 frontend/app/report/components/__tests__/ReportModals.test.tsx create mode 100644 frontend/app/report/components/__tests__/ReportTable.test.tsx create mode 100644 frontend/src/lib/__tests__/displayNames.test.ts create mode 100644 frontend/src/lib/__tests__/errorMessage.test.ts create mode 100644 frontend/src/services/__tests__/notifications-issues-api.test.ts create mode 100644 frontend/src/services/__tests__/reports-api.test.ts create mode 100644 frontend/src/services/__tests__/role-d-api.test.ts diff --git a/backend/tests/e_tests/test_dispatches_service.py b/backend/tests/e_tests/test_dispatches_service.py new file mode 100644 index 0000000..4b66e7e --- /dev/null +++ b/backend/tests/e_tests/test_dispatches_service.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import uuid + +import pytest + +from app.common.dependencies.lab_scope import LabScope +from app.common.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError +from app.db.models import Dispatch, Machine, Recipe, Wip +from app.modules.dispatches.schemas import AssignDispatchPayload, CreateDispatchPayload +from app.modules.dispatches.service import ( + STATUS_PENDING, + STATUS_SCHEDULING, + STATUS_WAITING_LOAD, + DispatchService, + _match_machine, + _order_by_strategy, +) + +pytestmark = pytest.mark.asyncio + + +LAB_SCOPE = LabScope(role="lab_engineer", lab_name="材料分析實驗室", lab_code="LAB-A") +NO_LAB_SCOPE = LabScope(role="lab_engineer", lab_name=None, lab_code=None) + + +class FakeDispatchRepo: + def __init__(self) -> None: + self.dispatches: dict[str, Dispatch] = {} + self.wips: dict[str, Wip] = {} + self.machines: dict[str, Machine] = {} + self.recipes: dict[str, Recipe] = {} + self.lab_codes = {"材料分析實驗室": "LAB-A", "電性測試實驗室": "LAB-B"} + self.history: list[object] = [] + self.added: list[Dispatch] = [] + self.commits = 0 + + async def list_dispatches(self, lab_code: str | None = None) -> list[Dispatch]: + return [d for d in self.dispatches.values() if lab_code is None or d.lab == lab_code] + + async def list_by_statuses(self, statuses: list[str], lab_code: str | None = None): + return [ + d + for d in self.dispatches.values() + if d.status in statuses and (lab_code is None or d.lab == lab_code) + ] + + async def get_by_dispatch_id(self, dispatch_id: str) -> Dispatch | None: + return self.dispatches.get(dispatch_id) + + async def get_wip_by_no(self, wip_no: str) -> Wip | None: + return self.wips.get(wip_no) + + async def lab_code_for_name(self, lab_name: str) -> str | None: + return self.lab_codes.get(lab_name) + + async def list_machines(self, lab_code: str | None = None) -> list[Machine]: + return [m for m in self.machines.values() if lab_code is None or m.lab == lab_code] + + async def get_machine(self, machine_id: str) -> Machine | None: + return self.machines.get(machine_id) + + async def get_recipe(self, recipe_id: str) -> Recipe | None: + return self.recipes.get(recipe_id) + + def add(self, dispatch: Dispatch) -> None: + self.added.append(dispatch) + self.dispatches[dispatch.dispatch_id] = dispatch + + def add_wip_history(self, history: object) -> None: + self.history.append(history) + + async def commit(self) -> None: + self.commits += 1 + + +def dispatch( + dispatch_id: str, + *, + priority: str = "中", + due_at: str | None = None, + item: str = "SEM", + status: str = STATUS_PENDING, + lab: str = "LAB-A", +) -> Dispatch: + return Dispatch( + dispatch_id=dispatch_id, + wip_id=f"WIP-{dispatch_id}", + order_id="ORD-1", + experiment_item=item, + priority=priority, + due_at=due_at, + status=status, + created_by="Alice", + lab=lab, + ) + + +def machine(machine_id: str = "SEM-A-001", lab: str = "LAB-A", items: list[str] | None = None): + return Machine( + machine_id=machine_id, + name=machine_id, + lab=lab, + status="閒置", + supported_items=items or ["SEM"], + utilization=0, + owner="Alice", + ) + + +def recipe(recipe_id: str = "SEM-R1", item: str = "SEM", machine_ids: list[str] | None = None): + return Recipe( + recipe_id=recipe_id, + name=recipe_id, + version="v1", + experiment_item=item, + machine_ids=machine_ids or ["SEM-A-001"], + method="method", + parameters={}, + updated_by="Alice", + ) + + +def wip(wip_no: str = "WIP-1", lab_name: str = "材料分析實驗室", status: str = "created") -> Wip: + return Wip( + id=uuid.uuid4(), + wip_no=wip_no, + sample_id=uuid.uuid4(), + order_no="ORD-1", + lab_name=lab_name, + experiment_item="SEM", + priority="normal", + status=status, + progress=0, + ) + + +def create_payload(dispatch_id: str = "D-1", wip_id: str = "WIP-1") -> CreateDispatchPayload: + return CreateDispatchPayload.model_validate( + { + "dispatchId": dispatch_id, + "wipId": wip_id, + "orderId": "ORD-1", + "experimentItem": "SEM", + "priority": "高", + "dueAt": "2026-01-02", + } + ) + + +def assign_payload( + machine_id: str = "SEM-A-001", recipe_id: str = "SEM-R1" +) -> AssignDispatchPayload: + return AssignDispatchPayload.model_validate( + { + "machineId": machine_id, + "recipeId": recipe_id, + "scheduledStart": "2026-01-01 09:00", + "scheduledEnd": "2026-01-01 10:00", + } + ) + + +async def test_strategy_ordering_and_machine_matching() -> None: + rows = [ + dispatch("D-2", priority="低", due_at="2026-01-03", item="IV"), + dispatch("D-1", priority="高", due_at="2026-01-05", item="SEM"), + dispatch("D-3", priority="中", due_at="2026-01-01", item="SEM"), + ] + + assert [d.dispatch_id for d in _order_by_strategy(rows, "FIFO")] == ["D-1", "D-2", "D-3"] + assert [d.dispatch_id for d in _order_by_strategy(rows, "Priority First")] == [ + "D-1", + "D-3", + "D-2", + ] + assert [d.dispatch_id for d in _order_by_strategy(rows, "Earliest Due Date")] == [ + "D-3", + "D-2", + "D-1", + ] + assert [d.dispatch_id for d in _order_by_strategy(rows, "Least Setup Change")] == [ + "D-2", + "D-1", + "D-3", + ] + assert [d.dispatch_id for d in _order_by_strategy(rows, "Hybrid")] == ["D-1", "D-3", "D-2"] + assert _match_machine("SEM", [machine("M-1", items=["IV"]), machine("M-2")]) == "M-2" + assert _match_machine("FIB", [machine("M-1")]) is None + + +async def test_create_dispatch_success_duplicate_missing_and_cross_lab() -> None: + repo = FakeDispatchRepo() + repo.wips["WIP-1"] = wip("WIP-1") + svc = DispatchService(repo, LAB_SCOPE) + + result = await svc.create(create_payload(), "Alice") + assert result["dispatchId"] == "D-1" + assert result["status"] == STATUS_PENDING + assert repo.wips["WIP-1"].status == "waiting_schedule" + assert repo.history[-1].action == "送入待排程" + assert repo.commits == 1 + + with pytest.raises(ConflictError): + await svc.create(create_payload(), "Alice") + with pytest.raises(NotFoundError): + await svc.create(create_payload("D-2", "missing"), "Alice") + repo.wips["WIP-B"] = wip("WIP-B", lab_name="電性測試實驗室") + with pytest.raises(ForbiddenError): + await svc.create(create_payload("D-3", "WIP-B"), "Alice") + + +async def test_list_suggest_and_replan_scoped_and_validate_strategy() -> None: + repo = FakeDispatchRepo() + repo.dispatches = { + "D-1": dispatch("D-1", priority="高", item="SEM"), + "D-2": dispatch("D-2", priority="低", item="IV"), + "D-B": dispatch("D-B", lab="LAB-B"), + } + for d in repo.dispatches.values(): + repo.wips[d.wip_id] = wip(d.wip_id, status="waiting_schedule") + repo.machines = { + "SEM": machine("SEM-A-001", items=["SEM"]), + "IV": machine("IV-A-001", items=["IV"]), + } + svc = DispatchService(repo, LAB_SCOPE) + + assert [d["dispatchId"] for d in await svc.list_dispatches()] == ["D-1", "D-2"] + assert await DispatchService(repo, NO_LAB_SCOPE).list_dispatches() == [] + + suggested = await svc.suggest("Priority First") + assert [d["dispatchId"] for d in suggested] == ["D-1", "D-2"] + assert repo.dispatches["D-1"].suggested_machine_id == "SEM-A-001" + assert repo.dispatches["D-2"].status == STATUS_SCHEDULING + + replanned = await svc.replan("rush", "Least Setup Change") + assert {d["replanReason"] for d in replanned} == {"rush"} + assert repo.commits == 2 + + with pytest.raises(ValidationError): + await svc.suggest("Bad Strategy") + + +async def test_assign_success_and_validation_errors() -> None: + repo = FakeDispatchRepo() + repo.dispatches["D-1"] = dispatch("D-1", status=STATUS_SCHEDULING) + repo.wips["WIP-D-1"] = wip("WIP-D-1", status="scheduled") + repo.machines["SEM-A-001"] = machine("SEM-A-001", items=["SEM"]) + repo.recipes["SEM-R1"] = recipe("SEM-R1", item="SEM", machine_ids=["SEM-A-001"]) + svc = DispatchService(repo, LAB_SCOPE) + + result = await svc.assign("D-1", assign_payload(), "Bob") + assert result["status"] == STATUS_WAITING_LOAD + assert result["assignedMachineId"] == "SEM-A-001" + assert repo.wips["WIP-D-1"].status == "dispatched" + assert repo.history[-1].action == "派工指派" + + repo.dispatches["not-ready"] = dispatch("not-ready", status=STATUS_PENDING) + with pytest.raises(ConflictError): + await svc.assign("not-ready", assign_payload(), "Bob") + with pytest.raises(NotFoundError): + await svc.assign("missing", assign_payload(), "Bob") + + repo.dispatches["D-2"] = dispatch("D-2", status=STATUS_SCHEDULING) + with pytest.raises(NotFoundError): + await svc.assign("D-2", assign_payload("missing", "SEM-R1"), "Bob") + + repo.machines["IV-B-001"] = machine("IV-B-001", lab="LAB-B", items=["SEM"]) + with pytest.raises(NotFoundError): + await svc.assign("D-2", assign_payload("IV-B-001", "SEM-R1"), "Bob") + + repo.machines["IV-A-001"] = machine("IV-A-001", items=["IV"]) + with pytest.raises(ValidationError): + await svc.assign("D-2", assign_payload("IV-A-001", "SEM-R1"), "Bob") + + with pytest.raises(NotFoundError): + await svc.assign("D-2", assign_payload("SEM-A-001", "missing"), "Bob") + + repo.recipes["IV-R1"] = recipe("IV-R1", item="IV", machine_ids=["SEM-A-001"]) + with pytest.raises(ValidationError): + await svc.assign("D-2", assign_payload("SEM-A-001", "IV-R1"), "Bob") + + repo.recipes["SEM-R2"] = recipe("SEM-R2", item="SEM", machine_ids=["OTHER"]) + with pytest.raises(ValidationError): + await svc.assign("D-2", assign_payload("SEM-A-001", "SEM-R2"), "Bob") + + +async def test_sync_wip_status_forward_only_and_strict_conflict() -> None: + repo = FakeDispatchRepo() + svc = DispatchService(repo, LAB_SCOPE) + + repo.wips["ahead"] = wip("ahead", status="dispatched") + await svc._sync_wip_status("ahead", "scheduled", "排程", "skip", "system") + assert repo.wips["ahead"].status == "dispatched" + assert repo.history == [] + + repo.wips["missing"] = None # type: ignore[assignment] + await svc._sync_wip_status("missing", "scheduled", "排程", "skip", "system") + assert repo.history == [] + + repo.wips["running"] = wip("running", status="running") + await svc._sync_wip_status("running", "scheduled", "排程", "skip", "system") + assert repo.wips["running"].status == "running" + + with pytest.raises(ConflictError): + await svc._sync_wip_status( + "running", "dispatched", "派工指派", "strict", "system", strict=True + ) diff --git a/backend/tests/e_tests/test_experiment_runs_service.py b/backend/tests/e_tests/test_experiment_runs_service.py new file mode 100644 index 0000000..c7f3a45 --- /dev/null +++ b/backend/tests/e_tests/test_experiment_runs_service.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace + +import pytest + +from app.common.dependencies.lab_scope import LabScope +from app.common.enums import OrderStatus, WipStatus +from app.common.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError +from app.db.models import Wip, WipExecution +from app.modules.experiment_runs.service import ABORT_PENDING, B_DISPATCHED, ExperimentRunService + +pytestmark = pytest.mark.asyncio + + +LAB_SCOPE = LabScope(role="lab_engineer", lab_name="材料分析實驗室", lab_code="LAB-A") +NO_LAB_SCOPE = LabScope(role="lab_engineer", lab_name=None, lab_code=None) + + +class FakeExperimentRepo: + def __init__(self) -> None: + self.wips: dict[str, Wip] = {} + self.execs: dict[str, WipExecution] = {} + self.orders: dict[str, SimpleNamespace] = {} + self.dispatches: dict[str, tuple[str | None, str | None]] = {} + self.operators = [("Alice", "lab_engineer"), ("Alice", "lab_supervisor")] + self.commits = 0 + self.session = SimpleNamespace() + + async def get_wip(self, wip_no: str) -> Wip | None: + return self.wips.get(wip_no) + + async def list_wips(self, lab_name: str | None = None) -> list[Wip]: + return [w for w in self.wips.values() if lab_name is None or w.lab_name == lab_name] + + async def get_exec(self, wip_no: str) -> WipExecution | None: + return self.execs.get(wip_no) + + async def get_execs_map(self, wip_nos: list[str]) -> dict[str, WipExecution]: + return {wip_no: self.execs[wip_no] for wip_no in wip_nos if wip_no in self.execs} + + async def ensure_exec(self, wip_no: str) -> WipExecution: + if wip_no not in self.execs: + self.execs[wip_no] = execution(wip_no) + return self.execs[wip_no] + + async def get_dispatch_assignment(self, wip_no: str) -> tuple[str | None, str | None]: + return self.dispatches.get(wip_no, (None, None)) + + async def get_dispatch_assignments( + self, wip_nos: list[str] + ) -> dict[str, tuple[str | None, str | None]]: + return {wip_no: self.dispatches.get(wip_no, (None, None)) for wip_no in wip_nos} + + async def list_lab_operators(self, _lab_name: str) -> list[tuple[str, str]]: + return self.operators + + async def get_order(self, order_no: str) -> SimpleNamespace | None: + return self.orders.get(order_no) + + async def list_wips_for_order(self, order_no: str) -> list[Wip]: + return [w for w in self.wips.values() if w.order_no == order_no] + + async def commit(self) -> None: + self.commits += 1 + + +def wip( + wip_no: str = "WIP-1", + *, + status: str = B_DISPATCHED, + lab_name: str = "材料分析實驗室", +) -> Wip: + item = Wip( + id=uuid.uuid4(), + wip_no=wip_no, + sample_id=uuid.uuid4(), + order_no="ORD-1", + lab_name=lab_name, + experiment_item="SEM", + priority="normal", + status=status, + progress=0, + ) + item.history = [] + return item + + +def execution(wip_no: str = "WIP-1", status: str = WipStatus.WAITING_LOAD.value) -> WipExecution: + return WipExecution( + wip_no=wip_no, + exec_status=status, + machine_id=None, + recipe=None, + operator=None, + data_verified=False, + ) + + +def service(repo: FakeExperimentRepo) -> ExperimentRunService: + svc = ExperimentRunService(repo, LAB_SCOPE) + + async def noop(*_args: object, **_kwargs: object) -> None: + return None + + svc._publish_wip_pipeline_change = noop # type: ignore[method-assign] + svc._advance_sample_flow = noop # type: ignore[method-assign] + svc._advance_sample_on_termination = noop # type: ignore[method-assign] + svc._notify_applicant_of_termination = noop # type: ignore[method-assign] + return svc + + +async def test_list_get_and_operator_helpers_respect_scope_and_fallbacks() -> None: + repo = FakeExperimentRepo() + repo.wips = {"WIP-1": wip(), "WIP-2": wip("WIP-2", lab_name="電性測試實驗室")} + repo.execs["WIP-1"] = execution(status=WipStatus.RUNNING.value) + repo.dispatches["WIP-1"] = ("SEM-A-001", "SEM-R1") + + rows = await service(repo).list_wips() + assert [row["wipId"] for row in rows] == ["WIP-1"] + assert await ExperimentRunService(repo, NO_LAB_SCOPE).list_wips() == [] + assert (await service(repo).get_wip("WIP-1"))["machineId"] == "SEM-A-001" + assert await service(repo).list_operators("WIP-1") == [{"name": "Alice", "role": "實驗室主管"}] + + repo.wips["WIP-empty-lab"] = wip("WIP-empty-lab", lab_name=None) + assert await ExperimentRunService(repo, LabScope.system()).list_operators("WIP-empty-lab") == [] + + with pytest.raises(NotFoundError): + await service(repo).get_wip("missing") + with pytest.raises(ForbiddenError): + await service(repo).get_wip("WIP-2") + + +async def test_check_in_out_progress_and_upload_result_lifecycle() -> None: + repo = FakeExperimentRepo() + repo.wips["WIP-1"] = wip() + repo.execs["WIP-1"] = execution() + repo.orders["ORD-1"] = SimpleNamespace(status=OrderStatus.SCHEDULED.value) + svc = service(repo) + + checked_in = await svc.check_in("WIP-1", "Alice", "SEM-A-001", "SEM-R1") + assert checked_in["status"] == "執行中" + assert repo.orders["ORD-1"].status == OrderStatus.IN_PROGRESS.value + assert repo.wips["WIP-1"].history[-1].action == "上機" + + updated = await svc.update_progress("WIP-1", 40) + assert updated["progress"] == 40 + + checked_out = await svc.check_out("WIP-1", "Alice", "done") + assert checked_out["status"] == "已下機" + + uploaded = await svc.upload_result("WIP-1", "ok", "raw.csv", True) + assert uploaded["status"] == "待確認" + assert uploaded["progress"] == 100 + assert uploaded["dataVerified"] is True + + +async def test_lifecycle_methods_reject_wrong_statuses() -> None: + repo = FakeExperimentRepo() + repo.wips["WIP-1"] = wip(status="created") + repo.execs["WIP-1"] = execution(status=WipStatus.WAITING_LOAD.value) + svc = service(repo) + + with pytest.raises(ConflictError): + await svc.check_in("WIP-1", "Alice", "SEM-A-001", "SEM-R1") + repo.wips["WIP-1"].status = B_DISPATCHED + repo.execs["WIP-1"].exec_status = WipStatus.UNLOADED.value + with pytest.raises(ConflictError): + await svc.check_in("WIP-1", "Alice", "SEM-A-001", "SEM-R1") + with pytest.raises(ConflictError): + await svc.update_progress("WIP-1", 10) + with pytest.raises(ConflictError): + await svc.check_out("WIP-1", "Alice", None) + repo.execs["WIP-1"].exec_status = WipStatus.COMPLETED.value + with pytest.raises(ConflictError): + await svc.upload_result("WIP-1", "ok", None, True) + + +async def test_verify_and_confirm_result_roll_up_order_status() -> None: + repo = FakeExperimentRepo() + repo.wips["WIP-1"] = wip() + repo.execs["WIP-1"] = execution(status=WipStatus.WAITING_CONFIRM.value) + repo.orders["ORD-1"] = SimpleNamespace(status=OrderStatus.IN_PROGRESS.value) + svc = service(repo) + + verified = await svc.verify_data("WIP-1", "Lead") + assert verified["dataVerified"] is True + with pytest.raises(ConflictError): + await svc.verify_data("WIP-1", "Lead") + + confirmed = await svc.confirm_result("WIP-1", "Lead") + assert confirmed["status"] == "已完成" + assert repo.orders["ORD-1"].status == OrderStatus.COMPLETED.value + + repo.execs["WIP-1"].exec_status = WipStatus.WAITING_CONFIRM.value + repo.execs["WIP-1"].data_verified = False + with pytest.raises(ValidationError): + await svc.confirm_result("WIP-1", "Lead") + + +async def test_abort_request_review_and_machine_completion() -> None: + repo = FakeExperimentRepo() + repo.wips["WIP-1"] = wip() + repo.execs["WIP-1"] = execution(status=WipStatus.RUNNING.value) + repo.orders["ORD-1"] = SimpleNamespace(status=OrderStatus.IN_PROGRESS.value) + svc = service(repo) + + requested = await svc.request_abort("WIP-1", "broken", "Alice") + assert requested["abort"]["status"] == ABORT_PENDING + + with pytest.raises(ConflictError): + await svc.request_abort("WIP-1", "again", "Alice") + + rejected = await svc.review_abort("WIP-1", False, "continue", "Lead") + assert rejected["abort"]["status"] == "已駁回" + assert rejected["status"] == "執行中" + + repo.execs["WIP-1"].abort_status = ABORT_PENDING + approved = await svc.review_abort("WIP-1", True, "stop", "Lead") + assert approved["status"] == "已終止" + assert repo.orders["ORD-1"].status == OrderStatus.TERMINATED.value + + repo.execs["WIP-1"].exec_status = WipStatus.RUNNING.value + repo.execs["WIP-1"].machine_id = "SEM-A-001" + assert await svc.apply_machine_completion("WIP-1") is True + assert repo.execs["WIP-1"].exec_status == WipStatus.WAITING_CONFIRM.value + assert repo.wips["WIP-1"].progress == 100 + + repo.execs["WIP-1"].exec_status = WipStatus.COMPLETED.value + assert await svc.apply_machine_completion("WIP-1") is False + + +async def test_abort_review_rejects_without_pending_request() -> None: + repo = FakeExperimentRepo() + repo.wips["WIP-1"] = wip() + repo.execs["WIP-1"] = execution(status=WipStatus.RUNNING.value) + + with pytest.raises(ConflictError): + await service(repo).review_abort("WIP-1", True, None, "Lead") diff --git a/backend/tests/e_tests/test_machines_service.py b/backend/tests/e_tests/test_machines_service.py new file mode 100644 index 0000000..20bef5d --- /dev/null +++ b/backend/tests/e_tests/test_machines_service.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import pytest + +from app.common.dependencies.lab_scope import LabScope +from app.common.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError +from app.db.models import Machine +from app.modules.machines.schemas import MachinePayload +from app.modules.machines.service import DEFAULT_STATUS, MachineService + +pytestmark = pytest.mark.asyncio + + +LAB_SCOPE = LabScope(role="lab_engineer", lab_name="材料分析實驗室", lab_code="LAB-A") +NO_LAB_SCOPE = LabScope(role="lab_engineer", lab_name=None, lab_code=None) + + +class FakeMachineRepo: + def __init__(self) -> None: + self.machines: dict[str, Machine] = {} + self.added: list[Machine] = [] + self.commits = 0 + self.last_list_lab: str | None = None + + async def list_machines(self, lab_name: str | None = None) -> list[Machine]: + self.last_list_lab = lab_name + rows = list(self.machines.values()) + return [m for m in rows if lab_name is None or m.lab == lab_name] + + async def get_by_machine_id(self, machine_id: str) -> Machine | None: + return self.machines.get(machine_id) + + def add(self, machine: Machine) -> None: + self.added.append(machine) + self.machines[machine.machine_id] = machine + + async def commit(self) -> None: + self.commits += 1 + + +def payload(machine_id: str = "M-1", **overrides: object) -> MachinePayload: + data = { + "machineId": machine_id, + "name": "SEM 機台", + "lab": "LAB-A", + "supportedItems": ["SEM"], + "owner": "Alice", + "utilization": 20, + "lastMaintenance": "2026-01-01", + } + data.update(overrides) + return MachinePayload.model_validate(data) + + +def machine(machine_id: str = "M-1", lab: str = "LAB-A", status: str = "閒置") -> Machine: + return Machine( + machine_id=machine_id, + name="SEM 機台", + lab=lab, + status=status, + supported_items=["SEM"], + owner="Alice", + utilization=20, + last_maintenance="2026-01-01", + ) + + +async def test_list_machines_scopes_by_lab_and_no_lab_scope_returns_empty() -> None: + repo = FakeMachineRepo() + repo.machines = {"A": machine("A", "LAB-A"), "B": machine("B", "LAB-B")} + + assert [m["machineId"] for m in await MachineService(repo, LAB_SCOPE).list_machines()] == ["A"] + assert repo.last_list_lab == "LAB-A" + assert await MachineService(repo, NO_LAB_SCOPE).list_machines() == [] + + +async def test_create_machine_defaults_status_and_commits() -> None: + repo = FakeMachineRepo() + result = await MachineService(repo, LAB_SCOPE).create(payload("M-new")) + + assert result["machineId"] == "M-new" + assert result["status"] == DEFAULT_STATUS + assert repo.commits == 1 + assert repo.added[0].supported_items == ["SEM"] + + +async def test_create_machine_rejects_cross_lab_duplicate_and_invalid_status() -> None: + repo = FakeMachineRepo() + repo.machines["M-existing"] = machine("M-existing", "LAB-A") + svc = MachineService(repo, LAB_SCOPE) + + with pytest.raises(ForbiddenError): + await svc.create(payload("M-x", lab="LAB-B")) + with pytest.raises(ConflictError): + await svc.create(payload("M-existing")) + with pytest.raises(ValidationError): + await svc.create(payload("M-bad", status="壞掉")) + + +async def test_update_preserves_status_unless_payload_supplies_one() -> None: + repo = FakeMachineRepo() + repo.machines["M-1"] = machine("M-1", status="使用中") + svc = MachineService(repo, LAB_SCOPE) + + result = await svc.update("M-1", payload("M-1", name="更新名稱")) + assert result["name"] == "更新名稱" + assert result["status"] == "使用中" + + result = await svc.update("M-1", payload("M-1", status="保養中")) + assert result["status"] == "保養中" + assert repo.commits == 2 + + +async def test_update_and_status_enforce_scope_missing_and_valid_status() -> None: + repo = FakeMachineRepo() + repo.machines = {"A": machine("A", "LAB-A"), "B": machine("B", "LAB-B")} + svc = MachineService(repo, LAB_SCOPE) + + with pytest.raises(NotFoundError): + await svc.update("missing", payload("missing")) + with pytest.raises(NotFoundError): + await svc.update("B", payload("B", lab="LAB-B")) + with pytest.raises(ForbiddenError): + await svc.update("A", payload("A", lab="LAB-B")) + with pytest.raises(ValidationError): + await svc.update_status("A", "未知") + + result = await svc.update_status("A", "停用") + assert result["status"] == "停用" + assert repo.commits == 1 diff --git a/backend/tests/e_tests/test_recipes_service.py b/backend/tests/e_tests/test_recipes_service.py new file mode 100644 index 0000000..81044d1 --- /dev/null +++ b/backend/tests/e_tests/test_recipes_service.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import pytest + +from app.common.dependencies.lab_scope import LabScope +from app.common.errors import ConflictError, ForbiddenError, NotFoundError +from app.db.models import Recipe +from app.modules.recipes.schemas import RecipePayload +from app.modules.recipes.service import RecipeService + +pytestmark = pytest.mark.asyncio + + +LAB_SCOPE = LabScope(role="lab_engineer", lab_name="材料分析實驗室", lab_code="LAB-A") +NO_LAB_SCOPE = LabScope(role="lab_engineer", lab_name=None, lab_code=None) + + +class FakeRecipeRepo: + def __init__(self) -> None: + self.recipes: dict[str, Recipe] = {} + self.lab_machine_ids = {"LAB-A": ["SEM-A-001"], "LAB-B": ["IV-B-001"]} + self.added: list[Recipe] = [] + self.commits = 0 + + async def list_recipes(self) -> list[Recipe]: + return list(self.recipes.values()) + + async def get_by_recipe_id(self, recipe_id: str) -> Recipe | None: + return self.recipes.get(recipe_id) + + async def machine_ids_in_lab(self, lab_code: str) -> list[str]: + return self.lab_machine_ids.get(lab_code, []) + + def add(self, recipe: Recipe) -> None: + self.added.append(recipe) + self.recipes[recipe.recipe_id] = recipe + + async def commit(self) -> None: + self.commits += 1 + + +def recipe(recipe_id: str, machine_ids: list[str]) -> Recipe: + return Recipe( + recipe_id=recipe_id, + name=f"Recipe {recipe_id}", + version="v1", + experiment_item="SEM", + machine_ids=machine_ids, + method="method", + parameters={"temp": "100C"}, + updated_by="Alice", + ) + + +def payload(recipe_id: str = "R-1", **overrides: object) -> RecipePayload: + data = { + "recipeId": recipe_id, + "name": "SEM recipe", + "version": "v2", + "experimentItem": "SEM", + "machineIds": ["SEM-A-001"], + "method": "updated", + "parameters": {"voltage": "5V"}, + "updatedBy": "Bob", + } + data.update(overrides) + return RecipePayload.model_validate(data) + + +async def test_list_recipes_filters_to_lab_owned_machines_and_no_lab_returns_empty() -> None: + repo = FakeRecipeRepo() + repo.recipes = { + "A": recipe("A", ["SEM-A-001"]), + "B": recipe("B", ["IV-B-001"]), + "orphan": recipe("orphan", []), + } + + assert [r["recipeId"] for r in await RecipeService(repo, LAB_SCOPE).list_recipes()] == ["A"] + assert await RecipeService(repo, NO_LAB_SCOPE).list_recipes() == [] + + +async def test_system_scope_lists_all_recipes() -> None: + repo = FakeRecipeRepo() + repo.recipes = {"A": recipe("A", ["SEM-A-001"]), "B": recipe("B", ["IV-B-001"])} + + result = await RecipeService(repo, LabScope.system()).list_recipes() + + assert {r["recipeId"] for r in result} == {"A", "B"} + + +async def test_create_recipe_success_duplicate_and_machine_scope() -> None: + repo = FakeRecipeRepo() + svc = RecipeService(repo, LAB_SCOPE) + + result = await svc.create(payload("R-new")) + assert result["recipeId"] == "R-new" + assert result["machineIds"] == ["SEM-A-001"] + assert repo.commits == 1 + + with pytest.raises(ConflictError): + await svc.create(payload("R-new")) + with pytest.raises(ForbiddenError): + await svc.create(payload("R-empty", machineIds=[])) + with pytest.raises(ForbiddenError): + await svc.create(payload("R-cross", machineIds=["IV-B-001"])) + + +async def test_update_recipe_enforces_visibility_and_machine_scope() -> None: + repo = FakeRecipeRepo() + repo.recipes = {"A": recipe("A", ["SEM-A-001"]), "B": recipe("B", ["IV-B-001"])} + svc = RecipeService(repo, LAB_SCOPE) + + result = await svc.update("A", payload("A", name="new name")) + assert result["name"] == "new name" + assert repo.commits == 1 + + with pytest.raises(NotFoundError): + await svc.update("missing", payload("missing")) + with pytest.raises(NotFoundError): + await svc.update("B", payload("B", machineIds=["IV-B-001"])) + with pytest.raises(ForbiddenError): + await svc.update("A", payload("A", machineIds=["IV-B-001"])) diff --git a/backend/tests/e_tests/test_reports_service.py b/backend/tests/e_tests/test_reports_service.py new file mode 100644 index 0000000..5da64ac --- /dev/null +++ b/backend/tests/e_tests/test_reports_service.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from types import SimpleNamespace + +import pytest + +from app.common.dependencies.lab_scope import LabScope +from app.common.enums import OrderStatus, ReportStatus, WipStatus +from app.common.enums.role_d_zh import REPORT_ZH +from app.common.errors import ConflictError, ForbiddenError, NotFoundError +from app.db.models import Report, ReportTemplate, ReportVersion, Wip, WipExecution +from app.modules.reports.service import ReportService + +pytestmark = pytest.mark.asyncio + + +LAB_SCOPE = LabScope(role="lab_engineer", lab_name="材料分析實驗室", lab_code="LAB-A") +NO_LAB_SCOPE = LabScope(role="lab_engineer", lab_name=None, lab_code=None) + + +class FakeScalarSession: + async def scalar(self, _stmt: object) -> str: + return "LAB-A" + + +class FakeReportRepo: + def __init__(self) -> None: + self.reports: dict[str, Report] = {} + self.wips: dict[str, Wip] = {} + self.execs: dict[str, WipExecution] = {} + self.templates: dict[int, ReportTemplate] = {} + self.orders: dict[str, SimpleNamespace] = {} + self.formal_count = 0 + self.added: list[object] = [] + self.commits = 0 + self._session = FakeScalarSession() + + async def get_report(self, report_id: str) -> Report | None: + return self.reports.get(report_id) + + async def get_wip(self, wip_id: str) -> Wip | None: + return self.wips.get(wip_id) + + async def get_exec(self, wip_id: str) -> WipExecution | None: + return self.execs.get(wip_id) + + async def get_template(self, template_id: int | None) -> ReportTemplate | None: + return self.templates.get(template_id or -1) + + async def list_reports( + self, status: str | None, order_id: str | None, lab_filter: str | None + ) -> list[Report]: + rows = list(self.reports.values()) + if status: + rows = [r for r in rows if r.status == status] + if order_id: + rows = [r for r in rows if r.order_id == order_id] + if lab_filter: + rows = [r for r in rows if self.wips[r.wip_id].lab_name == lab_filter] + return rows + + async def list_templates(self) -> list[ReportTemplate]: + return list(self.templates.values()) + + async def count_formal_reports_for_wip(self, _wip_id: str, _statuses: list[str]) -> int: + return self.formal_count + + async def count_reports_for_order(self, order_no: str) -> int: + return len([r for r in self.reports.values() if r.order_id == order_no]) + + async def get_order(self, order_no: str) -> SimpleNamespace | None: + return self.orders.get(order_no) + + async def add(self, obj: object) -> None: + self.added.append(obj) + if isinstance(obj, Report): + self.reports[obj.report_id] = obj + if isinstance(obj, ReportTemplate): + obj.id = obj.id or len(self.templates) + 1 + self.templates[obj.id] = obj + + async def commit(self) -> None: + self.commits += 1 + + +def wip(wip_id: str = "WIP-1", lab_name: str = "材料分析實驗室") -> Wip: + return Wip( + id=uuid.uuid4(), + wip_no=wip_id, + sample_id=uuid.uuid4(), + order_no="ORD-2026-001", + lab_name=lab_name, + experiment_item="SEM", + priority="normal", + status="completed", + progress=100, + ) + + +def execution( + wip_id: str = "WIP-1", + status: str = WipStatus.COMPLETED.value, + *, + experiment_data: dict | None = None, +) -> WipExecution: + return WipExecution( + wip_no=wip_id, + exec_status=status, + machine_id="SEM-A-001", + recipe="SEM-v1", + operator="Alice", + check_in_at=datetime(2026, 1, 1, 9, 0), + check_out_at=datetime(2026, 1, 1, 10, 0), + result_note="結果正常", + raw_data_url="raw.csv", + experiment_data=experiment_data, + data_verified=True, + ) + + +def report(report_id: str = "RPT-001", status: str = REPORT_ZH[ReportStatus.DRAFT]) -> Report: + rpt = Report( + report_id=report_id, + order_id="ORD-2026-001", + wip_id="WIP-1", + title="SEM 報告", + summary="summary", + conclusion="conclusion", + experiment_data={"SEM": {"value": "1"}}, + status=status, + created_at=datetime(2026, 1, 1), + created_by="Alice", + ) + rpt.versions = [ + ReportVersion(version=1, status=status, at=datetime(2026, 1, 1), actor="Alice", note="init") + ] + rpt.attachments = [] + return rpt + + +def template(template_id: int = 1) -> ReportTemplate: + return ReportTemplate( + id=template_id, + name="SEM 範本", + order_id="ORD-2026-001", + summary="範本摘要", + conclusion="範本結論", + created_by="Lead", + created_at=datetime(2026, 1, 1), + ) + + +async def test_list_reports_respects_restricted_scope_and_lab_filter() -> None: + repo = FakeReportRepo() + repo.wips = {"WIP-1": wip(), "WIP-2": wip("WIP-2", "電性測試實驗室")} + repo.reports = {"R1": report("R1"), "R2": report("R2")} + repo.reports["R2"].wip_id = "WIP-2" + + assert await ReportService(repo, NO_LAB_SCOPE).list_reports() == [] + rows = await ReportService(repo, LAB_SCOPE).list_reports() + assert [r["reportId"] for r in rows] == ["R1"] + + +async def test_get_report_missing_and_cross_lab_access_errors() -> None: + repo = FakeReportRepo() + svc = ReportService(repo, LAB_SCOPE) + + with pytest.raises(NotFoundError): + await svc.get_report("missing") + + repo.wips["WIP-1"] = wip(lab_name="電性測試實驗室") + repo.reports["R1"] = report("R1") + with pytest.raises(ForbiddenError): + await svc.get_report("R1") + + +async def test_create_report_uses_template_fallback_and_advances_order_on_submit() -> None: + repo = FakeReportRepo() + repo.wips["WIP-1"] = wip() + repo.execs["WIP-1"] = execution(experiment_data={"SEM": {"existing": "data"}}) + repo.templates[1] = template() + repo.orders["ORD-2026-001"] = SimpleNamespace(status=OrderStatus.COMPLETED.value) + + result = await ReportService(repo, LAB_SCOPE).create_report( + "WIP-1", + "Alice", + template_id=1, + submit=True, + ) + + assert result["summary"] == "範本摘要" + assert result["conclusion"] == "範本結論" + assert result["experimentData"] == {"SEM": {"existing": "data"}} + assert result["status"] == REPORT_ZH[ReportStatus.PENDING_REVIEW] + assert repo.orders["ORD-2026-001"].status == OrderStatus.WAITING_REPORT_RETURN.value + assert repo.commits == 1 + + +async def test_create_report_rejects_missing_invalid_cross_lab_and_formal_duplicate() -> None: + repo = FakeReportRepo() + svc = ReportService(repo, LAB_SCOPE) + + with pytest.raises(NotFoundError): + await svc.create_report("missing", "Alice") + + repo.wips["WIP-1"] = wip(lab_name="電性測試實驗室") + with pytest.raises(ForbiddenError): + await svc.create_report("WIP-1", "Alice") + + repo.wips["WIP-1"] = wip() + repo.execs["WIP-1"] = execution(status=WipStatus.RUNNING.value) + with pytest.raises(ConflictError): + await svc.create_report("WIP-1", "Alice") + + repo.execs["WIP-1"] = execution() + repo.formal_count = 1 + with pytest.raises(ConflictError): + await svc.create_report("WIP-1", "Alice") + + +async def test_save_template_can_copy_from_existing_report() -> None: + repo = FakeReportRepo() + repo.wips["WIP-1"] = wip() + repo.reports["R1"] = report("R1") + + result = await ReportService(repo, LAB_SCOPE).save_template( + "Copied", + "Lead", + from_report_id="R1", + ) + + assert result["name"] == "Copied" + assert result["summary"] == "summary" + assert result["conclusion"] == "conclusion" + assert repo.commits == 1 + + +async def test_edit_submit_review_and_publish_status_transitions() -> None: + repo = FakeReportRepo() + repo.wips["WIP-1"] = wip() + repo.orders["ORD-2026-001"] = SimpleNamespace(status=OrderStatus.COMPLETED.value) + repo.reports["R1"] = report("R1") + svc = ReportService(repo, LAB_SCOPE) + + edited = await svc.edit_report("R1", "new summary", "new conclusion", "file.pdf") + assert edited["summary"] == "new summary" + assert edited["attachments"][0]["name"] == "file.pdf" + + submitted = await svc.submit_report("R1", "Alice") + assert submitted["status"] == REPORT_ZH[ReportStatus.PENDING_REVIEW] + assert len(repo.reports["R1"].versions) == 2 + + rejected = await svc.review_report("R1", False, "補件", "Lead") + assert rejected["status"] == REPORT_ZH[ReportStatus.REVISED] + assert "補件" in repo.reports["R1"].versions[-1].note + + repo.reports["R1"].status = REPORT_ZH[ReportStatus.PENDING_REVIEW] + approved = await svc.review_report("R1", True, None, "Lead") + assert approved["status"] == REPORT_ZH[ReportStatus.CONFIRMED] + + published = await svc.publish_report("R1", "Lead") + assert published["status"] == REPORT_ZH[ReportStatus.RETURNED] + assert repo.commits == 5 + + +async def test_report_actions_reject_wrong_status() -> None: + repo = FakeReportRepo() + repo.wips["WIP-1"] = wip() + repo.reports["R1"] = report("R1", REPORT_ZH[ReportStatus.PUBLISHED]) + svc = ReportService(repo, LAB_SCOPE) + + with pytest.raises(ConflictError): + await svc.edit_report("R1", "x", None, None) + with pytest.raises(ConflictError): + await svc.submit_report("R1", "Alice") + with pytest.raises(ConflictError): + await svc.review_report("R1", True, None, "Lead") + with pytest.raises(ConflictError): + await svc.publish_report("R1", "Lead") diff --git a/backend/tests/e_tests/test_workflow_helpers.py b/backend/tests/e_tests/test_workflow_helpers.py new file mode 100644 index 0000000..9d38bde --- /dev/null +++ b/backend/tests/e_tests/test_workflow_helpers.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from app.services import workflow_helpers as wh + + +class Row: + def __init__(self, **values: object) -> None: + self._mapping = values + + +class Result: + def __init__(self, rows: list[Row] | None = None) -> None: + self.rows = rows or [] + + def __iter__(self): + return iter(self.rows) + + def fetchone(self): + return self.rows[0] if self.rows else None + + +class FakeDb: + def __init__(self, results: list[Result]) -> None: + self.results = results + self.calls: list[dict | None] = [] + self.rollbacks = 0 + + async def execute(self, _stmt: object, params: dict | None = None) -> Result: + self.calls.append(params) + if not self.results: + return Result() + result = self.results.pop(0) + if isinstance(result, Exception): + raise result + return result + + async def rollback(self) -> None: + self.rollbacks += 1 + + +def test_location_and_lab_code_helpers() -> None: + assert wh.lab_location(None, "收樣區") == "收樣區" + assert wh.lab_location("材料分析實驗室", "收樣區") == "材料分析實驗室 收樣區" + assert wh.lab_location("材料分析實驗室 收樣區", "收樣區") == "材料分析實驗室 收樣區" + assert wh.receive_location("材料分析實驗室") == "材料分析實驗室 收樣區" + assert wh.experiment_temp_location("材料分析實驗室") == "材料分析實驗室 實驗暫存區" + assert wh.pickup_location("材料分析實驗室") == "材料分析實驗室 待取件區" + + assert wh.normalize_lab_code_from_lab_code(None) is None + assert wh.normalize_lab_code_from_lab_code(" lab-a ") == "A" + assert wh.normalize_lab_code_from_lab_code("abc") == "ABC" + assert wh.normalize_lab_code(None) == "LAB" + assert wh.normalize_lab_code("LabB") == "B" + assert wh.normalize_lab_code("可靠度實驗室") == "C" + assert wh.normalize_lab_code("其他實驗室") == "其他實驗室" + + +def test_json_and_requested_experiment_parsing() -> None: + assert wh.safe_json_loads(None) is None + assert wh.safe_json_loads({"a": 1}) == {"a": 1} + assert wh.safe_json_loads("") is None + assert wh.safe_json_loads("{bad") is None + assert wh.safe_json_loads(123) is None + + value = '[{"lab":"材料分析實驗室","item":"SEM"},{"target_lab":"電性測試實驗室","name":"IV"}, 1]' + assert wh.normalize_requested_experiments(value) == [ + {"lab_name": "材料分析實驗室", "experiment_item": "SEM"}, + {"lab_name": "電性測試實驗室", "experiment_item": "IV"}, + ] + assert wh.normalize_requested_experiments({"not": "a list"}) == [] + assert wh.parse_requested_experiments_from_sample( + {"experiment_item": "材料分析實驗室:SEM、bad、電性測試實驗室:IV"} + ) == [ + {"lab_name": "材料分析實驗室", "experiment_item": "SEM"}, + {"lab_name": "電性測試實驗室", "experiment_item": "IV"}, + ] + + +async def test_generated_storage_locations_from_real_labs() -> None: + db = FakeDb( + [ + Result( + [ + Row( + id="lab-1", + code="LAB-A", + name="材料分析實驗室", + capacity=5, + is_active=True, + created_at=None, + updated_at=None, + ) + ] + ) + ] + ) + + locations = await wh.get_generated_storage_locations(db) # type: ignore[arg-type] + + assert [loc["area"] for loc in locations] == ["收樣區", "實驗暫存區", "待取件區"] + assert locations[0]["code"] == "LAB-A-RECEIVE" + + +async def test_get_real_users_falls_back_after_join_query_failure() -> None: + db = FakeDb([RuntimeError("no lab_id"), Result([Row(id="u-1", name="Alice")])]) + + users = await wh.get_real_users(db) # type: ignore[arg-type] + + assert users == [{"id": "u-1", "name": "Alice"}] + assert db.rollbacks == 1 + + +async def test_resolve_current_user_uses_headers_row_or_fallback() -> None: + request = type( + "Req", + (), + {"headers": {"x-user-id": "u-1", "x-user-email": "a@example.com", "x-user-name": "A"}}, + )() + db = FakeDb([Result([Row(id="u-1", name="Alice", email="a@example.com")])]) + + assert (await wh.resolve_current_user(db, request))["name"] == "Alice" # type: ignore[arg-type] + + fallback_db = FakeDb([RuntimeError("db down")]) + fallback = await wh.resolve_current_user(fallback_db, request) # type: ignore[arg-type] + assert fallback["id"] == "u-1" + assert fallback["role"] == "system_admin" + assert fallback_db.rollbacks == 1 + + +async def test_real_order_and_lab_resolution_helpers() -> None: + assert await wh.get_real_order(FakeDb([Result()]), "ORD-1") is None # type: ignore[arg-type] + assert (await wh.get_real_order(FakeDb([Result([Row(order_no="ORD-1")])]), "ORD-1")) == { + "order_no": "ORD-1" + } + assert await wh.resolve_real_lab_name(FakeDb([]), None) is None # type: ignore[arg-type] + assert await wh.resolve_real_lab_name(FakeDb([Result()]), "legacy") == "legacy" # type: ignore[arg-type] + assert ( + await wh.resolve_real_lab_name( + FakeDb([Result([Row(name="材料分析實驗室")])]), + "LAB-A", # type: ignore[arg-type] + ) + == "材料分析實驗室" + ) + + +async def test_sample_and_wip_number_generation() -> None: + sample_db = FakeDb([Result([Row(total=1)]), Result([Row(exists=1)]), Result()]) + assert await wh.generate_sample_no(sample_db) == "SMP-2026-0003" # type: ignore[arg-type] + + with pytest.raises(RuntimeError): + await wh.generate_sample_no(FakeDb([Result()])) # type: ignore[arg-type] + + lab_db = FakeDb([Result([Row(code="LAB-A")]), Result([Row(exists=1)]), Result()]) + assert ( + await wh.generate_unique_wip_no( + lab_db, + "SMP-2026-0001", + 1, + "材料分析實驗室", # type: ignore[arg-type] + ) + == "WIP-2026-0001-A-02" + ) + + +async def test_generation_raises_when_candidates_are_exhausted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("builtins.range", lambda *_args: [1]) + + with pytest.raises(HTTPException): + await wh.generate_sample_no(FakeDb([Result([Row(total=0)]), Result([Row(exists=1)])])) # type: ignore[arg-type] + with pytest.raises(HTTPException): + await wh.generate_unique_wip_no( + FakeDb([Result(), Result([Row(exists=1)])]), + "SMP-2026-0001", + 1, + "LAB-A", # type: ignore[arg-type] + ) diff --git a/frontend/app/report/components/__tests__/ReportModals.test.tsx b/frontend/app/report/components/__tests__/ReportModals.test.tsx new file mode 100644 index 0000000..d137ab9 --- /dev/null +++ b/frontend/app/report/components/__tests__/ReportModals.test.tsx @@ -0,0 +1,195 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Report, ReportTemplate, Wip } from "@/types/lab"; + +const { createMock, editMock } = vi.hoisted(() => ({ + createMock: vi.fn(), + editMock: vi.fn(), +})); + +vi.mock("@/services/reports-api", () => ({ + reportsApi: { + create: createMock, + edit: editMock, + }, +})); + +import CreateModal from "../CreateModal"; +import EditModal from "../EditModal"; + +const wips: Wip[] = [ + { + wipId: "WIP-1", + orderId: "O-1", + sample: "Sample A", + experimentItem: "SEM", + machineId: null, + recipe: null, + status: "待確認", + progress: 100, + operator: null, + checkInAt: null, + checkOutAt: null, + resultNote: null, + rawDataUrl: null, + dataVerified: true, + abort: null, + history: [], + }, + { + wipId: "WIP-2", + orderId: "O-2", + sample: "Sample B", + experimentItem: "IV", + machineId: null, + recipe: null, + status: "已完成", + progress: 100, + operator: null, + checkInAt: null, + checkOutAt: null, + resultNote: null, + rawDataUrl: null, + dataVerified: true, + abort: null, + history: [], + }, +]; + +const templates: ReportTemplate[] = [ + { + id: 7, + name: "可靠度範本", + orderId: "O-7", + summary: "template summary", + conclusion: "template conclusion", + createdBy: "Lead", + createdAt: "2026-01-01", + }, +]; + +const report: Report = { + reportId: "R-1", + orderId: "O-1", + wipId: "WIP-1", + title: "材料分析報告", + summary: "原摘要", + conclusion: "原結論", + attachments: [], + status: "草稿", + createdAt: "2026-01-01", + createdBy: "Alice", + versions: [{ version: 1, status: "草稿", at: "2026-01-01", by: "Alice", note: "created" }], +}; + +function runAndInvoke() { + return vi.fn(async (fn: () => Promise) => { + await fn(); + }); +} + +describe("CreateModal", () => { + beforeEach(() => { + createMock.mockReset(); + editMock.mockReset(); + }); + + it("shows the empty message and disables submit buttons without WIPs", () => { + render(); + + expect(screen.getByText("目前沒有「待確認 / 已完成」的實驗可建立報告。")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "建立草稿" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "建立並送審" })).toBeDisabled(); + }); + + it("creates a draft with selected WIP, template, summary, conclusion, and checked items", async () => { + createMock.mockResolvedValue({ reportId: "R-new" }); + const run = runAndInvoke(); + render(); + + const selects = screen.getAllByRole("combobox"); + fireEvent.change(selects[0], { target: { value: "WIP-2" } }); + fireEvent.change(selects[1], { target: { value: "7" } }); + fireEvent.change(screen.getByPlaceholderText(/自動帶入機台/), { + target: { value: "手動摘要" }, + }); + fireEvent.change(screen.getAllByRole("textbox")[1], { target: { value: "手動結論" } }); + fireEvent.click(screen.getByLabelText("SEM")); + + fireEvent.click(screen.getByRole("button", { name: "建立草稿" })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith("WIP-2", { + summary: "手動摘要", + conclusion: "手動結論", + experimentItems: ["IV", "SEM"], + templateId: 7, + submit: false, + }); + }); + expect(run).toHaveBeenCalledWith(expect.any(Function), "已建立報告草稿"); + }); + + it("sends null and undefined defaults when creating and submitting an empty report", async () => { + createMock.mockResolvedValue({ reportId: "R-new" }); + const run = runAndInvoke(); + render(); + + fireEvent.click(screen.getByLabelText("SEM")); + fireEvent.click(screen.getByRole("button", { name: "建立並送審" })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith("WIP-1", { + summary: null, + conclusion: null, + experimentItems: undefined, + templateId: null, + submit: true, + }); + }); + expect(run).toHaveBeenCalledWith(expect.any(Function), "已建立並送審"); + }); +}); + +describe("EditModal", () => { + beforeEach(() => { + createMock.mockReset(); + editMock.mockReset(); + }); + + it("edits summary, conclusion, and attachment name", async () => { + editMock.mockResolvedValue({ reportId: "R-1" }); + const run = runAndInvoke(); + render(); + + const textboxes = screen.getAllByRole("textbox"); + fireEvent.change(textboxes[0], { target: { value: "新摘要" } }); + fireEvent.change(textboxes[1], { target: { value: "新結論" } }); + fireEvent.change(textboxes[2], { target: { value: "report.pdf" } }); + fireEvent.click(screen.getByRole("button", { name: "儲存" })); + + await waitFor(() => { + expect(editMock).toHaveBeenCalledWith("R-1", { + summary: "新摘要", + conclusion: "新結論", + attachmentName: "report.pdf", + }); + }); + expect(run).toHaveBeenCalledWith(expect.any(Function), "報告已更新"); + }); + + it("sends null when attachment name is empty", async () => { + editMock.mockResolvedValue({ reportId: "R-1" }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "儲存" })); + + await waitFor(() => { + expect(editMock).toHaveBeenCalledWith("R-1", { + summary: "原摘要", + conclusion: "原結論", + attachmentName: null, + }); + }); + }); +}); diff --git a/frontend/app/report/components/__tests__/ReportTable.test.tsx b/frontend/app/report/components/__tests__/ReportTable.test.tsx new file mode 100644 index 0000000..6266333 --- /dev/null +++ b/frontend/app/report/components/__tests__/ReportTable.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Report } from "@/types/lab"; + +const { submitMock, reviewMock, publishMock } = vi.hoisted(() => ({ + submitMock: vi.fn(), + reviewMock: vi.fn(), + publishMock: vi.fn(), +})); + +vi.mock("@/services/reports-api", () => ({ + reportsApi: { + submit: submitMock, + review: reviewMock, + publish: publishMock, + }, +})); + +import ReportTable from "../ReportTable"; + +const baseReport: Report = { + reportId: "R-1", + orderId: "O-1", + wipId: "WIP-1", + title: "材料分析報告", + summary: "summary", + conclusion: "conclusion", + attachments: [], + status: "草稿", + createdAt: "2026-01-01T00:00:00Z", + createdBy: "Alice", + versions: [{ version: 1, status: "草稿", at: "2026-01-01", by: "Alice", note: "created" }], +}; + +function report(status: string, id = `R-${status}`): Report { + return { ...baseReport, reportId: id, status }; +} + +function setup(overrides: Partial[0]> = {}) { + const props = { + rows: [baseReport], + loading: false, + emptyText: "沒有報告", + canStaff: true, + isChief: false, + offline: false, + onEdit: vi.fn(), + onDetail: vi.fn(), + run: vi.fn(async (fn: () => Promise) => { + await fn(); + }), + ...overrides, + }; + + render(); + return props; +} + +describe("ReportTable", () => { + beforeEach(() => { + submitMock.mockReset(); + reviewMock.mockReset(); + publishMock.mockReset(); + }); + + it("renders loading and empty states through DataState", () => { + const { rerender } = render( + + ); + + expect(screen.getByText("載入中…")).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText("沒有報告")).toBeInTheDocument(); + }); + + it("opens detail when clicking the report id", () => { + const props = setup(); + + fireEvent.click(screen.getByRole("button", { name: "R-1" })); + + expect(props.onDetail).toHaveBeenCalledWith(baseReport); + }); + + it("lets staff edit and submit draft reports", async () => { + submitMock.mockResolvedValue(report("待審核")); + const props = setup({ rows: [report("草稿", "R-draft")] }); + + fireEvent.click(screen.getByRole("button", { name: "編輯" })); + fireEvent.click(screen.getByRole("button", { name: "送審" })); + + expect(props.onEdit).toHaveBeenCalledWith(expect.objectContaining({ reportId: "R-draft" })); + expect(props.run).toHaveBeenCalledWith(expect.any(Function), "已提交審核"); + expect(submitMock).toHaveBeenCalledWith("R-draft"); + }); + + it("lets chiefs reject or approve pending reports", async () => { + reviewMock.mockResolvedValue(report("已確認")); + const props = setup({ rows: [report("待審核", "R-review")], canStaff: false, isChief: true }); + + fireEvent.click(screen.getByRole("button", { name: "退回" })); + fireEvent.click(screen.getByRole("button", { name: "確認" })); + + expect(props.run).toHaveBeenNthCalledWith(1, expect.any(Function), "報告已退回"); + expect(props.run).toHaveBeenNthCalledWith(2, expect.any(Function), "報告已確認"); + expect(reviewMock).toHaveBeenNthCalledWith(1, "R-review", { approve: false }); + expect(reviewMock).toHaveBeenNthCalledWith(2, "R-review", { approve: true }); + }); + + it("lets staff publish confirmed reports", () => { + publishMock.mockResolvedValue(report("已發布")); + const props = setup({ rows: [report("已確認", "R-confirmed")] }); + + fireEvent.click(screen.getByRole("button", { name: "發布回傳" })); + + expect(props.run).toHaveBeenCalledWith(expect.any(Function), "報告已發布並回傳"); + expect(publishMock).toHaveBeenCalledWith("R-confirmed"); + }); + + it("lets users view published reports and disables offline actions", () => { + const props = setup({ + rows: [report("已發布", "R-pub"), report("草稿", "R-offline")], + offline: true, + }); + + fireEvent.click(screen.getByRole("button", { name: "查閱 / 下載" })); + + expect(props.onDetail).toHaveBeenCalledWith(expect.objectContaining({ reportId: "R-pub" })); + expect(within(screen.getByText("R-offline").closest("tr")!).getByText("編輯")).toBeDisabled(); + expect(within(screen.getByText("R-offline").closest("tr")!).getByText("送審")).toBeDisabled(); + }); + + it("shows a dash when no action applies", () => { + setup({ rows: [report("待審核", "R-no-action")], canStaff: false, isChief: false }); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/__tests__/displayNames.test.ts b/frontend/src/lib/__tests__/displayNames.test.ts new file mode 100644 index 0000000..0d05b3c --- /dev/null +++ b/frontend/src/lib/__tests__/displayNames.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + displayDepartmentName, + displayExperimentName, + displayLabName, + displayScopeName, + displayUserName, +} from "@/lib/displayNames"; + +const masterData = { + departments: [{ id: "dep-1", code: "D1", name: "製程部" }], + labs: [{ id: "lab-1", code: "L1", name: "材料分析實驗室" }], + experiments: [{ id: "exp-1", labId: "lab-1", name: "SEM" }], +}; + +describe("displayNames", () => { + it("displays users, current user fallback, and empty values", () => { + expect(displayUserName(null, {})).toBe("-"); + expect(displayUserName("u-1", {}, { id: "u-1", name: "" })).toBe("目前使用者"); + expect(displayUserName("u-2", { "u-2": "Alice" })).toBe("Alice"); + expect(displayUserName("missing", {})).toBe("未知使用者"); + }); + + it("displays department, lab, and experiment names by id or code", () => { + expect(displayDepartmentName(masterData, "dep-1")).toBe("製程部"); + expect(displayDepartmentName(masterData, "D1")).toBe("製程部"); + expect(displayDepartmentName(masterData, undefined)).toBe("-"); + expect(displayDepartmentName(masterData, "nope")).toBe("未知部門"); + + expect(displayLabName(masterData, "lab-1")).toBe("材料分析實驗室"); + expect(displayLabName(masterData, "L1")).toBe("材料分析實驗室"); + expect(displayLabName(masterData, "")).toBe("-"); + expect(displayLabName(masterData, "nope")).toBe("未知實驗室"); + + expect(displayExperimentName(masterData, "exp-1")).toBe("SEM"); + expect(displayExperimentName(masterData, null)).toBe("-"); + expect(displayExperimentName(masterData, "nope")).toBe("未知實驗"); + }); + + it("dispatches scope display by scope type", () => { + expect(displayScopeName(masterData, { "u-1": "Alice" }, "user", "u-1")).toBe("Alice"); + expect(displayScopeName(masterData, {}, "department", "D1")).toBe("製程部"); + expect(displayScopeName(masterData, {}, "lab", "L1")).toBe("材料分析實驗室"); + expect(displayScopeName(masterData, {}, "unknown", "raw")).toBe("raw"); + expect(displayScopeName(masterData, {}, "unknown", "")).toBe("-"); + }); +}); diff --git a/frontend/src/lib/__tests__/errorMessage.test.ts b/frontend/src/lib/__tests__/errorMessage.test.ts new file mode 100644 index 0000000..260d73c --- /dev/null +++ b/frontend/src/lib/__tests__/errorMessage.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { errorMessage } from "@/lib/errorMessage"; + +describe("errorMessage", () => { + it("prefers backend business error messages", () => { + expect( + errorMessage({ + response: { + data: { + error: { + code: "VALIDATION_ERROR", + message: "數據完整性尚未驗證", + }, + }, + }, + }) + ).toBe("數據完整性尚未驗證"); + }); + + it("falls back to Error.message, then the generic offline hint", () => { + expect(errorMessage(new Error("Request failed"))).toBe("Request failed"); + expect(errorMessage({})).toBe("操作失敗,請確認後端是否啟動"); + }); +}); diff --git a/frontend/src/services/__tests__/notifications-issues-api.test.ts b/frontend/src/services/__tests__/notifications-issues-api.test.ts new file mode 100644 index 0000000..8ecd684 --- /dev/null +++ b/frontend/src/services/__tests__/notifications-issues-api.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getMock, postMock, patchMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + patchMock: vi.fn(), +})); + +vi.mock("@/api/httpClient", () => ({ + httpClient: { + get: getMock, + post: postMock, + patch: patchMock, + }, +})); + +import { issueApi } from "@/services/issue-api"; +import { notificationApi } from "@/services/notification-api"; + +describe("issueApi", () => { + beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + patchMock.mockReset(); + }); + + it("list() returns the page response and forwards query params", async () => { + const page = { items: [{ id: "I-1" }], page: 1, pageSize: 20, total: 1 }; + const query = { status: "open", page: 1 }; + getMock.mockResolvedValue({ data: page }); + + await expect(issueApi.list(query as never)).resolves.toEqual(page); + + expect(getMock).toHaveBeenCalledWith("/issues", { params: query }); + }); + + it("getById(), create(), update(), and listAcknowledgements() unwrap data", async () => { + const issue = { id: "I-1" }; + const acknowledgement = { id: "A-1" }; + getMock + .mockResolvedValueOnce({ data: { data: issue } }) + .mockResolvedValueOnce({ data: { data: [acknowledgement] } }); + postMock.mockResolvedValue({ data: { data: issue } }); + patchMock.mockResolvedValue({ data: { data: issue } }); + + await expect(issueApi.getById("I-1")).resolves.toEqual(issue); + await expect(issueApi.create({ title: "bad" } as never)).resolves.toEqual(issue); + await expect(issueApi.update("I-1", { status: "closed" } as never)).resolves.toEqual(issue); + await expect(issueApi.listAcknowledgements("I-1")).resolves.toEqual([acknowledgement]); + + expect(getMock).toHaveBeenNthCalledWith(1, "/issues/I-1"); + expect(postMock).toHaveBeenCalledWith("/issues", { title: "bad" }); + expect(patchMock).toHaveBeenCalledWith("/issues/I-1", { status: "closed" }); + expect(getMock).toHaveBeenNthCalledWith(2, "/issues/I-1/acknowledgements"); + }); +}); + +describe("notificationApi", () => { + beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + patchMock.mockReset(); + }); + + it("list() returns the page response and forwards query params", async () => { + const page = { items: [{ id: "N-1" }], page: 1, pageSize: 20, total: 1 }; + const query = { unreadOnly: true, page: 1 }; + getMock.mockResolvedValue({ data: page }); + + await expect(notificationApi.list(query as never)).resolves.toEqual(page); + + expect(getMock).toHaveBeenCalledWith("/notifications", { params: query }); + }); + + it("getById() and markRead() unwrap data", async () => { + const notification = { id: "N-1" }; + const result = { updated: 1 }; + getMock.mockResolvedValue({ data: { data: notification } }); + postMock.mockResolvedValue({ data: { data: result } }); + + await expect(notificationApi.getById("N-1")).resolves.toEqual(notification); + await expect(notificationApi.markRead({ ids: ["N-1"] } as never)).resolves.toEqual(result); + + expect(getMock).toHaveBeenCalledWith("/notifications/N-1"); + expect(postMock).toHaveBeenCalledWith("/notifications/actions", { ids: ["N-1"] }); + }); +}); diff --git a/frontend/src/services/__tests__/reports-api.test.ts b/frontend/src/services/__tests__/reports-api.test.ts new file mode 100644 index 0000000..abc598e --- /dev/null +++ b/frontend/src/services/__tests__/reports-api.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getMock, postMock, patchMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + patchMock: vi.fn(), +})); + +vi.mock("@/api/httpClient", () => ({ + httpClient: { + get: getMock, + post: postMock, + patch: patchMock, + }, +})); + +import { reportsApi } from "@/services/reports-api"; + +describe("reportsApi", () => { + beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + patchMock.mockReset(); + }); + + it("list() GETs /reports and returns page items", async () => { + const reports = [{ reportId: "R-1" }]; + getMock.mockResolvedValue({ data: { items: reports } }); + + await expect(reportsApi.list()).resolves.toEqual(reports); + + expect(getMock).toHaveBeenCalledWith("/reports"); + }); + + it("create() POSTs wipId with options and unwraps data", async () => { + const report = { reportId: "R-2" }; + const opts = { summary: "s", conclusion: "c", submit: true }; + postMock.mockResolvedValue({ data: { data: report } }); + + await expect(reportsApi.create("WIP-1", opts)).resolves.toEqual(report); + + expect(postMock).toHaveBeenCalledWith("/reports", { wipId: "WIP-1", ...opts }); + }); + + it("listTemplates() GETs /reports/templates and returns page items", async () => { + const templates = [{ id: 1, name: "T" }]; + getMock.mockResolvedValue({ data: { items: templates } }); + + await expect(reportsApi.listTemplates()).resolves.toEqual(templates); + + expect(getMock).toHaveBeenCalledWith("/reports/templates"); + }); + + it("saveTemplate() POSTs the template payload and unwraps data", async () => { + const payload = { name: "T", orderId: "O-1", summary: "s", conclusion: "c" }; + const template = { id: 1, ...payload }; + postMock.mockResolvedValue({ data: { data: template } }); + + await expect(reportsApi.saveTemplate(payload)).resolves.toEqual(template); + + expect(postMock).toHaveBeenCalledWith("/reports/templates", payload); + }); + + it("edit() PATCHes a report and unwraps data", async () => { + const payload = { summary: "new", conclusion: null, attachmentName: "report.pdf" }; + const report = { reportId: "R-3" }; + patchMock.mockResolvedValue({ data: { data: report } }); + + await expect(reportsApi.edit("R-3", payload)).resolves.toEqual(report); + + expect(patchMock).toHaveBeenCalledWith("/reports/R-3", payload); + }); + + it("submit(), review(), and publish() POST action endpoints", async () => { + const report = { reportId: "R-4" }; + postMock.mockResolvedValue({ data: { data: report } }); + + await expect(reportsApi.submit("R-4")).resolves.toEqual(report); + await expect(reportsApi.review("R-4", { approve: false, comment: "fix" })).resolves.toEqual( + report + ); + await expect(reportsApi.publish("R-4")).resolves.toEqual(report); + + expect(postMock).toHaveBeenNthCalledWith(1, "/reports/R-4/submit"); + expect(postMock).toHaveBeenNthCalledWith(2, "/reports/R-4/review", { + approve: false, + comment: "fix", + }); + expect(postMock).toHaveBeenNthCalledWith(3, "/reports/R-4/publish"); + }); +}); diff --git a/frontend/src/services/__tests__/role-d-api.test.ts b/frontend/src/services/__tests__/role-d-api.test.ts new file mode 100644 index 0000000..142fbfd --- /dev/null +++ b/frontend/src/services/__tests__/role-d-api.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getMock, postMock, patchMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + patchMock: vi.fn(), +})); + +vi.mock("@/api/httpClient", () => ({ + httpClient: { + get: getMock, + post: postMock, + patch: patchMock, + }, +})); + +import { closuresApi } from "@/services/closures-api"; +import { dashboardApi } from "@/services/dashboard-api"; +import { dispatchesApi } from "@/services/dispatches-api"; +import { experimentsApi } from "@/services/experiments-api"; +import { machinesApi } from "@/services/machines-api"; +import { recipesApi } from "@/services/recipes-api"; + +describe("Role D service API wrappers", () => { + beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + patchMock.mockReset(); + }); + + it("closuresApi maps list, storage filters, and action endpoints", async () => { + const closure = { orderId: "O-1" }; + const storage = { storageId: "S-1" }; + getMock + .mockResolvedValueOnce({ data: { items: [closure] } }) + .mockResolvedValueOnce({ data: { items: [storage] } }) + .mockResolvedValueOnce({ data: { items: [storage] } }); + postMock + .mockResolvedValueOnce({ data: { data: closure } }) + .mockResolvedValueOnce({ data: { data: storage } }) + .mockResolvedValueOnce({ data: { data: storage } }) + .mockResolvedValueOnce({ data: { data: closure } }); + + await expect(closuresApi.list()).resolves.toEqual([closure]); + await expect(closuresApi.listStorage()).resolves.toEqual([storage]); + await expect(closuresApi.listStorage("已入庫")).resolves.toEqual([storage]); + await expect(closuresApi.toPickup("O-1")).resolves.toEqual(closure); + await expect(closuresApi.inbound("O-1", { operator: "A" })).resolves.toEqual(storage); + await expect(closuresApi.outbound("O-1", { note: "done" })).resolves.toEqual(storage); + await expect(closuresApi.close("O-1", { operator: "B" })).resolves.toEqual(closure); + + expect(getMock).toHaveBeenNthCalledWith(1, "/closures"); + expect(getMock).toHaveBeenNthCalledWith(2, "/closures/storage", undefined); + expect(getMock).toHaveBeenNthCalledWith(3, "/closures/storage", { + params: { status: "已入庫" }, + }); + expect(postMock).toHaveBeenNthCalledWith(1, "/closures/O-1/to-pickup"); + expect(postMock).toHaveBeenNthCalledWith(2, "/closures/O-1/inbound", { operator: "A" }); + expect(postMock).toHaveBeenNthCalledWith(3, "/closures/O-1/outbound", { note: "done" }); + expect(postMock).toHaveBeenNthCalledWith(4, "/closures/O-1/close", { operator: "B" }); + }); + + it("dispatchesApi maps list, create, suggest, replan, and assign", async () => { + const dispatch = { dispatchId: "D-1" }; + const createPayload = { wipId: "WIP-1", machineId: "M-1" }; + const assignPayload = { machineId: "M-2" }; + getMock.mockResolvedValue({ data: { items: [dispatch] } }); + postMock + .mockResolvedValueOnce({ data: { data: dispatch } }) + .mockResolvedValueOnce({ data: { data: [dispatch] } }) + .mockResolvedValueOnce({ data: { data: [dispatch] } }) + .mockResolvedValueOnce({ data: { data: dispatch } }); + + await expect(dispatchesApi.list()).resolves.toEqual([dispatch]); + await expect(dispatchesApi.create(createPayload as never)).resolves.toEqual(dispatch); + await expect(dispatchesApi.suggest("balanced" as never)).resolves.toEqual([dispatch]); + await expect(dispatchesApi.replan("urgent", "fastest" as never)).resolves.toEqual([dispatch]); + await expect(dispatchesApi.assign("D-1", assignPayload as never)).resolves.toEqual(dispatch); + + expect(getMock).toHaveBeenCalledWith("/dispatches"); + expect(postMock).toHaveBeenNthCalledWith(1, "/dispatches", createPayload); + expect(postMock).toHaveBeenNthCalledWith(2, "/dispatches/suggest", { strategy: "balanced" }); + expect(postMock).toHaveBeenNthCalledWith(3, "/dispatches/replan", { + reason: "urgent", + strategy: "fastest", + }); + expect(postMock).toHaveBeenNthCalledWith(4, "/dispatches/D-1/assign", assignPayload); + }); + + it("experimentsApi maps list and all action endpoints", async () => { + const wip = { wipId: "WIP-1" }; + const operators = [{ name: "Op", role: "lab_engineer" }]; + getMock + .mockResolvedValueOnce({ data: { items: [wip] } }) + .mockResolvedValueOnce({ data: { data: operators } }); + postMock.mockResolvedValue({ data: { data: wip } }); + patchMock.mockResolvedValue({ data: { data: wip } }); + + await expect(experimentsApi.list()).resolves.toEqual([wip]); + await expect(experimentsApi.getOperators("WIP-1")).resolves.toEqual(operators); + await expect( + experimentsApi.checkIn("WIP-1", { operator: "Op", machineId: "M-1", recipe: "R" }) + ).resolves.toEqual(wip); + await expect(experimentsApi.checkOut("WIP-1", { operator: "Op" })).resolves.toEqual(wip); + await expect(experimentsApi.updateProgress("WIP-1", 45)).resolves.toEqual(wip); + await expect( + experimentsApi.uploadResult("WIP-1", { note: "ok", dataVerified: true }) + ).resolves.toEqual(wip); + await expect(experimentsApi.verify("WIP-1", { operator: "Lead" })).resolves.toEqual(wip); + await expect(experimentsApi.confirm("WIP-1", { operator: "Lead" })).resolves.toEqual(wip); + await expect(experimentsApi.abortRequest("WIP-1", "bad sample")).resolves.toEqual(wip); + await expect( + experimentsApi.abortReview("WIP-1", { approve: true, note: "ok" }) + ).resolves.toEqual(wip); + await expect(experimentsApi.machineSignal("WIP-1")).resolves.toEqual(wip); + + expect(getMock).toHaveBeenNthCalledWith(1, "/experiment-runs"); + expect(getMock).toHaveBeenNthCalledWith(2, "/experiment-runs/WIP-1/operators"); + expect(postMock).toHaveBeenNthCalledWith(1, "/experiment-runs/WIP-1/check-in", { + operator: "Op", + machineId: "M-1", + recipe: "R", + }); + expect(postMock).toHaveBeenNthCalledWith(2, "/experiment-runs/WIP-1/check-out", { + operator: "Op", + }); + expect(patchMock).toHaveBeenCalledWith("/experiment-runs/WIP-1/progress", { progress: 45 }); + expect(postMock).toHaveBeenNthCalledWith(3, "/experiment-runs/WIP-1/result", { + note: "ok", + dataVerified: true, + }); + expect(postMock).toHaveBeenNthCalledWith(4, "/experiment-runs/WIP-1/verify", { + operator: "Lead", + }); + expect(postMock).toHaveBeenNthCalledWith(5, "/experiment-runs/WIP-1/confirm", { + operator: "Lead", + }); + expect(postMock).toHaveBeenNthCalledWith(6, "/experiment-runs/WIP-1/abort-request", { + reason: "bad sample", + }); + expect(postMock).toHaveBeenNthCalledWith(7, "/experiment-runs/WIP-1/abort-review", { + approve: true, + note: "ok", + }); + expect(postMock).toHaveBeenNthCalledWith(8, "/experiment-runs/WIP-1/machine-signal"); + }); + + it("dashboardApi unwraps the dashboard snapshot", async () => { + const snapshot = { kpis: [] }; + getMock.mockResolvedValue({ data: { data: snapshot } }); + + await expect(dashboardApi.getSnapshot()).resolves.toEqual(snapshot); + + expect(getMock).toHaveBeenCalledWith("/dashboard"); + }); + + it("machinesApi maps list, create, update, and status updates", async () => { + const machine = { machineId: "M-1" }; + const payload = { name: "M", labId: "L-1", status: "idle" }; + getMock.mockResolvedValue({ data: { items: [machine] } }); + postMock.mockResolvedValue({ data: { data: machine } }); + patchMock.mockResolvedValue({ data: { data: machine } }); + + await expect(machinesApi.list()).resolves.toEqual([machine]); + await expect(machinesApi.create(payload as never)).resolves.toEqual(machine); + await expect(machinesApi.update("M-1", payload as never)).resolves.toEqual(machine); + await expect(machinesApi.updateStatus("M-1", "maintenance" as never)).resolves.toEqual(machine); + + expect(getMock).toHaveBeenCalledWith("/machines"); + expect(postMock).toHaveBeenCalledWith("/machines", payload); + expect(patchMock).toHaveBeenNthCalledWith(1, "/machines/M-1", payload); + expect(patchMock).toHaveBeenNthCalledWith(2, "/machines/M-1/status", { + status: "maintenance", + }); + }); + + it("recipesApi maps list, create, and update", async () => { + const recipe = { recipeId: "R-1" }; + const payload = { name: "Recipe", machineId: "M-1" }; + getMock.mockResolvedValue({ data: { items: [recipe] } }); + postMock.mockResolvedValue({ data: { data: recipe } }); + patchMock.mockResolvedValue({ data: { data: recipe } }); + + await expect(recipesApi.list()).resolves.toEqual([recipe]); + await expect(recipesApi.create(payload as never)).resolves.toEqual(recipe); + await expect(recipesApi.update("R-1", payload as never)).resolves.toEqual(recipe); + + expect(getMock).toHaveBeenCalledWith("/recipes"); + expect(postMock).toHaveBeenCalledWith("/recipes", payload); + expect(patchMock).toHaveBeenCalledWith("/recipes/R-1", payload); + }); +}); From 4b3306d60b40e7c233fffc66246d9753b171b8f3 Mon Sep 17 00:00:00 2001 From: Han Date: Tue, 2 Jun 2026 14:06:14 +0800 Subject: [PATCH 5/6] fix: 4 confirmed backend bugs (auth, lab-id, MissingGreenlet x2) --- backend/app/modules/recipes/repository.py | 3 ++ backend/app/modules/recipes/service.py | 4 ++ backend/app/modules/reports/service.py | 7 +++- backend/app/repos/order_repo.py | 33 +++++++++++----- backend/app/routes/orders.py | 2 + backend/tests/a_tests/test_orders.py | 48 +++++++++++------------ backend/tests/c_tests/test_recipes.py | 25 ++---------- backend/tests/d_tests/test_reports.py | 43 ++++++-------------- 8 files changed, 78 insertions(+), 87 deletions(-) diff --git a/backend/app/modules/recipes/repository.py b/backend/app/modules/recipes/repository.py index 11de9dd..a5a32e3 100644 --- a/backend/app/modules/recipes/repository.py +++ b/backend/app/modules/recipes/repository.py @@ -42,3 +42,6 @@ async def flush(self) -> None: async def commit(self) -> None: await self._session.commit() + + async def refresh(self, recipe: Recipe) -> None: + await self._session.refresh(recipe) diff --git a/backend/app/modules/recipes/service.py b/backend/app/modules/recipes/service.py index 8b37659..8fb18a3 100644 --- a/backend/app/modules/recipes/service.py +++ b/backend/app/modules/recipes/service.py @@ -90,4 +90,8 @@ async def update(self, recipe_id: str, payload: RecipePayload) -> dict: recipe.parameters = dict(payload.parameters) recipe.updated_by = payload.updated_by await self._repo.commit() + # ``updated_at`` is a server-side onupdate column the DB regenerated on this + # UPDATE, so it is expired post-commit; refresh before serializing to avoid a + # lazy load on the async session (MissingGreenlet). + await self._repo.refresh(recipe) return recipe_dict(recipe) diff --git a/backend/app/modules/reports/service.py b/backend/app/modules/reports/service.py index d8ce217..991631e 100644 --- a/backend/app/modules/reports/service.py +++ b/backend/app/modules/reports/service.py @@ -181,7 +181,12 @@ async def create_report( OrderStatus.WAITING_REPORT_RETURN.value, ) await self._repo.commit() - return report_dict(rpt) + # Re-fetch via the eager-loading repo path so ``attachments``/``versions`` are + # loaded before serialization (the Report model requires repositories to + # eager-load; serializing a post-commit instance would lazy-load on the async + # session and raise MissingGreenlet when no attachment was appended). + created = await self._repo.get_report(rid) + return report_dict(created if created is not None else rpt) async def list_templates(self) -> list[dict]: return [template_dict(t) for t in await self._repo.list_templates()] diff --git a/backend/app/repos/order_repo.py b/backend/app/repos/order_repo.py index a9a5967..1d944b8 100644 --- a/backend/app/repos/order_repo.py +++ b/backend/app/repos/order_repo.py @@ -57,7 +57,7 @@ def __init__(self, db: AsyncSession) -> None: async def create_order(self, payload: OrderCreate, current_user: CurrentUser) -> Order: require_role(current_user, {"plant_user"}) - await self._validate_order_master_data(payload.department_id, payload.items) + labs_by_key = await self._validate_order_master_data(payload.department_id, payload.items) applicant_id = user_id(current_user) now = utc_now() @@ -72,7 +72,7 @@ async def create_order(self, payload: OrderCreate, current_user: CurrentUser) -> created_at=now, updated_at=now, ) - order.items = [self._make_item(item, now) for item in payload.items] + order.items = [self._make_item(item, now, labs_by_key) for item in payload.items] self.db.add(order) await self.db.flush() @@ -183,7 +183,7 @@ async def update_order( for item in order.items ] - await self._validate_order_master_data(next_department_id, next_items) + labs_by_key = await self._validate_order_master_data(next_department_id, next_items) if payload.department_id is not None: order.department_id = payload.department_id @@ -211,7 +211,7 @@ async def update_order( raise bad_request("Only returned order items can be edited individually") # validate mapping for new lab/experiment - await self._validate_order_master_data( + patch_labs_by_key = await self._validate_order_master_data( order.department_id, [ OrderItemCreate( @@ -228,7 +228,7 @@ async def update_order( target.sample_id = item_patch.sample_id target.sample_name = item_patch.sample_name - target.lab_id = item_patch.lab_id + target.lab_id = self._resolve_lab_id(item_patch.lab_id, patch_labs_by_key) target.experiment_id = item_patch.experiment_id target.target_group = item_patch.target_group target.target = item_patch.target @@ -247,7 +247,7 @@ async def update_order( else: order.items.clear() await self.db.flush() - order.items = [self._make_item(item, now) for item in payload.items] + order.items = [self._make_item(item, now, labs_by_key) for item in payload.items] order.total_items = len(order.items) order.updated_at = now @@ -989,11 +989,24 @@ async def _get_order_model(self, order_id: int) -> OrderModel: raise not_found("Order not found") return order - def _make_item(self, payload: OrderItemCreate, now: datetime) -> OrderItemModel: + @staticmethod + def _resolve_lab_id(lab_key: str, labs_by_key: dict[str, Lab]) -> str: + """Normalize a submitted lab key (Lab.code or UUID string) to the canonical + ``Lab.id`` UUID string that every order-item reader expects (approve gate, + dashboard scoping, lab-name lookups all compare against ``Lab.id``).""" + lab = labs_by_key.get(lab_key) + return str(lab.id) if lab is not None else lab_key + + def _make_item( + self, + payload: OrderItemCreate, + now: datetime, + labs_by_key: dict[str, Lab], + ) -> OrderItemModel: return OrderItemModel( sample_id=payload.sample_id, sample_name=payload.sample_name, - lab_id=payload.lab_id, + lab_id=self._resolve_lab_id(payload.lab_id, labs_by_key), experiment_id=payload.experiment_id, target_group=payload.target_group, target=payload.target, @@ -1007,7 +1020,7 @@ async def _validate_order_master_data( self, department_id: str, items: Sequence[OrderItemMasterData], - ) -> None: + ) -> dict[str, Lab]: department_exists = ( await self.db.execute( select(Department.id).where( @@ -1063,6 +1076,8 @@ async def _validate_order_master_data( f"Experiment {item.experiment_id} does not belong to lab {item.lab_id}" ) + return labs_by_key + @staticmethod def _truncate_text(value: str | None, max_length: int) -> str | None: if value is None: diff --git a/backend/app/routes/orders.py b/backend/app/routes/orders.py index 757f0b2..5005004 100644 --- a/backend/app/routes/orders.py +++ b/backend/app/routes/orders.py @@ -82,6 +82,7 @@ async def list_orders_by_applicant( @router.get("/{order_id}") async def get_order( order_id: int, + current_user: CurrentUser = Depends(get_current_user), service: OrderService = Depends(get_order_service), ) -> ApiResponse: order = await service.get_order(order_id) @@ -132,6 +133,7 @@ async def handle_order_action( @router.get("/{order_id}/history") async def get_order_history( order_id: int, + current_user: CurrentUser = Depends(get_current_user), service: OrderService = Depends(get_order_service), ) -> ApiResponse: return ApiResponse( diff --git a/backend/tests/a_tests/test_orders.py b/backend/tests/a_tests/test_orders.py index 537cb38..856ab8f 100644 --- a/backend/tests/a_tests/test_orders.py +++ b/backend/tests/a_tests/test_orders.py @@ -92,7 +92,6 @@ import uuid -import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -483,21 +482,14 @@ async def test_plant_user_cannot_approve_is_403( assert res.json()["error"]["code"] == "FORBIDDEN", res.text -@pytest.mark.xfail( - reason=( - "KNOWN BUG: order items persist lab_id as the submitted CODE ('LAB-A') but " - "the approval lab-gate compares against the supervisor's lab UUID, so a " - "LAB-A supervisor cannot approve a LAB-A order. This xfail documents the " - "INTENDED behaviour (a same-lab supervisor SHOULD be able to approve); it " - "will XPASS and flag the moment the lab-id mismatch is fixed." - ), - strict=True, -) async def test_lab_supervisor_can_approve_own_lab_order( plant_user_client: AsyncClient, supervisor_a_client: AsyncClient, db_session: AsyncSession, ) -> None: + """A LAB-A supervisor can approve a LAB-A order: order items persist ``lab_id`` + as the canonical ``Lab.id`` UUID (normalized at creation), so the approval + lab-gate — which compares against the supervisor's lab UUID — matches.""" order_id = await _create_order(plant_user_client, db_session, _uid("SUP-APPROVE")) await plant_user_client.post(f"/api/orders/{order_id}/actions", json={"action": "submit"}) @@ -582,37 +574,41 @@ async def test_create_order_unauthenticated_is_401(client: AsyncClient) -> None: assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text -async def test_get_one_order_is_not_auth_gated( +async def test_get_one_order_requires_auth( plant_user_client: AsyncClient, client: AsyncClient, db_session: AsyncSession, ) -> None: - """KNOWN BUG, locked in: ``GET /api/orders/{order_id}`` has NO - ``Depends(get_current_user)`` (only ``get_order_history`` and this route omit - it; every OTHER orders route is auth-gated). So an UNAUTHENTICATED client can - read any order by integer id — it returns 200, not 401. We assert the actual - behaviour so a future addition of the auth dependency flips this test and is - reviewed deliberately. Contrast ``test_list_orders_unauthenticated_is_401``, - which IS gated.""" + """``GET /api/orders/{order_id}`` is auth-gated like every other orders route: + an UNAUTHENTICATED client gets 401 / UNAUTHORIZED. The authenticated happy path + is covered below. Contrast ``test_list_orders_unauthenticated_is_401``.""" order_id = await _create_order(plant_user_client, db_session, _uid("NOAUTH-GET")) res = await client.get(f"/api/orders/{order_id}") - assert res.status_code == 200, res.text - assert res.json()["data"]["id"] == order_id + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + auth_res = await plant_user_client.get(f"/api/orders/{order_id}") + assert auth_res.status_code == 200, auth_res.text + assert auth_res.json()["data"]["id"] == order_id -async def test_get_order_history_is_not_auth_gated( +async def test_get_order_history_requires_auth( plant_user_client: AsyncClient, client: AsyncClient, db_session: AsyncSession, ) -> None: - """Same missing-auth gap as the get-one route: ``GET /api/orders/{id}/history`` - omits ``Depends(get_current_user)``, so an unauthenticated client reads it.""" + """``GET /api/orders/{id}/history`` is auth-gated: an unauthenticated client + gets 401 / UNAUTHORIZED, while an authenticated caller reads the history.""" order_id = await _create_order(plant_user_client, db_session, _uid("NOAUTH-HIST")) res = await client.get(f"/api/orders/{order_id}/history") - assert res.status_code == 200, res.text - assert res.json()["data"][0]["action"] == "create" + assert res.status_code == 401, res.text + assert res.json()["error"]["code"] == "UNAUTHORIZED", res.text + + auth_res = await plant_user_client.get(f"/api/orders/{order_id}/history") + assert auth_res.status_code == 200, auth_res.text + assert auth_res.json()["data"][0]["action"] == "create" async def test_engineer_cannot_create_order_is_403( diff --git a/backend/tests/c_tests/test_recipes.py b/backend/tests/c_tests/test_recipes.py index 129ab9f..b7d5cdd 100644 --- a/backend/tests/c_tests/test_recipes.py +++ b/backend/tests/c_tests/test_recipes.py @@ -55,7 +55,6 @@ import uuid -import pytest from httpx import AsyncClient # NOTE: no ``pytestmark = pytest.mark.asyncio`` — ``asyncio_mode = "auto"`` @@ -265,27 +264,11 @@ async def test_create_recipe_empty_machines_is_403( # UPDATE — PATCH /api/recipes/{recipe_id} # Happy path + 404. We create our own row first so we never mutate a shared one. # --------------------------------------------------------------------------- -@pytest.mark.xfail( - reason=( - "KNOWN BUG: RecipeService.update returns recipe_dict(recipe) right after " - "commit(); the serializer reads recipe.updated_at, which TimestampMixin " - "mutates with a SERVER-SIDE onupdate=func.now(). After commit the new " - "server-generated value is unloaded on the instance (even with " - "expire_on_commit=False, a server-side onupdate column is expired), so the " - "lazy refresh fires await IO on the async session -> MissingGreenlet -> 500 " - "DATABASE_ERROR. The recipe IS persisted; only the update RESPONSE " - "serialization 500s. (create_recipe is NOT affected: on INSERT updated_at " - "comes from server_default and is populated without a post-commit refresh.) " - "INTENDED behaviour: update returns 200 with the changed fields. This strict " - "xfail XPASSes the moment update awaits repo.refresh(recipe) / eager-loads " - "updated_at (or the serializer stops touching the expired column). Mirrors " - "D's create_report lazy-load-after-commit bug." - ), - strict=True, -) async def test_update_recipe_success(supervisor_a_client: AsyncClient) -> None: - """Regression anchor for the update-response post-commit lazy-load bug. We - assert the INTENDED 200; today it returns 500, so this xfails (strict).""" + """Updating a recipe returns 200 with the changed fields. ``RecipeService.update`` + awaits ``repo.refresh(recipe)`` after commit so the server-side ``updated_at`` + (onupdate=func.now()) is reloaded before serialization, avoiding a post-commit + lazy load on the async session (MissingGreenlet).""" recipe_id = _uid("RCP-UPDATE") await supervisor_a_client.post("/api/recipes", json=_recipe_payload(recipe_id)) diff --git a/backend/tests/d_tests/test_reports.py b/backend/tests/d_tests/test_reports.py index 07ebb29..2ec2196 100644 --- a/backend/tests/d_tests/test_reports.py +++ b/backend/tests/d_tests/test_reports.py @@ -53,7 +53,6 @@ import uuid -import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession @@ -80,18 +79,11 @@ async def _make_source_wip( """Arrange a WIP + WipExecution in a report-eligible state (waiting_confirm or completed), so ``create_report`` clears its source-state gate. Returns wip_no. - NOTE on ``raw_data_url``: we set it by DEFAULT so the created Report gets a - ``ReportAttachment`` appended in ``create_report``. That is a WORKAROUND for a - real bug (pinned separately in ``test_create_report_no_attachment_is_500``): - ``create_report`` returns ``report_dict(rpt)`` right after ``commit()``, and - the serializer lazy-accesses ``rpt.attachments`` / ``rpt.versions``. When NO - attachment was appended (i.e. the source exec row had no ``raw_data_url``), - the empty ``attachments`` collection is unloaded post-commit and the lazy - load raises ``MissingGreenlet`` on the async session -> 500. Giving the source - WIP a ``raw_data_url`` forces an attachment to be appended (so that collection - is in-memory/loaded), letting the create response serialize. This lets the - downstream machine tests (which need a real created report) run against the - other routes, which all eager-load via ``repo.get_report`` and are unaffected. + ``raw_data_url`` defaults to a value so the created Report gets a + ``ReportAttachment`` appended; pass ``raw_data_url=None`` to exercise the + no-attachment path (see ``test_create_report_no_attachment_succeeds``). + ``create_report`` re-fetches via the eager-loading repo path before serializing, + so both paths serialize without a post-commit lazy load on the async session. """ wip_no = _uid("WIP") order_no = _uid("ORD") @@ -241,29 +233,20 @@ async def test_create_report_with_submit_goes_pending_review( assert res.json()["data"]["status"] == "待審核" -@pytest.mark.xfail( - reason=( - "KNOWN BUG: ReportService.create_report returns report_dict(rpt) right after " - "commit(); the serializer lazy-accesses rpt.attachments. When the source WIP's " - "exec row has NO raw_data_url, no ReportAttachment is appended, so the empty " - "attachments collection is unloaded after commit and the lazy load raises " - "MissingGreenlet on the async session -> 500 DATABASE_ERROR. The report IS " - "persisted (a follow-up GET succeeds via the eager-loaded repo path); only the " - "create RESPONSE serialization 500s. INTENDED behaviour: create returns 200. " - "This strict xfail XPASSes the moment create_report eager-loads/refreshes the " - "relationships (or the serializer stops touching unloaded collections)." - ), - strict=True, -) -async def test_create_report_no_attachment_is_500( +async def test_create_report_no_attachment_succeeds( engineer_a_client: AsyncClient, db_session: AsyncSession, ) -> None: - """Regression anchor for the create-response lazy-load bug. We assert the - INTENDED 200; today it returns 500, so this xfails (strict).""" + """Creating a report from a WIP with NO ``raw_data_url`` (so no ReportAttachment + is appended) returns 200. ``create_report`` re-fetches via the eager-loading repo + path before serializing, so the empty ``attachments`` collection is loaded and no + post-commit lazy load fires on the async session.""" wip_no = await _make_source_wip(db_session, raw_data_url=None) res = await engineer_a_client.post("/api/reports", json={"wipId": wip_no}) assert res.status_code == 200, res.text + body = res.json()["data"] + assert body["wipId"] == wip_no + assert body["attachments"] == [] async def test_create_report_wip_not_ready_is_409( From 966faaa52fa45df4f8e779aac4ba34915dd99e85 Mon Sep 17 00:00:00 2001 From: Han Date: Tue, 2 Jun 2026 15:30:49 +0800 Subject: [PATCH 6/6] fix: test route integration --- backend/tests/e_tests/test_recipes_service.py | 6 +++ backend/tests/test_route_integration.py | 51 +++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/backend/tests/e_tests/test_recipes_service.py b/backend/tests/e_tests/test_recipes_service.py index 81044d1..cc72d2a 100644 --- a/backend/tests/e_tests/test_recipes_service.py +++ b/backend/tests/e_tests/test_recipes_service.py @@ -38,6 +38,12 @@ def add(self, recipe: Recipe) -> None: async def commit(self) -> None: self.commits += 1 + async def refresh(self, recipe: Recipe) -> None: + # No-op: the in-memory fake holds the live object, nothing to reload. + # Mirrors RecipeRepository.refresh (added to fix the post-commit + # MissingGreenlet on the server-side ``updated_at`` column). + return None + def recipe(recipe_id: str, machine_ids: list[str]) -> Recipe: return Recipe( diff --git a/backend/tests/test_route_integration.py b/backend/tests/test_route_integration.py index a38e5ed..e82a4d1 100644 --- a/backend/tests/test_route_integration.py +++ b/backend/tests/test_route_integration.py @@ -570,7 +570,6 @@ def test_samples_route_filters_visibility_and_status(client): assert status_response.json() == [] -@pytest.mark.xfail(reason="/api/wips/dependency/next is not available in current app") def test_wip_dependency_next_claims_lowest_utilization_candidate(client, integration_db): integration_db.execute( text( @@ -619,7 +618,7 @@ def test_wip_dependency_next_claims_lowest_utilization_candidate(client, integra first_response = client.post( "/api/wips/dependency/next", headers=ADMIN_HEADERS, - json={"sampleId": SAMPLE_A_ID}, + json={"sampleId": "SMP-2026-0001"}, ) assert first_response.status_code == 200 @@ -637,17 +636,39 @@ def test_wip_dependency_next_claims_lowest_utilization_candidate(client, integra ) assert claimed_first["dependency_check"] == 1 + # Mark the claimed Lab B / 光學量測 experiment as completed via a finished WIP. + # The endpoint advances to the next group's candidate only when the prior + # experiment is actually done (is_dependency_item_completed), not merely + # dependency_check-claimed — so without this row the lowest-utilization 992 + # would stay "sticky" and be returned again. + integration_db.execute( + text( + """ + INSERT INTO wips ( + id, wip_no, sample_id, order_no, lab_name, experiment_item, + status, progress + ) + VALUES ( + 'wip-0000-0000-0000-000000000992', 'WIP-2026-0992', + :sample_a_id, 'ORD-2026-0001', 'Lab B', '光學量測', + 'completed', 100 + ) + """ + ), + {"sample_a_id": SAMPLE_A_ID}, + ) + integration_db.commit() + second_response = client.post( "/api/wips/dependency/next", headers=ADMIN_HEADERS, - json={"sampleId": SAMPLE_A_ID}, + json={"sampleId": "SMP-2026-0001"}, ) assert second_response.status_code == 200 assert second_response.json()["data"]["orderItemId"] == 991 -@pytest.mark.xfail(reason="/api/wips/dependency/next is not available in current app") def test_wip_dependency_next_returns_null_when_done(client, integration_db): integration_db.execute( text( @@ -678,12 +699,32 @@ def test_wip_dependency_next_returns_null_when_done(client, integration_db): """ ) ) + # The single Lab A / SEM 觀察 experiment is fully done: a completed WIP marks + # it via is_dependency_item_completed, so there is no pending dependency item + # left and the endpoint returns null. (dependency_check=1 alone is not enough + # — the endpoint would otherwise re-return the already-claimed item.) + integration_db.execute( + text( + """ + INSERT INTO wips ( + id, wip_no, sample_id, order_no, lab_name, experiment_item, + status, progress + ) + VALUES ( + 'wip-0000-0000-0000-000000001001', 'WIP-2026-1001', + :sample_a_id, 'ORD-2026-0001', 'Lab A', 'SEM 觀察', + 'completed', 100 + ) + """ + ), + {"sample_a_id": SAMPLE_A_ID}, + ) integration_db.commit() response = client.post( "/api/wips/dependency/next", headers=ADMIN_HEADERS, - json={"sampleId": SAMPLE_A_ID}, + json={"sampleId": "SMP-2026-0001"}, ) assert response.status_code == 200