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