支持已绑定设备追加 NFC 卡
- 新增已绑定设备追加卡会话和小程序入口 - 允许同设备多张 active 卡,保留 card_uuid 唯一并补齐老库迁移 - 收紧未知卡自动绑定和跨设备卡复用 验证:talkingq-url/.venv/bin/python -m pytest tests;compileall;npm run build:weapp
This commit is contained in:
@@ -2,8 +2,8 @@ from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from banban.dao.binding import SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED
|
||||
from banban.service.binding import BindingService
|
||||
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:
|
||||
@@ -22,6 +22,80 @@ class FakeSession:
|
||||
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_finalize_nfc_bind_expires_late_card_without_binding(monkeypatch):
|
||||
service = BindingService()
|
||||
@@ -92,6 +166,7 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
||||
"target_child_id": 7,
|
||||
"initiator_user_id": 99,
|
||||
"expires_at": datetime.utcnow() + timedelta(minutes=10),
|
||||
"bind_mode": BIND_MODE_DEVICE,
|
||||
}
|
||||
marked_statuses = []
|
||||
activated_cards = []
|
||||
@@ -110,6 +185,11 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
||||
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)
|
||||
|
||||
@@ -148,8 +228,86 @@ async def test_finalize_nfc_bind_completes_before_expiry(monkeypatch):
|
||||
"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
|
||||
|
||||
108
talkingq-url/tests/test_card_service.py
Normal file
108
talkingq-url/tests/test_card_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import pytest
|
||||
|
||||
from services.card_service import Card, CardService
|
||||
|
||||
|
||||
class FakeSession:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_card_keeps_existing_device_cards(monkeypatch):
|
||||
service = CardService()
|
||||
existing = Card(
|
||||
card_id=1,
|
||||
card_uuid="CARD_A",
|
||||
device_id="TalkingQ_device001",
|
||||
status=1,
|
||||
)
|
||||
saved_cards = []
|
||||
session = FakeSession()
|
||||
|
||||
async def fake_init_database():
|
||||
return None
|
||||
|
||||
async def fake_get_card_by_uuid(card_uuid, force_refresh=False, db_session=None):
|
||||
del force_refresh, db_session
|
||||
return None
|
||||
|
||||
async def fake_save_card_to_db(card, async_session, commit=True):
|
||||
del async_session, commit
|
||||
saved_cards.append(card)
|
||||
card.card_id = 2
|
||||
|
||||
monkeypatch.setattr(service, "_init_database", fake_init_database)
|
||||
monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid)
|
||||
monkeypatch.setattr(service, "_save_card_to_db", fake_save_card_to_db)
|
||||
|
||||
new_card = await service.activate_card(
|
||||
card_uuid="CARD_B",
|
||||
device_id="TalkingQ_device001",
|
||||
db_session=session,
|
||||
)
|
||||
|
||||
assert existing.device_id == "TalkingQ_device001"
|
||||
assert existing.status == 1
|
||||
assert new_card.card_uuid == "CARD_B"
|
||||
assert new_card.device_id == "TalkingQ_device001"
|
||||
assert new_card.status == 1
|
||||
assert saved_cards == [new_card]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_card_rejects_card_bound_to_other_device(monkeypatch):
|
||||
service = CardService()
|
||||
existing = Card(
|
||||
card_id=1,
|
||||
card_uuid="CARD_A",
|
||||
device_id="TalkingQ_device002",
|
||||
status=1,
|
||||
)
|
||||
saved_cards = []
|
||||
session = FakeSession()
|
||||
|
||||
async def fake_init_database():
|
||||
return None
|
||||
|
||||
async def fake_get_card_by_uuid(card_uuid, force_refresh=False, db_session=None):
|
||||
del force_refresh, db_session
|
||||
return existing if card_uuid == "CARD_A" else None
|
||||
|
||||
async def fake_save_card_to_db(card, async_session, commit=True):
|
||||
del async_session, commit
|
||||
saved_cards.append(card)
|
||||
|
||||
monkeypatch.setattr(service, "_init_database", fake_init_database)
|
||||
monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid)
|
||||
monkeypatch.setattr(service, "_save_card_to_db", fake_save_card_to_db)
|
||||
|
||||
with pytest.raises(ValueError, match="card is already bound to another device"):
|
||||
await service.activate_card(
|
||||
card_uuid="CARD_A",
|
||||
device_id="TalkingQ_device001",
|
||||
db_session=session,
|
||||
)
|
||||
|
||||
assert existing.device_id == "TalkingQ_device002"
|
||||
assert saved_cards == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_active_for_device_requires_matching_active_card(monkeypatch):
|
||||
service = CardService()
|
||||
active_card = Card(card_uuid="CARD_A", device_id="TalkingQ_device001", status=1)
|
||||
inactive_card = Card(card_uuid="CARD_B", device_id="TalkingQ_device001", status=0)
|
||||
|
||||
async def fake_get_card_by_uuid(card_uuid):
|
||||
return {
|
||||
"CARD_A": active_card,
|
||||
"CARD_B": inactive_card,
|
||||
"CARD_C": Card(card_uuid="CARD_C", device_id="TalkingQ_device002", status=1),
|
||||
}.get(card_uuid)
|
||||
|
||||
monkeypatch.setattr(service, "get_card_by_uuid", fake_get_card_by_uuid)
|
||||
|
||||
assert await service.is_active_for_device("CARD_A", "TalkingQ_device001") is True
|
||||
assert await service.is_active_for_device("CARD_B", "TalkingQ_device001") is False
|
||||
assert await service.is_active_for_device("CARD_C", "TalkingQ_device001") is False
|
||||
assert await service.is_active_for_device("CARD_MISSING", "TalkingQ_device001") is False
|
||||
66
talkingq-url/tests/test_db_init_cards.py
Normal file
66
talkingq-url/tests/test_db_init_cards.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
|
||||
from database.init_db import _ensure_bind_session_card_columns, _ensure_cards_allow_multiple_per_device
|
||||
|
||||
|
||||
class FakeScalarResult:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def scalar(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self):
|
||||
self.stat_counts = {
|
||||
"idx_device_bind_sessions_card_uuid": 0,
|
||||
"idx_cards_device_id": 0,
|
||||
"uq_cards_device_id": 1,
|
||||
}
|
||||
self.column_counts = {
|
||||
"bind_mode": 0,
|
||||
"card_uuid": 0,
|
||||
}
|
||||
self.sql = []
|
||||
|
||||
async def execute(self, statement, params=None):
|
||||
sql = str(statement)
|
||||
self.sql.append(sql)
|
||||
|
||||
if "INFORMATION_SCHEMA.COLUMNS" in sql:
|
||||
return FakeScalarResult(self.column_counts.get(params["column_name"], 0))
|
||||
|
||||
if "INFORMATION_SCHEMA.STATISTICS" in sql:
|
||||
for index_name, count in self.stat_counts.items():
|
||||
if f"INDEX_NAME = '{index_name}'" in sql:
|
||||
return FakeScalarResult(count)
|
||||
return FakeScalarResult(0)
|
||||
|
||||
return FakeScalarResult(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_bind_session_card_columns_adds_missing_columns_and_index():
|
||||
conn = FakeConnection()
|
||||
|
||||
await _ensure_bind_session_card_columns(conn)
|
||||
|
||||
executed = "\n".join(conn.sql)
|
||||
assert "ALTER TABLE device_bind_sessions ADD COLUMN bind_mode TINYINT NOT NULL DEFAULT 1 AFTER status" in executed
|
||||
assert "ALTER TABLE device_bind_sessions ADD COLUMN card_uuid VARCHAR(64) NULL AFTER bind_mode" in executed
|
||||
assert "ALTER TABLE device_bind_sessions ADD INDEX idx_device_bind_sessions_card_uuid (card_uuid)" in executed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_cards_allow_multiple_per_device_replaces_unique_device_index():
|
||||
conn = FakeConnection()
|
||||
|
||||
await _ensure_cards_allow_multiple_per_device(conn)
|
||||
|
||||
executed = "\n".join(conn.sql)
|
||||
assert "ALTER TABLE cards ADD INDEX idx_cards_device_id (device_id)" in executed
|
||||
assert "ALTER TABLE cards DROP INDEX uq_cards_device_id" in executed
|
||||
assert executed.index("ALTER TABLE cards ADD INDEX idx_cards_device_id") < executed.index(
|
||||
"ALTER TABLE cards DROP INDEX uq_cards_device_id"
|
||||
)
|
||||
Reference in New Issue
Block a user