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