test_commit_before_response_3320.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. """A write is committed before its response is sent (#3320).
  2. ``get_db`` commits after ``yield``. With FastAPI's default ``scope="request"``
  3. that code runs after ``await response(...)``: the client had its 200 while the
  4. row was still uncommitted, so a read issued straight after it could 404, and a
  5. commit that failed was rolled back behind a 200 the client already had.
  6. An httpx client only returns once the whole ASGI call has finished, so it
  7. cannot see the order. These tests drive the app directly and look at the
  8. database at the moment the response starts.
  9. """
  10. import json
  11. import pytest
  12. from sqlalchemy import select
  13. from sqlalchemy.exc import OperationalError
  14. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  15. from backend.app.core.database import Base, get_db
  16. from backend.app.main import app
  17. from backend.app.models.project import Project
  18. async def _call(method: str, path: str, body: dict, on_start) -> list[dict]:
  19. """Run one HTTP request through the ASGI app; ``on_start`` runs as the response starts."""
  20. payload = json.dumps(body).encode()
  21. scope = {
  22. "type": "http",
  23. "asgi": {"version": "3.0"},
  24. "http_version": "1.1",
  25. "method": method,
  26. "scheme": "http",
  27. "path": path,
  28. "raw_path": path.encode(),
  29. "query_string": b"",
  30. "root_path": "",
  31. "headers": [(b"host", b"test"), (b"content-type", b"application/json")],
  32. "client": ("127.0.0.1", 1234),
  33. "server": ("test", 80),
  34. }
  35. sent = False
  36. async def receive():
  37. nonlocal sent
  38. if sent:
  39. return {"type": "http.disconnect"}
  40. sent = True
  41. return {"type": "http.request", "body": payload, "more_body": False}
  42. messages: list[dict] = []
  43. async def send(message):
  44. if message["type"] == "http.response.start":
  45. await on_start(message)
  46. messages.append(message)
  47. await app(scope, receive, send)
  48. return messages
  49. @pytest.fixture
  50. async def file_engine(async_client, tmp_path, monkeypatch):
  51. """The production ``get_db`` on a file database instead of the conftest override.
  52. The shared test engine is in-memory SQLite: one connection, on which a
  53. "fresh" session sees rows nobody committed yet. A file gives every session
  54. its own connection, as a real install has.
  55. """
  56. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'bambuddy.db'}")
  57. async with engine.begin() as conn:
  58. await conn.run_sync(Base.metadata.create_all)
  59. sessions = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  60. monkeypatch.setattr("backend.app.core.database.async_session", sessions)
  61. monkeypatch.setattr("backend.app.core.auth.async_session", sessions)
  62. override = app.dependency_overrides.pop(get_db, None)
  63. yield engine
  64. if override is not None:
  65. app.dependency_overrides[get_db] = override
  66. await engine.dispose()
  67. class TestCommitBeforeResponse:
  68. async def test_new_row_is_committed_when_the_response_starts(self, file_engine):
  69. fresh = async_sessionmaker(file_engine, class_=AsyncSession, expire_on_commit=False)
  70. seen: list[int] = []
  71. async def on_start(message):
  72. assert message["status"] == 200
  73. async with fresh() as session:
  74. rows = (await session.execute(select(Project.id).where(Project.name == "issue-3320"))).all()
  75. seen.append(len(rows))
  76. # create_project only flushes; the commit is get_db's.
  77. await _call("POST", "/api/v1/projects/", {"name": "issue-3320"}, on_start)
  78. assert seen == [1]
  79. async def test_failed_commit_is_an_error_not_a_200(self, file_engine, monkeypatch):
  80. failing = async_sessionmaker(file_engine, class_=AsyncSession, expire_on_commit=False)
  81. async def refuse(self):
  82. raise OperationalError("COMMIT", {}, Exception("database is locked"))
  83. def factory():
  84. session = failing()
  85. monkeypatch.setattr(session, "commit", refuse.__get__(session))
  86. return session
  87. monkeypatch.setattr("backend.app.core.database.async_session", factory)
  88. statuses: list[int] = []
  89. async def on_start(message):
  90. statuses.append(message["status"])
  91. try:
  92. await _call("POST", "/api/v1/projects/", {"name": "issue-3320-locked"}, on_start)
  93. except OperationalError:
  94. pass # surfaced to the server once nothing was sent yet
  95. assert 200 not in statuses