From 21f8bae5acec2dbe4508c48eb2737c15404ab49a Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:50:42 +0200 Subject: [PATCH] Task results: upload allegati per MeasurementTec, percorsi in task_results/, test e migrazione 018 (#55 #53 #54) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/files.py | 3 +- src/backend/api/routers/task_results.py | 68 +++++- src/backend/tests/test_migrations.py | 65 ++++++ src/backend/tests/test_task_loop_fields.py | 208 ++++++++++++++++++ src/backend/tests/test_task_results.py | 240 +++++++++++++++++++++ 5 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 src/backend/tests/test_task_loop_fields.py create mode 100644 src/backend/tests/test_task_results.py diff --git a/src/backend/api/routers/files.py b/src/backend/api/routers/files.py index e6cc22e..72898ef 100644 --- a/src/backend/api/routers/files.py +++ b/src/backend/api/routers/files.py @@ -61,7 +61,8 @@ def detect_file_type(content: bytes) -> tuple[str, str] | None: # Top-level folders nothing may DELETE through this router: statistics/reports are # audit records, the logo is only ever replaced via POST /api/settings/logo. -_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/") +# task_results/: allegati degli esiti dei task (#55), record di audit. +_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/") # GET under these needs a role that is meant to see production data, not just # any authenticated user (a Maker or MeasurementTec has no business reading diff --git a/src/backend/api/routers/task_results.py b/src/backend/api/routers/task_results.py index ee7fa3b..e6881b4 100644 --- a/src/backend/api/routers/task_results.py +++ b/src/backend/api/routers/task_results.py @@ -4,13 +4,20 @@ Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con valori dei campi extra e gli allegati. I task di misura non passano di qui: le loro letture sono Measurement. """ +import uuid +from datetime import datetime, timezone +from pathlib import Path from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.api.middleware.api_key import require_measurement_tec +from src.backend.api.routers.files import ( + _UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename, +) +from src.backend.config import settings from src.backend.database import get_db from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse from src.backend.models.orm.production import ProductionRun @@ -26,11 +33,62 @@ def _unprocessable(detail: str) -> HTTPException: return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) +# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit). +ATTACHMENT_DIR = "task_results" + + def _safe_relative_path(path: str) -> bool: - """Percorso di upload: relativo, senza '..', senza unita' o backslash.""" + """Allegato: relativo, dentro task_results/, senza '..', unita' o backslash.""" if path.startswith("/") or "\\" in path or ":" in path: return False - return ".." not in path.split("/") + parts = path.split("/") + return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1 + + +@router.post("/attachments", status_code=status.HTTP_201_CREATED) +async def upload_attachment( + file: UploadFile = File(...), + _user: User = Depends(require_measurement_tec), +): + """Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload). + + Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione + con lettura a blocchi, nome ripulito. Salva sotto task_results///. + """ + max_bytes = settings.max_upload_size_mb * 1024 * 1024 + buffer = bytearray() + while True: + chunk = await file.read(_UPLOAD_CHUNK_SIZE) + if not chunk: + break + buffer.extend(chunk) + if len(buffer) > max_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB", + ) + content = bytes(buffer) + + detected = detect_file_type(content) + if detected is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File type not allowed. Must be a recognised image or PDF.", + ) + _mime, ext = detected + + # nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata + name = sanitize_filename(file.filename or "") + now = datetime.now(timezone.utc) + storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}" + storage_dir.mkdir(parents=True, exist_ok=True) + target = storage_dir / f"{uuid.uuid4().hex}{ext}" + target.write_bytes(content) + + return { + "file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"), + "name": name, + } @router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED) @@ -73,7 +131,9 @@ async def create_task_result( attachments = [] for att in data.attachments or []: if not _safe_relative_path(att.path): - raise _unprocessable(f"Percorso allegato non valido: {att.path}") + raise _unprocessable( + f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}" + ) if att.key is not None and att.key not in fields: raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task") attachments.append(att.model_dump()) diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 64e9664..9ba63d4 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -168,3 +168,68 @@ class TestUserPinMigration: assert not (new & cols()) command.upgrade(cfg, "head") # e di nuovo su assert new <= cols() + + +_SEED_USER_RECIPE_VERSION = [ + "INSERT INTO users (username, password_hash, display_name, roles, is_admin, active," + " language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')", + "INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)", + "INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)" + " VALUES (1, 1, 1, 1)", +] + + +class TestTaskLoopResultsMigration: + """018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55).""" + + def test_018_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + + def cols(): + return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")} + + command.upgrade(cfg, "head") + assert {"in_loop", "at_close", "fields"} <= cols() + assert "task_results" in _inspect(db_path).get_table_names() + command.downgrade(cfg, "017_user_pin") + assert not ({"in_loop", "at_close", "fields"} & cols()) + assert "task_results" not in _inspect(db_path).get_table_names() + command.upgrade(cfg, "head") # e di nuovo su + + def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + command.upgrade(cfg, "017_user_pin") + engine = create_engine(f"sqlite:///{db_path}") + with engine.begin() as conn: + for stmt in _SEED_USER_RECIPE_VERSION: + conn.execute(text(stmt)) + for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")): + conn.execute(text( + "INSERT INTO recipe_tasks (version_id, order_index, task_type, title)" + f" VALUES (1, {i}, '{t}', '{t}')")) + engine.dispose() + + command.upgrade(cfg, "head") + engine = create_engine(f"sqlite:///{db_path}") + with engine.connect() as conn: + rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all()) + engine.dispose() + assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0} + + def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + command.upgrade(cfg, "head") + engine = create_engine(f"sqlite:///{db_path}") + with engine.begin() as conn: + for stmt in _SEED_USER_RECIPE_VERSION: + conn.execute(text(stmt)) + conn.execute(text( + "INSERT INTO recipe_tasks (version_id, order_index, task_type, title)" + " VALUES (1, 0, 'check', 'c')")) + engine.dispose() + + with pytest.raises(RuntimeError, match="check"): + command.downgrade(cfg, "017_user_pin") diff --git a/src/backend/tests/test_task_loop_fields.py b/src/backend/tests/test_task_loop_fields.py new file mode 100644 index 0000000..6814f9e --- /dev/null +++ b/src/backend/tests/test_task_loop_fields.py @@ -0,0 +1,208 @@ +"""Test di in_loop / at_close / fields e del tipo 'check' sui task (#53 #54 #55), +di close_task_ids nella risposta del run e del bucket di rate limit del login PIN.""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from src.backend.api.middleware.rate_limit import RateLimitMiddleware +from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.recipe import RecipeVersion +from src.backend.models.orm.station import Station +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.tests.conftest import auth_headers, create_test_recipe + +FIELDS = [ + {"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False}, + {"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True}, +] + + +async def _post_task(client, user, recipe_id, **body): + return await client.post( + f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user), json=body, + ) + + +async def test_create_defaults_in_loop_from_type(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L1") + await db_session.commit() + expected = {"note": False, "drawing": False, "measure": True, + "camera_measure": True, "dxf_compare": True, "check": True} + for task_type, in_loop in expected.items(): + resp = await _post_task(client, maker_user, recipe.id, title=task_type, task_type=task_type) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["in_loop"] is in_loop, task_type + assert body["at_close"] is False + assert body["fields"] is None + + +async def test_create_explicit_in_loop_and_note_fields(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L2") + await db_session.commit() + resp = await _post_task( + client, maker_user, recipe.id, title="Nota", task_type="note", + in_loop=True, at_close=True, fields=FIELDS, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["in_loop"] is True and body["at_close"] is True + assert body["fields"] == FIELDS + + +@pytest.mark.parametrize("task_type", ["measure", "check", "drawing"]) +async def test_create_at_close_or_fields_rejected_for_other_types( + client: AsyncClient, maker_user, db_session, task_type, +): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=f"REC-L3{task_type}") + await db_session.commit() + resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, at_close=True) + assert resp.status_code == 422 + assert isinstance(resp.json()["detail"], str) + resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, fields=FIELDS) + assert resp.status_code == 422 + + +@pytest.mark.parametrize("bad", [ + [{"key": "A b", "label": "x", "type": "text"}], # chiave non slug + [{"key": "k", "label": "", "type": "text"}], # etichetta vuota + [{"key": "k", "label": "x", "type": "date"}], # tipo ignoto + [{"key": "k", "label": "x", "type": "text"}, {"key": "k", "label": "y", "type": "text"}], # duplicata + [{"key": "k", "label": "x", "type": "number", "default": "abc"}], # default non numerico + [{"key": f"k{i}", "label": "x", "type": "text"} for i in range(21)], # troppi +]) +async def test_create_invalid_fields_rejected(client: AsyncClient, maker_user, db_session, bad): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L4") + await db_session.commit() + resp = await _post_task(client, maker_user, recipe.id, title="n", task_type="note", fields=bad) + assert resp.status_code == 422 + assert isinstance(resp.json()["detail"], str) + + +async def test_update_fields_null_clears_and_type_rules(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L5") + await db_session.commit() + created = (await _post_task( + client, maker_user, recipe.id, title="Nota", task_type="note", + at_close=True, fields=FIELDS, + )).json() + headers = auth_headers(maker_user) + + # il frontend manda sempre i tre campi: fields null = nessun campo + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"in_loop": True, "at_close": False, "fields": None}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["in_loop"] is True and body["at_close"] is False and body["fields"] is None + + # tipo diverso da note con at_close o fields: 422 + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"task_type": "measure", "at_close": True, "fields": None}, + ) + assert resp.status_code == 422 + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"task_type": "check", "at_close": False, "fields": FIELDS}, + ) + assert resp.status_code == 422 + + # un task che ha gia' at_close non puo' passare a measure senza toglierlo + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"at_close": True, "fields": FIELDS}, + ) + assert resp.status_code == 200 + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, json={"task_type": "measure"}, + ) + assert resp.status_code == 422 + + +async def test_copy_on_write_carries_the_three_fields( + client: AsyncClient, maker_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L6") + await db_session.commit() + created = (await _post_task( + client, maker_user, recipe.id, title="Nota", task_type="note", + in_loop=True, at_close=True, fields=FIELDS, + )).json() + + # una misura sulla versione corrente forza il copy-on-write alla prossima modifica + version = (await db_session.execute( + select(RecipeVersion).where( + RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712 + ) + )).scalar_one() + subtask_id = (await db_session.execute( + select(RecipeSubtask.id).join(RecipeSubtask.task) + .where(RecipeSubtask.task.has(version_id=version.id)) + )).scalars().first() + db_session.add(Measurement( + subtask_id=subtask_id, version_id=version.id, measured_by=maker_user.id, + value=10.0, pass_fail="pass", deviation=0.0, input_method="manual", + )) + await db_session.commit() + + resp = await client.put( + f"/api/tasks/{created['id']}", headers=auth_headers(maker_user), + json={"title": "Nota rinominata"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["version_id"] != version.id # e' la copia nella nuova versione + assert body["in_loop"] is True and body["at_close"] is True + assert body["fields"] == FIELDS + + +async def test_run_response_loop_and_close_task_ids( + client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L7") + db_session.add(Station(code="ST-L7", name="S", active=True, created_by=admin_user.id)) + await db_session.commit() + + async def mk(**body): + resp = await _post_task(client, maker_user, recipe.id, **body) + assert resp.status_code == 201, resp.text + return resp.json() + + check = await mk(title="Check", task_type="check") + note_loop = await mk(title="Nota ciclo", task_type="note", in_loop=True) + note_out = await mk(title="Nota fuori", task_type="note") + close_a = await mk(title="Chiusura A", task_type="note", at_close=True) + measure_off = await mk(title="Misura fuori", task_type="measure", in_loop=False) + close_b = await mk(title="Chiusura B", task_type="note", at_close=True) + + first_id = (await db_session.execute( + select(RecipeTask.id).where(RecipeTask.version_id == check["version_id"]) + .order_by(RecipeTask.order_index) + )).scalars().first() + + resp = await client.post( + "/api/production-runs", headers=auth_headers(measurement_tec_user), + json={"station_code": "ST-L7", "recipe_id": recipe.id}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + # ciclo: solo in_loop, in ordine (misura della fixture, check, nota in ciclo) + assert body["measurement_task_ids"] == [first_id, check["id"], note_loop["id"]] + assert note_out["id"] not in body["measurement_task_ids"] + assert measure_off["id"] not in body["measurement_task_ids"] + assert body["close_task_ids"] == [close_a["id"], close_b["id"]] + + +class TestLoginPinRateLimitBucket: + """#59: il login con PIN sta nel bucket 'login' come il login con password.""" + + def test_login_pin_is_login_bucket(self): + assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login-pin") + + def test_login_pin_get_is_not_login_bucket(self): + assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login-pin") + + def test_pin_users_list_is_not_login_bucket(self): + assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/pin-users") diff --git a/src/backend/tests/test_task_results.py b/src/backend/tests/test_task_results.py new file mode 100644 index 0000000..1b2952e --- /dev/null +++ b/src/backend/tests/test_task_results.py @@ -0,0 +1,240 @@ +"""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