Files

320 lines
10 KiB
Python
Raw Permalink Normal View History

2026-08-01 11:15:34 +08:00
"""Tests for app.db.models — ORM model definitions round-trip through SQLite.
These tests build an isolated in-memory SQLite async engine (same pattern as
tests/test_db_base.py) and exercise the real ORM models defined in
app.db.models. They never touch the production MySQL engine.
"""
import datetime as dt
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import (
ChatGroup,
Customer,
Department,
GroupMember,
Media,
Message,
PhoneCall,
Sms,
Staff,
SyncRun,
SyncState,
WechatAccount,
WhatsappAccount,
YkAccount,
message_table_name,
)
ALL_MODELS = (
Department,
Staff,
WechatAccount,
WhatsappAccount,
Customer,
ChatGroup,
GroupMember,
Message,
Media,
PhoneCall,
Sms,
SyncState,
YkAccount,
SyncRun,
)
EXPECTED_TABLES = (
"department",
"staff",
"wechat_account",
"whatsapp_account",
"customer",
"chat_group",
"group_member",
"message",
"media",
"phone_call",
"sms",
"sync_state",
"yk_account",
"sync_run",
)
async def _make_engine():
"""Create an in-memory async SQLite engine with all tables created."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine
def test_all_models_extend_base():
"""Every model must subclass Base and declare a __tablename__."""
for model in ALL_MODELS:
assert issubclass(model, Base), f"{model.__name__} must extend Base"
assert isinstance(model.__tablename__, str) and model.__tablename__, (
f"{model.__name__} must declare __tablename__"
)
def test_table_names_match_expected():
"""The 14 tables must use the documented snake_case names."""
for model, expected in zip(ALL_MODELS, EXPECTED_TABLES):
assert model.__tablename__ == expected, (
f"{model.__name__}.__tablename__ = {model.__tablename__!r}, "
f"expected {expected!r}"
)
async def test_all_tables_create_on_sqlite():
"""All 14 tables must be createable on SQLite (types stay portable)."""
engine = await _make_engine()
try:
async with engine.begin() as conn:
tables = await conn.run_sync(
lambda sync_conn: sa.inspect(sync_conn).get_table_names()
)
for name in EXPECTED_TABLES:
assert name in tables, f"missing table: {name}"
finally:
await engine.dispose()
async def test_customer_round_trip():
"""A Customer row round-trips its key fields through SQLite."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
cust = Customer(
friend_id="wxid_abc123",
channel="wechat",
owner_account_id="wxid_staff",
friend_nickname="Alice",
phone="13800138000",
message_count=42,
)
session.add(cust)
await session.commit()
fetched = (
await session.execute(
sa.select(Customer).where(Customer.friend_id == "wxid_abc123")
)
).scalar_one()
assert fetched.friend_id == "wxid_abc123"
assert fetched.channel == "wechat"
assert fetched.owner_account_id == "wxid_staff"
assert fetched.friend_nickname == "Alice"
assert fetched.phone == "13800138000"
assert fetched.message_count == 42
assert fetched.fetched_at is not None
finally:
await engine.dispose()
async def test_customer_raw_json_round_trip():
"""raw_json must round-trip an arbitrary dict (stored as JSON)."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
payload = {
"friendWxId": "wxid_abc",
"list": [1, 2, 3],
"nested": {"k": "v", "flag": True},
}
cust = Customer(
friend_id="wxid_raw",
channel="wechat",
owner_account_id="wxid_staff",
raw_json=payload,
)
session.add(cust)
await session.commit()
fetched = (
await session.execute(
sa.select(Customer).where(Customer.friend_id == "wxid_raw")
)
).scalar_one()
assert fetched.raw_json == payload
finally:
await engine.dispose()
async def test_message_natural_pk():
"""Message PK is msg_id (the source msgSvrId / keyId)."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
msg = Message(
msg_id="1000069900000000001",
channel="wechat",
owner_account_id="wxid_staff",
talker_id="wxid_abc",
is_send=False,
type="text",
type_raw=1,
2026-08-01 11:15:34 +08:00
text="hello",
timestamp=1700000000000,
)
session.add(msg)
await session.commit()
fetched = (
await session.execute(
sa.select(Message).where(Message.msg_id == "1000069900000000001")
)
).scalar_one()
assert fetched.msg_id == "1000069900000000001"
assert fetched.text == "hello"
assert fetched.timestamp == 1700000000000
assert fetched.is_send is False
assert fetched.type == "text"
assert fetched.type_raw == 1
2026-08-01 11:15:34 +08:00
finally:
await engine.dispose()
def test_message_table_name_helper_with_ms_timestamp():
"""message_table_name(channel, ms_epoch) -> message_<channel>_<YYYYMM>."""
ts_ms = int(
dt.datetime(2023, 11, 15, 12, 0, 0, tzinfo=dt.UTC).timestamp() * 1000
)
assert message_table_name("wechat", ts_ms) == "message_wechat_202311"
def test_message_table_name_helper_with_datetime():
"""message_table_name also accepts a datetime instance."""
assert (
message_table_name("whatsapp", dt.datetime(2024, 1, 5, tzinfo=dt.UTC))
== "message_whatsapp_202401"
)
async def test_message_composite_index_present():
"""The (channel, owner_account_id, talker_id, timestamp) index must exist."""
engine = await _make_engine()
try:
async with engine.begin() as conn:
indexes = await conn.run_sync(
lambda sync_conn: sa.inspect(sync_conn).get_indexes("message")
)
names = {idx["name"] for idx in indexes}
# The composite index name is implementation detail; assert one index
# covers exactly those four columns.
cols_to_index = {
"channel",
"owner_account_id",
"talker_id",
"timestamp",
}
assert any(
set(idx["column_names"]) == cols_to_index for idx in indexes
), f"no composite index on {cols_to_index}; got: {names}"
finally:
await engine.dispose()
async def test_group_member_composite_pk():
"""GroupMember uses a composite PK (channel, room_id, member_id)."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
gm = GroupMember(
channel="wechat",
room_id="12345@chatroom",
member_id="wxid_member1",
nickname="Bob",
)
session.add(gm)
await session.commit()
fetched = (
await session.execute(
sa.select(GroupMember).where(
GroupMember.room_id == "12345@chatroom",
GroupMember.member_id == "wxid_member1",
GroupMember.channel == "wechat",
)
)
).scalar_one()
assert fetched.nickname == "Bob"
finally:
await engine.dispose()
async def test_moments_post_round_trip():
"""A MomentsPost row round-trips JSON list columns + ms create_time."""
from app.db.models import MomentsPost
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
row = MomentsPost(
id="69e082a3c3524a7fe3ac53b4",
wechat_id="wxid_x",
sns_id="-3546042415332912522",
author_name="Yoyo",
author_wxid="wxid_x",
author_avatar="https://wx.qlogo.cn/avatars/yoyo",
content="Welcome to HESU",
type_raw="1",
create_time=1776302051000,
praise_json=[{"name": "E**U"}],
comment_json=[],
image_urls=["https://wx.oss.example/a.jpg"],
raw_json={"id": "69e082a3c3524a7fe3ac53b4"},
)
session.add(row)
await session.commit()
fetched = (
await session.execute(
sa.select(MomentsPost).where(
MomentsPost.id == "69e082a3c3524a7fe3ac53b4"
)
)
).scalar_one()
assert fetched.wechat_id == "wxid_x"
assert fetched.sns_id == "-3546042415332912522"
# JSON list columns must round-trip as lists (not joined strings).
assert fetched.praise_json[0]["name"] == "E**U"
assert fetched.comment_json == []
assert fetched.image_urls == ["https://wx.oss.example/a.jpg"]
# create_time stays the ms-epoch int exactly as written.
assert fetched.create_time == 1776302051000
assert fetched.type_raw == "1"
assert fetched.raw_json == {"id": "69e082a3c3524a7fe3ac53b4"}
assert fetched.fetched_at is not None
finally:
await engine.dispose()