Files
banban/talkingq-url/tests/test_family_display_name.py
2026-07-07 16:58:05 +08:00

355 lines
11 KiB
Python

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"] == "小明微信"