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