# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The daily-limit and vote gates, tested against a harmless command.""" from __future__ import annotations from dataclasses import replace from typing import TYPE_CHECKING import pytest from signal_bot.decorators import cooldown, daily_limit, vote_gated from tests.conftest import SECOND_GROUP, WATCHED_GROUP from tests.gate_helpers import DAY, gate_context, refused if TYPE_CHECKING: from signal_bot.config import Config from signal_bot.context import CommandContext from signal_bot.registry import Handler from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport @pytest.fixture def runs() -> list[str]: return [] @pytest.fixture def handler(runs: list[str]) -> Handler: async def _handler(ctx: CommandContext) -> None: runs.append(ctx.message.source_uuid) return _handler async def test_daily_limit_allows_under_the_cap( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: await daily_limit(1, "no more")(handler)(gate_context(store, transport, config)) assert runs == ["u1"] async def test_daily_limit_blocks_at_the_cap( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: store.record_use(WATCHED_GROUP, "u1", "ping", 100.0, DAY) await refused( daily_limit(1, "no more, used {used}/{max_uses}")(handler), gate_context(store, transport, config), ) assert runs == [] assert transport.texts == ["no more, used 1/1"] async def test_vote_gate_waits_for_quorum( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: gated = vote_gated(2, 900, "{votes}/{required}")(handler) await refused(gated, gate_context(store, transport, config, uuid="u1")) assert runs == [] assert transport.texts == ["1/2"] async def test_vote_gate_fires_on_quorum( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: gated = vote_gated(2, 900, "{votes}/{required}")(handler) await refused(gated, gate_context(store, transport, config, uuid="u1")) await gated(gate_context(store, transport, config, uuid="u2")) assert runs == ["u2"] async def test_repeat_vote_does_not_reach_quorum( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: gated = vote_gated(2, 900, "{votes}/{required}")(handler) await refused(gated, gate_context(store, transport, config, uuid="u1")) await refused(gated, gate_context(store, transport, config, uuid="u1", now=101.0)) assert runs == [] async def test_votes_expire_outside_the_window( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: gated = vote_gated(2, 60, "{votes}/{required}")(handler) await refused(gated, gate_context(store, transport, config, uuid="u1", now=100.0)) await refused(gated, gate_context(store, transport, config, uuid="u2", now=1000.0)) assert runs == [] async def test_vote_tally_resets_after_firing( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: gated = vote_gated(2, 900, "{votes}/{required}")(handler) await refused(gated, gate_context(store, transport, config, uuid="u1")) await gated(gate_context(store, transport, config, uuid="u2")) assert store.count_votes(WATCHED_GROUP, "ping", 0.0) == 0 async def test_gates_stack( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: stacked = cooldown(10, "wait {seconds}")(daily_limit(2, "no more")(handler)) await stacked(gate_context(store, transport, config)) assert runs == ["u1"] assert replace(config, cooldown_seconds=10).cooldown_seconds == 10 async def test_daily_limit_is_per_group( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: store.record_use(WATCHED_GROUP, "u1", "ping", 100.0, DAY) await daily_limit(1, "no more")(handler)( gate_context(store, transport, config, group=SECOND_GROUP) ) assert runs == ["u1"] async def test_votes_do_not_carry_between_groups( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: """Quorum in one group must not fire the command in another.""" gated = vote_gated(2, 900, "{votes}/{required}")(handler) await refused(gated, gate_context(store, transport, config, uuid="u1")) await refused(gated, gate_context(store, transport, config, uuid="u2", group=SECOND_GROUP)) assert runs == [] assert transport.texts == ["1/2", "1/2"]