test_spoolman_slot_concurrency.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  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. # #1457: assign route enumerates spools to clear stale fallback-tag links.
  54. client.get_spools = AsyncMock(return_value=[])
  55. client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
  56. with patch(
  57. "backend.app.api.routes.spoolman_inventory._get_client",
  58. AsyncMock(return_value=client),
  59. ):
  60. yield client
  61. class TestSlotAssignmentConcurrency:
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_concurrent_assign_same_slot_idempotent(
  65. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  66. ):
  67. """Concurrent POST requests for the same slot must not produce duplicate rows."""
  68. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  69. payload = {
  70. "spoolman_spool_id": 10,
  71. "printer_id": test_printer.id,
  72. "ams_id": 0,
  73. "tray_id": 0,
  74. }
  75. async def assign():
  76. return await async_client.post(
  77. "/api/v1/spoolman/inventory/slot-assignments",
  78. json=payload,
  79. )
  80. responses = await asyncio.gather(assign(), assign(), assign())
  81. for resp in responses:
  82. assert resp.status_code == 200
  83. # Exactly one row for this (printer, ams, tray) combination
  84. result = await db_session.execute(
  85. select(SpoolmanSlotAssignment).where(
  86. SpoolmanSlotAssignment.printer_id == test_printer.id,
  87. SpoolmanSlotAssignment.ams_id == 0,
  88. SpoolmanSlotAssignment.tray_id == 0,
  89. )
  90. )
  91. rows = result.scalars().all()
  92. assert len(rows) == 1
  93. assert rows[0].spoolman_spool_id == 10
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_reassign_slot_updates_spool_id(
  97. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  98. ):
  99. """Re-assigning a slot to a different spool updates the existing row."""
  100. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  101. base = {"printer_id": test_printer.id, "ams_id": 1, "tray_id": 2}
  102. resp1 = await async_client.post(
  103. "/api/v1/spoolman/inventory/slot-assignments",
  104. json={**base, "spoolman_spool_id": 10},
  105. )
  106. assert resp1.status_code == 200
  107. # Re-assign same slot to a different spool
  108. mock_client.get_spool.return_value = {**SAMPLE_SPOOL, "id": 20}
  109. resp2 = await async_client.post(
  110. "/api/v1/spoolman/inventory/slot-assignments",
  111. json={**base, "spoolman_spool_id": 20},
  112. )
  113. assert resp2.status_code == 200
  114. # Only one row; spool_id updated to 20
  115. result = await db_session.execute(
  116. select(SpoolmanSlotAssignment).where(
  117. SpoolmanSlotAssignment.printer_id == test_printer.id,
  118. SpoolmanSlotAssignment.ams_id == 1,
  119. SpoolmanSlotAssignment.tray_id == 2,
  120. )
  121. )
  122. rows = result.scalars().all()
  123. assert len(rows) == 1
  124. assert rows[0].spoolman_spool_id == 20