test_locations_api.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. """Integration tests for /inventory/locations (#1004)."""
  2. import pytest
  3. from httpx import AsyncClient
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.models.location import Location
  6. from backend.app.services.location_service import assign_location_name
  7. @pytest.mark.asyncio
  8. @pytest.mark.integration
  9. async def test_locations_crud_and_spool_link(async_client: AsyncClient, db_session: AsyncSession):
  10. create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})
  11. assert create_resp.status_code == 201
  12. loc = create_resp.json()
  13. assert loc["name"] == "Shelf A"
  14. assert loc["spool_count"] == 0
  15. dup_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "shelf a"})
  16. assert dup_resp.status_code == 409
  17. spool_resp = await async_client.post(
  18. "/api/v1/inventory/spools",
  19. json={"material": "PLA", "location_id": loc["id"]},
  20. )
  21. assert spool_resp.status_code == 200
  22. spool = spool_resp.json()
  23. assert spool["location_id"] == loc["id"]
  24. assert spool["storage_location"] == "Shelf A"
  25. list_resp = await async_client.get("/api/v1/inventory/locations")
  26. assert list_resp.status_code == 200
  27. listed = {item["id"]: item for item in list_resp.json()}
  28. assert listed[loc["id"]]["spool_count"] == 1
  29. delete_resp = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
  30. assert delete_resp.status_code == 409
  31. clear_resp = await async_client.patch(
  32. f"/api/v1/inventory/spools/{spool['id']}",
  33. json={"location_id": None},
  34. )
  35. assert clear_resp.status_code == 200
  36. delete_resp2 = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
  37. assert delete_resp2.status_code == 200
  38. @pytest.mark.asyncio
  39. @pytest.mark.integration
  40. async def test_list_locations_sorts_naturally_not_lexicographically(async_client: AsyncClient):
  41. # Created out of order and with a name-shape ("Drybox N") that a plain
  42. # ORDER BY name would sort as "Drybox 1", "Drybox 10", "Drybox 2".
  43. for name in ["Drybox 10", "Drybox 2", "Drybox 1", "Shelf A"]:
  44. resp = await async_client.post("/api/v1/inventory/locations", json={"name": name})
  45. assert resp.status_code == 201, resp.text
  46. list_resp = await async_client.get("/api/v1/inventory/locations")
  47. assert list_resp.status_code == 200
  48. names = [loc["name"] for loc in list_resp.json()]
  49. assert names == ["Drybox 1", "Drybox 2", "Drybox 10", "Shelf A"]
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. async def test_rename_location_updates_spool_count(async_client: AsyncClient):
  53. create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Old Name"})
  54. loc = create_resp.json()
  55. await async_client.post(
  56. "/api/v1/inventory/spools",
  57. json={"material": "PLA", "location_id": loc["id"]},
  58. )
  59. list_before = await async_client.get("/api/v1/inventory/locations")
  60. by_id = {item["id"]: item for item in list_before.json()}
  61. assert by_id[loc["id"]]["spool_count"] == 1
  62. rename_resp = await async_client.patch(
  63. f"/api/v1/inventory/locations/{loc['id']}",
  64. json={"name": "New Name"},
  65. )
  66. assert rename_resp.status_code == 200
  67. assert rename_resp.json()["name"] == "New Name"
  68. assert rename_resp.json()["spool_count"] == 1
  69. @pytest.mark.asyncio
  70. @pytest.mark.integration
  71. async def test_rename_location_collision_returns_409(async_client: AsyncClient):
  72. first = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})
  73. second = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf B"})
  74. assert first.status_code == 201
  75. assert second.status_code == 201
  76. collision = await async_client.patch(
  77. f"/api/v1/inventory/locations/{second.json()['id']}",
  78. json={"name": "Shelf A"},
  79. )
  80. assert collision.status_code == 409
  81. assert collision.json()["detail"] == "A location with this name already exists"
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_create_location_duplicate_after_commit_returns_409(async_client: AsyncClient):
  85. """Second create with the same name_key must return 409, not 500."""
  86. first = await async_client.post("/api/v1/inventory/locations", json={"name": "Race Shelf"})
  87. second = await async_client.post("/api/v1/inventory/locations", json={"name": "race shelf"})
  88. assert first.status_code == 201
  89. assert second.status_code == 409
  90. assert second.json()["detail"] == "A location with this name already exists"
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_list_locations_is_read_only(async_client: AsyncClient, db_session: AsyncSession):
  94. """GET /locations is a pure read — no catalog rows appear without explicit writes."""
  95. from sqlalchemy import func, select
  96. loc = Location()
  97. assign_location_name(loc, "Local Only")
  98. db_session.add(loc)
  99. await db_session.commit()
  100. before = await db_session.scalar(select(func.count()).select_from(Location))
  101. resp = await async_client.get("/api/v1/inventory/locations")
  102. after = await db_session.scalar(select(func.count()).select_from(Location))
  103. assert resp.status_code == 200
  104. assert len(resp.json()) == 1
  105. assert before == after == 1
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_update_location_404_on_unknown_id(async_client: AsyncClient):
  109. resp = await async_client.patch(
  110. "/api/v1/inventory/locations/99999",
  111. json={"name": "Ghost"},
  112. )
  113. assert resp.status_code == 404
  114. assert resp.json()["detail"] == "Location not found"
  115. @pytest.mark.asyncio
  116. @pytest.mark.integration
  117. async def test_delete_location_404_on_unknown_id(async_client: AsyncClient):
  118. resp = await async_client.delete("/api/v1/inventory/locations/99999")
  119. assert resp.status_code == 404
  120. assert resp.json()["detail"] == "Location not found"
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_locations_routes_require_auth_when_enabled(async_client: AsyncClient):
  124. """All five /locations endpoints must return 401 when auth is enabled and
  125. no credentials are presented. Mirror of the pattern from
  126. test_queue_start_user_attribution._enable_auth_with_admin — required by
  127. project policy: every permission-gated route gets a fail-closed test on
  128. first ship, no follow-ups (the two CVSS 9.8/9.9 advisories shipped from
  129. this exact gap)."""
  130. await async_client.post(
  131. "/api/v1/auth/setup",
  132. json={
  133. "auth_enabled": True,
  134. "admin_username": "locations1505admin",
  135. "admin_password": "AdminPass1!",
  136. },
  137. )
  138. # GET /locations — read-gated
  139. list_resp = await async_client.get("/api/v1/inventory/locations")
  140. assert list_resp.status_code == 401, list_resp.text
  141. # POST /locations — write-gated
  142. create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Locked"})
  143. assert create_resp.status_code == 401, create_resp.text
  144. # PATCH /locations/{id} — write-gated. Use a synthetic id; the auth gate
  145. # runs before the not-found check, so 401 is the correct expectation even
  146. # when the id doesn't exist.
  147. patch_resp = await async_client.patch("/api/v1/inventory/locations/99999", json={"name": "Locked2"})
  148. assert patch_resp.status_code == 401, patch_resp.text
  149. # DELETE /locations/{id} — write-gated
  150. delete_resp = await async_client.delete("/api/v1/inventory/locations/99999")
  151. assert delete_resp.status_code == 401, delete_resp.text