diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 687316e4265e2dc6d710911007fd767184d3b14b..46a495ffdc06ab2517f3d69aecd9f3724bbc383b 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -564,6 +564,41 @@ class RistrettoRedeemer(object): return passes +def token_count_for_group(num_groups, total_tokens, group_number): + """ + Determine a number of tokens to retrieve for a particular group out of an + overall redemption attempt. + + :param int num_groups: The total number of groups the tokens will be + divided into. + + :param int total_tokens: The total number of tokens to divide up. + + :param int group_number: The particular group for which to determine a + token count. + + :return int: A number of tokens to redeem in this group. + """ + if total_tokens < num_groups: + raise ValueError( + "Cannot distribute {} tokens among {} groups coherently.".format( + total_tokens, + num_groups, + ), + ) + if group_number >= num_groups or group_number < 0: + raise ValueError( + "Group number {} is out of valid range [0..{})".format( + group_number, + num_groups, + ), + ) + group_size, remainder = divmod(total_tokens, num_groups) + if group_number < remainder: + return group_size + 1 + return group_size + + @attr.s class PaymentController(object): """ diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 7776ff739b3ec50b88b37f8ecf1a307a7eba1318..3baf11475b7daa296d8ef621ba493175238a3a45 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -359,6 +359,19 @@ def voucher_counters(): ) +def redemption_group_counts(): + """ + Build integers which can represent the number of groups in the redemption + process. + """ + return integers( + min_value=1, + # Make this similar to the max_value of voucher_counters since those + # counters count through the groups. + max_value=256, + ) + + def byte_strings(label, length, entropy): """ Build byte strings of the given length with at most the given amount of diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index befe9fcf6e623722a9af3234d2bc0772b372e1f3..9c618973a3ae0ca633e8b4416cc1dda25ab2e19b 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -18,6 +18,7 @@ Tests for ``_zkapauthorizer.controller``. from __future__ import ( absolute_import, + division, ) from json import ( @@ -56,6 +57,7 @@ from testtools.twistedsupport import ( from hypothesis import ( given, + assume, ) from hypothesis.strategies import ( integers, @@ -107,6 +109,7 @@ from ..controller import ( PaymentController, AlreadySpent, Unpaid, + token_count_for_group, ) from ..model import ( @@ -123,16 +126,86 @@ from .strategies import ( vouchers, voucher_objects, voucher_counters, + redemption_group_counts, dummy_ristretto_keys, clocks, ) from .matchers import ( Provides, + raises, + between, ) from .fixtures import ( TemporaryVoucherStore, ) + +class TokenCountForGroupTests(TestCase): + """ + Tests for ``token_count_for_group``. + """ + @given( + integers(), + integers(), + integers(), + ) + def test_out_of_bounds(self, num_groups, total_tokens, group_number): + """ + If there are not enough tokens so that each group gets at least one or if + the indicated group number does properly identify a group from the + range then ``ValueError`` is raised. + """ + assume( + group_number < 0 or + group_number >= num_groups or + total_tokens < num_groups + ) + self.assertThat( + lambda: token_count_for_group(num_groups, total_tokens, group_number), + raises(ValueError), + ) + + @given( + redemption_group_counts(), + integers(min_value=1), + ) + def test_sum(self, num_groups, total_tokens): + """ + The sum of the token count for all groups equals the requested total + tokens. + """ + assume(total_tokens >= num_groups) + self.assertThat( + sum( + token_count_for_group(num_groups, total_tokens, group_number) + for group_number + in range(num_groups) + ), + Equals(total_tokens), + ) + + @given( + redemption_group_counts(), + integers(min_value=1), + ) + def test_well_distributed(self, num_groups, total_tokens): + """ + Tokens are distributed roughly evenly across all group numbers. + """ + assume(total_tokens >= num_groups) + + lower_bound = total_tokens // num_groups + upper_bound = total_tokens // num_groups + 1 + + self.assertThat( + list( + token_count_for_group(num_groups, total_tokens, group_number) + for group_number + in range(num_groups) + ), + AllMatch(between(lower_bound, upper_bound)), + ) + class PaymentControllerTests(TestCase): """ Tests for ``PaymentController``.