"""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"", "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