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