diff --git a/src/backend/tests/test_recipes.py b/src/backend/tests/test_recipes.py index 67173ae..154339c 100644 --- a/src/backend/tests/test_recipes.py +++ b/src/backend/tests/test_recipes.py @@ -1,12 +1,40 @@ """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.""" @@ -160,18 +188,13 @@ class TestCreateRecipe: class TestUpdateRecipe: """PUT /api/recipes/{recipe_id} tests.""" - async def test_update_recipe( + async def test_update_recipe_in_place( self, client: AsyncClient, maker_user: User, db_session: AsyncSession, ): - """Updating a recipe creates a new version (copy-on-write). - - We verify the new version exists by listing versions, since - SQLAlchemy identity-map staleness with StaticPool can cause - ``is_current`` to appear stale in some responses. - """ + """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}", @@ -183,7 +206,9 @@ class TestUpdateRecipe: ) assert resp.status_code == 200 - # Verify via versions list which always does a clean query + # 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), @@ -191,8 +216,38 @@ class TestUpdateRecipe: assert versions_resp.status_code == 200 versions = versions_resp.json() version_numbers = [v["version_number"] for v in versions] - assert 1 in version_numbers - assert 2 in version_numbers + 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 TestDeleteRecipe: @@ -222,31 +277,34 @@ class TestRecipeVersioning: maker_user: User, db_session: AsyncSession, ): - """Multiple updates create consecutive versions.""" + """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 + # 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 + # 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() - # List versions 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 = [v["version_number"] for v in versions] - assert 1 in version_numbers - assert 2 in version_numbers - assert 3 in version_numbers + version_numbers = sorted(v["version_number"] for v in versions) + assert version_numbers == [1, 2, 3] diff --git a/src/backend/tests/test_tasks.py b/src/backend/tests/test_tasks.py index fb27c4b..a63b531 100644 --- a/src/backend/tests/test_tasks.py +++ b/src/backend/tests/test_tasks.py @@ -189,7 +189,7 @@ class TestReorderTasks: """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 via copy-on-write + # 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), @@ -197,7 +197,11 @@ class TestReorderTasks: ) assert add_resp.status_code == 201 - # Get tasks from new version + # 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), diff --git a/src/frontend/flask_app/tests/test_measure.py b/src/frontend/flask_app/tests/test_measure.py index 9992c4b..2eaa664 100644 --- a/src/frontend/flask_app/tests/test_measure.py +++ b/src/frontend/flask_app/tests/test_measure.py @@ -61,7 +61,7 @@ class TestSaveMeasurement: mock_api_client.post.return_value = { "id": 1, "subtask_id": 10, - "task_id": 5, + "version_id": 3, "value": 9.95, "pass_fail": "pass", } @@ -70,10 +70,8 @@ class TestSaveMeasurement: "/measure/save-measurement", json={ "subtask_id": 10, - "task_id": 5, + "version_id": 3, "value": 9.95, - "pass_fail": "pass", - "deviation": 0.05, }, content_type="application/json", ) @@ -86,7 +84,7 @@ class TestSaveMeasurement: """Missing required fields return 400.""" resp = logged_in_client.post( "/measure/save-measurement", - json={"subtask_id": 10}, # missing task_id and value + json={"subtask_id": 10}, # missing version_id and value content_type="application/json", ) assert resp.status_code == 400