fix(tests): align stale tests with current API + bypass identity-map cache

- test_recipes: split update test into in-place vs copy-on-write paths
  (PUT now updates in-place when current version has no measurements).
  Added _seed_measurement() helper to force copy-on-write where needed.
- test_tasks::reorder: expunge_all() between POST and GET so the shared
  test session re-fetches RecipeVersion.tasks instead of returning the
  cached collection. Prod uses one session per request, so identity-map
  staleness only affects tests.
- test_measure::save_measurement_proxy: payload aligned to current API
  (version_id is required; pass_fail/deviation are computed server-side).

All 179 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 17:07:32 +02:00
parent 6a8931e108
commit fb96bad000
3 changed files with 85 additions and 25 deletions
+76 -18
View File
@@ -1,12 +1,40 @@
"""Tests for recipes router (/api/recipes).""" """Tests for recipes router (/api/recipes)."""
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe 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: class TestListRecipes:
"""GET /api/recipes tests.""" """GET /api/recipes tests."""
@@ -160,18 +188,13 @@ class TestCreateRecipe:
class TestUpdateRecipe: class TestUpdateRecipe:
"""PUT /api/recipes/{recipe_id} tests.""" """PUT /api/recipes/{recipe_id} tests."""
async def test_update_recipe( async def test_update_recipe_in_place(
self, self,
client: AsyncClient, client: AsyncClient,
maker_user: User, maker_user: User,
db_session: AsyncSession, db_session: AsyncSession,
): ):
"""Updating a recipe creates a new version (copy-on-write). """Updating a recipe without measurements updates current version in-place."""
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.
"""
recipe = await create_test_recipe(db_session, maker_user.id) recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.put( resp = await client.put(
f"/api/recipes/{recipe.id}", f"/api/recipes/{recipe.id}",
@@ -183,7 +206,9 @@ class TestUpdateRecipe:
) )
assert resp.status_code == 200 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( versions_resp = await client.get(
f"/api/recipes/{recipe.id}/versions", f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
@@ -191,8 +216,38 @@ class TestUpdateRecipe:
assert versions_resp.status_code == 200 assert versions_resp.status_code == 200
versions = versions_resp.json() versions = versions_resp.json()
version_numbers = [v["version_number"] for v in versions] version_numbers = [v["version_number"] for v in versions]
assert 1 in version_numbers assert version_numbers == [1]
assert 2 in version_numbers
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: class TestDeleteRecipe:
@@ -222,31 +277,34 @@ class TestRecipeVersioning:
maker_user: User, maker_user: User,
db_session: AsyncSession, 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) 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( await client.put(
f"/api/recipes/{recipe.id}", f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
json={"change_notes": "Version 2"}, 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( await client.put(
f"/api/recipes/{recipe.id}", f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
json={"change_notes": "Version 3"}, json={"change_notes": "Version 3"},
) )
db_session.expunge_all()
# List versions
resp = await client.get( resp = await client.get(
f"/api/recipes/{recipe.id}/versions", f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
) )
assert resp.status_code == 200 assert resp.status_code == 200
versions = resp.json() versions = resp.json()
version_numbers = [v["version_number"] for v in versions] version_numbers = sorted(v["version_number"] for v in versions)
assert 1 in version_numbers assert version_numbers == [1, 2, 3]
assert 2 in version_numbers
assert 3 in version_numbers
+6 -2
View File
@@ -189,7 +189,7 @@ class TestReorderTasks:
"""Maker can reorder tasks by providing task IDs in desired order.""" """Maker can reorder tasks by providing task IDs in desired order."""
recipe = await create_test_recipe(db_session, maker_user.id) 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( add_resp = await client.post(
f"/api/recipes/{recipe.id}/tasks", f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
@@ -197,7 +197,11 @@ class TestReorderTasks:
) )
assert add_resp.status_code == 201 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( tasks_resp = await client.get(
f"/api/recipes/{recipe.id}/tasks", f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
+3 -5
View File
@@ -61,7 +61,7 @@ class TestSaveMeasurement:
mock_api_client.post.return_value = { mock_api_client.post.return_value = {
"id": 1, "id": 1,
"subtask_id": 10, "subtask_id": 10,
"task_id": 5, "version_id": 3,
"value": 9.95, "value": 9.95,
"pass_fail": "pass", "pass_fail": "pass",
} }
@@ -70,10 +70,8 @@ class TestSaveMeasurement:
"/measure/save-measurement", "/measure/save-measurement",
json={ json={
"subtask_id": 10, "subtask_id": 10,
"task_id": 5, "version_id": 3,
"value": 9.95, "value": 9.95,
"pass_fail": "pass",
"deviation": 0.05,
}, },
content_type="application/json", content_type="application/json",
) )
@@ -86,7 +84,7 @@ class TestSaveMeasurement:
"""Missing required fields return 400.""" """Missing required fields return 400."""
resp = logged_in_client.post( resp = logged_in_client.post(
"/measure/save-measurement", "/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", content_type="application/json",
) )
assert resp.status_code == 400 assert resp.status_code == 400