feat(access): демо-QR — многоразовое приглашение для показа
CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s

- invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50),
  живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses
- users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR
- «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟
- миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres
- SPEC A-3a, ADR-017, DEMO.md, .env.example

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 18:11:53 +03:00
co-authored by Claude Opus 5.5
parent 7a12b3525a
commit e8c3a321a3
14 changed files with 247 additions and 15 deletions
+69
View File
@@ -136,3 +136,72 @@ async def test_touch_is_throttled_but_tracks_renames(svc: AccessService, clock:
await svc.touch(same, TgUser(ADMIN, "Новое имя", "boss"))
renamed = await svc.get_member(ADMIN)
assert renamed is not None and renamed.full_name == "Новое имя"
@pytest.fixture
async def demo_svc(sessions: SessionMaker, clock: Clock) -> AccessService:
settings = AccessSettings(admin_ids=frozenset({ADMIN}), demo_invite_max_uses=3, demo_invite_ttl_hours=2)
service = AccessService(sessions, settings, clock=clock)
await service.bootstrap_admins()
return service
def guest(n: int) -> TgUser:
return TgUser(tg_id=700 + n, full_name=f"Гость {n}")
async def test_demo_invite_lets_in_up_to_the_limit(
demo_svc: AccessService, sessions: SessionMaker, clock: Clock
) -> None:
invite = await demo_svc.create_invite(ADMIN, demo=True)
assert invite.max_uses == 3 and invite.expires_at == clock.now + timedelta(hours=2)
assert [await demo_svc.redeem(invite.token, guest(n)) for n in range(4)] == [RedeemStatus.OK] * 3 + [
RedeemStatus.REJECTED
]
for n in range(3):
member = await demo_svc.get_member(guest(n).tg_id)
assert member is not None and member.guest and member.role is Role.MEMBER
async with sessions() as s:
stored = await s.scalar(select(Invite))
assert stored is not None and stored.uses == 3
assert await s.get(User, guest(3).tg_id) is None
async def test_demo_invite_expires(demo_svc: AccessService, clock: Clock) -> None:
invite = await demo_svc.create_invite(ADMIN, demo=True)
clock.now += timedelta(hours=2, seconds=1)
assert await demo_svc.redeem(invite.token, guest(0)) is RedeemStatus.REJECTED
async def test_personal_invite_is_not_a_guest_one(demo_svc: AccessService) -> None:
invite = await demo_svc.create_invite(ADMIN)
assert invite.max_uses == 1
await demo_svc.redeem(invite.token, NEWBIE)
member = await demo_svc.get_member(NEWBIE.tg_id)
assert member is not None and not member.guest
async def test_revoke_guests_cuts_off_only_guests_and_kills_the_qr(demo_svc: AccessService) -> None:
await demo_svc.redeem((await demo_svc.create_invite(ADMIN)).token, NEWBIE)
demo = await demo_svc.create_invite(ADMIN, demo=True)
for n in range(2):
await demo_svc.redeem(demo.token, guest(n))
assert await demo_svc.revoke_guests() == 2
assert [m.tg_id for m in await demo_svc.list_members()] == [ADMIN, NEWBIE.tg_id]
assert await demo_svc.redeem(demo.token, guest(2)) is RedeemStatus.REJECTED
assert await demo_svc.revoke_guests() == 0
async def test_guest_invited_personally_later_is_no_longer_a_guest(demo_svc: AccessService) -> None:
await demo_svc.redeem((await demo_svc.create_invite(ADMIN, demo=True)).token, guest(0))
await demo_svc.revoke_guests()
await demo_svc.redeem((await demo_svc.create_invite(ADMIN)).token, guest(0))
member = await demo_svc.get_member(guest(0).tg_id)
assert member is not None and not member.guest