"""Tests for GET /api/calls — phone/voice call log. Seeds 3 calls (2 phone_call for user u1 at 1000/3000, 1 voice_call for u2 at 2000) and asserts filters + ordering (``start_call_time desc``) + pagination. """ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import PhoneCall async def _seed(session: AsyncSession) -> None: session.add_all( [ PhoneCall( call_id="c1", channel="phone_call", user_id="u1", user_id_name="Alice", start_call_time=1000, call_duration=30, call_status=1, incoming_call=0, call_number="111", sim_phone="111", ), PhoneCall( call_id="c2", channel="voice_call", user_id="u2", user_id_name="Bob", start_call_time=2000, call_duration=60, call_status=1, incoming_call=1, ), PhoneCall( call_id="c3", channel="phone_call", user_id="u1", user_id_name="Alice", start_call_time=3000, call_duration=45, call_status=0, incoming_call=0, call_number="111", record_file="https://oss/c3.mp3", ), ] ) await session.commit() async def test_default_list_returns_all_ordered_desc(make_client): """No filters -> 3 rows, ordered by ``start_call_time desc``.""" client = await make_client(_seed) r = await client.get("/api/calls") assert r.status_code == 200 body = r.json() assert body["total"] == 3 assert [it["call_id"] for it in body["items"]] == ["c3", "c2", "c1"] for key in ( "call_id", "channel", "start_call_time", "call_duration", "call_status", "incoming_call", "user_id", "user_id_name", "call_number", "sim_phone", "record_file", "raw_json", ): assert key in body["items"][0] async def test_raw_json_serializes_verbatim(make_client): """``raw_json`` round-trips the verbatim upstream call record. Phone-call and voice-call payloads carry different camelCase shapes; both must come back unmolested so the all-fields drawer can render every column the 云客 通话记录 page shows. Rows without a blob serialize as JSON null. """ async def seed(session: AsyncSession) -> None: await _seed(session) session.add_all( [ PhoneCall( call_id="c_phone_raw", channel="phone_call", user_id="u1", user_id_name="Alice", start_call_time=9000, call_duration=12, call_status=2, incoming_call=0, raw_json={ "id": "c_phone_raw", "startCallTime": 9000, "callDuration": 12, "callSeconds": 12, "ringTime": 3, "callStatus": 2, "incomingCall": 0, "userId": "u1", "userIdName": "Alice", "departmentId": "d1", "callNumber": "13800000000", "simPhone": "13900000000", "simNumber": "1", "imei": "356938035643809", "recordFile": "https://oss/c_phone_raw.mp3", "isUpload": 1, "downloadCount": 2, "aiAsr": "translated text", "qcStatus": 1, "planCustomerName": "Carol", "version": "v1", }, ), PhoneCall( call_id="c_voice_raw", channel="voice_call", user_id="u2", user_id_name="Bob", start_call_time=9500, call_duration=20, raw_json={ "ossFileName": "voice/c_voice_raw.mp3", "duration": 20, "startTime": 9500, "endTime": 9520, "remark": "voice memo", "userId": "u2", }, ), ] ) await session.commit() client = await make_client(seed) r = await client.get("/api/calls", params={"start": 9000}) assert r.status_code == 200 by_id = {it["call_id"]: it for it in r.json()["items"]} assert by_id["c_phone_raw"]["raw_json"] == { "id": "c_phone_raw", "startCallTime": 9000, "callDuration": 12, "callSeconds": 12, "ringTime": 3, "callStatus": 2, "incomingCall": 0, "userId": "u1", "userIdName": "Alice", "departmentId": "d1", "callNumber": "13800000000", "simPhone": "13900000000", "simNumber": "1", "imei": "356938035643809", "recordFile": "https://oss/c_phone_raw.mp3", "isUpload": 1, "downloadCount": 2, "aiAsr": "translated text", "qcStatus": 1, "planCustomerName": "Carol", "version": "v1", } # Voice-call payload shape round-trips too. assert by_id["c_voice_raw"]["raw_json"] == { "ossFileName": "voice/c_voice_raw.mp3", "duration": 20, "startTime": 9500, "endTime": 9520, "remark": "voice memo", "userId": "u2", } # Seeded without an explicit raw_json -> JSON null. assert by_id.get("c3", {}).get("raw_json") is None async def test_filter_by_user_id(make_client): """``?user_id=u1`` -> 2 calls for Alice.""" client = await make_client(_seed) r = await client.get("/api/calls", params={"user_id": "u1"}) assert r.status_code == 200 body = r.json() assert body["total"] == 2 assert {it["call_id"] for it in body["items"]} == {"c1", "c3"} async def test_filter_by_channel_voice_call(make_client): """``?channel=voice_call`` -> the single voice call.""" client = await make_client(_seed) r = await client.get("/api/calls", params={"channel": "voice_call"}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert body["items"][0]["call_id"] == "c2" async def test_time_window_filter(make_client): """``?start=1500&end=2500`` -> only c2 (start 2000).""" client = await make_client(_seed) r = await client.get("/api/calls", params={"start": 1500, "end": 2500}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert body["items"][0]["call_id"] == "c2" async def test_filters_compose(make_client): """``user_id=u1 & start=1500`` -> only c3 (u1's later call).""" client = await make_client(_seed) r = await client.get( "/api/calls", params={"user_id": "u1", "start": 1500} ) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert body["items"][0]["call_id"] == "c3" async def test_pagination(make_client): """``page_size=1`` -> 1 item, total 3.""" client = await make_client(_seed) r = await client.get("/api/calls", params={"page": 1, "page_size": 1}) assert r.status_code == 200 body = r.json() assert body["total"] == 3 assert len(body["items"]) == 1 assert body["items"][0]["call_id"] == "c3" async def test_record_file_serializes(make_client): """``record_file`` (OSS recording URL) round-trips so the Calls view can render a 播放录音 action — present when set, None when unset. """ client = await make_client(_seed) r = await client.get("/api/calls") assert r.status_code == 200 by_id = {it["call_id"]: it for it in r.json()["items"]} # c3 was seeded with a recording URL. assert by_id["c3"]["record_file"] == "https://oss/c3.mp3" # c1/c2 have no record_file set -> None. assert by_id["c1"]["record_file"] is None assert by_id["c2"]["record_file"] is None async def test_record_media_hash_serializes(make_client): """``record_media_hash`` (sha256 of the localized recording) round-trips so the frontend can detect already-downloaded files — present when set, None until the sync pass localizes the recording. """ async def seed(session: AsyncSession) -> None: await _seed(session) session.add( PhoneCall( call_id="c_hash", channel="phone_call", user_id="u1", user_id_name="Alice", start_call_time=8000, call_duration=10, call_status=1, incoming_call=0, record_media_hash="abc123", ) ) await session.commit() client = await make_client(seed) r = await client.get("/api/calls", params={"user_id": "u1", "start": 8000}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert body["items"][0]["call_id"] == "c_hash" assert body["items"][0]["record_media_hash"] == "abc123" # Rows without an explicit hash serialize as JSON null. r_all = await client.get("/api/calls") by_id = {it["call_id"]: it for it in r_all.json()["items"]} assert by_id["c1"]["record_media_hash"] is None assert by_id["c2"]["record_media_hash"] is None assert by_id["c3"]["record_media_hash"] is None # --------------------------------------------------------------------------- # scope filter (voice-call 好友/群 server-side filter, 2026-08-18) # --------------------------------------------------------------------------- async def _seed_voice_scopes(session: AsyncSession) -> None: """Friend + group voice calls (group peer id ends '@chatroom').""" session.add_all( [ PhoneCall( call_id="v_friend", channel="voice_call", user_id="u2", user_id_name="Bob", start_call_time=1000, call_number="q290059956", ), PhoneCall( call_id="v_group", channel="voice_call", user_id="u2", user_id_name="Bob", start_call_time=2000, call_number="45877925713@chatroom", ), ] ) await session.commit() async def test_scope_group_returns_only_chatroom_calls(make_client): """scope=group -> only '@chatroom' rows, total reflects the filter.""" client = await make_client(_seed_voice_scopes) r = await client.get("/api/calls", params={"channel": "voice_call", "scope": "group"}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert [it["call_id"] for it in body["items"]] == ["v_group"] async def test_scope_friend_excludes_chatroom_calls(make_client): """scope=friend -> only non-'@chatroom' rows.""" client = await make_client(_seed_voice_scopes) r = await client.get("/api/calls", params={"channel": "voice_call", "scope": "friend"}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert [it["call_id"] for it in body["items"]] == ["v_friend"] async def test_scope_invalid_value_rejected(make_client): """scope is Literal['friend','group'] — anything else is a 422.""" client = await make_client(_seed_voice_scopes) r = await client.get("/api/calls", params={"scope": "bogus"}) assert r.status_code == 422