test_spoolman_slot_concurrency.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """T-Gap 3: Concurrency test for POST /slot-assignments upsert+cleanup race."""
  2. import asyncio
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import pytest
  5. from httpx import AsyncClient
  6. from sqlalchemy import select
  7. SAMPLE_SPOOL = {
  8. "id": 10,
  9. "filament": {
  10. "id": 1,
  11. "name": "PLA Basic",
  12. "material": "PLA",
  13. "color_hex": "FF0000",
  14. "weight": 1000,
  15. "vendor": {"id": 1, "name": "Test Brand"},
  16. },
  17. "remaining_weight": 800.0,
  18. "used_weight": 200.0,
  19. "location": None,
  20. "comment": None,
  21. "first_used": None,
  22. "last_used": None,
  23. "registered": "2024-01-01T00:00:00+00:00",
  24. "archived": False,
  25. "price": None,
  26. "extra": {},
  27. }
  28. @pytest.fixture
  29. async def slot_settings(db_session):
  30. from backend.app.models.settings import Settings
  31. db_session.add(Settings(key="spoolman_enabled", value="true"))
  32. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  33. await db_session.commit()
  34. @pytest.fixture
  35. async def test_printer(db_session):
  36. from backend.app.models.printer import Printer
  37. printer = Printer(
  38. name="Concurrency Test Printer",
  39. serial_number="CONCTEST001",
  40. ip_address="192.168.1.99",
  41. access_code="12345678",
  42. )
  43. db_session.add(printer)
  44. await db_session.commit()
  45. await db_session.refresh(printer)
  46. return printer
  47. @pytest.fixture
  48. def mock_client():
  49. client = MagicMock()
  50. client.base_url = "http://localhost:7912"
  51. client.health_check = AsyncMock(return_value=True)
  52. client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
  53. with patch(
  54. "backend.app.api.routes.spoolman_inventory._get_client",
  55. AsyncMock(return_value=client),
  56. ):
  57. yield client
  58. class TestSlotAssignmentConcurrency:
  59. @pytest.mark.asyncio
  60. @pytest.mark.integration
  61. async def test_concurrent_assign_same_slot_idempotent(
  62. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  63. ):
  64. """Concurrent POST requests for the same slot must not produce duplicate rows."""
  65. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  66. payload = {
  67. "spoolman_spool_id": 10,
  68. "printer_id": test_printer.id,
  69. "ams_id": 0,
  70. "tray_id": 0,
  71. }
  72. async def assign():
  73. return await async_client.post(
  74. "/api/v1/spoolman/inventory/slot-assignments",
  75. json=payload,
  76. )
  77. responses = await asyncio.gather(assign(), assign(), assign())
  78. for resp in responses:
  79. assert resp.status_code == 200
  80. # Exactly one row for this (printer, ams, tray) combination
  81. result = await db_session.execute(
  82. select(SpoolmanSlotAssignment).where(
  83. SpoolmanSlotAssignment.printer_id == test_printer.id,
  84. SpoolmanSlotAssignment.ams_id == 0,
  85. SpoolmanSlotAssignment.tray_id == 0,
  86. )
  87. )
  88. rows = result.scalars().all()
  89. assert len(rows) == 1
  90. assert rows[0].spoolman_spool_id == 10
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_reassign_slot_updates_spool_id(
  94. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  95. ):
  96. """Re-assigning a slot to a different spool updates the existing row."""
  97. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  98. base = {"printer_id": test_printer.id, "ams_id": 1, "tray_id": 2}
  99. resp1 = await async_client.post(
  100. "/api/v1/spoolman/inventory/slot-assignments",
  101. json={**base, "spoolman_spool_id": 10},
  102. )
  103. assert resp1.status_code == 200
  104. # Re-assign same slot to a different spool
  105. mock_client.get_spool.return_value = {**SAMPLE_SPOOL, "id": 20}
  106. resp2 = await async_client.post(
  107. "/api/v1/spoolman/inventory/slot-assignments",
  108. json={**base, "spoolman_spool_id": 20},
  109. )
  110. assert resp2.status_code == 200
  111. # Only one row; spool_id updated to 20
  112. result = await db_session.execute(
  113. select(SpoolmanSlotAssignment).where(
  114. SpoolmanSlotAssignment.printer_id == test_printer.id,
  115. SpoolmanSlotAssignment.ams_id == 1,
  116. SpoolmanSlotAssignment.tray_id == 2,
  117. )
  118. )
  119. rows = result.scalars().all()
  120. assert len(rows) == 1
  121. assert rows[0].spoolman_spool_id == 20