"""Tests for app.api — minimal customer list API + health check. Drives the FastAPI app over an in-process transport (``httpx.AsyncClient`` with ``ASGITransport``) — no real network socket. The ``get_session`` dependency is overridden to yield an isolated in-memory async SQLite session. ``StaticPool`` keeps a single connection behind every session so all requests inside a test see the same seeded schema/rows. Coverage (Task 1.8 acceptance): * ``GET /api/health`` -> 200 ``{"status": "ok"}``. * ``GET /api/customers?channel=wechat`` -> ``total == 2`` and correct shape. * ``GET /api/customers?channel=whatsapp`` -> ``total == 1``. * Pagination: ``page_size=1&page=1`` -> 1 item, ``total`` still 3. * Ordering: ``last_chat_time desc`` (nulls last) then ``friend_id``. * Documented filters ``owner_account_id`` and ``department_id`` actually filter. """ from __future__ import annotations import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import StaticPool from app.api import deps from app.api.main import app from app.db.base import Base from app.db.models import Customer, Staff @pytest.fixture async def client(): """Yield an ``AsyncClient`` against ``app`` with a seeded in-memory DB. Seeds three Customers (two wechat, one whatsapp) plus two Staff rows so the ``department_id`` filter (Customer.user_id -> Staff.dept_id) is exercisable. """ engine = create_async_engine( "sqlite+aiosqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}, ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: session.add_all( [ Customer( friend_id="wx_a", channel="wechat", owner_account_id="wx_owner_1", user_id="u1", friend_nickname="Alice", last_chat_time=2000, message_count=10, version="v1", # Verbatim upstream record — the all-fields drawer reads # these camelCase keys off raw_json. raw_json={ "friendWxId": "wx_a", "friendWxAlias": "alice_wx", "friendWxNickname": "Alice", "saleName": "Tom", "addTime": 1690000000000, "fromType": "qrcode", }, ), Customer( friend_id="wx_b", channel="wechat", owner_account_id="wx_owner_1", user_id="u2", friend_nickname="Bob", last_chat_time=1000, version="v1", raw_json=None, ), Customer( friend_id="wa_c", channel="whatsapp", owner_account_id="wa_owner_1", user_id="u3", friend_nickname="Carol", last_chat_time=None, version="v1", ), Staff(user_id="u1", dept_id="d100"), Staff(user_id="u2", dept_id="d200"), ] ) await session.commit() async def _override_session(): async with SessionLocal() as s: yield s app.dependency_overrides[deps.get_session] = _override_session try: async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac finally: app.dependency_overrides.clear() await engine.dispose() async def test_health_returns_ok(client: AsyncClient): """``GET /api/health`` -> 200 ``{"status": "ok"}``.""" r = await client.get("/api/health") assert r.status_code == 200 assert r.json() == {"status": "ok"} async def test_list_default_page_returns_all(client: AsyncClient): """No query params -> page 1, page_size 20, all 3 rows.""" r = await client.get("/api/customers") assert r.status_code == 200 body = r.json() assert body["page"] == 1 assert body["page_size"] == 20 assert body["total"] == 3 assert len(body["items"]) == 3 # Shape: every documented CustomerOut field is present, including the # verbatim upstream record exposed for the all-fields drawer. item = body["items"][0] for key in ( "friend_id", "channel", "owner_account_id", "friend_nickname", "friend_remark", "real_name", "phone", "region", "sale_name", "dept_name", "head_image_url", "last_chat_time", "message_count", "version", "raw_json", ): assert key in item async def test_raw_json_serializes_verbatim(client: AsyncClient): """``raw_json`` round-trips the verbatim upstream record unmolested. Seeded ``wx_a`` carries a populated blob; ``wx_b``/``wa_c`` have NULL raw_json (omitted on the model). Both must serialize: present dict vs null. """ r = await client.get("/api/customers", params={"channel": "wechat"}) assert r.status_code == 200 by_id = {it["friend_id"]: it for it in r.json()["items"]} # Populated blob: every camelCase key preserved verbatim. alice = by_id["wx_a"] assert alice["raw_json"] == { "friendWxId": "wx_a", "friendWxAlias": "alice_wx", "friendWxNickname": "Alice", "saleName": "Tom", "addTime": 1690000000000, "fromType": "qrcode", } # NULL raw_json serializes as JSON null (not omitted, not an error). bob = by_id["wx_b"] assert bob["raw_json"] is None async def test_list_filters_by_channel_wechat(client: AsyncClient): """``?channel=wechat`` -> total 2, both wechat rows.""" r = await client.get( "/api/customers", params={"channel": "wechat", "page": 1, "page_size": 10} ) assert r.status_code == 200 body = r.json() assert body["total"] == 2 assert len(body["items"]) == 2 assert {it["friend_id"] for it in body["items"]} == {"wx_a", "wx_b"} assert all(it["channel"] == "wechat" for it in body["items"]) async def test_list_filters_by_channel_whatsapp(client: AsyncClient): """``?channel=whatsapp`` -> total 1.""" r = await client.get("/api/customers", params={"channel": "whatsapp"}) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert body["items"][0]["friend_id"] == "wa_c" async def test_pagination_page_size_one(client: AsyncClient): """``page_size=1&page=1`` -> 1 item but ``total`` still reflects all rows.""" r = await client.get( "/api/customers", params={"page": 1, "page_size": 1} ) assert r.status_code == 200 body = r.json() assert body["total"] == 3 assert len(body["items"]) == 1 async def test_order_is_last_chat_time_desc_nulls_last(client: AsyncClient): """Order: ``last_chat_time DESC`` (nulls last) — 云客 sorts most-recent-chat-first.""" r = await client.get( "/api/customers", params={"page": 1, "page_size": 20} ) assert r.status_code == 200 ids = [it["friend_id"] for it in r.json()["items"]] assert ids == ["wx_a", "wx_b", "wa_c"] async def test_filter_by_owner_account_id(client: AsyncClient): """``?owner_account_id=wa_owner_1`` -> only the whatsapp customer.""" r = await client.get( "/api/customers", params={"owner_account_id": "wa_owner_1"} ) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert body["items"][0]["friend_id"] == "wa_c" async def test_filter_by_department_id(client: AsyncClient): """``?department_id=d100`` -> customers whose ``user_id`` maps to that dept. Customer ``wx_a`` has ``user_id=u1``; Staff ``u1`` belongs to ``d100``. """ r = await client.get( "/api/customers", params={"department_id": "d100"} ) assert r.status_code == 200 body = r.json() assert body["total"] == 1 assert body["items"][0]["friend_id"] == "wx_a" async def test_filters_compose_channel_and_owner(client: AsyncClient): """Two filters AND together: channel=wechat + owner=wx_owner_1 -> 2 rows.""" r = await client.get( "/api/customers", params={"channel": "wechat", "owner_account_id": "wx_owner_1"}, ) assert r.status_code == 200 body = r.json() assert body["total"] == 2 assert {it["friend_id"] for it in body["items"]} == {"wx_a", "wx_b"} @pytest.fixture async def filter_client(): """Independent in-memory client seeded for the new add_time/last_chat filters. Deliberately separate from the shared ``client`` fixture: that one seeds 3 rows and four existing tests assert ``total == 3`` against it, so adding rows there would break them. This fixture mirrors the engine + override pattern of ``client`` but seeds rows that specifically exercise the ``user_id`` / ``add_time_since`` / ``last_chat_since`` filters. """ engine = create_async_engine( "sqlite+aiosqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}, ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with SessionLocal() as session: session.add_all( [ Customer( friend_id="u1_a", channel="wechat", user_id="U1", add_time=1000, last_chat_time=500, version="v1", ), Customer( friend_id="u1_b", channel="wechat", user_id="U1", add_time=2000, last_chat_time=2000, version="v1", ), Customer( friend_id="u2_a", channel="wechat", user_id="U2", add_time=3000, last_chat_time=3000, version="v1", ), ] ) await session.commit() async def _override_session(): async with SessionLocal() as s: yield s app.dependency_overrides[deps.get_session] = _override_session try: async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac finally: app.dependency_overrides.clear() await engine.dispose() async def test_filter_by_user_id(filter_client: AsyncClient): """``?user_id=U1`` -> only customers owned by U1.""" r = await filter_client.get("/api/customers", params={"user_id": "U1"}) assert r.status_code == 200 assert {c["friend_id"] for c in r.json()["items"]} == {"u1_a", "u1_b"} async def test_filter_by_add_time_since(filter_client: AsyncClient): """``?add_time_since=1500`` -> rows with add_time >= 1500 (u1_b, u2_a).""" r = await filter_client.get("/api/customers", params={"add_time_since": 1500}) assert r.status_code == 200 ids = [c["friend_id"] for c in r.json()["items"]] assert "u1_a" not in ids and "u1_b" in ids and "u2_a" in ids async def test_filter_by_last_chat_since(filter_client: AsyncClient): """``?last_chat_since=1500`` -> rows with last_chat_time >= 1500 (u1_a out).""" r = await filter_client.get("/api/customers", params={"last_chat_since": 1500}) assert r.status_code == 200 assert "u1_a" not in [c["friend_id"] for c in r.json()["items"]]