feat: backend read api for all entities (messages/groups/calls/sms/org/sync)

Adds the read-only API surface the upcoming Vue frontend needs to browse all
mirrored entities. Follows the existing customer-router pattern: Annotated
deps, Page[T] envelope, select+where+order_by, count() for total.

Endpoints (all GET):
* /api/departments          -> list[DepartmentOut] (path-ordered, plain list)
* /api/staff                -> Page[StaffOut] (department_id, is_on_job filters)
* /api/accounts             -> Page[AccountOut] (channel filter; unified
                             wechat+whatsapp projection built in memory)
* /api/messages             -> Page[MessageOut] (channel/owner/talker/room/
                             [start,end] filters; ordered timestamp asc)
* /api/groups               -> Page[GroupOut] (channel/owner filters)
* /api/groups/{id}/members  -> list[GroupMemberOut] (404 on unknown room)
* /api/calls                -> Page[PhoneCallOut] (channel/user/time window;
                             ordered start_call_time desc)
* /api/sms                  -> Page[SmsOut] (user/time window; create_time desc)
* /api/sync/status          -> {states, recent_runs} monitor payload

Schemas (Pydantic v2, from_attributes=True): MessageOut, GroupOut,
GroupMemberOut, PhoneCallOut, SmsOut, DepartmentOut, StaffOut, AccountOut
(unified across two tables), SyncStateOut, SyncRunOut, SyncStatusOut.
Only human-relevant fields per entity are exposed.

Tests: per-router TDD via httpx ASGITransport against an in-memory SQLite
override of get_session. A shared make_client factory in conftest.py builds
the seeded client, deduping the ~40-line fixture across the six new files.
41 new tests cover shape + every documented filter + pagination + ordering
(nulls-last verified on all desc/asc time columns).

Suite: 226 -> 267 passing. ruff check clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 16:15:40 +08:00
parent 44d64dd237
commit ff7e9cb1d9
15 changed files with 1687 additions and 2 deletions
+79 -1
View File
@@ -1,8 +1,22 @@
"""Shared pytest fixtures for the yunke-server test suite."""
import pytest
from __future__ import annotations
from collections.abc import AsyncIterator, Awaitable, Callable
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import StaticPool
from app.api import deps
from app.api.main import app
from app.config import get_settings
from app.db.base import Base
@pytest.fixture(autouse=True)
@@ -34,3 +48,67 @@ def _disable_request_throttle(monkeypatch):
monkeypatch.setenv("REQUEST_JITTER", "0")
yield
get_settings.cache_clear()
@pytest.fixture
async def make_client() -> AsyncIterator[
Callable[[Callable[[AsyncSession], Awaitable[None]]], Awaitable[AsyncClient]]
]:
"""Factory yielding an ``AsyncClient`` over a seeded in-memory SQLite DB.
Mirrors the override pattern established in ``tests/test_api_customer.py``
(``StaticPool`` + single in-memory connection + ``get_session`` dependency
override) but lets each test supply its own seed coroutine so the read-API
test files don't each re-~40-line the boilerplate.
Usage::
async def test_x(make_client):
async def seed(session):
session.add(Department(dept_id="d1", ...))
await session.commit()
client = await make_client(seed)
r = await client.get("/api/departments")
...
The factory owns the engine + client lifetime: both are torn down
(``aclose`` / ``dispose``) and the dependency override cleared when the
test exits.
"""
created: list[tuple[AsyncClient, object]] = []
async def _factory(
seed: Callable[[AsyncSession], Awaitable[None]],
) -> AsyncClient:
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:
await seed(session)
async def _override_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as s:
yield s
app.dependency_overrides[deps.get_session] = _override_session
client = AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
)
created.append((client, engine))
return client
try:
yield _factory
finally:
for client, engine in created:
await client.aclose()
await engine.dispose()
app.dependency_overrides.clear()
+128
View File
@@ -0,0 +1,128 @@
"""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",
),
]
)
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",
):
assert key in body["items"][0]
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"
+144
View File
@@ -0,0 +1,144 @@
"""Tests for GET /api/groups and /api/groups/{room_id}/members.
Seeds 2 groups (one wechat, one whatsapp) with one member each on the wechat
group, plus a second wechat group owned by another account, then asserts list
filters + member-list shape + 404 for unknown room.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import ChatGroup, GroupMember
async def _seed(session: AsyncSession) -> None:
session.add_all(
[
ChatGroup(
room_id="room_a@chatroom",
channel="wechat",
room_name="Team A",
owner_account_id="wx_owner_1",
owner_account_nickname="WxOne",
member_count=2,
message_count=10,
last_chat_time=2000,
),
ChatGroup(
room_id="room_b@chatroom",
channel="wechat",
room_name="Team B",
owner_account_id="wx_owner_2",
member_count=1,
last_chat_time=1000,
),
ChatGroup(
room_id="wa_room_x@s.whatsapp.net",
channel="whatsapp",
room_name="WA Team",
owner_account_id="wa_owner_1",
member_count=3,
last_chat_time=None,
),
GroupMember(
channel="wechat",
room_id="room_a@chatroom",
member_id="mem1",
nickname="Alice",
display_name="Alice",
),
GroupMember(
channel="wechat",
room_id="room_a@chatroom",
member_id="mem2",
nickname="Bob",
display_name="Bob",
),
]
)
await session.commit()
async def test_default_list_returns_all_ordered_desc(make_client):
"""No filters -> 3 groups, ordered by ``last_chat_time desc`` (nulls last)."""
client = await make_client(_seed)
r = await client.get("/api/groups")
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
# 2000, 1000, null -> [room_a, room_b, wa_room_x]
assert [it["room_id"] for it in body["items"]] == [
"room_a@chatroom",
"room_b@chatroom",
"wa_room_x@s.whatsapp.net",
]
for key in (
"room_id",
"channel",
"room_name",
"owner_account_id",
"owner_account_nickname",
"member_count",
"message_count",
"last_chat_time",
):
assert key in body["items"][0]
async def test_filter_by_channel_whatsapp(make_client):
"""``?channel=whatsapp`` -> the single WA group."""
client = await make_client(_seed)
r = await client.get("/api/groups", params={"channel": "whatsapp"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["room_id"] == "wa_room_x@s.whatsapp.net"
async def test_filter_by_owner_account_id(make_client):
"""``?owner_account_id=wx_owner_1`` -> only Team A."""
client = await make_client(_seed)
r = await client.get("/api/groups", params={"owner_account_id": "wx_owner_1"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["room_id"] == "room_a@chatroom"
async def test_pagination(make_client):
"""``page_size=1`` -> 1 item, total 3."""
client = await make_client(_seed)
r = await client.get("/api/groups", 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_members_returns_list(make_client):
"""``/api/groups/room_a@chatroom/members`` -> 2 members (plain list)."""
client = await make_client(_seed)
r = await client.get("/api/groups/room_a@chatroom/members")
assert r.status_code == 200
items = r.json()
assert isinstance(items, list)
assert len(items) == 2
assert {it["member_id"] for it in items} == {"mem1", "mem2"}
for key in ("channel", "room_id", "member_id", "nickname", "display_name", "avatar"):
assert key in items[0]
async def test_members_unknown_room_404(make_client):
"""Unknown room_id -> 404 (distinguishes missing vs empty)."""
client = await make_client(_seed)
r = await client.get("/api/groups/nope@chatroom/members")
assert r.status_code == 404
async def test_members_empty_room_is_200(make_client):
"""Known room with no seeded members -> 200 + empty list (not 404)."""
client = await make_client(_seed)
r = await client.get("/api/groups/room_b@chatroom/members")
assert r.status_code == 200
assert r.json() == []
+187
View File
@@ -0,0 +1,187 @@
"""Tests for GET /api/messages — chat timeline.
Seeds 3 messages (2 for talker t_a across times 1000/3000, 1 for talker t_b at
2000) and asserts filters (channel, owner, talker, time window) + ordering
(``timestamp asc``) + pagination.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Message
async def _seed(session: AsyncSession) -> None:
session.add_all(
[
Message(
msg_id="m1",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_a",
room_id=None,
text="hello",
timestamp=1000,
is_send=True,
type="text",
type_raw=1,
),
Message(
msg_id="m2",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_b",
room_id=None,
text="hi",
timestamp=2000,
is_send=False,
type="text",
type_raw=1,
),
Message(
msg_id="m3",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_a",
room_id=None,
text="bye",
timestamp=3000,
is_send=True,
type="text",
type_raw=1,
),
]
)
await session.commit()
async def test_default_page_returns_all_ordered_asc(make_client):
"""No filters -> 3 rows ordered by ``timestamp asc`` (timeline order)."""
client = await make_client(_seed)
r = await client.get("/api/messages")
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
assert len(body["items"]) == 3
assert [it["msg_id"] for it in body["items"]] == ["m1", "m2", "m3"]
for key in (
"msg_id",
"channel",
"owner_account_id",
"talker_id",
"room_id",
"is_send",
"type",
"type_raw",
"text",
"file_url",
"sender_name",
"sender_head_url",
"timestamp",
):
assert key in body["items"][0]
async def test_filter_by_talker_id(make_client):
"""``?talker_id=t_a`` -> 2 messages for that 1:1 conversation."""
client = await make_client(_seed)
r = await client.get("/api/messages", params={"talker_id": "t_a"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert {it["msg_id"] for it in body["items"]} == {"m1", "m3"}
async def test_filter_by_owner_account_id(make_client):
"""``?owner_account_id=wx_owner_1`` -> all 3 (all owned by this account)."""
client = await make_client(_seed)
r = await client.get("/api/messages", params={"owner_account_id": "wx_owner_1"})
assert r.status_code == 200
assert r.json()["total"] == 3
async def test_filter_by_owner_account_id_no_match(make_client):
"""A non-existent owner account -> 0 rows."""
client = await make_client(_seed)
r = await client.get("/api/messages", params={"owner_account_id": "nobody"})
assert r.status_code == 200
assert r.json()["total"] == 0
async def test_time_window_filter(make_client):
"""``?start=1500&end=2500`` -> only m2 (timestamp 2000)."""
client = await make_client(_seed)
r = await client.get(
"/api/messages", params={"start": 1500, "end": 2500}
)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["msg_id"] == "m2"
async def test_time_window_combined_with_talker(make_client):
"""``talker_id=t_a & start=1500`` -> only m3 (t_a's later message)."""
client = await make_client(_seed)
r = await client.get(
"/api/messages", params={"talker_id": "t_a", "start": 1500}
)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["msg_id"] == "m3"
async def test_channel_filter(make_client):
"""``?channel=whatsapp`` -> 0 (all seeded are wechat)."""
client = await make_client(_seed)
r = await client.get("/api/messages", params={"channel": "whatsapp"})
assert r.status_code == 200
assert r.json()["total"] == 0
async def test_pagination_page_size_one(make_client):
"""``page_size=1&page=1`` -> 1 item, total still 3; page 2 -> next item."""
client = await make_client(_seed)
r1 = await client.get(
"/api/messages", params={"page": 1, "page_size": 1}
)
b1 = r1.json()
assert b1["total"] == 3
assert len(b1["items"]) == 1
assert b1["items"][0]["msg_id"] == "m1"
r2 = await client.get(
"/api/messages", params={"page": 2, "page_size": 1}
)
b2 = r2.json()
assert len(b2["items"]) == 1
assert b2["items"][0]["msg_id"] == "m2"
async def test_group_chat_filter_by_room_id(make_client):
"""``?room_id=room_x`` -> only the group message."""
async def seed(session: AsyncSession) -> None:
await _seed(session)
session.add(
Message(
msg_id="m4",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id=None,
room_id="room_x",
text="group hi",
timestamp=4000,
type="text",
type_raw=1,
)
)
await session.commit()
client = await make_client(seed)
r = await client.get("/api/messages", params={"room_id": "room_x"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["msg_id"] == "m4"
+206
View File
@@ -0,0 +1,206 @@
"""Tests for GET /api/departments, /api/staff, /api/accounts.
Seeds a small org (3 departments, 3 staff, 2 wechat + 1 whatsapp accounts) and
asserts shape + filters + pagination across all three endpoints.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Department, Staff, WechatAccount, WhatsappAccount
async def _seed(session: AsyncSession) -> None:
session.add_all(
[
Department(dept_id="root", parent_id=None, name="Root", path="root"),
Department(
dept_id="d1", parent_id="root", name="Sales", path="root/d1"
),
Department(
dept_id="d2", parent_id="root", name="Support", path="root/d2"
),
Staff(
user_id="u1",
name="Alice",
dept_id="d1",
is_on_job=True,
phone="111",
),
Staff(
user_id="u2",
name="Bob",
dept_id="d2",
is_on_job=False,
),
Staff(user_id="u3", name="Carol", dept_id="d1", is_on_job=True),
WechatAccount(
wx_id="wx_a",
nick_name="WxOne",
user_id="u1",
user_name="Alice",
phone="111",
contacts_num=10,
group_num=2,
is_on_job=True,
),
WechatAccount(
wx_id="wx_b",
nick_name="WxTwo",
user_id="u2",
contacts_num=5,
is_on_job=False,
),
WhatsappAccount(
uid="wa_c@s.whatsapp.net",
nick_name="WaOne",
user_id="u3",
user_name="Carol",
phone="333",
contacts_num=7,
group_num=1,
is_on_job=True,
),
]
)
await session.commit()
async def test_departments_returns_all_ordered_by_path(make_client):
"""``GET /api/departments`` -> plain list, ordered by ``path``."""
client = await make_client(_seed)
r = await client.get("/api/departments")
assert r.status_code == 200
items = r.json()
assert isinstance(items, list)
assert len(items) == 3
# Path order: root, root/d1, root/d2
assert [it["dept_id"] for it in items] == ["root", "d1", "d2"]
# Shape
for key in ("dept_id", "parent_id", "name", "path"):
assert key in items[0]
async def test_staff_default_page_returns_all(make_client):
"""``GET /api/staff`` -> 3 rows, page 1 / page_size 20."""
client = await make_client(_seed)
r = await client.get("/api/staff")
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
for key in ("user_id", "name", "phone", "sale_phone", "dept_id", "is_on_job"):
assert key in body["items"][0]
async def test_staff_filter_by_department_id(make_client):
"""``?department_id=d1`` -> 2 staff (Alice + Carol)."""
client = await make_client(_seed)
r = await client.get("/api/staff", params={"department_id": "d1"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert {it["user_id"] for it in body["items"]} == {"u1", "u3"}
async def test_staff_filter_by_is_on_job(make_client):
"""``?is_on_job=true`` -> 2 on-job staff (Alice + Carol)."""
client = await make_client(_seed)
r = await client.get("/api/staff", params={"is_on_job": "true"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert all(it["is_on_job"] is True for it in body["items"])
async def test_staff_filters_compose(make_client):
"""``department_id=d1 & is_on_job=true`` AND together -> still 2."""
client = await make_client(_seed)
r = await client.get(
"/api/staff", params={"department_id": "d1", "is_on_job": "true"}
)
assert r.status_code == 200
assert r.json()["total"] == 2
async def test_staff_pagination(make_client):
"""``page_size=1&page=2`` -> 1 item; total still 3."""
client = await make_client(_seed)
r = await client.get("/api/staff", params={"page": 2, "page_size": 1})
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
assert len(body["items"]) == 1
async def test_accounts_default_returns_both_channels(make_client):
"""``GET /api/accounts`` with no channel -> 3 rows across both tables."""
client = await make_client(_seed)
r = await client.get("/api/accounts")
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
# Sorted by (channel, account_id): wechat first, then whatsapp.
ids = [(it["channel"], it["account_id"]) for it in body["items"]]
assert ids == [
("wechat", "wx_a"),
("wechat", "wx_b"),
("whatsapp", "wa_c@s.whatsapp.net"),
]
# Unified shape exposes channel + key fields.
item = body["items"][0]
for key in (
"channel",
"account_id",
"nickname",
"user_id",
"user_name",
"phone",
"contacts_num",
"group_num",
"is_on_job",
):
assert key in item
assert item["channel"] == "wechat"
assert item["nickname"] == "WxOne"
assert item["user_name"] == "Alice"
async def test_accounts_filter_channel_wechat(make_client):
"""``?channel=wechat`` -> only the 2 wechat accounts."""
client = await make_client(_seed)
r = await client.get("/api/accounts", params={"channel": "wechat"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert all(it["channel"] == "wechat" for it in body["items"])
async def test_accounts_filter_channel_whatsapp(make_client):
"""``?channel=whatsapp`` -> the single whatsapp account."""
client = await make_client(_seed)
r = await client.get("/api/accounts", params={"channel": "whatsapp"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["account_id"] == "wa_c@s.whatsapp.net"
assert body["items"][0]["user_name"] == "Carol"
async def test_accounts_pagination(make_client):
"""``page_size=2&page=1`` -> 2 items, total 3; page 2 -> 1 item."""
client = await make_client(_seed)
r1 = await client.get("/api/accounts", params={"page": 1, "page_size": 2})
assert r1.status_code == 200
b1 = r1.json()
assert b1["total"] == 3
assert len(b1["items"]) == 2
r2 = await client.get("/api/accounts", params={"page": 2, "page_size": 2})
assert r2.status_code == 200
b2 = r2.json()
assert len(b2["items"]) == 1
assert b2["items"][0]["account_id"] == "wa_c@s.whatsapp.net"
+111
View File
@@ -0,0 +1,111 @@
"""Tests for GET /api/sms — SMS log.
Seeds 3 SMS rows (2 for user u1 at create_time 1000/3000, 1 for u2 at 2000)
and asserts filters + ordering (``create_time desc``) + pagination.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Sms
async def _seed(session: AsyncSession) -> None:
session.add_all(
[
Sms(
sms_id="s1",
channel="sms",
user_id="u1",
receive_name="Alice",
content_preview="hello",
create_time=1000,
direction=1,
sender="111",
customer_phone="999",
),
Sms(
sms_id="s2",
channel="sms",
user_id="u2",
receive_name="Bob",
content_preview="hi",
create_time=2000,
direction=0,
),
Sms(
sms_id="s3",
channel="sms",
user_id="u1",
receive_name="Alice",
content_preview="bye",
create_time=3000,
direction=1,
),
]
)
await session.commit()
async def test_default_list_returns_all_ordered_desc(make_client):
"""No filters -> 3 rows ordered by ``create_time desc``."""
client = await make_client(_seed)
r = await client.get("/api/sms")
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
assert [it["sms_id"] for it in body["items"]] == ["s3", "s2", "s1"]
for key in (
"sms_id",
"customer_phone",
"content_preview",
"sender",
"direction",
"user_id",
"receive_name",
"create_time",
"sim_phone",
):
assert key in body["items"][0]
async def test_filter_by_user_id(make_client):
"""``?user_id=u1`` -> 2 SMS for Alice."""
client = await make_client(_seed)
r = await client.get("/api/sms", params={"user_id": "u1"})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert {it["sms_id"] for it in body["items"]} == {"s1", "s3"}
async def test_time_window_filter(make_client):
"""``?start=1500&end=2500`` -> only s2."""
client = await make_client(_seed)
r = await client.get("/api/sms", params={"start": 1500, "end": 2500})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["sms_id"] == "s2"
async def test_filters_compose(make_client):
"""``user_id=u1 & end=2000`` -> only s1."""
client = await make_client(_seed)
r = await client.get("/api/sms", params={"user_id": "u1", "end": 2000})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["sms_id"] == "s1"
async def test_pagination(make_client):
"""``page_size=1&page=1`` -> 1 item, total 3."""
client = await make_client(_seed)
r = await client.get("/api/sms", 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]["sms_id"] == "s3"
+136
View File
@@ -0,0 +1,136 @@
"""Tests for GET /api/sync/status — sync monitor.
Seeds 2 SyncState cursors and 3 SyncRun audit rows and asserts the aggregate
shape ``{states, recent_runs}``, ordering of runs (newest first), and the
``recent`` cap.
"""
from __future__ import annotations
import datetime as dt
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import SyncRun, SyncState
async def _seed(session: AsyncSession) -> None:
session.add_all(
[
SyncState(
state_id=1,
entity_type="message",
channel="wechat",
scope_id="wx_owner_1",
last_version="v100",
last_timestamp=1000,
last_synced_at=dt.datetime(2026, 8, 1, 10, 0, 0, tzinfo=dt.UTC),
),
SyncState(
state_id=2,
entity_type="customer",
channel="whatsapp",
scope_id=None,
last_version="v9",
last_timestamp=2000,
),
SyncRun(
run_id=1,
entity_type="message",
channel="wechat",
scope_id="wx_owner_1",
status="success",
started_at=dt.datetime(2026, 8, 1, 9, 0, 0, tzinfo=dt.UTC),
finished_at=dt.datetime(2026, 8, 1, 9, 0, 5, tzinfo=dt.UTC),
records_fetched=100,
records_upserted=95,
),
SyncRun(
run_id=2,
entity_type="customer",
channel="whatsapp",
status="failed",
started_at=dt.datetime(2026, 8, 1, 10, 0, 0, tzinfo=dt.UTC),
records_fetched=0,
records_upserted=0,
error="upstream 500",
),
SyncRun(
run_id=3,
entity_type="message",
channel="wechat",
status="running",
started_at=dt.datetime(2026, 8, 1, 11, 0, 0, tzinfo=dt.UTC),
records_fetched=0,
records_upserted=0,
),
]
)
await session.commit()
async def test_status_returns_states_and_recent_runs(make_client):
"""``GET /api/sync/status`` -> both keys present with correct shapes."""
client = await make_client(_seed)
r = await client.get("/api/sync/status")
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {"states", "recent_runs"}
assert len(body["states"]) == 2
for key in (
"state_id",
"entity_type",
"channel",
"scope_id",
"last_version",
"last_timestamp",
"last_synced_at",
):
assert key in body["states"][0]
assert len(body["recent_runs"]) == 3
for key in (
"run_id",
"entity_type",
"channel",
"scope_id",
"status",
"started_at",
"finished_at",
"records_fetched",
"records_upserted",
"error",
):
assert key in body["recent_runs"][0]
async def test_recent_runs_ordered_newest_first(make_client):
"""Runs ordered by ``started_at desc`` -> run 3, 2, 1."""
client = await make_client(_seed)
r = await client.get("/api/sync/status")
body = r.json()
ids = [run["run_id"] for run in body["recent_runs"]]
assert ids == [3, 2, 1]
async def test_recent_limit_caps_results(make_client):
"""``?recent=2`` -> only the 2 most recent runs."""
client = await make_client(_seed)
r = await client.get("/api/sync/status", params={"recent": 2})
assert r.status_code == 200
body = r.json()
assert len(body["recent_runs"]) == 2
assert [run["run_id"] for run in body["recent_runs"]] == [3, 2]
async def test_empty_db_returns_empty_lists(make_client):
"""No seed -> 200 with empty states + recent_runs (no crash)."""
async def empty_seed(session: AsyncSession) -> None:
await session.commit()
client = await make_client(empty_seed)
r = await client.get("/api/sync/status")
assert r.status_code == 200
body = r.json()
assert body == {"states": [], "recent_runs": []}