from datetime import datetime, timezone from types import SimpleNamespace import pytest from banban.dao.im import ConversationMessageCreateResult from banban.schemas.im import ChildConversationMessageItem from banban.service.im import ImService from banban.service.message_audio_storage import StoredMessageAudio from utils.audio_transcode import infer_audio_source_format, prepare_audio_as_mp3 def test_infer_audio_source_format_distinguishes_aac_from_mp3_frame_sync(): assert infer_audio_source_format(b"\xff\xf1P\x80\x00\x1f\xfc") == "aac" assert infer_audio_source_format(b"\xff\xfb\x90\x64" + b"\x00" * 32) == "mp3" @pytest.mark.asyncio async def test_prepare_audio_as_mp3_keeps_mp3_without_transcoding(monkeypatch): async def fail_transcode(*args, **kwargs): raise AssertionError("mp3 upload should not be transcoded") monkeypatch.setattr("utils.audio_transcode.transcode_audio_to_mp3", fail_transcode) original = b"ID3" + b"\x00" * 32 prepared = await prepare_audio_as_mp3( original, filename="voice.aac", content_type="audio/aac", ) assert prepared.content == original assert prepared.mime_type == "audio/mpeg" assert prepared.extension == "mp3" assert prepared.size_bytes == len(original) assert prepared.source_format == "mp3" assert prepared.transcoded is False @pytest.mark.asyncio async def test_prepare_audio_as_mp3_transcodes_aac_upload(monkeypatch): calls = [] async def fake_transcode(audio_data, **kwargs): calls.append({"audio_data": audio_data, **kwargs}) return b"ID3 converted" monkeypatch.setattr("utils.audio_transcode.transcode_audio_to_mp3", fake_transcode) original = b"\xff\xf1P\x80\x00\x1f\xfc" prepared = await prepare_audio_as_mp3( original, filename="voice.aac", content_type="audio/aac", ) assert prepared.content == b"ID3 converted" assert prepared.mime_type == "audio/mpeg" assert prepared.extension == "mp3" assert prepared.size_bytes == len(b"ID3 converted") assert prepared.source_format == "aac" assert prepared.original_mime_type == "audio/aac" assert prepared.original_extension == "aac" assert prepared.transcoded is True assert calls == [ { "audio_data": original, "source_format": "aac", "source_suffix": ".aac", "session_device_id": "system", "session_id": "audio_transcode", } ] @pytest.mark.asyncio async def test_parent_voice_upload_stores_and_queues_mp3_for_device(monkeypatch): service = ImService() uploaded = [] pending_messages = [] duration_jobs = [] async def fake_assert_parent_child_access(**kwargs): return {"child_id": kwargs["child_id"], "child_name": "child"} async def fake_get_child_binding(child_id, parent_user_id): return {"device_id": "TalkingQ_XQSN00001007"} async def fake_upload_audio(**kwargs): uploaded.append(kwargs) return StoredMessageAudio( file_key="messages/audio/parent-4/2026/06/11/converted.mp3", public_url="https://cos.example/messages/audio/parent-4/converted.mp3", ) async def fake_create_parent_child_message(*, parent_user_id, child_id, payload): message = ChildConversationMessageItem( id=181, conversation_id=11, seq=1, sender_type="parent", sender_id=str(parent_user_id), receiver_type="child", receiver_id=str(child_id), content_type=payload.content_type, content_text=payload.content_text, content_json=payload.content_json, media_file_key=payload.media_file_key, media_duration_ms=payload.media_duration_ms, media_mime_type=payload.media_mime_type, media_size_bytes=payload.media_size_bytes, media_transcript_text=payload.media_transcript_text, client_msg_id=payload.client_msg_id, sender_name_snapshot="parent", sender_avatar_snapshot=None, receiver_name_snapshot="child", receiver_avatar_snapshot=None, ext_json=payload.ext_json, created_at=datetime.now(timezone.utc), ) return ConversationMessageCreateResult( idempotent=False, conversation_id=11, conversation_type=2, message=message, ) async def fake_get_raw_message_media_file_key(**kwargs): return "messages/audio/parent-4/2026/06/11/converted.mp3" async def fake_get_device_audio_url(file_key_or_url, *, device_id=None): assert file_key_or_url.endswith(".mp3") assert device_id == "TalkingQ_XQSN00001007" return "http://127.0.0.1:8080/device-audio/converted.mp3" async def fake_add_pending_message(**kwargs): pending_messages.append(kwargs) async def fake_schedule_message_media_duration_parse(**kwargs): duration_jobs.append(kwargs) async def fake_prepare_audio_as_mp3(content, **kwargs): assert content == b"\xff\xf1 aac" assert kwargs["filename"] == "voice.aac" assert kwargs["content_type"] == "audio/aac" return SimpleNamespace( content=b"ID3 converted mp3", mime_type="audio/mpeg", extension="mp3", size_bytes=len(b"ID3 converted mp3"), source_format="aac", original_mime_type="audio/aac", original_extension="aac", transcoded=True, ) monkeypatch.setattr(service, "assert_parent_child_access", fake_assert_parent_child_access) monkeypatch.setattr("banban.service.im.binding_service.get_child_binding", fake_get_child_binding) monkeypatch.setattr(service.audio_storage, "upload_audio", fake_upload_audio) monkeypatch.setattr(service, "create_parent_child_message", fake_create_parent_child_message) monkeypatch.setattr(service, "_get_raw_message_media_file_key", fake_get_raw_message_media_file_key) monkeypatch.setattr("banban.service.im.device_audio_cache_service.get_device_audio_url", fake_get_device_audio_url) monkeypatch.setattr("banban.service.im.pending_voice_message_service.add_pending_message", fake_add_pending_message) monkeypatch.setattr(service, "schedule_message_media_duration_parse", fake_schedule_message_media_duration_parse) monkeypatch.setattr("banban.service.im.prepare_audio_as_mp3", fake_prepare_audio_as_mp3) result = await service.create_parent_child_voice_message( parent_user_id=4, child_id=7, filename="voice.aac", content_type="audio/aac", content=b"\xff\xf1 aac", media_duration_ms=1234, media_transcript_text=None, client_msg_id="client-1", ext_json={ "message_kind": "leave_message", "source": "parent_weapp_voice", }, ) assert result.message.media_file_key.endswith(".mp3") assert result.message.media_mime_type == "audio/mpeg" assert result.message.media_size_bytes == len(b"ID3 converted mp3") assert result.message.ext_json["message_kind"] == "leave_message" assert result.message.ext_json["normalized_audio_format"] == "mp3" assert result.message.ext_json["source_audio_format"] == "aac" assert result.message.ext_json["audio_transcoded"] is True assert uploaded == [ { "device_id": "parent-4", "content": b"ID3 converted mp3", "content_type": "audio/mpeg", "extension": "mp3", } ] assert pending_messages == [ { "target_device_id": "TalkingQ_XQSN00001007", "sender_device_id": None, "im_message_id": 181, "media_file_key": "messages/audio/parent-4/2026/06/11/converted.mp3", "audio_url": "http://127.0.0.1:8080/device-audio/converted.mp3", "source": "parent_child_voice", } ] assert duration_jobs == [ { "message_id": 181, "audio_data": b"ID3 converted mp3", "mime_type": "audio/mpeg", "source": "parent_weapp_voice", } ]