355 lines
11 KiB
Python
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"] == "小明微信"
|