2b00f83a2e
Sospetto MissingGreenlet su TaskResponse.recipe_id (via RecipeTask.version) quando create_task passa per il copy-on-write - il task appena creato non ha .version precaricato. Test aggiunto: verde, non riproducibile. La lazy load risolve via identity map (new_version è già nella sessione, FK semplice → use_get), senza toccare il DB. Test tenuto come guardia di regressione. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
536 lines
19 KiB
Python
536 lines
19 KiB
Python
"""Tests for tasks router (/api/recipes/{id}/tasks, /api/tasks, /api/subtasks)."""
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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."""
|
|
|
|
async def test_list_tasks(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Authenticated user can list tasks for a recipe."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
resp = await client.get(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) >= 1
|
|
assert data[0]["title"] == "Test Task"
|
|
assert len(data[0]["subtasks"]) >= 1
|
|
|
|
|
|
class TestCreateTask:
|
|
"""POST /api/recipes/{recipe_id}/tasks tests."""
|
|
|
|
async def test_create_task(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Maker can add a task (triggers copy-on-write new version)."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
resp = await client.post(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
json={
|
|
"title": "New Task",
|
|
"directive": "Inspect surface",
|
|
"subtasks": [
|
|
{
|
|
"marker_number": 1,
|
|
"description": "Surface roughness",
|
|
"nominal": 5.0,
|
|
"utl": 5.5,
|
|
"ltl": 4.5,
|
|
"unit": "um",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["title"] == "New Task"
|
|
assert len(data["subtasks"]) == 1
|
|
assert data["subtasks"][0]["description"] == "Surface roughness"
|
|
|
|
async def test_create_task_on_version_with_measurements_has_recipe_id(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
measurement_tec_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Copy-on-write branch (gitea#4): TaskResponse.recipe_id must not crash."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-CT")
|
|
task = (await client.get(
|
|
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
|
)).json()[0]
|
|
subtask_id = task["subtasks"][0]["id"]
|
|
assert (await _measure(
|
|
client, measurement_tec_user, subtask_id, task["version_id"]
|
|
)).status_code == 200
|
|
|
|
resp = await client.post(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
json={
|
|
"title": "New Task After Measurement",
|
|
"subtasks": [
|
|
{
|
|
"marker_number": 1,
|
|
"description": "Surface roughness",
|
|
"nominal": 5.0,
|
|
"utl": 5.5,
|
|
"ltl": 4.5,
|
|
"unit": "um",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
data = resp.json()
|
|
assert data["recipe_id"] == recipe.id
|
|
assert data["version_id"] != task["version_id"]
|
|
|
|
|
|
class TestUpdateTask:
|
|
"""PUT /api/tasks/{task_id} tests."""
|
|
|
|
async def test_update_task(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Maker can update a task on the current version."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
# Get task list to find the task ID
|
|
tasks_resp = await client.get(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
)
|
|
task_id = tasks_resp.json()[0]["id"]
|
|
|
|
resp = await client.put(
|
|
f"/api/tasks/{task_id}",
|
|
headers=auth_headers(maker_user),
|
|
json={"title": "Updated Task Title"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["title"] == "Updated Task Title"
|
|
|
|
|
|
class TestDeleteTask:
|
|
"""DELETE /api/tasks/{task_id} tests."""
|
|
|
|
async def test_delete_task(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Maker can delete a task from the current version."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
tasks_resp = await client.get(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
)
|
|
task_id = tasks_resp.json()[0]["id"]
|
|
|
|
resp = await client.delete(
|
|
f"/api/tasks/{task_id}", headers=auth_headers(maker_user)
|
|
)
|
|
assert resp.status_code == 204
|
|
|
|
|
|
class TestCreateSubtask:
|
|
"""POST /api/tasks/{task_id}/subtasks tests."""
|
|
|
|
async def test_create_subtask(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Maker can add a subtask to a task."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
tasks_resp = await client.get(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
)
|
|
task_id = tasks_resp.json()[0]["id"]
|
|
|
|
resp = await client.post(
|
|
f"/api/tasks/{task_id}/subtasks",
|
|
headers=auth_headers(maker_user),
|
|
json={
|
|
"marker_number": 2,
|
|
"description": "Width measurement",
|
|
"nominal": 20.0,
|
|
"utl": 20.5,
|
|
"ltl": 19.5,
|
|
"unit": "mm",
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["marker_number"] == 2
|
|
assert data["description"] == "Width measurement"
|
|
|
|
|
|
class TestUpdateSubtask:
|
|
"""PUT /api/subtasks/{subtask_id} tests."""
|
|
|
|
async def test_update_subtask(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Maker can update a subtask."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
tasks_resp = await client.get(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
)
|
|
subtask_id = tasks_resp.json()[0]["subtasks"][0]["id"]
|
|
|
|
resp = await client.put(
|
|
f"/api/subtasks/{subtask_id}",
|
|
headers=auth_headers(maker_user),
|
|
# 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"
|
|
|
|
|
|
class TestReorderTasks:
|
|
"""PUT /api/tasks/reorder tests."""
|
|
|
|
async def test_reorder_tasks(
|
|
self,
|
|
client: AsyncClient,
|
|
maker_user: User,
|
|
db_session: AsyncSession,
|
|
):
|
|
"""Maker can reorder tasks by providing task IDs in desired order."""
|
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
|
|
|
# Add a second task (no measurements → appended to current version)
|
|
add_resp = await client.post(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
json={"title": "Second Task", "subtasks": []},
|
|
)
|
|
assert add_resp.status_code == 201
|
|
|
|
# Force SQLA to re-fetch RecipeVersion.tasks: the shared test session
|
|
# caches the collection from create_test_recipe, so the GET below would
|
|
# otherwise return a stale list. In prod each request gets its own session.
|
|
db_session.expunge_all()
|
|
|
|
tasks_resp = await client.get(
|
|
f"/api/recipes/{recipe.id}/tasks",
|
|
headers=auth_headers(maker_user),
|
|
)
|
|
tasks = tasks_resp.json()
|
|
assert len(tasks) >= 2
|
|
|
|
task_ids = [t["id"] for t in tasks]
|
|
# Reverse the order
|
|
reversed_ids = list(reversed(task_ids))
|
|
|
|
resp = await client.put(
|
|
"/api/tasks/reorder",
|
|
headers=auth_headers(maker_user),
|
|
json={"task_ids": reversed_ids},
|
|
)
|
|
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
|