fix(tasks): copy-on-write su modifica/cancellazione e ordine tolleranze (#4, #26)

#4 - api/routers/tasks.py: update_task, delete_task, create_subtask,
update_subtask, delete_subtask controllavano solo is_current, non
version_has_measurements: la versione con misure veniva modificata sul posto,
cambiando i limiti di quote gia' giudicate pass/fail. Nuovi helper
_task_for_write / _subtask_for_write riusano recipe_service.create_new_version
(lo stesso copy-on-write di create_task): se la versione ha misure creano una
nuova versione, ritrovano il task per order_index e la quota per marker_number
(chiavi che _copy_tasks_to_version preserva), e la scrittura va sulla copia.
La versione misurata resta intatta.
Trovato e corretto nello stesso punto: il task ritrovato nella copia non ha
.version precaricato (new_version.tasks viene da selectinload, che non popola
il backref figlio->genitore), e TaskResponse.recipe_id legge task.version.recipe_id
- senza assegnare esplicitamente candidate.version = new_version, update_task
va in MissingGreenlet (lazy load fuori da un await) invece di rispondere 200.

#26 - models/api/task.py: nuovo validate_tolerance_order (ltl<=lwl<=nominal<=
uwl<=utl sui soli campi valorizzati), usato come model_validator su
SubtaskCreate e richiamato dal router in update_subtask sui valori risultanti
(esistenti + parziali) prima del flush, perche' SubtaskUpdate non ha un insieme
fisso di campi da validare da solo.

Test: src/backend/tests/test_tasks.py, TestCopyOnWriteOnEdit (5) e
TestToleranceOrder (4); adattato test_update_subtask esistente (nominal 12.0
violava il nuovo ordine contro utl=10.5 invariato).
PROVA: uv run --extra server --extra dev pytest src/backend/tests/test_tasks.py -q -> 16 passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:48:08 +02:00
parent 7b015078e9
commit 77d95fdcc4
3 changed files with 411 additions and 13 deletions
+274 -1
View File
@@ -7,6 +7,14 @@ from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _measure(client, user, subtask_id, version_id, value=10.0):
return await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={"subtask_id": subtask_id, "version_id": version_id, "value": value},
)
class TestListTasks:
"""GET /api/recipes/{recipe_id}/tasks tests."""
@@ -171,7 +179,9 @@ class TestUpdateSubtask:
resp = await client.put(
f"/api/subtasks/{subtask_id}",
headers=auth_headers(maker_user),
json={"description": "Updated description", "nominal": 12.0},
# nominal stays inside the fixture's own lwl(9.7)/uwl(10.3): #26
# rejects a nominal like 12.0 that would land past utl(10.5).
json={"description": "Updated description", "nominal": 10.2},
)
assert resp.status_code == 200
assert resp.json()["description"] == "Updated description"
@@ -221,3 +231,266 @@ class TestReorderTasks:
assert resp.status_code == 200
reordered = resp.json()
assert [t["id"] for t in reordered] == reversed_ids
class TestCopyOnWriteOnEdit:
"""A version with measurements is never edited in place (gitea#4)."""
async def test_update_task_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-UT")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
task_id, old_version_id = task["id"], task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.put(
f"/api/tasks/{task_id}",
headers=auth_headers(maker_user),
json={"title": "Titolo nuovo"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["version_id"] != old_version_id
assert resp.json()["title"] == "Titolo nuovo"
# The measured version keeps its original title and its versions count grew.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert old["tasks"][0]["title"] == "Test Task"
versions = (await client.get(
f"/api/recipes/{recipe.id}/versions", headers=auth_headers(maker_user),
)).json()
assert len(versions) == 2
# The measurement is still findable under the old version.
listed = (await client.get(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
params={"version_id": old_version_id},
)).json()
assert listed["total"] == 1
async def test_delete_task_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-DT")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
task_id, old_version_id = task["id"], task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.delete(
f"/api/tasks/{task_id}", headers=auth_headers(maker_user),
)
assert resp.status_code == 204
# The measured version still has its task untouched.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert len(old["tasks"]) == 1
# The new current version has the task removed. expunge_all forces a
# fresh read - the shared test session otherwise still has the old
# RecipeVersion.tasks collection cached from create_test_recipe.
db_session.expunge_all()
current = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()
assert current == []
async def test_update_subtask_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-US")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
old_version_id = task["version_id"]
subtask = task["subtasks"][0]
subtask_id = subtask["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.put(
f"/api/subtasks/{subtask_id}",
headers=auth_headers(maker_user),
json={"description": "Quota rivista"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["description"] == "Quota rivista"
assert resp.json()["id"] != subtask_id, "la modifica va sulla copia, non sull'originale"
# The measured version's own quote is untouched.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert old["tasks"][0]["subtasks"][0]["description"] == subtask["description"]
async def test_delete_subtask_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-DS")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
old_version_id = task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.delete(
f"/api/subtasks/{subtask_id}", headers=auth_headers(maker_user),
)
assert resp.status_code == 204
# The measured version keeps its quote.
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert len(old["tasks"][0]["subtasks"]) == 1
# The new current version has no quotes left on the task. expunge_all
# forces a fresh read past the shared test session's cached collection.
db_session.expunge_all()
current = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()
assert current[0]["subtasks"] == []
async def test_create_subtask_with_measurements_creates_new_version(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-CS")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
task_id, old_version_id = task["id"], task["version_id"]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, old_version_id
)).status_code == 200
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 9, "description": "Nuova quota", "utl": 5.0},
)
assert resp.status_code == 201, resp.text
# The measured version's task is unchanged (still one quote).
old = (await client.get(
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
)).json()
assert len(old["tasks"][0]["subtasks"]) == 1
# The new current version's task has both quotes. expunge_all forces a
# fresh read past the shared test session's cached collection.
db_session.expunge_all()
current = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()
assert len(current[0]["subtasks"]) == 2
class TestToleranceOrder:
"""ltl <= lwl <= nominal <= uwl <= utl, or 422 (gitea#26)."""
async def test_create_subtask_utl_below_ltl_is_422(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-A")
task_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["id"]
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 5, "description": "Quota invertita", "ltl": 8, "utl": 6},
)
assert resp.status_code == 422
async def test_create_subtask_uwl_above_utl_is_422(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-B")
task_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["id"]
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 5, "description": "Warning oltre tolleranza", "uwl": 11, "utl": 10},
)
assert resp.status_code == 422
async def test_create_subtask_one_sided_tolerance_is_ok(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-C")
task_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["id"]
resp = await client.post(
f"/api/tasks/{task_id}/subtasks",
headers=auth_headers(maker_user),
json={"marker_number": 5, "description": "Solo utl", "utl": 10.0},
)
assert resp.status_code == 201, resp.text
async def test_update_subtask_that_would_invert_the_order_is_422(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
):
"""Partial update: the new value is checked against what stays (#26)."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-D")
subtask_id = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]["subtasks"][0]["id"]
# Fixture quote: ltl=9.5, lwl=9.7, nominal=10.0, uwl=10.3, utl=10.5.
# Raising ltl past the existing utl inverts the sequence.
resp = await client.put(
f"/api/subtasks/{subtask_id}",
headers=auth_headers(maker_user),
json={"ltl": 11.0},
)
assert resp.status_code == 422