oidc_icon.py 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """Shared test data and mock builders for OIDC icon tests (#1333).
  2. Cross-imported from ``backend.tests.unit.*`` and ``backend.tests.integration.*``
  3. following the established pattern (see ``backend/tests/unit/services/conftest.py``
  4. which imports ``mock_ftp_server``).
  5. Two mock builders are provided because ``fetch_icon`` evolved from a single
  6. ``client.get(...)`` call into a streaming ``client.stream(...).aiter_bytes()``
  7. loop:
  8. * ``build_get_icon_mock`` — the pre-streaming pattern, kept for tests that
  9. exercise httpx.AsyncClient.get() directly (e.g. routes that use httpx
  10. outside the icon-fetcher).
  11. * ``build_streaming_icon_mock`` — the current ``fetch_icon`` pattern; tests
  12. that exercise the size-cap early-exit need this.
  13. Both produce ``(MockHttpxClient, call_recorder)`` tuples for patching.
  14. """
  15. import hashlib
  16. from types import SimpleNamespace
  17. from unittest.mock import AsyncMock
  18. # Tiny valid 1×1 transparent PNG (~70 bytes) — small enough to fit in one
  19. # 4 KB chunk during streaming tests; suitable for the happy-path everywhere.
  20. PNG_BYTES = bytes.fromhex(
  21. "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
  22. "890000000d49444154789c63000100000005000100"
  23. "0d0a2db40000000049454e44ae426082"
  24. )
  25. PNG_ETAG = hashlib.sha256(PNG_BYTES).hexdigest()
  26. def build_get_icon_mock(
  27. *,
  28. body: bytes = PNG_BYTES,
  29. content_type: str | None = "image/png",
  30. status_code: int = 200,
  31. ):
  32. """Returns ``(MockHttpxClient, mock_get)`` for the pre-streaming pattern.
  33. ``mock_get`` is an ``AsyncMock`` so tests can assert call count and
  34. inspect kwargs (e.g. ``follow_redirects=False``).
  35. Passing ``content_type=None`` produces a response with no Content-Type
  36. header at all (distinct from ``""``) — used to exercise the missing-
  37. header branch.
  38. """
  39. headers: dict[str, str] = {"content-type": content_type} if content_type is not None else {}
  40. response = SimpleNamespace(status_code=status_code, headers=headers, content=body)
  41. mock_get = AsyncMock(return_value=response)
  42. class _MockHttpxClient:
  43. def __init__(self, *_args, **_kwargs):
  44. pass
  45. async def __aenter__(self):
  46. return SimpleNamespace(get=mock_get)
  47. async def __aexit__(self, *_exc):
  48. return False
  49. return _MockHttpxClient, mock_get
  50. def build_streaming_icon_mock(
  51. *,
  52. body: bytes = PNG_BYTES,
  53. content_type: str | None = "image/png",
  54. status_code: int = 200,
  55. chunk_size: int = 4096,
  56. ):
  57. """Returns ``(MockHttpxClient, stream_recorder)`` for the current
  58. ``client.stream("GET", url, follow_redirects=...)`` + ``aiter_bytes()`` path.
  59. ``stream_recorder`` is an ``AsyncMock`` that records every ``.stream()``
  60. call so tests can assert e.g. ``follow_redirects=False`` was passed.
  61. ``body`` is emitted in ``chunk_size``-byte chunks. Tests of the size-cap
  62. early-exit should pick a chunk_size that crosses the cap mid-stream
  63. (e.g. body = 2 MB, chunk_size = 4096 → cap fires after ~256 chunks
  64. without buffering the whole payload).
  65. """
  66. headers: dict[str, str] = {"content-type": content_type} if content_type is not None else {}
  67. stream_recorder = AsyncMock()
  68. async def _aiter_bytes():
  69. for i in range(0, len(body), chunk_size):
  70. yield body[i : i + chunk_size]
  71. response = SimpleNamespace(
  72. status_code=status_code,
  73. headers=headers,
  74. aiter_bytes=_aiter_bytes,
  75. )
  76. class _StreamCtx:
  77. def __init__(self, *args, **kwargs):
  78. # Record the .stream() call positional + keyword args so tests
  79. # can assert `follow_redirects=False` etc.
  80. stream_recorder(*args, **kwargs)
  81. async def __aenter__(self):
  82. return response
  83. async def __aexit__(self, *_exc):
  84. return False
  85. class _MockHttpxClient:
  86. def __init__(self, *_args, **_kwargs):
  87. pass
  88. async def __aenter__(self):
  89. return self
  90. async def __aexit__(self, *_exc):
  91. return False
  92. def stream(self, *args, **kwargs):
  93. return _StreamCtx(*args, **kwargs)
  94. return _MockHttpxClient, stream_recorder