|
| 1 | +"""POST /v1|/v2/files answers 400 for a file type the pipeline cannot process, never 500. |
| 2 | +
|
| 3 | +Live prod signature (backend image c5b0b5d, api.omi.me): |
| 4 | + * ``PIL.UnidentifiedImageError: cannot identify image file '/tmp/<uuid>_<name>.heic'`` |
| 5 | + - mimetypes maps .heic to image/heic, so File.is_image() is true and generate_thumbnail() |
| 6 | + runs, but Pillow ships no HEIF decoder. An iPhone camera-roll photo 500'd. |
| 7 | + * ``openai.BadRequestError: Invalid extension ogg`` from openai.files.create - the provider's |
| 8 | + accepted-extension list has no audio formats. An .ogg voice note 500'd. |
| 9 | +
|
| 10 | +Both are ordinary user input arriving over a public route, so both belong on the 4xx side of the |
| 11 | +request boundary. The route is driven over HTTP here (real routers.chat, real utils.other.chat_file) |
| 12 | +so the assertion is the status code the client actually receives. |
| 13 | +""" |
| 14 | + |
| 15 | +import sys |
| 16 | +from types import ModuleType, SimpleNamespace |
| 17 | +from unittest.mock import MagicMock |
| 18 | + |
| 19 | +import openai |
| 20 | +import pytest |
| 21 | +from fastapi import FastAPI |
| 22 | +from fastapi.testclient import TestClient |
| 23 | + |
| 24 | +from tests.unit import _chat_router_test_harness as harness |
| 25 | +from tests.unit._chat_router_test_harness import BACKEND_DIR |
| 26 | + |
| 27 | +# A one-byte payload with a .heic name: mimetypes keys off the extension, and Pillow fails to |
| 28 | +# identify the bytes exactly as it does for real HEIC input. |
| 29 | +HEIC_BYTES = b'\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1' |
| 30 | +OGG_BYTES = b'OggS\x00\x02' + b'\x00' * 20 |
| 31 | + |
| 32 | + |
| 33 | +def _make_chat_client(): |
| 34 | + saved = {k: v for k, v in sys.modules.items()} |
| 35 | + |
| 36 | + harness.install_package('models', BACKEND_DIR / 'models') |
| 37 | + harness.install_package('database', BACKEND_DIR / 'database') |
| 38 | + harness.install_package('utils', BACKEND_DIR / 'utils') |
| 39 | + harness.install_package('utils.other', BACKEND_DIR / 'utils' / 'other') |
| 40 | + harness.install_package('utils.sync', BACKEND_DIR / 'utils' / 'sync') |
| 41 | + harness.install_package('utils.stt', BACKEND_DIR / 'utils' / 'stt') |
| 42 | + harness.install_package('utils.llm', BACKEND_DIR / 'utils' / 'llm') |
| 43 | + harness.install_package('utils.retrieval', BACKEND_DIR / 'utils' / 'retrieval') |
| 44 | + |
| 45 | + harness.wire_common_stubs(harness.install_module) |
| 46 | + harness.install_module('models.app') |
| 47 | + |
| 48 | + # The gateway telemetry chat_file imports is out of scope here (and pulls the real gateway |
| 49 | + # client stack in); keep it inert so the upload path itself is what runs. |
| 50 | + gateway_client = harness.install_module('utils.llm.gateway_client', ModuleType('utils.llm.gateway_client')) |
| 51 | + gateway_client.should_route_features_through_gateway = MagicMock(return_value=False) |
| 52 | + gateway_obs = harness.install_module( |
| 53 | + 'utils.llm.gateway_observability', ModuleType('utils.llm.gateway_observability') |
| 54 | + ) |
| 55 | + gateway_obs.record_direct_exception_surface = MagicMock() |
| 56 | + |
| 57 | + # wire_common_stubs replaces chat_file with a MagicMock; this suite needs the real module, |
| 58 | + # because the defect lives in its PIL and provider error handling. |
| 59 | + harness.load_real_module('utils.other.chat_file', BACKEND_DIR / 'utils' / 'other' / 'chat_file.py') |
| 60 | + |
| 61 | + chat_utils = harness.install_module('utils.chat', ModuleType('utils.chat')) |
| 62 | + for name in ( |
| 63 | + 'acquire_chat_session', |
| 64 | + 'emit_stream_error_fallback', |
| 65 | + 'initial_message_util', |
| 66 | + 'process_voice_message_segment_stream', |
| 67 | + 'resolve_voice_message_language', |
| 68 | + 'transcribe_voice_message_segment', |
| 69 | + 'transcribe_pcm_bytes', |
| 70 | + ): |
| 71 | + setattr(chat_utils, name, MagicMock()) |
| 72 | + |
| 73 | + graph = harness.install_module('utils.retrieval.graph', ModuleType('utils.retrieval.graph')) |
| 74 | + graph.execute_chat_stream = MagicMock() |
| 75 | + graph.execute_graph_chat = MagicMock() |
| 76 | + graph.execute_persona_chat_stream = MagicMock() |
| 77 | + |
| 78 | + sys.modules.pop('routers.chat', None) |
| 79 | + module = harness.load_real_module('routers.chat', BACKEND_DIR / 'routers' / 'chat.py') |
| 80 | + |
| 81 | + app = FastAPI() |
| 82 | + app.include_router(module.router) |
| 83 | + return TestClient(app), module, saved |
| 84 | + |
| 85 | + |
| 86 | +@pytest.fixture |
| 87 | +def chat_client(): |
| 88 | + client, module, saved = _make_chat_client() |
| 89 | + try: |
| 90 | + yield client, module |
| 91 | + finally: |
| 92 | + harness.cleanup(saved) |
| 93 | + |
| 94 | + |
| 95 | +def _bad_request(**_kwargs): |
| 96 | + raise openai.BadRequestError( |
| 97 | + message="Error code: 400 - Invalid extension ogg.", |
| 98 | + response=SimpleNamespace(request=None, status_code=400, headers={}), |
| 99 | + body=None, |
| 100 | + ) |
| 101 | + |
| 102 | + |
| 103 | +@pytest.mark.parametrize('route', ['/v2/files', '/v1/files']) |
| 104 | +def test_heic_photo_is_rejected_as_bad_request(chat_client, route, monkeypatch): |
| 105 | + client, module = chat_client |
| 106 | + chat_file = sys.modules['utils.other.chat_file'] |
| 107 | + # Nothing should reach the provider: the failure is local, in thumbnail generation. |
| 108 | + monkeypatch.setattr(chat_file.openai, 'files', SimpleNamespace(create=_unreachable)) |
| 109 | + |
| 110 | + response = client.post(route, files={'files': ('photo.heic', HEIC_BYTES, 'image/heic')}) |
| 111 | + |
| 112 | + assert response.status_code == 400 |
| 113 | + assert 'heic' in response.json()['detail'] |
| 114 | + module.chat_db.add_multi_files.assert_not_called() |
| 115 | + |
| 116 | + |
| 117 | +@pytest.mark.parametrize('route', ['/v2/files', '/v1/files']) |
| 118 | +def test_provider_rejected_extension_is_bad_request(chat_client, route, monkeypatch): |
| 119 | + client, module = chat_client |
| 120 | + chat_file = sys.modules['utils.other.chat_file'] |
| 121 | + monkeypatch.setattr(chat_file.openai, 'files', SimpleNamespace(create=_bad_request)) |
| 122 | + |
| 123 | + response = client.post(route, files={'files': ('note.ogg', OGG_BYTES, 'audio/ogg')}) |
| 124 | + |
| 125 | + assert response.status_code == 400 |
| 126 | + assert 'ogg' in response.json()['detail'] |
| 127 | + module.chat_db.add_multi_files.assert_not_called() |
| 128 | + |
| 129 | + |
| 130 | +def test_supported_file_still_uploads(chat_client, monkeypatch): |
| 131 | + """The guard must not swallow the happy path.""" |
| 132 | + client, module = chat_client |
| 133 | + chat_file = sys.modules['utils.other.chat_file'] |
| 134 | + monkeypatch.setattr( |
| 135 | + chat_file.openai, |
| 136 | + 'files', |
| 137 | + SimpleNamespace(create=lambda **_kwargs: SimpleNamespace(id='file-1', filename='note.txt')), |
| 138 | + ) |
| 139 | + |
| 140 | + response = client.post('/v2/files', files={'files': ('note.txt', b'hello', 'text/plain')}) |
| 141 | + |
| 142 | + assert response.status_code == 200 |
| 143 | + assert response.json()[0]['openai_file_id'] == 'file-1' |
| 144 | + module.chat_db.add_multi_files.assert_called_once() |
| 145 | + |
| 146 | + |
| 147 | +def _unreachable(**_kwargs): |
| 148 | + raise AssertionError('provider upload must not be attempted for an undecodable image') |
0 commit comments