21f8bae5ac
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
241 lines
11 KiB
Python
241 lines
11 KiB
Python
"""Test di POST/GET /api/task-results (#54 check OK/NOK, #55 note con campi)."""
|
|
from unittest.mock import patch
|
|
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from src.backend.models.orm.production import ProductionRun
|
|
from src.backend.models.orm.station import Station
|
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
|
|
|
FIELDS = [
|
|
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
|
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
|
]
|
|
|
|
|
|
async def _setup(client, db_session, maker_user, admin_user, code):
|
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=code)
|
|
db_session.add(Station(code=f"ST-{code}", name="S", active=True, created_by=admin_user.id))
|
|
await db_session.commit()
|
|
h = auth_headers(maker_user)
|
|
check = (await client.post(
|
|
f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "C", "task_type": "check"},
|
|
)).json()
|
|
note = (await client.post(
|
|
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
|
json={"title": "N", "task_type": "note", "fields": FIELDS},
|
|
)).json()
|
|
return recipe, check, note
|
|
|
|
|
|
async def _open_run(client, user, recipe, code):
|
|
resp = await client.post(
|
|
"/api/production-runs", headers=auth_headers(user),
|
|
json={"station_code": f"ST-{code}", "recipe_id": recipe.id},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
return resp.json()
|
|
|
|
|
|
async def _post(client, user, **body):
|
|
return await client.post("/api/task-results", headers=auth_headers(user), json=body)
|
|
|
|
|
|
async def test_requires_auth_and_role(client: AsyncClient, maker_user, admin_user, db_session):
|
|
_, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR0")
|
|
resp = await client.post("/api/task-results", json={"task_id": check["id"], "outcome": "ok"})
|
|
assert resp.status_code == 401
|
|
# un Maker senza ruolo di reparto non registra esiti
|
|
resp = await _post(client, maker_user, task_id=check["id"], outcome="ok")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_check_ok_and_nok_rules(
|
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
|
):
|
|
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR1")
|
|
run = await _open_run(client, measurement_tec_user, recipe, "TR1")
|
|
|
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
|
production_run_id=run["id"], outcome="ok")
|
|
assert resp.status_code == 201, resp.text
|
|
body = resp.json()
|
|
assert body["outcome"] == "ok"
|
|
assert body["version_id"] == check["version_id"]
|
|
assert body["user_id"] == measurement_tec_user.id
|
|
assert body["production_run_id"] == run["id"]
|
|
assert body["created_at"]
|
|
|
|
for note in (None, "", " "):
|
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
|
outcome="nok", note=note)
|
|
assert resp.status_code == 422
|
|
assert resp.json()["detail"] == "Per un NOK serve la nota"
|
|
|
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
|
outcome="nok", note=" graffio ")
|
|
assert resp.status_code == 201
|
|
assert resp.json()["note"] == "graffio"
|
|
|
|
# un check non si chiude con 'done'
|
|
resp = await _post(client, measurement_tec_user, task_id=check["id"], outcome="done")
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_note_values_and_attachments(
|
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
|
):
|
|
recipe, _, note = await _setup(client, db_session, maker_user, admin_user, "TR2")
|
|
ok = await _post(
|
|
client, measurement_tec_user, task_id=note["id"], outcome="done",
|
|
values={"peso": "12,5"},
|
|
attachments=[{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}],
|
|
)
|
|
assert ok.status_code == 201, ok.text
|
|
assert ok.json()["values"] == {"peso": "12,5"}
|
|
assert ok.json()["attachments"] == [{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}]
|
|
|
|
# solo done
|
|
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="ok")).status_code == 422
|
|
# chiave fuori dai campi
|
|
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
|
values={"altro": "1"})).status_code == 422
|
|
# numero non convertibile
|
|
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
|
values={"peso": "abc"})).status_code == 422
|
|
# allegati con percorso non sicuro
|
|
for bad in ("../x.jpg", "/etc/passwd", "a/../b.jpg", "C:/x.jpg", "a\\b.jpg"):
|
|
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
|
attachments=[{"key": None, "path": bad, "name": "x"}])
|
|
assert resp.status_code == 422, bad
|
|
# chiave allegato fuori dai campi
|
|
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
|
attachments=[{"key": "boh", "path": "task_results/a.jpg", "name": "x"}])
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_other_task_types_rejected_and_unknown_task(
|
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
|
):
|
|
recipe, _, _ = await _setup(client, db_session, maker_user, admin_user, "TR3")
|
|
measure = (await client.post(
|
|
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
|
json={"title": "M", "task_type": "measure"},
|
|
)).json()
|
|
resp = await _post(client, measurement_tec_user, task_id=measure["id"], outcome="ok")
|
|
assert resp.status_code == 422
|
|
resp = await _post(client, measurement_tec_user, task_id=999999, outcome="ok")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_run_must_be_open_and_same_version(
|
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
|
):
|
|
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4")
|
|
_, other_check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4b")
|
|
run = await _open_run(client, measurement_tec_user, recipe, "TR4")
|
|
|
|
# task di un'altra versione
|
|
resp = await _post(client, measurement_tec_user, task_id=other_check["id"],
|
|
production_run_id=run["id"], outcome="ok")
|
|
assert resp.status_code == 422
|
|
# run inesistente
|
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
|
production_run_id=999999, outcome="ok")
|
|
assert resp.status_code == 404
|
|
|
|
# run chiuso: non registra piu'
|
|
row = (await db_session.execute(
|
|
select(ProductionRun).where(ProductionRun.id == run["id"])
|
|
)).scalar_one()
|
|
row.status = "closed"
|
|
await db_session.commit()
|
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
|
production_run_id=run["id"], outcome="ok")
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_list_filters_and_order(
|
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
|
):
|
|
recipe, check, note = await _setup(client, db_session, maker_user, admin_user, "TR5")
|
|
run = await _open_run(client, measurement_tec_user, recipe, "TR5")
|
|
a = (await _post(client, measurement_tec_user, task_id=check["id"],
|
|
production_run_id=run["id"], outcome="ok")).json()
|
|
b = (await _post(client, measurement_tec_user, task_id=note["id"],
|
|
production_run_id=run["id"], outcome="done")).json()
|
|
c = (await _post(client, measurement_tec_user, task_id=check["id"], outcome="ok")).json()
|
|
h = auth_headers(measurement_tec_user)
|
|
|
|
resp = await client.get(f"/api/task-results?production_run_id={run['id']}", headers=h)
|
|
assert [r["id"] for r in resp.json()] == [b["id"], a["id"]] # piu' recente prima
|
|
resp = await client.get(f"/api/task-results?task_id={check['id']}", headers=h)
|
|
assert [r["id"] for r in resp.json()] == [c["id"], a["id"]]
|
|
resp = await client.get(
|
|
f"/api/task-results?task_id={check['id']}&production_run_id={run['id']}", headers=h)
|
|
assert [r["id"] for r in resp.json()] == [a["id"]]
|
|
assert (await client.get("/api/task-results")).status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/task-results/attachments: l'operatore carica gli allegati (#55)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# firma PNG valida (magic bytes) piu' un po' di zeri
|
|
PNG = bytes([0x89]) + b"PNG" + bytes([13, 10, 26, 10]) + bytes(32)
|
|
|
|
|
|
async def test_attachment_upload_ok_for_measurement_tec(
|
|
client: AsyncClient, measurement_tec_user, tmp_path,
|
|
):
|
|
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
|
mock_settings.upload_path = tmp_path
|
|
mock_settings.max_upload_size_mb = 50
|
|
resp = await client.post(
|
|
"/api/task-results/attachments", headers=auth_headers(measurement_tec_user),
|
|
files={"file": ("foto bella (1).png", PNG, "image/png")},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
body = resp.json()
|
|
assert body["name"] == "fotobella1.png"
|
|
assert body["file_path"].startswith("task_results/")
|
|
assert body["file_path"].endswith(".png")
|
|
assert (tmp_path / body["file_path"]).read_bytes() == PNG
|
|
|
|
|
|
async def test_attachment_upload_rejects_wrong_type_size_and_role(
|
|
client: AsyncClient, measurement_tec_user, maker_user, tmp_path,
|
|
):
|
|
h = auth_headers(measurement_tec_user)
|
|
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
|
mock_settings.upload_path = tmp_path
|
|
mock_settings.max_upload_size_mb = 1
|
|
# tipo non ammesso (HTML dichiarato come png): come upload_file, 400
|
|
resp = await client.post(
|
|
"/api/task-results/attachments", headers=h,
|
|
files={"file": ("x.png", b"<html></html>", "image/png")},
|
|
)
|
|
assert resp.status_code == 400
|
|
# oltre il limite
|
|
resp = await client.post(
|
|
"/api/task-results/attachments", headers=h,
|
|
files={"file": ("x.png", PNG + bytes(1024 * 1024 + 1), "image/png")},
|
|
)
|
|
assert resp.status_code == 400
|
|
# un Maker senza ruolo di reparto non carica
|
|
resp = await client.post(
|
|
"/api/task-results/attachments", headers=auth_headers(maker_user),
|
|
files={"file": ("x.png", PNG, "image/png")},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert not any(tmp_path.rglob("*.png"))
|
|
|
|
|
|
async def test_task_results_dir_is_undeletable(client: AsyncClient, admin_user):
|
|
resp = await client.delete(
|
|
"/api/files/task_results/2026/10/abc.png", headers=auth_headers(admin_user),
|
|
)
|
|
assert resp.status_code in (403, 404)
|
|
assert resp.status_code == 403
|