完善设备端体验与微信校验支持

This commit is contained in:
stu2not
2026-07-07 16:58:05 +08:00
parent b4fef6d2e0
commit 937e3023fc
38 changed files with 3377 additions and 51 deletions

View File

@@ -31,7 +31,7 @@ async def test_alarm_event_response_includes_v2_audio_url(monkeypatch):
"status": "success",
"type": 0,
"params": {
"url": "http://101.35.224.118:8080/assets/audio/message_ok.mp3",
"url": "http://101.35.224.118:8080/assets/roles/banban/warn_parent_zh.mp3",
},
},
)

View File

@@ -1,8 +1,18 @@
import pytest
from banban.routers.devices import _row_to_current_location_response
from banban.service.device_alarm import DeviceAlarmService
from banban.service.location import LocationService, is_valid_coordinate_pair
from handlers.mqtt_handler import TalkingQMQTTService
def test_zero_zero_coordinate_is_invalid():
assert is_valid_coordinate_pair(23.2413923, 113.6051866) is True
assert is_valid_coordinate_pair(0, 0) is False
assert is_valid_coordinate_pair("0.0000000", "0.0000000") is False
assert is_valid_coordinate_pair(None, 113.6051866) is False
@pytest.mark.asyncio
async def test_alarm_requests_fresh_gps_before_sms(monkeypatch):
service = TalkingQMQTTService({"broker": "127.0.0.1"})
@@ -58,7 +68,7 @@ async def test_alarm_requests_fresh_gps_before_sms(monkeypatch):
"status": "success",
"type": 0,
"params": {
"url": "http://192.168.101.78:8080/assets/audio/message_ok.mp3",
"url": "http://192.168.101.78:8080/assets/roles/banban/warn_parent_zh.mp3",
},
},
),
@@ -94,6 +104,125 @@ async def test_alarm_requests_fresh_gps_before_sms(monkeypatch):
]
@pytest.mark.asyncio
async def test_alarm_ignores_invalid_zero_zero_location(monkeypatch):
service = DeviceAlarmService()
calls = []
async def fake_update_alarm_location(**kwargs):
calls.append(("update_alarm_location", kwargs))
return True
async def fake_resolve_alarm_address(**kwargs):
calls.append(("resolve_alarm_address", kwargs))
monkeypatch.setattr(service, "update_alarm_location", fake_update_alarm_location)
monkeypatch.setattr(service, "resolve_alarm_address", fake_resolve_alarm_address)
updated = await service.apply_alarm_location(
alarm_id=7,
device_id="TalkingQ_device001",
location={
"coord_type": "gcj02",
"lat": 0,
"lng": 0,
"updated_at": "2026-06-22 10:00:00",
},
)
assert updated is False
assert calls == []
@pytest.mark.asyncio
async def test_alarm_current_location_reuses_existing_address(monkeypatch):
service = DeviceAlarmService()
calls = []
async def fake_get_alarm_event(**kwargs):
calls.append(("get_alarm_event", kwargs))
return {
"alarm_id": 7,
"device_id": "TalkingQ_device001",
"lat": 23.2413923,
"lng": 113.6051866,
"address": "广东省广州市增城区中新镇叶岭新村街",
}
async def fake_resolve_alarm_address(**kwargs):
calls.append(("resolve_alarm_address", kwargs))
monkeypatch.setattr(service, "get_alarm_event", fake_get_alarm_event)
monkeypatch.setattr(service, "resolve_alarm_address", fake_resolve_alarm_address)
resolved = await service.resolve_alarm_current_location(
alarm_id=7,
device_id="TalkingQ_device001",
)
assert resolved is True
assert calls == [
("get_alarm_event", {"alarm_id": 7, "device_id": "TalkingQ_device001"}),
]
@pytest.mark.asyncio
async def test_alarm_record_ignores_invalid_current_location_snapshot(monkeypatch):
service = DeviceAlarmService()
calls = []
class FakeDAO:
def __init__(self, session):
del session
async def get_active_binding_context(self, *, device_id):
calls.append(("binding", device_id))
return {"owner_user_id": 3, "child_id": 9}
async def get_current_location_snapshot(self, *, child_id):
calls.append(("snapshot", child_id))
return {
"coord_type": "gcj02",
"lat": 0,
"lng": 0,
"updated_at": "2026-06-22 10:00:00",
"address": None,
}
async def create(self, **kwargs):
calls.append(("create", kwargs))
return 7
class FakeSession:
async def close(self):
calls.append(("close",))
async def fake_get_session():
return FakeSession()
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.device_alarm.DeviceAlarmDAO", FakeDAO)
alarm_id = await service.record_alarm_event(device_id="TalkingQ_device001")
assert alarm_id == 7
assert calls == [
("binding", "TalkingQ_device001"),
("snapshot", 9),
(
"create",
{
"device_id": "TalkingQ_device001",
"owner_user_id": 3,
"child_id": 9,
"source_msg_id": "010",
"location": None,
},
),
("close",),
]
@pytest.mark.asyncio
async def test_alarm_falls_back_to_existing_alarm_location_when_gps_times_out(monkeypatch):
service = TalkingQMQTTService({"broker": "127.0.0.1"})
@@ -148,6 +277,27 @@ async def test_alarm_falls_back_to_existing_alarm_location_when_gps_times_out(mo
]
@pytest.mark.asyncio
async def test_mqtt_location_report_ignores_zero_zero(monkeypatch):
service = LocationService()
sessions = []
async def fail_get_session():
sessions.append("called")
raise AssertionError("invalid location should not open a database session")
monkeypatch.setattr(service, "get_session", fail_get_session)
row = await service.report_mqtt_device_location(
device_id="TalkingQ_device001",
latitude=0,
longitude=0,
)
assert row is None
assert sessions == []
@pytest.mark.asyncio
async def test_gps_waiter_returns_persisted_location(monkeypatch):
service = TalkingQMQTTService({"broker": "127.0.0.1"})
@@ -198,3 +348,31 @@ async def test_gps_waiter_returns_persisted_location(monkeypatch):
]
assert location["lat"] == 30.245
assert location["lng"] == 120.215
def test_current_location_without_address_displays_pending_text():
row = {
"child_id": 9,
"child_name": "测试孩子",
"device_id": "TalkingQ_device001",
"coord_type": "gcj02",
"lat": 23.2413923,
"lng": 113.6051866,
"accuracy_m": None,
"altitude_m": None,
"speed_mps": None,
"heading_deg": None,
"source": 1,
"battery_pct": 80,
"device_time": "2026-06-22 10:00:00",
"server_time": "2026-06-22 10:00:01",
"updated_at": "2026-06-22 10:00:01",
"address": None,
"address_resolved_at": None,
"address_resolve_status": 2,
}
response = _row_to_current_location_response(row)
assert response.address == "地址解析中"
assert response.address_resolve_status == 2

View File

@@ -0,0 +1,39 @@
from datetime import datetime, timedelta
from banban.routers.devices import _row_to_alarm_item
def _alarm_row(created_at: datetime, location_updated_at: datetime):
return {
"alarm_id": 1,
"device_id": "TalkingQ_device001",
"child_id": 2,
"child_name": "测试孩子",
"source_msg_id": "010",
"coord_type": "gcj02",
"lat": 30.245,
"lng": 120.215,
"location_updated_at": location_updated_at,
"address": "浙江省杭州市上城区四季青街道剧院路杭州大剧院",
"address_resolved_at": location_updated_at,
"address_resolve_status": 1,
"created_at": created_at,
}
def test_alarm_location_updated_after_alarm_is_not_stale():
created_at = datetime(2026, 6, 8, 13, 56, 24)
row = _alarm_row(created_at, created_at + timedelta(seconds=2))
item = _row_to_alarm_item(row)
assert item.location_stale is False
def test_alarm_location_far_from_alarm_time_is_stale():
created_at = datetime(2026, 6, 8, 13, 56, 24)
row = _alarm_row(created_at, created_at - timedelta(minutes=20))
item = _row_to_alarm_item(row)
assert item.location_stale is True

View File

@@ -96,6 +96,91 @@ async def test_start_card_bind_rejects_family_member_who_is_not_owner(monkeypatc
assert db_session.closed is True
@pytest.mark.asyncio
async def test_list_card_groups_groups_cards_by_visible_device(monkeypatch):
service = BindingService()
db_session = FakeSession()
async def fake_get_session():
return db_session
async def fake_list_card_groups_by_user(self, user_id):
assert user_id == 99
return [
{
"device_id": "TalkingQ_device001",
"child_id": 7,
"child_name": "孩子一",
"bound_at": datetime(2026, 6, 8, 10, 0, 0),
"card_id": 11,
"card_uuid": "CARD_UUID_A",
"card_name": None,
"card_status": 1,
"card_updated_at": datetime(2026, 6, 8, 10, 1, 0),
},
{
"device_id": "TalkingQ_device001",
"child_id": 7,
"child_name": "孩子一",
"bound_at": datetime(2026, 6, 8, 10, 0, 0),
"card_id": 10,
"card_uuid": "CARD_UUID_B",
"card_name": "备用卡",
"card_status": 1,
"card_updated_at": datetime(2026, 6, 8, 10, 0, 30),
},
{
"device_id": "TalkingQ_device002",
"child_id": None,
"child_name": None,
"bound_at": datetime(2026, 6, 8, 9, 0, 0),
"card_id": None,
"card_uuid": None,
"card_name": None,
"card_status": None,
"card_updated_at": None,
},
]
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.binding.BindingDAO.list_card_groups_by_user", fake_list_card_groups_by_user)
groups = await service.list_card_groups(user_id=99)
assert groups == [
{
"device_id": "TalkingQ_device001",
"child_id": 7,
"child_name": "孩子一",
"bound_at": datetime(2026, 6, 8, 10, 0, 0),
"cards": [
{
"card_id": 11,
"card_uuid": "CARD_UUID_A",
"card_name": None,
"status": 1,
"updated_at": datetime(2026, 6, 8, 10, 1, 0),
},
{
"card_id": 10,
"card_uuid": "CARD_UUID_B",
"card_name": "备用卡",
"status": 1,
"updated_at": datetime(2026, 6, 8, 10, 0, 30),
},
],
},
{
"device_id": "TalkingQ_device002",
"child_id": None,
"child_name": None,
"bound_at": datetime(2026, 6, 8, 9, 0, 0),
"cards": [],
},
]
assert db_session.closed is True
@pytest.mark.asyncio
async def test_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch):
service = BindingService()
@@ -311,3 +396,137 @@ async def test_finalize_nfc_bind_adds_card_without_rebinding_device(monkeypatch)
assert db_session.commits == 1
assert db_session.rollbacks == 0
assert db_session.closed is True
@pytest.mark.asyncio
async def test_update_card_name_allows_device_owner(monkeypatch):
service = BindingService()
db_session = FakeSession()
updated_cards = []
async def fake_get_session():
return db_session
async def fake_get_visible_card_by_id(self, card_id, user_id):
assert card_id == 11
assert user_id == 99
return {
"card_id": 11,
"card_uuid": "CARD_UUID_A",
"card_name": None,
"card_status": 1,
"card_updated_at": datetime(2026, 6, 8, 10, 1, 0),
"device_id": "TalkingQ_device001",
"owner_user_id": 99,
}
async def fake_update_card_name(self, card_id, card_name):
updated_cards.append((card_id, card_name))
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.binding.BindingDAO.get_visible_card_by_id", fake_get_visible_card_by_id)
monkeypatch.setattr("banban.service.binding.BindingDAO.update_card_name", fake_update_card_name)
result = await service.update_card_name(card_id=11, card_name=" 奶奶的卡 ", user_id=99)
assert result == {
"card_id": 11,
"card_uuid": "CARD_UUID_A",
"card_name": "奶奶的卡",
"status": 1,
"updated_at": datetime(2026, 6, 8, 10, 1, 0),
}
assert updated_cards == [(11, "奶奶的卡")]
assert db_session.commits == 1
assert db_session.closed is True
@pytest.mark.asyncio
async def test_update_card_name_rejects_family_member(monkeypatch):
service = BindingService()
db_session = FakeSession()
updated_cards = []
async def fake_get_session():
return db_session
async def fake_get_visible_card_by_id(self, card_id, user_id):
assert card_id == 11
assert user_id == 88
return {
"card_id": 11,
"card_uuid": "CARD_UUID_A",
"card_name": None,
"card_status": 1,
"card_updated_at": datetime(2026, 6, 8, 10, 1, 0),
"device_id": "TalkingQ_device001",
"owner_user_id": 99,
}
async def fake_update_card_name(self, card_id, card_name):
updated_cards.append((card_id, card_name))
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.binding.BindingDAO.get_visible_card_by_id", fake_get_visible_card_by_id)
monkeypatch.setattr("banban.service.binding.BindingDAO.update_card_name", fake_update_card_name)
with pytest.raises(BindingError, match="only the device owner can rename cards"):
await service.update_card_name(card_id=11, card_name="备用卡", user_id=88)
assert updated_cards == []
assert db_session.commits == 0
assert db_session.closed is True
@pytest.mark.asyncio
async def test_update_card_name_allows_clearing_name(monkeypatch):
service = BindingService()
db_session = FakeSession()
updated_cards = []
async def fake_get_session():
return db_session
async def fake_get_visible_card_by_id(self, card_id, user_id):
assert card_id == 11
assert user_id == 99
return {
"card_id": 11,
"card_uuid": "CARD_UUID_A",
"card_name": "备用卡",
"card_status": 1,
"card_updated_at": None,
"device_id": "TalkingQ_device001",
"owner_user_id": 99,
}
async def fake_update_card_name(self, card_id, card_name):
updated_cards.append((card_id, card_name))
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.binding.BindingDAO.get_visible_card_by_id", fake_get_visible_card_by_id)
monkeypatch.setattr("banban.service.binding.BindingDAO.update_card_name", fake_update_card_name)
result = await service.update_card_name(card_id=11, card_name=" ", user_id=99)
assert result["card_name"] is None
assert updated_cards == [(11, None)]
assert db_session.commits == 1
assert db_session.closed is True
@pytest.mark.asyncio
async def test_update_card_name_rejects_too_long_name(monkeypatch):
service = BindingService()
db_session = FakeSession()
async def fake_get_session():
return db_session
monkeypatch.setattr(service, "get_session", fake_get_session)
with pytest.raises(BindingError, match="64 characters or fewer"):
await service.update_card_name(card_id=11, card_name="" * 65, user_id=99)
assert db_session.commits == 0
assert db_session.closed is False

View File

@@ -1,6 +1,10 @@
import pytest
from database.init_db import _ensure_bind_session_card_columns, _ensure_cards_allow_multiple_per_device
from database.init_db import (
_ensure_bind_session_card_columns,
_ensure_cards_allow_multiple_per_device,
_ensure_device_family_tables,
)
class FakeScalarResult:
@@ -21,6 +25,7 @@ class FakeConnection:
self.column_counts = {
"bind_mode": 0,
"card_uuid": 0,
"display_name": 0,
}
self.sql = []
@@ -64,3 +69,15 @@ async def test_ensure_cards_allow_multiple_per_device_replaces_unique_device_ind
assert executed.index("ALTER TABLE cards ADD INDEX idx_cards_device_id") < executed.index(
"ALTER TABLE cards DROP INDEX uq_cards_device_id"
)
@pytest.mark.asyncio
async def test_ensure_device_family_tables_adds_display_name_column():
conn = FakeConnection()
await _ensure_device_family_tables(conn)
executed = "\n".join(conn.sql)
assert "CREATE TABLE IF NOT EXISTS device_family_members" in executed
assert "display_name VARCHAR(64) NULL" in executed
assert "ALTER TABLE device_family_members ADD COLUMN display_name VARCHAR(64) NULL AFTER role" in executed

View File

@@ -0,0 +1,354 @@
import sqlite3
import pytest
from fastapi import HTTPException
from banban.dao.family import FAMILY_ROLE_MEMBER, FAMILY_ROLE_OWNER, FAMILY_STATUS_ACTIVE, FamilyDAO
from banban.service.family import FamilyService
class FakeSession:
def __init__(self):
self.commits = 0
self.rollbacks = 0
self.closed = False
async def commit(self):
self.commits += 1
async def rollback(self):
self.rollbacks += 1
async def close(self):
self.closed = True
class SqliteMappingResult:
def __init__(self, rows):
self.rows = rows
def all(self):
return self.rows
def first(self):
return self.rows[0] if self.rows else None
class SqliteResult:
def __init__(self, rows, rowcount):
self.rows = rows
self.rowcount = rowcount
def mappings(self):
return SqliteMappingResult(self.rows)
def scalar(self):
if not self.rows:
return None
return next(iter(self.rows[0].values()))
def scalar_one_or_none(self):
return self.scalar()
class SqliteAsyncSession:
def __init__(self):
self.conn = sqlite3.connect(":memory:")
self.conn.row_factory = sqlite3.Row
async def execute(self, statement, params=None):
cursor = self.conn.execute(str(statement), params or {})
rows = [dict(row) for row in cursor.fetchall()] if cursor.description else []
return SqliteResult(rows, cursor.rowcount)
def _create_family_dao_session() -> SqliteAsyncSession:
session = SqliteAsyncSession()
conn = session.conn
conn.executescript(
"""
CREATE TABLE parents (
user_id INTEGER PRIMARY KEY,
nickname TEXT,
avatar_url TEXT,
status INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE device_bindings (
device_id TEXT PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
child_id INTEGER,
status INTEGER NOT NULL,
bound_at TEXT
);
CREATE TABLE device_family_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
role INTEGER NOT NULL,
display_name TEXT,
status INTEGER NOT NULL,
invited_by_user_id INTEGER,
joined_at TEXT NOT NULL,
removed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (device_id, user_id)
);
"""
)
conn.executemany(
"INSERT INTO parents (user_id, nickname, avatar_url, status) VALUES (?, ?, ?, 1)",
[
(10, "妈妈微信", None),
(20, "爸爸微信", None),
(77, "姑姑微信", None),
(88, "小明微信", None),
],
)
conn.executemany(
"INSERT INTO device_bindings (device_id, owner_user_id, child_id, status, bound_at) VALUES (?, ?, ?, 1, ?)",
[
("TalkingQ_family_a", 10, 1001, "2026-06-08 10:00:00"),
("TalkingQ_family_b", 20, 1002, "2026-06-08 10:00:00"),
],
)
conn.executemany(
"""
INSERT INTO device_family_members (
device_id,
user_id,
role,
display_name,
status,
invited_by_user_id,
joined_at,
removed_at,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
""",
[
("TalkingQ_family_a", 10, FAMILY_ROLE_OWNER, None, FAMILY_STATUS_ACTIVE, None, "2026-06-08 10:00:00", "2026-06-08 10:00:00", "2026-06-08 10:00:00"),
("TalkingQ_family_a", 88, FAMILY_ROLE_MEMBER, None, FAMILY_STATUS_ACTIVE, 10, "2026-06-08 10:01:00", "2026-06-08 10:01:00", "2026-06-08 10:01:00"),
("TalkingQ_family_a", 77, FAMILY_ROLE_MEMBER, None, FAMILY_STATUS_ACTIVE, 10, "2026-06-08 10:02:00", "2026-06-08 10:02:00", "2026-06-08 10:02:00"),
("TalkingQ_family_b", 20, FAMILY_ROLE_OWNER, None, FAMILY_STATUS_ACTIVE, None, "2026-06-08 10:00:00", "2026-06-08 10:00:00", "2026-06-08 10:00:00"),
("TalkingQ_family_b", 88, FAMILY_ROLE_MEMBER, None, FAMILY_STATUS_ACTIVE, 20, "2026-06-08 10:01:00", "2026-06-08 10:01:00", "2026-06-08 10:01:00"),
],
)
conn.commit()
return session
def _row_by_user(rows, user_id):
return next(row for row in rows if int(row["user_id"]) == user_id)
@pytest.mark.asyncio
async def test_update_member_display_name_allows_owner(monkeypatch):
service = FamilyService()
db_session = FakeSession()
updates = []
async def fake_get_session():
return db_session
async def fake_get_binding_for_owner(self, device_id, user_id):
assert device_id == "TalkingQ_device001"
assert user_id == 99
return {"device_id": device_id, "owner_user_id": user_id}
async def fake_update_member_display_name(self, **kwargs):
updates.append(kwargs)
return {
"user_id": 88,
"nickname": "奶奶",
"display_name": "奶奶",
"account_nickname": "微信昵称",
"avatar_url": None,
"role": 2,
"is_owner": 0,
"joined_at": None,
}
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.family.FamilyDAO.get_binding_for_owner", fake_get_binding_for_owner)
monkeypatch.setattr("banban.service.family.FamilyDAO.update_member_display_name", fake_update_member_display_name)
result = await service.update_member_display_name(
device_id="TalkingQ_device001",
member_user_id=88,
display_name=" 奶奶 ",
user_id=99,
)
assert result["display_name"] == "奶奶"
assert updates == [
{
"device_id": "TalkingQ_device001",
"member_user_id": 88,
"owner_user_id": 99,
"display_name": "奶奶",
}
]
assert db_session.commits == 1
assert db_session.rollbacks == 0
assert db_session.closed is True
@pytest.mark.asyncio
async def test_update_member_display_name_rejects_non_owner(monkeypatch):
service = FamilyService()
db_session = FakeSession()
updates = []
async def fake_get_session():
return db_session
async def fake_get_binding_for_owner(self, device_id, user_id):
return None
async def fake_update_member_display_name(self, **kwargs):
updates.append(kwargs)
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.family.FamilyDAO.get_binding_for_owner", fake_get_binding_for_owner)
monkeypatch.setattr("banban.service.family.FamilyDAO.update_member_display_name", fake_update_member_display_name)
with pytest.raises(HTTPException) as exc:
await service.update_member_display_name(
device_id="TalkingQ_device001",
member_user_id=88,
display_name="奶奶",
user_id=77,
)
assert exc.value.status_code == 403
assert updates == []
assert db_session.commits == 0
assert db_session.rollbacks == 1
assert db_session.closed is True
@pytest.mark.asyncio
async def test_update_member_display_name_allows_clearing(monkeypatch):
service = FamilyService()
db_session = FakeSession()
updates = []
async def fake_get_session():
return db_session
async def fake_get_binding_for_owner(self, device_id, user_id):
return {"device_id": device_id, "owner_user_id": user_id}
async def fake_update_member_display_name(self, **kwargs):
updates.append(kwargs)
return {
"user_id": 88,
"nickname": "微信昵称",
"display_name": None,
"account_nickname": "微信昵称",
"avatar_url": None,
"role": 2,
"is_owner": 0,
"joined_at": None,
}
monkeypatch.setattr(service, "get_session", fake_get_session)
monkeypatch.setattr("banban.service.family.FamilyDAO.get_binding_for_owner", fake_get_binding_for_owner)
monkeypatch.setattr("banban.service.family.FamilyDAO.update_member_display_name", fake_update_member_display_name)
result = await service.update_member_display_name(
device_id="TalkingQ_device001",
member_user_id=88,
display_name=" ",
user_id=99,
)
assert result["display_name"] is None
assert updates[0]["display_name"] is None
assert db_session.commits == 1
assert db_session.closed is True
@pytest.mark.asyncio
async def test_update_member_display_name_rejects_too_long_value():
service = FamilyService()
with pytest.raises(HTTPException) as exc:
await service.update_member_display_name(
device_id="TalkingQ_device001",
member_user_id=88,
display_name="" * 65,
user_id=99,
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_display_name_is_scoped_per_family_and_visible_to_every_member_view():
session = _create_family_dao_session()
dao = FamilyDAO(session)
updated = await dao.update_member_display_name(
device_id="TalkingQ_family_a",
member_user_id=88,
owner_user_id=10,
display_name="奶奶",
)
assert updated is not None
assert updated["user_id"] == 88
assert updated["display_name"] == "奶奶"
assert updated["nickname"] == "奶奶"
assert updated["account_nickname"] == "小明微信"
for viewer_user_id in (10, 88, 77):
rows = await dao.list_members(device_id="TalkingQ_family_a", user_id=viewer_user_id)
renamed_member = _row_by_user(rows, 88)
assert renamed_member["display_name"] == "奶奶"
assert renamed_member["nickname"] == "奶奶"
assert renamed_member["account_nickname"] == "小明微信"
for viewer_user_id in (20, 88):
rows = await dao.list_members(device_id="TalkingQ_family_b", user_id=viewer_user_id)
same_account_in_other_family = _row_by_user(rows, 88)
assert same_account_in_other_family["display_name"] is None
assert same_account_in_other_family["nickname"] == "小明微信"
assert same_account_in_other_family["account_nickname"] == "小明微信"
parent_row = session.conn.execute("SELECT nickname FROM parents WHERE user_id = ?", (88,)).fetchone()
assert parent_row["nickname"] == "小明微信"
@pytest.mark.asyncio
async def test_cleared_display_name_falls_back_to_account_nickname_from_self_view():
session = _create_family_dao_session()
dao = FamilyDAO(session)
await dao.update_member_display_name(
device_id="TalkingQ_family_a",
member_user_id=88,
owner_user_id=10,
display_name="奶奶",
)
cleared = await dao.update_member_display_name(
device_id="TalkingQ_family_a",
member_user_id=88,
owner_user_id=10,
display_name=None,
)
assert cleared is not None
assert cleared["display_name"] is None
assert cleared["nickname"] == "小明微信"
assert cleared["account_nickname"] == "小明微信"
rows = await dao.list_members(device_id="TalkingQ_family_a", user_id=88)
self_view = _row_by_user(rows, 88)
assert self_view["display_name"] is None
assert self_view["nickname"] == "小明微信"

View File

@@ -28,6 +28,10 @@ async def test_device_info_report_persists_metadata_and_periodic_location(monkey
monkeypatch.setattr(service, "_schedule_persistence", fake_schedule_persistence)
monkeypatch.setattr("handlers.mqtt_handler.device_setting_service.insert_or_update", fake_insert_or_update)
monkeypatch.setattr("handlers.mqtt_handler.location_service.report_mqtt_device_location", fake_report_mqtt_device_location)
monkeypatch.setattr(
"handlers.mqtt_handler.wechat_mp_notification_service.schedule_low_battery_notification",
lambda **kwargs: None,
)
await service._handle_device_info(
"TalkingQ_XQSN00001005",
@@ -102,6 +106,10 @@ async def test_device_info_report_without_location_does_not_write_location(monke
monkeypatch.setattr(service, "_publish", fake_publish)
monkeypatch.setattr(service, "_schedule_persistence", fake_schedule_persistence)
monkeypatch.setattr("handlers.mqtt_handler.device_setting_service.insert_or_update", fake_insert_or_update)
monkeypatch.setattr(
"handlers.mqtt_handler.wechat_mp_notification_service.schedule_low_battery_notification",
lambda **kwargs: None,
)
await service._handle_device_info(
"TalkingQ_XQSN00001005",

View File

@@ -0,0 +1,215 @@
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",
}
]

View File

@@ -175,6 +175,7 @@ async def test_resolve_notification_link_returns_chat_detail_params(monkeypatch)
def first(self):
return {
"conversation_id": 6,
"message_id": 209,
"child_id": 8,
"child_name": "小明",
"device_id": "TalkingQ_XQSN00001007",
@@ -186,6 +187,7 @@ async def test_resolve_notification_link_returns_chat_detail_params(monkeypatch)
assert params["conversation_id"] == 6
assert params["child_id"] == 8
assert params["user_id"] == 4
assert params["device_id"] == "TalkingQ_XQSN00001007"
return FakeResult()
async def close(self):
@@ -231,10 +233,89 @@ async def test_resolve_notification_link_rejects_other_user(monkeypatch):
}
)
class FakeResult:
def mappings(self):
return self
def first(self):
return None
class FakeSession:
async def execute(self, sql, params):
assert params["message_id"] == 209
assert params["conversation_id"] == 6
assert params["child_id"] == 8
assert params["user_id"] == 5
return FakeResult()
async def close(self):
pass
async def fake_get_session():
return FakeSession()
monkeypatch.setattr(service, "get_session", fake_get_session)
with pytest.raises(HTTPException) as exc_info:
await service.resolve_notification_link(token=token, user_id=5)
assert exc_info.value.status_code == 403
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_resolve_notification_link_redirects_to_current_family_member_message(monkeypatch):
configure_enabled_wechat_mp(monkeypatch)
service = WechatMpNotificationService()
token = service._create_notification_token(
{
"type": "leave_message",
"user_id": 4,
"conversation_id": 6,
"message_id": 209,
"child_id": 8,
"child_name": "小明",
"device_id": "TalkingQ_XQSN00001007",
}
)
class FakeResult:
def mappings(self):
return self
def first(self):
return {
"conversation_id": 22,
"message_id": 216,
"child_id": 8,
"child_name": "小明",
"device_id": "TalkingQ_XQSN00001007",
}
class FakeSession:
async def execute(self, sql, params):
assert params["message_id"] == 209
assert params["conversation_id"] == 6
assert params["child_id"] == 8
assert params["user_id"] == 3
assert params["user_id_str"] == "3"
assert params["device_id"] == "TalkingQ_XQSN00001007"
return FakeResult()
async def close(self):
pass
async def fake_get_session():
return FakeSession()
monkeypatch.setattr(service, "get_session", fake_get_session)
result = await service.resolve_notification_link(token=token, user_id=3)
assert result["route"] == "pages/chat/detail/index"
assert result["params"]["id"] == 22
assert result["params"]["parentUserId"] == 3
assert result["params"]["peerId"] == "3"
assert result["params"]["messageId"] == 216
@pytest.mark.asyncio

View File

@@ -0,0 +1,22 @@
import pytest
from httpx import ASGITransport, AsyncClient
from main import app
@pytest.mark.asyncio
async def test_wechat_verify_file_served_at_root():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
response = await client.get("/HXNd3XMbPq.txt")
assert response.status_code == 200
assert response.text == "60756bcfc3d838155f64397bec395a44"
assert response.headers["content-type"].startswith("text/plain")
@pytest.mark.asyncio
async def test_unknown_root_txt_file_is_not_exposed():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
response = await client.get("/unknown-wechat-verify.txt")
assert response.status_code == 404