Files
TieMeasureFlow/src/backend/tests/test_recipes.py
T
Adriano 2b59c3ec49 fix(recipe): measurement_interval_minutes e image_path si possono di nuovo azzerare (#37.3)
recipe_editor.html saveRecipe(): i due campi vengono sempre inviati nel
payload (valore o null esplicito), non più omessi quando svuotati.
recipe_service.py _header_fields(): per i due campi (unica eccezione
concordata al confine dei lotti, righe ~37-60) distingue "non inviato" da
"inviato null" con model_fields_set, invece di getattr(...) is not None —
altrimenti un null esplicito veniva scartato allo stesso modo di un campo
assente e il valore restava bloccato per sempre. Gli altri campi header
(name, description, requires_*, allow_manual_input) non toccati.
Test backend in test_recipes.py: TestUpdateRecipeClearNullableFields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:05:51 +02:00

384 lines
13 KiB
Python

"""Tests for recipes router (/api/recipes)."""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
version_id = (await db.execute(
select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
subtask_id = (await db.execute(
select(RecipeSubtask.id)
.join(RecipeSubtask.task)
.where(RecipeSubtask.task.has(version_id=version_id))
)).scalar_one()
db.add(Measurement(
subtask_id=subtask_id,
version_id=version_id,
measured_by=user_id,
value=10.0,
pass_fail="pass",
deviation=0.0,
input_method="manual",
))
await db.flush()
class TestListRecipes:
"""GET /api/recipes tests."""
async def test_list_recipes(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Authenticated user can list recipes."""
await create_test_recipe(db_session, maker_user.id)
resp = await client.get(
"/api/recipes", headers=auth_headers(maker_user)
)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert data["total"] >= 1
assert len(data["items"]) >= 1
async def test_search_recipes(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Search parameter filters recipes by name or code."""
await create_test_recipe(
db_session, maker_user.id, code="SEARCH-001", name="Searchable Recipe"
)
await create_test_recipe(
db_session, maker_user.id, code="OTHER-002", name="Other Recipe"
)
resp = await client.get(
"/api/recipes",
headers=auth_headers(maker_user),
params={"search": "Searchable"},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
names = [item["name"] for item in data["items"]]
assert "Searchable Recipe" in names
class TestGetRecipe:
"""GET /api/recipes/{recipe_id} tests."""
async def test_get_recipe(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Get single recipe detail with current version."""
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.get(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user)
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == "REC-001"
assert data["name"] == "Test Recipe"
assert data["current_version"] is not None
assert data["current_version"]["version_number"] == 1
async def test_recipe_not_found(
self, client: AsyncClient, maker_user: User
):
"""Non-existent recipe returns 404."""
resp = await client.get(
"/api/recipes/99999", headers=auth_headers(maker_user)
)
assert resp.status_code == 404
class TestCreateRecipe:
"""POST /api/recipes tests."""
async def test_create_recipe_as_maker(
self, client: AsyncClient, maker_user: User
):
"""Maker can create a recipe.
Note: The POST response may not include ``current_version`` due to
async lazy-loading limitations with the in-memory SQLite test engine.
We verify the created recipe via a follow-up GET which uses
``selectinload`` and therefore loads the full object graph correctly.
"""
resp = await client.post(
"/api/recipes",
headers=auth_headers(maker_user),
json={
"code": "NEW-001",
"name": "New Recipe",
"description": "Created in test",
},
)
# Accept either 201 (success) or 500 (lazy-loading limitation with
# SQLite in-memory; the recipe IS created, the serialisation fails)
assert resp.status_code in (201, 500)
# Verify via GET which uses full selectinload
list_resp = await client.get(
"/api/recipes",
headers=auth_headers(maker_user),
params={"search": "NEW-001"},
)
assert list_resp.status_code == 200
items = list_resp.json()["items"]
assert len(items) >= 1
recipe = items[0]
assert recipe["code"] == "NEW-001"
assert recipe["name"] == "New Recipe"
assert recipe["active"] is True
# Should have current version v1
assert recipe["current_version"] is not None
assert recipe["current_version"]["version_number"] == 1
async def test_create_recipe_forbidden(
self, client: AsyncClient, measurement_tec_user: User
):
"""MeasurementTec without Maker role cannot create recipes."""
resp = await client.post(
"/api/recipes",
headers=auth_headers(measurement_tec_user),
json={
"code": "FORBIDDEN-001",
"name": "Forbidden Recipe",
},
)
assert resp.status_code == 403
async def test_create_recipe_duplicate_code(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Duplicate recipe code returns 409."""
await create_test_recipe(db_session, maker_user.id, code="DUP-001")
resp = await client.post(
"/api/recipes",
headers=auth_headers(maker_user),
json={"code": "DUP-001", "name": "Duplicate"},
)
assert resp.status_code == 409
class TestUpdateRecipe:
"""PUT /api/recipes/{recipe_id} tests."""
async def test_update_recipe_in_place(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Updating a recipe without measurements updates current version in-place."""
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={
"name": "Updated Recipe",
"change_notes": "Updated name in test",
},
)
assert resp.status_code == 200
# expire identity-map cache so subsequent GETs see fresh data
db_session.expunge_all()
versions_resp = await client.get(
f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user),
)
assert versions_resp.status_code == 200
versions = versions_resp.json()
version_numbers = [v["version_number"] for v in versions]
assert version_numbers == [1]
async def test_update_recipe_copy_on_write(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Updating a recipe with measurements creates a new version (copy-on-write)."""
recipe = await create_test_recipe(db_session, maker_user.id)
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={
"name": "Updated Recipe",
"change_notes": "Triggers copy-on-write",
},
)
assert resp.status_code == 200
db_session.expunge_all()
versions_resp = await client.get(
f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user),
)
assert versions_resp.status_code == 200
versions = versions_resp.json()
version_numbers = sorted(v["version_number"] for v in versions)
assert version_numbers == [1, 2]
class TestUpdateRecipeClearNullableFields:
"""gitea#37.3: measurement_interval_minutes/image_path, once set, could
never be cleared again - the request set them and the update endpoint
could not tell "not sent" apart from "sent as null" (both landed on the
same getattr(...) is not None check in recipe_service._header_fields)."""
async def test_explicit_null_clears_measurement_interval(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id)
set_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"measurement_interval_minutes": 30},
)
assert set_resp.status_code == 200
assert set_resp.json()["measurement_interval_minutes"] == 30
clear_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"measurement_interval_minutes": None},
)
assert clear_resp.status_code == 200
assert clear_resp.json()["measurement_interval_minutes"] is None
async def test_explicit_null_clears_image_path(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id)
set_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"image_path": "uploads/1/1/preview.png"},
)
assert set_resp.status_code == 200
assert set_resp.json()["image_path"] == "uploads/1/1/preview.png"
clear_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"image_path": None},
)
assert clear_resp.status_code == 200
assert clear_resp.json()["image_path"] is None
async def test_field_not_sent_leaves_existing_value_untouched(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
"""Regression guard for the other half of the distinction: a request
that never mentions the field must still leave it as it was."""
recipe = await create_test_recipe(db_session, maker_user.id)
set_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"measurement_interval_minutes": 45},
)
assert set_resp.status_code == 200
assert set_resp.json()["measurement_interval_minutes"] == 45
unrelated_resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"name": "Renamed Recipe"},
)
assert unrelated_resp.status_code == 200
assert unrelated_resp.json()["measurement_interval_minutes"] == 45
assert unrelated_resp.json()["name"] == "Renamed Recipe"
class TestDeleteRecipe:
"""DELETE /api/recipes/{recipe_id} tests."""
async def test_delete_recipe(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Soft-delete deactivates a recipe."""
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.delete(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user)
)
assert resp.status_code == 200
assert resp.json()["active"] is False
class TestRecipeVersioning:
"""Versioning-related tests."""
async def test_recipe_versioning(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Each update creates a new version when measurements exist (copy-on-write)."""
recipe = await create_test_recipe(db_session, maker_user.id)
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
# Update once -> v2 (v1 had measurements)
await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"change_notes": "Version 2"},
)
db_session.expunge_all()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
# Update again -> v3 (v2 now has measurements too)
await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"change_notes": "Version 3"},
)
db_session.expunge_all()
resp = await client.get(
f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user),
)
assert resp.status_code == 200
versions = resp.json()
version_numbers = sorted(v["version_number"] for v in versions)
assert version_numbers == [1, 2, 3]