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:
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user