533 lines
18 KiB
Python
533 lines
18 KiB
Python
from datetime import datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from banban.dao.binding import BIND_MODE_CARD, BIND_MODE_DEVICE, SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED
|
|
from banban.service.binding import BindingError, BindingService
|
|
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_card_bind_requires_existing_authorized_binding_and_sends_nfc_command(monkeypatch):
|
|
service = BindingService()
|
|
db_session = FakeSession()
|
|
started_binds = []
|
|
sent_commands = []
|
|
|
|
async def fake_get_session():
|
|
return db_session
|
|
|
|
async def fake_get_by_device(self, device_id, user_id):
|
|
assert device_id == "TalkingQ_device001"
|
|
assert user_id == 99
|
|
return {
|
|
"device_id": "TalkingQ_device001",
|
|
"child_id": 7,
|
|
"owner_user_id": 99,
|
|
}
|
|
|
|
async def fake_start_bind(self, user_id, device_id, child_id, bind_mode):
|
|
started_binds.append((user_id, device_id, child_id, bind_mode))
|
|
return "bind-token-card", datetime.utcnow() + timedelta(minutes=10)
|
|
|
|
async def fake_send_bind_nfc_command(device_id):
|
|
sent_commands.append(device_id)
|
|
|
|
monkeypatch.setattr(service, "get_session", fake_get_session)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.get_by_device", fake_get_by_device)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.start_bind", fake_start_bind)
|
|
monkeypatch.setattr(service, "_send_bind_nfc_command", fake_send_bind_nfc_command)
|
|
|
|
bind_token, expires_at = await service.start_card_bind(user_id=99, device_id="TalkingQ_device001")
|
|
|
|
assert bind_token == "bind-token-card"
|
|
assert expires_at > datetime.utcnow()
|
|
assert started_binds == [(99, "TalkingQ_device001", 7, BIND_MODE_CARD)]
|
|
assert sent_commands == ["TalkingQ_device001"]
|
|
assert db_session.commits == 1
|
|
assert db_session.closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_card_bind_rejects_family_member_who_is_not_owner(monkeypatch):
|
|
service = BindingService()
|
|
db_session = FakeSession()
|
|
started_binds = []
|
|
|
|
async def fake_get_session():
|
|
return db_session
|
|
|
|
async def fake_get_by_device(self, device_id, user_id):
|
|
assert device_id == "TalkingQ_device001"
|
|
assert user_id == 88
|
|
return {
|
|
"device_id": "TalkingQ_device001",
|
|
"child_id": 7,
|
|
"owner_user_id": 99,
|
|
}
|
|
|
|
async def fake_start_bind(self, *args, **kwargs):
|
|
started_binds.append((args, kwargs))
|
|
|
|
monkeypatch.setattr(service, "get_session", fake_get_session)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.get_by_device", fake_get_by_device)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.start_bind", fake_start_bind)
|
|
|
|
with pytest.raises(BindingError, match="only the device owner can add cards"):
|
|
await service.start_card_bind(user_id=88, device_id="TalkingQ_device001")
|
|
|
|
assert started_binds == []
|
|
assert db_session.commits == 0
|
|
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()
|
|
db_session = FakeSession()
|
|
expired_session = {
|
|
"id": 42,
|
|
"bind_token": "bind-token-expired",
|
|
"device_id": "TalkingQ_device001",
|
|
"target_child_id": 7,
|
|
"initiator_user_id": 99,
|
|
"expires_at": datetime.utcnow() - timedelta(seconds=1),
|
|
}
|
|
marked_statuses = []
|
|
activated_cards = []
|
|
completed_sessions = []
|
|
|
|
async def fake_get_session():
|
|
return db_session
|
|
|
|
async def fake_get_latest_pending_session_by_device(self, device_id):
|
|
assert device_id == "TalkingQ_device001"
|
|
return expired_session
|
|
|
|
async def fake_mark_session_status(self, session_id, status):
|
|
marked_statuses.append((session_id, status))
|
|
|
|
async def fake_activate_card(**kwargs):
|
|
activated_cards.append(kwargs)
|
|
|
|
async def fake_complete_nfc_bind(self, **kwargs):
|
|
completed_sessions.append(kwargs)
|
|
|
|
monkeypatch.setattr(service, "get_session", fake_get_session)
|
|
monkeypatch.setattr(
|
|
"banban.service.binding.BindingDAO.get_latest_pending_session_by_device",
|
|
fake_get_latest_pending_session_by_device,
|
|
)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.mark_session_status", fake_mark_session_status)
|
|
monkeypatch.setattr("banban.service.binding.card_service.activate_card", fake_activate_card)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_nfc_bind", fake_complete_nfc_bind)
|
|
|
|
result = await service.finalize_nfc_bind(
|
|
device_id="TalkingQ_device001",
|
|
card_uuid="CARD_UUID_LATE",
|
|
)
|
|
|
|
assert result == {
|
|
"device_id": "TalkingQ_device001",
|
|
"bind_token": "bind-token-expired",
|
|
"status": SESSION_STATUS_EXPIRED,
|
|
}
|
|
assert marked_statuses == [(42, SESSION_STATUS_EXPIRED)]
|
|
assert activated_cards == []
|
|
assert completed_sessions == []
|
|
assert db_session.commits == 1
|
|
assert db_session.rollbacks == 0
|
|
assert db_session.closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
|
service = BindingService()
|
|
db_session = FakeSession()
|
|
pending_session = {
|
|
"id": 43,
|
|
"bind_token": "bind-token-active",
|
|
"device_id": "TalkingQ_device001",
|
|
"target_child_id": 7,
|
|
"initiator_user_id": 99,
|
|
"expires_at": datetime.utcnow() + timedelta(minutes=10),
|
|
"bind_mode": BIND_MODE_DEVICE,
|
|
}
|
|
marked_statuses = []
|
|
activated_cards = []
|
|
completed_sessions = []
|
|
|
|
async def fake_get_session():
|
|
return db_session
|
|
|
|
async def fake_get_latest_pending_session_by_device(self, device_id):
|
|
assert device_id == "TalkingQ_device001"
|
|
return pending_session
|
|
|
|
async def fake_mark_session_status(self, session_id, status):
|
|
marked_statuses.append((session_id, status))
|
|
|
|
async def fake_activate_card(**kwargs):
|
|
activated_cards.append(kwargs)
|
|
|
|
class FakeCard:
|
|
card_uuid = kwargs["card_uuid"]
|
|
|
|
return FakeCard()
|
|
|
|
async def fake_complete_nfc_bind(self, **kwargs):
|
|
completed_sessions.append(kwargs)
|
|
|
|
monkeypatch.setattr(service, "get_session", fake_get_session)
|
|
monkeypatch.setattr(
|
|
"banban.service.binding.BindingDAO.get_latest_pending_session_by_device",
|
|
fake_get_latest_pending_session_by_device,
|
|
)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.mark_session_status", fake_mark_session_status)
|
|
monkeypatch.setattr("banban.service.binding.card_service.activate_card", fake_activate_card)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_nfc_bind", fake_complete_nfc_bind)
|
|
|
|
result = await service.finalize_nfc_bind(
|
|
device_id="TalkingQ_device001",
|
|
card_uuid="CARD_UUID_ON_TIME",
|
|
)
|
|
|
|
assert result == {
|
|
"device_id": "TalkingQ_device001",
|
|
"bind_token": "bind-token-active",
|
|
"status": SESSION_STATUS_COMPLETED,
|
|
"child_id": 7,
|
|
"card_uuid": "CARD_UUID_ON_TIME",
|
|
}
|
|
assert marked_statuses == []
|
|
assert activated_cards == [
|
|
{
|
|
"card_uuid": "CARD_UUID_ON_TIME",
|
|
"device_id": "TalkingQ_device001",
|
|
"db_session": db_session,
|
|
}
|
|
]
|
|
assert completed_sessions == [
|
|
{
|
|
"session_id": 43,
|
|
"device_id": "TalkingQ_device001",
|
|
"child_id": 7,
|
|
"user_id": 99,
|
|
"card_uuid": "CARD_UUID_ON_TIME",
|
|
}
|
|
]
|
|
assert db_session.commits == 1
|
|
assert db_session.rollbacks == 0
|
|
assert db_session.closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_nfc_bind_adds_card_without_rebinding_device(monkeypatch):
|
|
service = BindingService()
|
|
db_session = FakeSession()
|
|
pending_session = {
|
|
"id": 44,
|
|
"bind_token": "bind-token-card",
|
|
"device_id": "TalkingQ_device001",
|
|
"target_child_id": 7,
|
|
"initiator_user_id": 99,
|
|
"expires_at": datetime.utcnow() + timedelta(minutes=10),
|
|
"bind_mode": BIND_MODE_CARD,
|
|
}
|
|
activated_cards = []
|
|
completed_card_sessions = []
|
|
completed_device_sessions = []
|
|
|
|
async def fake_get_session():
|
|
return db_session
|
|
|
|
async def fake_get_latest_pending_session_by_device(self, device_id):
|
|
assert device_id == "TalkingQ_device001"
|
|
return pending_session
|
|
|
|
async def fake_mark_session_status(self, session_id, status):
|
|
raise AssertionError(f"unexpected status mark: {session_id}, {status}")
|
|
|
|
async def fake_activate_card(**kwargs):
|
|
activated_cards.append(kwargs)
|
|
|
|
class FakeCard:
|
|
card_uuid = kwargs["card_uuid"]
|
|
|
|
return FakeCard()
|
|
|
|
async def fake_complete_card_bind(self, **kwargs):
|
|
completed_card_sessions.append(kwargs)
|
|
|
|
async def fake_complete_nfc_bind(self, **kwargs):
|
|
completed_device_sessions.append(kwargs)
|
|
|
|
monkeypatch.setattr(service, "get_session", fake_get_session)
|
|
monkeypatch.setattr(
|
|
"banban.service.binding.BindingDAO.get_latest_pending_session_by_device",
|
|
fake_get_latest_pending_session_by_device,
|
|
)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.mark_session_status", fake_mark_session_status)
|
|
monkeypatch.setattr("banban.service.binding.card_service.activate_card", fake_activate_card)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_card_bind", fake_complete_card_bind)
|
|
monkeypatch.setattr("banban.service.binding.BindingDAO.complete_nfc_bind", fake_complete_nfc_bind)
|
|
|
|
result = await service.finalize_nfc_bind(
|
|
device_id="TalkingQ_device001",
|
|
card_uuid="CARD_UUID_EXTRA",
|
|
)
|
|
|
|
assert result == {
|
|
"device_id": "TalkingQ_device001",
|
|
"bind_token": "bind-token-card",
|
|
"status": SESSION_STATUS_COMPLETED,
|
|
"child_id": 7,
|
|
"card_uuid": "CARD_UUID_EXTRA",
|
|
}
|
|
assert activated_cards == [
|
|
{
|
|
"card_uuid": "CARD_UUID_EXTRA",
|
|
"device_id": "TalkingQ_device001",
|
|
"db_session": db_session,
|
|
}
|
|
]
|
|
assert completed_card_sessions == [{"session_id": 44, "card_uuid": "CARD_UUID_EXTRA"}]
|
|
assert completed_device_sessions == []
|
|
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
|