Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb96bad000 | |||
| 6a8931e108 |
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user