squash.booking.lambdas.core.RuleManagerTest.java Source code

Java tutorial

Introduction

Here is the source code for squash.booking.lambdas.core.RuleManagerTest.java

Source

/**
 * Copyright 2016-2017 Robin Steel
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package squash.booking.lambdas.core;

import static org.hamcrest.Matchers.startsWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.jmock.Expectations;
import org.jmock.Mockery;
import org.jmock.Sequence;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;

import com.amazonaws.AmazonServiceException;
import com.amazonaws.services.lambda.runtime.LambdaLogger;
import com.amazonaws.services.simpledb.model.Attribute;
import com.amazonaws.services.simpledb.model.ReplaceableAttribute;
import com.amazonaws.services.sns.AmazonSNS;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * Tests the {@link RuleManager}.
 * 
 * @author robinsteel19@outlook.com (Robin Steel)
 */
public class RuleManagerTest {
    // Variables for setting up subclass of class under test
    LocalDate fakeCurrentSaturdayDate;
    String fakeCurrentSaturdayDateString;
    squash.booking.lambdas.core.RuleManagerTest.TestRuleManager ruleManager;
    private String adminSnsTopicArn;

    // Mocks
    Mockery mockery = new Mockery();
    LambdaLogger mockLogger;
    IBookingManager mockBookingManager;
    IOptimisticPersister mockOptimisticPersister;
    ILifecycleManager mockLifecycleManager;
    AmazonSNS mockSNSClient;

    // Create some example booking rules to test with
    BookingRule existingThursdayNonRecurringRule;
    BookingRule existingFridayRecurringRuleWithoutExclusions;
    BookingRule existingSaturdayRecurringRuleWithExclusion;

    // Create Booking to use for the existing rules
    String ruleBookingDate;
    String ruleExclusionDate;
    Booking ruleBooking;

    List<BookingRule> existingBookingRules;
    List<Booking> expectedBookingRules;

    String ruleItemName;

    @Rule
    public ExpectedException thrown = ExpectedException.none();

    @Before
    public void beforeTest() {

        // Set up the rules' booking
        ruleBookingDate = "2016-07-21"; // Thursday
        ruleBooking = new Booking(1, 2, 3, 2, "J.Power/A.Shabana");
        ruleBooking.setDate(ruleBookingDate);

        // Set up the existing test booking rules
        existingThursdayNonRecurringRule = new BookingRule(ruleBooking, false, new String[0]);
        // Tweak day-of-week to avoid clash
        String newDate = LocalDate.parse(ruleBookingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusDays(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        ruleBooking.setDate(newDate); // Friday;
        existingFridayRecurringRuleWithoutExclusions = new BookingRule(ruleBooking, true, new String[0]);
        // Tweak day-of-week again to avoid clash
        newDate = LocalDate.parse(ruleBookingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusDays(2)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        ruleBooking.setDate(newDate); // Saturday
        // Create an exclusion date
        ruleExclusionDate = "2016-09-17"; // Saturday
        existingSaturdayRecurringRuleWithExclusion = new BookingRule(ruleBooking, true,
                new String[] { ruleExclusionDate });
        existingBookingRules = new ArrayList<>();
        existingBookingRules.add(existingThursdayNonRecurringRule);
        existingBookingRules.add(existingFridayRecurringRuleWithoutExclusions);
        existingBookingRules.add(existingSaturdayRecurringRuleWithExclusion);

        // Set up mock logger
        mockLogger = mockery.mock(LambdaLogger.class);
        mockery.checking(new Expectations() {
            {
                ignoring(mockLogger);
            }
        });
        mockBookingManager = mockery.mock(IBookingManager.class);
        mockOptimisticPersister = mockery.mock(IOptimisticPersister.class);

        // Set up mock lifecycle manager
        mockLifecycleManager = mockery.mock(ILifecycleManager.class);
        mockery.checking(new Expectations() {
            {
                ignoring(mockLifecycleManager);
            }
        });

        // Set up the rule manager
        fakeCurrentSaturdayDate = LocalDate.of(2015, 12, 24);
        fakeCurrentSaturdayDateString = fakeCurrentSaturdayDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        ruleManager = new squash.booking.lambdas.core.RuleManagerTest.TestRuleManager();
        ruleManager.setOptimisticPersister(mockOptimisticPersister);
        ruleManager.setCurrentLocalDate(fakeCurrentSaturdayDate);
        adminSnsTopicArn = "adminSnsTopicArn";
        ruleManager.setAdminSnsTopicArn(adminSnsTopicArn);

        ruleItemName = "BookingRulesAndExclusions";
    }

    private void initialiseRuleManager() throws Exception {
        // Call this to initialise the rule manager in tests where this
        // initialisation is not the subject of the test.

        // Allow the call when the rule manager is initialised.
        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).initialise(with.intIs(anything()), with(anything()));
            }
        });
        ruleManager.initialise(mockBookingManager, mockLifecycleManager, mockLogger);
    }

    private void expectOptimisticPersisterToReturnVersionedAttributes(int expectedVersion) throws Exception {
        expectOptimisticPersisterToReturnVersionedAttributes(expectedVersion, existingBookingRules, 1);
    }

    private void expectOptimisticPersisterToReturnVersionedAttributes(int expectedVersion, int numCalls)
            throws Exception {
        expectOptimisticPersisterToReturnVersionedAttributes(expectedVersion, existingBookingRules, numCalls);
    }

    private void expectOptimisticPersisterToReturnVersionedAttributes(int expectedVersion,
            List<BookingRule> bookingRules) throws Exception {
        expectOptimisticPersisterToReturnVersionedAttributes(expectedVersion, bookingRules, 1);
    }

    private void expectOptimisticPersisterToReturnVersionedAttributes(int expectedVersion,
            List<BookingRule> bookingRules, int numCalls) throws Exception {

        // Set up attributes to be returned from the database's booking rule item
        Set<Attribute> attributes = new HashSet<>();
        for (BookingRule bookingRule : bookingRules) {
            Attribute attribute = new Attribute();
            attribute.setName(getAttributeNameFromBookingRule(bookingRule));
            String[] datesToExclude = bookingRule.getDatesToExclude();
            attribute.setValue(StringUtils.join(datesToExclude, ","));
            attributes.add(attribute);
        }
        mockery.checking(new Expectations() {
            {
                exactly(numCalls).of(mockOptimisticPersister).get(with(equal(ruleItemName)));
                will(returnValue(new ImmutablePair<>(Optional.of(expectedVersion), attributes)));
            }
        });
    }

    private void expectToDeleteRulesViaOptimisticPersister(List<BookingRule> rulesToDelete) throws Exception {

        // Set up attributes to be deleted from the database's booking rule item
        for (BookingRule ruleToDelete : rulesToDelete) {
            Attribute attribute = new Attribute();
            attribute.setName(getAttributeNameFromBookingRule(ruleToDelete));
            String[] datesToExclude = ruleToDelete.getDatesToExclude();
            attribute.setValue(StringUtils.join(datesToExclude, ","));

            mockery.checking(new Expectations() {
                {
                    oneOf(mockOptimisticPersister).delete(with(equal(ruleItemName)), with(equal(attribute)));
                }
            });
        }
    }

    private void expectToPutRuleToOptimisticPersister(int expectedVersion, BookingRule ruleToPut) throws Exception {

        // Set up attributes to be returned from the database's booking rule item
        ReplaceableAttribute attribute = new ReplaceableAttribute();
        attribute.setName(getAttributeNameFromBookingRule(ruleToPut));
        String[] datesToExclude = ruleToPut.getDatesToExclude();
        attribute.setValue(StringUtils.join(datesToExclude, ","));
        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(Optional.of(expectedVersion)),
                        with(equal(attribute)));
            }
        });
    }

    private void expectToAddOrDeleteRuleExclusionViaOptimisticPersister(int expectedVersion, String dateToExclude,
            Boolean doAdd, BookingRule ruleToAddExclusionTo) throws Exception {

        // Set up attribute to be put to the database's booking rule item
        ReplaceableAttribute replaceableAttribute = new ReplaceableAttribute();
        replaceableAttribute.setName(getAttributeNameFromBookingRule(ruleToAddExclusionTo));
        List<String> datesToExclude = new ArrayList<>();
        datesToExclude.addAll(Arrays.asList(ruleToAddExclusionTo.getDatesToExclude()));
        if (doAdd) {
            datesToExclude.add(dateToExclude);
        } else {
            datesToExclude.remove(dateToExclude);
        }
        if (datesToExclude.size() > 0) {
            replaceableAttribute.setValue(StringUtils.join(datesToExclude.toArray(), ","));
        } else {
            replaceableAttribute.setValue("");
        }
        replaceableAttribute.setReplace(true);

        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(Optional.of(expectedVersion)),
                        with(equal(replaceableAttribute)));
            }
        });
    }

    private void expectBookingManagerCall(Booking bookingToCreate) throws Exception {
        mockery.checking(new Expectations() {
            {
                oneOf(mockBookingManager).createBooking(with(equal(bookingToCreate)), with.booleanIs(equal(false)));
            }
        });
    }

    private void expectPurgeExpiredRulesAndRuleExclusions(int expectedVersion,
            List<BookingRule> existingBookingRules) throws Exception {
        expectPurgeExpiredRulesAndRuleExclusions(expectedVersion, existingBookingRules, Optional.empty(),
                Optional.empty());
    }

    private void expectPurgeExpiredRulesAndRuleExclusions(int expectedVersion,
            List<BookingRule> existingBookingRules, Optional<BookingRule> ruleToDelete,
            Optional<ImmutablePair<BookingRule, String>> ruleExclusionToDelete) throws Exception {
        expectOptimisticPersisterToReturnVersionedAttributes(expectedVersion, existingBookingRules);
        if (ruleToDelete.isPresent()) {
            List<BookingRule> rulesToDelete = new ArrayList<>();
            rulesToDelete.add(ruleToDelete.get());
            expectToDeleteRulesViaOptimisticPersister(rulesToDelete);
        }
        if (ruleExclusionToDelete.isPresent()) {
            expectToAddOrDeleteRuleExclusionViaOptimisticPersister(expectedVersion,
                    ruleExclusionToDelete.get().right, false, ruleExclusionToDelete.get().left);
        }
    }

    private String getAttributeNameFromBookingRule(BookingRule bookingRule) {
        return bookingRule.getBooking().getDate().toString() + "-" + bookingRule.getBooking().getCourt().toString()
                + "-" + bookingRule.getBooking().getCourtSpan().toString() + "-"
                + bookingRule.getBooking().getSlot().toString() + "-"
                + bookingRule.getBooking().getSlotSpan().toString() + "-" + bookingRule.getIsRecurring().toString()
                + "-" + bookingRule.getBooking().getName();
    }

    @After
    public void afterTest() {
        mockery.assertIsSatisfied();
    }

    // Define a test rule manager with some overrides to facilitate testing
    public class TestRuleManager extends RuleManager {
        private AmazonSNS snsClient;
        private LocalDate currentLocalDate;
        private String adminSnsTopicArn;

        public void setOptimisticPersister(IOptimisticPersister optimisticPersister) {
            this.optimisticPersister = optimisticPersister;
        }

        @Override
        public IOptimisticPersister getOptimisticPersister() {
            return optimisticPersister;
        }

        public void setSNSClient(AmazonSNS snsClient) {
            this.snsClient = snsClient;
        }

        @Override
        public AmazonSNS getSNSClient() {
            return snsClient;
        }

        public void setCurrentLocalDate(LocalDate localDate) {
            currentLocalDate = localDate;
        }

        @Override
        public LocalDate getCurrentLocalDate() {
            return currentLocalDate;
        }

        public void setMaxNumberOfDatesToExclude(int maxNumberOfDatesToExclude) {
            this.maxNumberOfDatesToExclude = maxNumberOfDatesToExclude;
        }

        public void setAdminSnsTopicArn(String adminSnsTopicArn) {
            this.adminSnsTopicArn = adminSnsTopicArn;
        }

        @Override
        public String getEnvironmentVariable(String variableName) {
            if (variableName.equals("AdminSNSTopicArn")) {
                return adminSnsTopicArn;
            }
            if (variableName.equals("AWS_REGION")) {
                return "eu-west-1";
            }
            return null;
        }
    }

    private Set<BookingRule> doTestCreateRuleClashesOrNotWithExistingRule(BookingRule ruleToCreate,
            Boolean expectThrow) throws Exception {

        initialiseRuleManager();

        // createRule will first query persister for its existing attributes...
        int versionToUse = 1; // Arbitrary
        expectOptimisticPersisterToReturnVersionedAttributes(versionToUse);

        // ...and then write the attribute for the new rule (unless we're expecting
        // to throw before we put the new rule - e.g. bc of a clash).
        if (!expectThrow) {
            mockery.checking(new Expectations() {
                {
                    expectToPutRuleToOptimisticPersister(versionToUse, ruleToCreate);
                }
            });
        }

        // ACT
        return ruleManager.createRule(ruleToCreate, true);
    }

    @Test
    public void testInitialiseThrowsWhenRuleManagerAlreadyInitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has already been initialised");

        initialiseRuleManager();

        // ACT
        // Initialise a second time - which should throw
        ruleManager.initialise(mockBookingManager, mockLifecycleManager, mockLogger);
    }

    @Test
    public void testCreateRuleThrowsWhenTheOptimisticPersisterThrows() throws Exception {
        // N.B. This applies except when the optimistic persister throws a
        // conditional check failed exclusion, which is covered by other tests.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test OptimisticPersister exception";
        thrown.expectMessage(message);

        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingRule = new BookingRule(existingThursdayNonRecurringRule);
        // Change day-of-week so it no longer clashes
        String existingDate = nonClashingRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusDays(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingRule.getBooking().setDate(newDate); // Wednesday
        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingRule, true);
    }

    @Test
    public void testCreateRuleThrowsIfTheOptimisticPersisterThrowsAConditionalCheckFailedExceptionThreeTimesRunning()
            throws Exception {
        // The optimistic persister can throw a conditional check failed exclusion
        // if two database writes happen to get interleaved. Almost always, a retry
        // should fix this, and we allow up to three tries. This tests that if all
        // three tries fail then the rule manager will give up and throw.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Database put failed - conditional check failed";
        thrown.expectMessage(message);
        int versionToUse = 1; // Arbitrary
        expectOptimisticPersisterToReturnVersionedAttributes(versionToUse, 3);

        initialiseRuleManager();

        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingRule = new BookingRule(existingThursdayNonRecurringRule);
        // Change day-of-week so it no longer clashes
        String existingDate = nonClashingRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusDays(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingRule.getBooking().setDate(newDate); // Wednesday
        mockery.checking(new Expectations() {
            {
                // All three tries throw
                exactly(3).of(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()),
                        with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // ACT
        // This should throw - albeit after three tries internally.
        // N.B. The second parameter is arbitrary here.
        ruleManager.createRule(nonClashingRule, true);
    }

    @Test
    public void testCreateRuleDoesNotThrowIfTheOptimisticPersisterThrowsAConditionalCheckFailedExceptionOnlyTwice()
            throws Exception {
        // The optimistic persister can throw a conditional check failed exclusion
        // if two database writes happen to get interleaved. Almost always, a retry
        // should fix this, and we allow up to three tries. This tests that if we
        // throw twice but the third try succeeds, then the rule manager does not
        // throw.

        // ARRANGE
        int versionToUse = 1; // Arbitrary
        expectOptimisticPersisterToReturnVersionedAttributes(versionToUse, 3);
        initialiseRuleManager();

        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingRule = new BookingRule(existingThursdayNonRecurringRule);
        // Change day-of-week so it no longer clashes
        String existingDate = nonClashingRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusDays(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingRule.getBooking().setDate(newDate); // Wednesday

        final Sequence retrySequence = mockery.sequence("retry");
        mockery.checking(new Expectations() {
            {
                // Two failures...
                exactly(2).of(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()),
                        with(anything()));
                will(throwException(new Exception("Database put failed - conditional check failed")));
                inSequence(retrySequence);
                // ... but third attempt succeeds
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
                will(returnValue(2));
                inSequence(retrySequence);
            }
        });

        // ACT
        // This should _not_ throw - we are allowed three tries
        // N.B. The second parameter is arbitrary here.
        ruleManager.createRule(nonClashingRule, true);
    }

    @Test
    public void testCreateRuleThrowsWhenRuleManagerUninitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has not been initialised");

        // ACT
        // Do not initialise the rule manager first - so createRule should throw
        // N.B. The second parameter is arbitrary here.
        ruleManager.createRule(existingThursdayNonRecurringRule, true);
    }

    @Test
    public void testCreateNonrecurringRuleHappyPathCallsTheOptimisticPersisterCorrectly() throws Exception {
        // Happy path where createRule goes right through and creates the
        // non-recurring rule.

        // ARRANGE

        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingRule = new BookingRule(existingThursdayNonRecurringRule);
        // Change day-of-week so it no longer clashes
        String existingDate = nonClashingRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusDays(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingRule.getBooking().setDate(newDate); // Wednesday

        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingRule, false);
    }

    @Test
    public void testCreateRecurringRuleHappyPathCallsTheOptimisticPersisterCorrectly() throws Exception {
        // Happy path where createRule goes right through and creates the recurring
        // rule.

        // ARRANGE

        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingRule = new BookingRule(existingFridayRecurringRuleWithoutExclusions);
        // Change day-of-week so it no longer clashes
        String existingDate = nonClashingRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusDays(2)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingRule.getBooking().setDate(newDate); // Sunday

        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingRule, false);
    }

    @Test
    public void testCreateNonrecurringRuleHappyPathReturnsCorrectBookingRules() throws Exception {

        // ARRANGE

        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingRule = new BookingRule(existingThursdayNonRecurringRule);
        // Change day-of-week so it no longer clashes
        String existingDate = nonClashingRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusDays(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingRule.getBooking().setDate(newDate); // Wednesday

        Set<BookingRule> returnedBookingRules = doTestCreateRuleClashesOrNotWithExistingRule(nonClashingRule,
                false);
        Set<BookingRule> expectedBookingRules = new HashSet<>();
        expectedBookingRules.addAll(existingBookingRules);
        expectedBookingRules.add(nonClashingRule);

        assertEquals("Unexpected booking rules returned by createRule", returnedBookingRules, expectedBookingRules);
    }

    // Next we have a longish set of tests checking the clash-detection logic:

    // Cases that clash (or not) with an existing non-recurring rule:
    @Test
    public void testCreateRuleThrowsWhenNewRuleClashesWithExistingRules_ExistingNonRecurringNewRecurringSameDayOfWeekOverlappingNoExclusion()
            throws Exception {
        // If the new rule is recurring and there are any overlapping, non-recurring
        // existing rules after the new rule starts, then we have a clash, unless
        // the new rule has a relevant exclusion.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule creation failed");

        // Set up a rule to create that clashes in required way with existing rules
        BookingRule clashingThursdayRule = new BookingRule(existingThursdayNonRecurringRule);
        clashingThursdayRule.setIsRecurring(true);

        // Move date to same day-of-week but to some date earlier than the existing
        // non-recurring rule.
        String existingDate = clashingThursdayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        clashingThursdayRule.getBooking().setDate(newDate);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(clashingThursdayRule, true);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingNonRecurringNewRecurringSameDayOfWeekOverlappingWithExclusion()
            throws Exception {
        // If the new rule is recurring and there are any overlapping, non-recurring
        // existing rules after the new rule starts, then we do not have a clash if
        // the new rule has a relevant exclusion. N.B. We allow exclusions at
        // creation time to support backup/restore.

        // ARRANGE

        // Set up a rule to create that clashes in required way with existing rules
        BookingRule nonClashingThursdayRule = new BookingRule(existingThursdayNonRecurringRule);
        nonClashingThursdayRule.setIsRecurring(true);

        // Move date to same day-of-week but to some date earlier than the existing
        // non-recurring rule.
        String existingDate = nonClashingThursdayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingThursdayRule.getBooking().setDate(newDate);

        // Add exclusion to new rule - so it becomes non-clashing
        nonClashingThursdayRule
                .setDatesToExclude(new String[] { existingThursdayNonRecurringRule.getBooking().getDate() });

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingThursdayRule, false);
    }

    @Test
    public void testCreateRuleThrowsWhenNewRuleClashesWithExistingRules_ExistingNonRecurringNewRecurringSameDayOfWeekPartiallyOverlappingNoExclusion()
            throws Exception {
        // If the new rule is recurring and there are any overlapping, non-recurring
        // existing rules after the new rule starts, then we have a clash. N.B. This
        // tests where the court/time blocks overlap only partially.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule creation failed");

        // Set up a rule to create that clashes in required way with existing rules
        BookingRule clashingThursdayRule = new BookingRule(existingThursdayNonRecurringRule);
        clashingThursdayRule.setIsRecurring(true);

        // Move date to same day-of-week but to some date earlier than the existing
        // non-recurring rule.
        String existingDate = clashingThursdayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        clashingThursdayRule.getBooking().setDate(newDate);

        // Tweak court so that overlap is partial only
        clashingThursdayRule.getBooking().setCourt(clashingThursdayRule.getBooking().getCourt()
                + clashingThursdayRule.getBooking().getCourtSpan() - 1);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(clashingThursdayRule, true);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingNonRecurringInPastNewRecurringSameDayOfWeekOverlappingNoExclusion()
            throws Exception {
        // This checks that a new recurring rule does not clash with an existing
        // non-recurring one if that existing rule has a date before the new
        // recurring rule starts.

        // ARRANGE
        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingThursdayRule = new BookingRule(existingThursdayNonRecurringRule);
        nonClashingThursdayRule.setIsRecurring(true);

        // Tweak date to be ahead of the existing non-recurring rule
        String existingDate = nonClashingThursdayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingThursdayRule.getBooking().setDate(newDate);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingThursdayRule, false);
    }

    @Test
    public void testCreateRuleThrowsWhenNewRuleClashesWithExistingRules_ExistingNonRecurringNewNonRecurringSameDateOverlapping()
            throws Exception {
        // If both the new rule and an existing rule are non-recurring for the same
        // date, and overlap, then we have a clash.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule creation failed");

        // Set up a rule to create that clashes in required way with existing rules
        BookingRule clashingThursdayRule = new BookingRule(existingThursdayNonRecurringRule);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(clashingThursdayRule, true);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingNonRecurringNewRecurringSameDayOfWeekNonOverlappingCourt()
            throws Exception {
        // Tests case where we would have a clash if the court blocks
        // overlapped - but they don't, so should not clash.

        // ARRANGE
        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingThursdayRule = new BookingRule(existingThursdayNonRecurringRule);
        nonClashingThursdayRule.setIsRecurring(true);

        // Tweak court to avoid overlap
        nonClashingThursdayRule.getBooking().setCourt(nonClashingThursdayRule.getBooking().getCourt()
                + nonClashingThursdayRule.getBooking().getCourtSpan());

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingThursdayRule, false);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingNonRecurringNewRecurringSameDayOfWeekNonOverlappingTime()
            throws Exception {
        // Tests case where we would have a clash if the time blocks
        // overlapped - but they don't, so should not clash.

        // ARRANGE
        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingThursdayRule = new BookingRule(existingThursdayNonRecurringRule);
        nonClashingThursdayRule.setIsRecurring(true);

        // Tweak time slot to avoid overlap
        nonClashingThursdayRule.getBooking().setSlot(nonClashingThursdayRule.getBooking().getSlot()
                + nonClashingThursdayRule.getBooking().getSlotSpan());

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingThursdayRule, false);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingNonRecurringNewRecurringDifferentDayOfWeekOverlapping()
            throws Exception {
        // Tests case where we would have a clash if the days-of-the-week agreed -
        // but they don't, so should not clash.

        // ARRANGE
        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingWednesdayRule = new BookingRule(existingThursdayNonRecurringRule);
        nonClashingWednesdayRule.setIsRecurring(true);

        // Move date to a different day of the week
        String existingDate = nonClashingWednesdayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusDays(6)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingWednesdayRule.getBooking().setDate(newDate); // Wednesday

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingWednesdayRule, false);
    }

    // Cases that clash (or not) with an existing recurring rule:
    @Test
    public void testCreateRuleThrowsWhenNewRuleClashesWithExistingRules_ExistingRecurringNewRecurringSameDayOfWeekOverlapping()
            throws Exception {
        // Always clash if both new and existing rules are recurring and
        // overlapping.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule creation failed");

        // Set up a rule to create that clashes in required way with existing rules
        BookingRule clashingFridayRule = new BookingRule(existingFridayRecurringRuleWithoutExclusions);
        // Move date to same day-of-week but to some date later than the existing
        // non-recurring rule - should make no difference.
        String existingDate = clashingFridayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusWeeks(100)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        clashingFridayRule.getBooking().setDate(newDate);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(clashingFridayRule, true);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingRecurringNewNonRecurringSameDayOfWeekOverlappingExclusion()
            throws Exception {
        // If existing rule is recurring and new rule is non-recurring for
        // overlapping court/time and same day-of-the-week for a date in the future
        // and there is a relevant exclusion, then we do not have a clash.

        // ARRANGE
        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingSaturdayRule = new BookingRule(existingSaturdayRecurringRuleWithExclusion);
        nonClashingSaturdayRule.setIsRecurring(false);
        nonClashingSaturdayRule.setDatesToExclude(new String[0]);

        // Move date to equal an exclusion on the existing recurring rule
        String exclusionDate = existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0];
        nonClashingSaturdayRule.getBooking().setDate(exclusionDate);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingSaturdayRule, false);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingRecurringNewNonRecurringSameDayOfWeekOverlappingExclusions()
            throws Exception {
        // If existing rule is recurring and new rule is non-recurring for
        // overlapping court/time and same day-of-the-week for a date in the future
        // and there is a relevant exclusion, then we do not have a clash. This
        // checks that when the relevant exclusion is not the first exclusion in the
        // exclusions array, it is still respected.

        // ARRANGE
        // Set up a rule to create that does not clash with existing rules
        BookingRule nonClashingSaturdayRule = new BookingRule(existingSaturdayRecurringRuleWithExclusion);
        nonClashingSaturdayRule.setIsRecurring(false);
        nonClashingSaturdayRule.setDatesToExclude(new String[0]);

        // Move date to equal the second exclusion on the existing recurring rule
        // Add second exclusion:
        String existingExclusion = existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0];
        String newExclusion = LocalDate.parse(existingExclusion, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .plusWeeks(12).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        String[] newExcludeDates = new String[] { existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                newExclusion };
        existingSaturdayRecurringRuleWithExclusion.setDatesToExclude(newExcludeDates);

        nonClashingSaturdayRule.getBooking().setDate(newExcludeDates[1]);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingSaturdayRule, false);
    }

    @Test
    public void testCreateRuleThrowsWhenNewRuleClashesWithExistingRules_ExistingRecurringNewNonRecurringSameDayOfWeekOverlappingNoExclusion()
            throws Exception {
        // If existing rule is recurring and new rule is non-recurring for
        // overlapping court/time and same day-of-the-week for a date in the future
        // and there is no relevant exclusion - we have a clash.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule creation failed");

        // Set up a rule to create that clashes in required way with existing rules
        BookingRule clashingFridayRule = new BookingRule(existingFridayRecurringRuleWithoutExclusions);
        clashingFridayRule.setIsRecurring(false);
        // Move date to same day-of-week but to some future date
        String existingDate = clashingFridayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusWeeks(12)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        clashingFridayRule.getBooking().setDate(newDate);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(clashingFridayRule, true);
    }

    @Test
    public void testCreateRuleDoesNotThrowWhenNewRuleDoesNotClashWithExistingRules_ExistingRecurringNewNonRecurringBeforeStartSameDayOfWeekOverlappingNoExclusion()
            throws Exception {
        // If existing rule is recurring and new rule is non-recurring for
        // overlapping court/time and same day-of-the-week, but for a date both not
        // in the past and before the recurring rule starts, we do not have a clash.

        // ARRANGE

        // Set up a rule to create that avoids clash in required way with existing
        // rules
        BookingRule nonClashingFridayRule = new BookingRule(existingFridayRecurringRuleWithoutExclusions);
        nonClashingFridayRule.setIsRecurring(false);
        // Move date to one week earlier i.e. to before recurring rule starts
        String existingDate = nonClashingFridayRule.getBooking().getDate();
        String newDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        nonClashingFridayRule.getBooking().setDate(newDate);

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(nonClashingFridayRule, false);
    }

    @Test
    public void testCreateRuleThrowsWhenNewNonRecurringRuleIsInThePast() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule creation failed");

        // Set up a rule to create that clashes in required way with existing rules
        BookingRule pastRule = new BookingRule(existingThursdayNonRecurringRule);
        // Tweak so does not clash
        pastRule.getBooking().setCourt(pastRule.getBooking().getCourt() + pastRule.getBooking().getCourtSpan());
        pastRule.getBooking().setSlot(pastRule.getBooking().getSlot() + pastRule.getBooking().getSlotSpan());

        // Set current date to be ahead of this new rule
        ruleManager.setCurrentLocalDate(LocalDate
                .parse(pastRule.getBooking().getDate(), DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusDays(1));

        // ACT
        doTestCreateRuleClashesOrNotWithExistingRule(pastRule, true);
    }

    @Test
    public void testCreateRuleThrowsIfLifecycleManagerThrows() throws Exception {
        // The lifecycle manager signifies rule creation is invalid in current
        // lifecycle state by throwing. This checks any such throw is thrown on by
        // the rule manager.

        // ARRANGE

        thrown.expect(Exception.class);
        String message = "Test lifecycle manager exception";
        thrown.expectMessage(message);

        // Set up mock lifecycle manager to throw
        mockLifecycleManager = mockery.mock(ILifecycleManager.class, "replacementLifecycleManagerMock");
        mockery.checking(new Expectations() {
            {
                oneOf(mockLifecycleManager).throwIfOperationInvalidForCurrentLifecycleState(
                        with.booleanIs(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });
        initialiseRuleManager();

        // ACT and ASSERT
        // N.B. Parameters are arbitrary here - since it should throw before using
        // them.
        ruleManager.createRule(existingThursdayNonRecurringRule, true);
    }

    @Test
    public void testGetRulesThrowsWhenRuleManagerUninitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has not been initialised");

        // ACT
        // Do not initialise the rule manager first - so getRules should throw
        // N.B. The parameter is arbitrary here.
        ruleManager.getRules(true);
    }

    @Test
    public void testGetRulesReturnsCorrectRules() throws Exception {

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(1);

        // ACT
        // N.B. The parameter is arbitrary here.
        List<BookingRule> returnedBookingRules = ruleManager.getRules(true);

        // Assert lists equal - ignoring order
        assertTrue("Unexpected booking rules returned by getRules",
                returnedBookingRules.containsAll(existingBookingRules)
                        && existingBookingRules.containsAll(returnedBookingRules)
                        && (new HashSet<>(returnedBookingRules).size() == returnedBookingRules.size()));
    }

    @Test
    public void testGetRulesReturnsCorrectRules_HyphenatedNames() throws Exception {
        // This tests hyphenated players' names are handled correctly.

        // ARRANGE
        initialiseRuleManager();

        // Ensure an existing rule has hyphenated palyers names
        List<BookingRule> existingRules = new ArrayList<>();
        existingRules.addAll(existingBookingRules);
        // Create a booking rule without exclusions
        BookingRule hyphenatedRule = new BookingRule(existingSaturdayRecurringRuleWithExclusion);
        hyphenatedRule.getBooking().setName("I.AmHyphen-ated/S.O-am-I");
        existingRules.remove(existingSaturdayRecurringRuleWithExclusion);
        existingRules.add(hyphenatedRule);
        expectOptimisticPersisterToReturnVersionedAttributes(2, existingRules);

        // ACT
        // N.B. The parameter is arbitrary here.
        List<BookingRule> returnedBookingRules = ruleManager.getRules(true);

        // Assert lists equal - ignoring order
        assertTrue("Unexpected booking rules returned by getRules",
                returnedBookingRules.containsAll(existingRules) && existingRules.containsAll(returnedBookingRules)
                        && (new HashSet<>(returnedBookingRules).size() == returnedBookingRules.size()));
    }

    @Test
    public void testGetRulesCallsTheOptimisticPersisterCorrectly() throws Exception {

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(1);

        // ACT
        // N.B. The parameter is arbitrary here.
        ruleManager.getRules(true);
    }

    @Test
    public void testGetRulesThrowsIfLifecycleManagerThrows() throws Exception {
        // The lifecycle manager signifies rule retrieval is invalid in current
        // lifecycle state by throwing. This checks any such throw is thrown on by
        // the rule manager.

        // ARRANGE

        thrown.expect(Exception.class);
        String message = "Test lifecycle manager exception";
        thrown.expectMessage(message);

        // Set up mock lifecycle manager to throw
        mockLifecycleManager = mockery.mock(ILifecycleManager.class, "replacementLifecycleManagerMock");
        mockery.checking(new Expectations() {
            {
                oneOf(mockLifecycleManager).throwIfOperationInvalidForCurrentLifecycleState(
                        with.booleanIs(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });
        initialiseRuleManager();

        // ACT and ASSERT
        // N.B. The parameter is arbitrary here.
        ruleManager.getRules(true);
    }

    @Test
    public void testDeleteRuleThrowsWhenRuleManagerUninitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has not been initialised");

        // ACT
        // Do not initialise the rule manager first - so deleteRule should throw
        // N.B. The second parameter is arbitrary here.
        ruleManager.deleteRule(existingFridayRecurringRuleWithoutExclusions, true);
    }

    @Test
    public void testDeleteRuleCallsTheOptimisticPersisterCorrectly() throws Exception {

        // ARRANGE
        initialiseRuleManager();
        List<BookingRule> rulesToDelete = new ArrayList<>();
        rulesToDelete.add(existingFridayRecurringRuleWithoutExclusions);
        expectToDeleteRulesViaOptimisticPersister(rulesToDelete);

        // ACT
        // N.B. The second parameter is arbitrary here.
        ruleManager.deleteRule(existingFridayRecurringRuleWithoutExclusions, true);
    }

    @Test
    public void testDeleteRuleThrowsIfLifecycleManagerThrows() throws Exception {
        // The lifecycle manager signifies rule deletion is invalid in current
        // lifecycle state by throwing. This checks any such throw is thrown on by
        // the rule manager.

        // ARRANGE

        thrown.expect(Exception.class);
        String message = "Test lifecycle manager exception";
        thrown.expectMessage(message);

        // Set up mock lifecycle manager to throw
        mockLifecycleManager = mockery.mock(ILifecycleManager.class, "replacementLifecycleManagerMock");
        mockery.checking(new Expectations() {
            {
                oneOf(mockLifecycleManager).throwIfOperationInvalidForCurrentLifecycleState(
                        with.booleanIs(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });
        initialiseRuleManager();

        // ACT and ASSERT
        // N.B. The second parameter is arbitrary here.
        ruleManager.deleteRule(existingFridayRecurringRuleWithoutExclusions, true);
    }

    @Test
    public void testDeleteAllBookingRulesThrowsWhenRuleManagerUninitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has not been initialised");

        // ACT
        // Do not initialise the rule manager first - so deleteAllBookingRules
        // should throw.
        // N.B. The parameter is arbitrary here.
        ruleManager.deleteAllBookingRules(false);
    }

    @Test
    public void testDeleteAllBookingRulesThrowsIfLifecycleManagerThrows() throws Exception {
        // The lifecycle manager signifies rule deletion is invalid in current
        // lifecycle state by throwing. This checks any such throw is thrown on by
        // the rule manager.

        // ARRANGE

        thrown.expect(Exception.class);
        String message = "Test lifecycle manager exception";
        thrown.expectMessage(message);

        // Set up mock lifecycle manager to throw
        mockLifecycleManager = mockery.mock(ILifecycleManager.class, "replacementLifecycleManagerMock");
        mockery.checking(new Expectations() {
            {
                oneOf(mockLifecycleManager).throwIfOperationInvalidForCurrentLifecycleState(
                        with.booleanIs(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });
        initialiseRuleManager();

        // ACT and ASSERT
        // N.B. The parameter is arbitrary here.
        ruleManager.deleteAllBookingRules(false);
    }

    @Test
    public void testDeleteRuleThrowsWhenTheOptimisticPersisterThrows() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test OptimisticPersister exception";
        thrown.expectMessage(message);

        initialiseRuleManager();

        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).delete(with(equal(ruleItemName)), with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // ACT
        // This should throw
        // N.B. The second parameter is arbitrary here.
        ruleManager.deleteRule(existingThursdayNonRecurringRule, true);
    }

    @Test
    public void testDeleteAllBookingRulesThrowsIfTheRuleManagerThrowsTooManyRequestsExceptionsThreeTimesRunning()
            throws Exception {
        // The rule manager can throw a TooManyRequests exception
        // if there are many booking rules being deleted. If this happens we should
        // pause for a short time and then continue deleting. We allow up to three
        // attempts to delete each booking rule before giving up. This tests that
        // if all three tries fail then the rule manager will give up and throw.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Boom!";
        thrown.expectMessage(message);

        // ACT
        initialiseRuleManager();

        // Tweak rules to have just one rule - that's all we need here
        existingBookingRules = new ArrayList<>();
        existingBookingRules.add(existingThursdayNonRecurringRule);
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // Set up mock optimistic persister to throw too many requests errors
        // Configure the TooManyRequests error (429)
        AmazonServiceException ase = new AmazonServiceException(message);
        ase.setErrorCode("429");
        mockery.checking(new Expectations() {
            {
                // All three tries throw
                exactly(3).of(mockOptimisticPersister).delete(with(equal(ruleItemName)), with(anything()));
                will(throwException(ase));
            }
        });
        ruleManager.setOptimisticPersister(mockOptimisticPersister);

        // ACT
        // This should throw - albeit after three tries
        // N.B. The parameter is arbitrary here.
        ruleManager.deleteAllBookingRules(false);
    }

    @Test
    public void testDeleteAllBookingRulesDoesNotThrowIfTheRuleManagerThrowsTooManyRequestsExceptionsOnlyTwice()
            throws Exception {
        // The rule manager can throw a TooManyRequests exception
        // if there are many booking rules being deleted. If this happens we should
        // pause for a short time and then continue deleting. We allow up to three
        // attempts to delete each booking rule before giving up. This tests that
        // if we throw twice but the third try succeeds, then the rule manager
        // does not throw.

        // ARRANGE
        String message = "Boom!";

        // ACT
        initialiseRuleManager();

        // Tweak rules to have just one rule - that's all we need here
        existingBookingRules = new ArrayList<>();
        existingBookingRules.add(existingThursdayNonRecurringRule);
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // Set up mock optimistic persister to throw too many requests errors
        // Configure the TooManyRequests error (429)
        AmazonServiceException ase = new AmazonServiceException(message);
        ase.setErrorCode("429");
        mockery.checking(new Expectations() {
            {
                // Throw twice...
                exactly(2).of(mockOptimisticPersister).delete(with(equal(ruleItemName)), with(anything()));
                will(throwException(ase));
                // ...but succeed on the third try
                oneOf(mockOptimisticPersister).delete(with(equal(ruleItemName)), with(anything()));
            }
        });
        ruleManager.setOptimisticPersister(mockOptimisticPersister);

        // ACT
        // This should _not_ throw - we are allowed three tries
        // N.B. The parameter is arbitrary here.
        ruleManager.deleteAllBookingRules(false);
    }

    @Test
    public void testDeleteAllBookingRulesCallsTheOptimisticPersisterCorrectly() throws Exception {

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary
        expectToDeleteRulesViaOptimisticPersister(existingBookingRules);

        // ACT
        // N.B. The parameter is arbitrary here.
        ruleManager.deleteAllBookingRules(false);
    }

    @Test
    public void testDeleteAllBookingRulesThrowsWhenTheOptimisticPersisterThrows() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test OptimisticPersister exception";
        thrown.expectMessage(message);

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).delete(with(equal(ruleItemName)), with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // ACT
        // This should throw
        // N.B. The parameter is arbitrary here.
        ruleManager.deleteAllBookingRules(false);
    }

    @Test
    public void testAddRuleExclusionHappyPathCallsTheOptimisticPersisterCorrectly() throws Exception {
        // Happy path where addRuleExclusion goes right through and creates the
        // exclusion.

        // ARRANGE
        initialiseRuleManager();
        int versionToUse = 22; // Arbitrary
        expectOptimisticPersisterToReturnVersionedAttributes(versionToUse);

        // Create an arbitrary, valid exclusion date to add
        String saturdayExclusionDate = LocalDate
                .parse(existingSaturdayRecurringRuleWithExclusion.getBooking().getDate(),
                        DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .plusWeeks(12).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        expectToAddOrDeleteRuleExclusionViaOptimisticPersister(versionToUse, saturdayExclusionDate, true,
                existingSaturdayRecurringRuleWithExclusion);

        // ACT
        // N.B. The third parameter is arbitrary here.
        Optional<BookingRule> updatedRule = ruleManager.addRuleExclusion(saturdayExclusionDate,
                existingSaturdayRecurringRuleWithExclusion, true);

        // ASSERT
        String[] newExcludeDates = new String[] { existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                saturdayExclusionDate };
        existingSaturdayRecurringRuleWithExclusion.setDatesToExclude(newExcludeDates);
        assertTrue("The updated rule should be returned", updatedRule.isPresent());
        assertTrue("The updated rule should be correct",
                updatedRule.get().equals(existingSaturdayRecurringRuleWithExclusion));
    }

    @Test
    public void testAddRuleExclusionThrowsWhenRuleManagerUninitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has not been initialised");

        // ACT
        // Do not initialise the rule manager first - so addRuleExclusion should
        // throw
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion("2016-08-26", existingFridayRecurringRuleWithoutExclusions, true);
    }

    @Test
    public void testAddRuleExclusionThrowsWhenTheOptimisticPersisterThrows() throws Exception {
        // N.B. This applies except when the optimistic persister throws a
        // conditional check failed exclusion, which is covered by other tests.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test OptimisticPersister exception";
        thrown.expectMessage(message);

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        String existingDate = existingFridayRecurringRuleWithoutExclusions.getBooking().getDate();
        String exclusionDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should throw
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion(exclusionDate, existingFridayRecurringRuleWithoutExclusions, true);
    }

    @Test
    public void testAddRuleExclusionThrowsIfTheOptimisticPersisterThrowsAConditionalCheckFailedExceptionThreeTimesRunning()
            throws Exception {
        // The optimistic persister can throw a conditional check failed exclusion
        // if two database writes happen to get interleaved. Almost always, a retry
        // should fix this, and we allow up to three tries. This tests that if all
        // three tries fail then the rule manager will give up and throw.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Database put failed - conditional check failed";
        thrown.expectMessage(message);

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2, 3); // 2 arbitrary

        mockery.checking(new Expectations() {
            {
                // All three tries throw
                exactly(3).of(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()),
                        with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        String existingDate = existingFridayRecurringRuleWithoutExclusions.getBooking().getDate();
        String exclusionDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should throw - albeit after three tries internally
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion(exclusionDate, existingFridayRecurringRuleWithoutExclusions, true);
    }

    @Test
    public void testAddRuleExclusionDoesNotThrowIfTheOptimisticPersisterThrowsAConditionalCheckFailedExceptionOnlyTwice()
            throws Exception {
        // The optimistic persister can throw a conditional check failed exclusion
        // if two database writes happen to get interleaved. Almost always, a retry
        // should fix this, and we allow up to three tries. This tests that if we
        // throw twice but the third try succeeds, then the rule manager does not
        // throw.

        // ARRANGE

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2, 3); // 2 arbitrary

        final Sequence retrySequence = mockery.sequence("retry");
        mockery.checking(new Expectations() {
            {
                // Two failures...
                exactly(2).of(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()),
                        with(anything()));
                will(throwException(new Exception("Database put failed - conditional check failed")));
                inSequence(retrySequence);
                // ... but third attempt succeeds
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
                will(returnValue(2));
                inSequence(retrySequence);
            }
        });

        String existingDate = existingFridayRecurringRuleWithoutExclusions.getBooking().getDate();
        String exclusionDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should _not_ throw - we are allowed three tries internally
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion(exclusionDate, existingFridayRecurringRuleWithoutExclusions, true);
    }

    @Test
    public void testAddRuleExclusionThrowsIfRuleDoesNotExist() throws Exception {
        // Rule manager should throw if we attempt to add an exclusion to a
        // non-existent rule.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule exclusion addition failed");

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // Create a booking rule that the rule manager does not return
        BookingRule nonExistentRule = new BookingRule(existingFridayRecurringRuleWithoutExclusions);
        // Tweak so no longer matches an existing rule
        nonExistentRule.setIsRecurring(false);

        // ACT
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion("2016-08-26", nonExistentRule, true);
    }

    @Test
    public void testAddRuleExclusionThrowsIfRuleIsNonrecurring() throws Exception {
        // It's not valid to add exclusions to non-recurring rules.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule exclusion addition failed");

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // ACT
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion("2016-08-26", existingThursdayNonRecurringRule, true);
    }

    @Test
    public void testAddRuleExclusionDoesNothingIfExclusionAlreadyExists() throws Exception {
        // If the exclusion already exists we should return early and not attempt to
        // add it again.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // Rule manager should not attempt to add the exclusion again:
        mockery.checking(new Expectations() {
            {
                never(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
            }
        });

        // ACT
        // Try to add exclusion that already exists
        // N.B. The third parameter is arbitrary here.
        Optional<BookingRule> updatedRule = ruleManager.addRuleExclusion(
                existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                existingSaturdayRecurringRuleWithExclusion, true);

        // ASSERT
        // No updated rule should be returned - since no change was made.
        assertTrue("The updated rule should be empty", !updatedRule.isPresent());
    }

    @Test
    public void testAddRuleExclusionThrowsIfExclusionIsForWrongDayOfWeek() throws Exception {
        // Exclusions must be for the same day of the week as their rule.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule exclusion addition failed");

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // ACT
        // N.B. The third parameter is arbitrary here.
        String fridayExclusionDate = "2016-08-26";
        ruleManager.addRuleExclusion(fridayExclusionDate, existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testAddRuleExclusionThrowsIfExclusionOccursBeforeRuleStarts() throws Exception {
        // Exclusions must be for dates after their rule begins.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule exclusion addition failed");

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        String ruleStartDate = existingSaturdayRecurringRuleWithExclusion.getBooking().getDate();
        String saturdayExclusionDateBeforeRuleStarts = LocalDate
                .parse(ruleStartDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion(saturdayExclusionDateBeforeRuleStarts,
                existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testAddRuleExclusionThrowsIfExclusionOccursInThePast() throws Exception {
        // Exclusions must be for future dates (not just after their rule begins).
        // To test this we need to try adding an exclusion in the past, but after
        // the rule begins.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule exclusion addition failed");

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // Set current date to a fortnight after the rule begins
        String startSaturday = existingSaturdayRecurringRuleWithExclusion.getBooking().getDate();
        String fortnightAfterStart = LocalDate.parse(startSaturday, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .plusWeeks(2).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        ruleManager.setCurrentLocalDate(
                LocalDate.parse(fortnightAfterStart, DateTimeFormatter.ofPattern("yyyy-MM-dd")));

        // Choose exclusion date before the fake current date, but after the start
        // date.
        String weekAfterStart = LocalDate.parse(startSaturday, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .plusWeeks(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion(weekAfterStart, existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testAddRuleExclusionThrowsIfMaximumNumberOfExclusionsExists() throws Exception {
        // There is a certain maximum number of exclusions that can be present on
        // each rule. Trying to add another should throw.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule exclusion addition failed");

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // Set maximum exclusion number equal to that already present
        ruleManager.setMaxNumberOfDatesToExclude(1);
        // Create an arbitrary-but-otherwise-valid exclusion date to add
        String saturdayExclusionDate = LocalDate
                .parse(existingSaturdayRecurringRuleWithExclusion.getBooking().getDate(),
                        DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .plusWeeks(12).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should throw as adding it would exceed the maximum number
        // N.B. The third parameter is arbitrary here.
        ruleManager.addRuleExclusion(saturdayExclusionDate, existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testAddRuleExclusionThrowsIfLifecycleManagerThrows() throws Exception {
        // The lifecycle manager signifies rule exclusion creation is invalid in
        // current lifecycle state by throwing. This checks any such throw is thrown
        // on by the rule manager.

        // ARRANGE

        thrown.expect(Exception.class);
        String message = "Test lifecycle manager exception";
        thrown.expectMessage(message);

        // Set up mock lifecycle manager to throw
        mockLifecycleManager = mockery.mock(ILifecycleManager.class, "replacementLifecycleManagerMock");
        mockery.checking(new Expectations() {
            {
                oneOf(mockLifecycleManager).throwIfOperationInvalidForCurrentLifecycleState(
                        with.booleanIs(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });
        initialiseRuleManager();

        // ACT and ASSERT
        // N.B. The parameters are arbitrary here - as we should throw before using
        // them.
        ruleManager.addRuleExclusion("2020-03-03", existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testDeleteRuleExclusionHappyPathCallsTheOptimisticPersisterCorrectly() throws Exception {
        // Happy path where deleteRuleExclusion goes right through and deletes the
        // exclusion.

        // ARRANGE
        initialiseRuleManager();
        int versionToUse = 24; // Arbitrary
        expectOptimisticPersisterToReturnVersionedAttributes(versionToUse);
        expectToAddOrDeleteRuleExclusionViaOptimisticPersister(versionToUse,
                existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0], false,
                existingSaturdayRecurringRuleWithExclusion);

        // ACT
        // N.B. The third parameter is arbitrary here.
        Optional<BookingRule> updatedRule = ruleManager.deleteRuleExclusion(
                existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                existingSaturdayRecurringRuleWithExclusion, true);

        // ASSERT
        assertTrue("The updated rule should be returned", updatedRule.isPresent());
        assertTrue("The updated rule should be correct", updatedRule.get().getDatesToExclude().length == 0);
    }

    @Test
    public void testDeleteRuleExclusionThrowsWhenRuleManagerUninitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has not been initialised");

        // ACT
        // Do not initialise the rule manager first - so deleteRuleExclusion should
        // throw
        // N.B. The third parameter is arbitrary here.
        ruleManager.deleteRuleExclusion("2016-09-17", existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testDeleteRuleExclusionThrowsWhenTheOptimisticPersisterThrows() throws Exception {
        // N.B. This applies except when the optimistic persister throws a
        // conditional check failed exclusion, which is covered by other tests.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test OptimisticPersister exception";
        thrown.expectMessage(message);

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // ACT
        // This should throw
        // N.B. The third parameter is arbitrary here.
        ruleManager.deleteRuleExclusion(existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testDeleteRuleExclusionThrowsIfTheOptimisticPersisterThrowsAConditionalCheckFailedExceptionThreeTimesRunning()
            throws Exception {
        // The optimistic persister can throw a conditional check failed exclusion
        // if two database writes happen to get interleaved. Almost always, a retry
        // should fix this, and we allow up to three tries. This tests that if all
        // three tries fail then the rule manager will give up and throw.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Database put failed - conditional check failed";
        thrown.expectMessage(message);

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2, 3); // 2 arbitrary
        ;
        mockery.checking(new Expectations() {
            {
                // All three tries throw
                exactly(3).of(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()),
                        with(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // ACT
        // This should throw - albeit after three tries internally
        // N.B. The third parameter is arbitrary here.
        ruleManager.deleteRuleExclusion(existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testDeleteRuleExclusionDoesNotThrowIfTheOptimisticPersisterThrowsAConditionalCheckFailedExceptionOnlyTwice()
            throws Exception {
        // The optimistic persister can throw a conditional check failed exclusion
        // if two database writes happen to get interleaved. Almost always, a retry
        // should fix this, and we allow up to three tries. This tests that if we
        // throw twice but the third try succeeds, then the rule manager does not
        // throw.

        // ARRANGE

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2, 3); // 2 arbitrary

        final Sequence retrySequence = mockery.sequence("retry");
        mockery.checking(new Expectations() {
            {
                // Two failures...
                exactly(2).of(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()),
                        with(anything()));
                will(throwException(new Exception("Database put failed - conditional check failed")));
                inSequence(retrySequence);
                // ... but third attempt succeeds
                oneOf(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
                will(returnValue(2));
                inSequence(retrySequence);
            }
        });

        // ACT
        // This should _not_ throw - as we're allowed three tries
        // N.B. The third parameter is arbitrary here.
        ruleManager.deleteRuleExclusion(existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testDeleteRuleExclusionDoesNothingIfRuleDoesNotExist() throws Exception {
        // If the rule does not exist we should return early and not attempt to
        // delete the exclusion from it.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(2); // 2 arbitrary

        // Rule manager should not attempt to delete the exclusion:
        mockery.checking(new Expectations() {
            {
                never(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
            }
        });

        // Create a booking rule that the rule manager does not return
        BookingRule nonExistentRule = new BookingRule(existingSaturdayRecurringRuleWithExclusion);
        // Tweak so no longer matches an existing rule
        nonExistentRule.getBooking().setName("A.NewPlayer/B.NewPlayer");

        // ACT
        // N.B. The third parameter is arbitrary here.
        Optional<BookingRule> updatedRule = ruleManager.deleteRuleExclusion(nonExistentRule.getDatesToExclude()[0],
                nonExistentRule, true);

        // ASSERT
        assertTrue("The updated rule should be empty", !updatedRule.isPresent());
    }

    @Test
    public void testDeleteRuleExclusionDoesNothingIfExclusionDoesNotExist() throws Exception {
        // If the rule does not have the exclusion being deleted we should return
        // early and not attempt to delete the exclusion from it.

        // ARRANGE
        initialiseRuleManager();

        List<BookingRule> existingRules = new ArrayList<>();
        existingRules.addAll(existingBookingRules);
        // Create a booking rule without exclusions
        BookingRule noExclusionRule = new BookingRule(existingSaturdayRecurringRuleWithExclusion);
        noExclusionRule.setDatesToExclude(new String[0]);
        existingRules.remove(existingSaturdayRecurringRuleWithExclusion);
        existingRules.add(noExclusionRule);
        expectOptimisticPersisterToReturnVersionedAttributes(2, existingRules);

        // Rule manager should not attempt to delete the exclusion:
        mockery.checking(new Expectations() {
            {
                never(mockOptimisticPersister).put(with(equal(ruleItemName)), with(anything()), with(anything()));
            }
        });

        // ACT
        // N.B. The third parameter is arbitrary here.
        Optional<BookingRule> updatedRule = ruleManager.deleteRuleExclusion(
                existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0], noExclusionRule, true);

        // ASSERT
        assertTrue("The updated rule should be empty", !updatedRule.isPresent());
    }

    @Test
    public void testDeleteRuleExclusionThrowsIfExclusionDeletionExposesRuleClash() throws Exception {
        // Removing an exclusion can expose a latent clash between existing rules if
        // a non-recurring rule would have clashed with the recurring rule the
        // exclusion is being removed from - were it not for the exclusion. If such
        // a latent clash is present we should throw and not allow the deletion.

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("Booking rule exclusion deletion failed");

        initialiseRuleManager();

        List<BookingRule> existingRules = new ArrayList<>();
        existingRules.addAll(existingBookingRules);
        // Create a non-recurring booking rule that will expose the clash
        BookingRule latentClashingSaturdayRule = new BookingRule(existingSaturdayRecurringRuleWithExclusion);
        latentClashingSaturdayRule.setIsRecurring(false);
        latentClashingSaturdayRule.getBooking()
                .setDate(existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0]);
        existingRules.add(latentClashingSaturdayRule);
        expectOptimisticPersisterToReturnVersionedAttributes(42, existingRules);

        // ACT
        // Attempt to remove the exclusion to expose the latent clash
        // N.B. The third parameter is arbitrary here.
        ruleManager.deleteRuleExclusion(existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0],
                existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testDeleteRuleExclusionThrowsIfLifecycleManagerThrows() throws Exception {
        // The lifecycle manager signifies rule exclusion deletion is invalid in
        // current lifecycle state by throwing. This checks any such throw is thrown
        // on by the rule manager.

        // ARRANGE

        thrown.expect(Exception.class);
        String message = "Test lifecycle manager exception";
        thrown.expectMessage(message);

        // Set up mock lifecycle manager to throw
        mockLifecycleManager = mockery.mock(ILifecycleManager.class, "replacementLifecycleManagerMock");
        mockery.checking(new Expectations() {
            {
                oneOf(mockLifecycleManager).throwIfOperationInvalidForCurrentLifecycleState(
                        with.booleanIs(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });
        initialiseRuleManager();

        // ACT and ASSERT
        // N.B. The parameters are arbitrary here - as we should throw before using
        // them.
        ruleManager.deleteRuleExclusion("2020-03-03", existingSaturdayRecurringRuleWithExclusion, true);
    }

    @Test
    public void testApplyRulesThrowsWhenRuleManagerUninitialised() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        thrown.expectMessage("The rule manager has not been initialised");

        // ACT
        // Do not initialise the rule manager first - so applyRules should throw
        ruleManager.applyRules(existingSaturdayRecurringRuleWithExclusion.getBooking().getDate(), false);
    }

    @Test
    public void testApplyRulesThrowsWhenTheOptimisticPersisterThrows() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test OptimisticPersister exception";
        thrown.expectMessage(message);

        initialiseRuleManager();

        mockery.checking(new Expectations() {
            {
                oneOf(mockOptimisticPersister).get(with(equal(ruleItemName)));
                will(throwException(new Exception(message)));
            }
        });

        // Set up mock SNS client
        mockSNSClient = mockery.mock(AmazonSNS.class);
        mockery.checking(new Expectations() {
            {
                ignoring(mockSNSClient);
            }
        });
        ruleManager.setSNSClient(mockSNSClient);

        // ACT
        // This should throw
        ruleManager.applyRules(existingSaturdayRecurringRuleWithExclusion.getBooking().getDate(), false);
    }

    @Test
    public void testApplyRulesThrowsWhenTheBookingManagerThrows() throws Exception {

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test BookingManager exception";
        thrown.expectMessage(message);

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);

        mockery.checking(new Expectations() {
            {
                oneOf(mockBookingManager).createBooking(with(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // Set up mock SNS client
        mockSNSClient = mockery.mock(AmazonSNS.class);
        mockery.checking(new Expectations() {
            {
                ignoring(mockSNSClient);
            }
        });
        ruleManager.setSNSClient(mockSNSClient);

        // ACT
        // This should throw
        ruleManager.applyRules(existingSaturdayRecurringRuleWithExclusion.getBooking().getDate(), false);
    }

    @Test
    public void testApplyRulesNotifiesTheSnsTopicWhenItThrows() throws Exception {
        // It is useful for the admin user to be notified whenever the application
        // of booking rules does not succeed - so that they can apply rule bookings
        // manually instead. This tests that whenever the rule manager catches an
        // exception while applying rules, it notifies the admin SNS topic.

        // ARRANGE
        thrown.expect(Exception.class);
        String message = "Test BookingManager exception";
        thrown.expectMessage(message);

        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);

        mockery.checking(new Expectations() {
            {
                oneOf(mockBookingManager).createBooking(with(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });

        // Set up mock SNS client to expect a notification
        mockSNSClient = mockery.mock(AmazonSNS.class);
        String partialMessage = "Apologies - but there was an error applying the booking rules";
        mockery.checking(new Expectations() {
            {
                oneOf(mockSNSClient).publish(with(equal(adminSnsTopicArn)), with(startsWith(partialMessage)),
                        with(equal("Sqawsh booking rules failed to apply")));
            }
        });
        ruleManager.setSNSClient(mockSNSClient);

        // ACT
        // This should throw - and notify the SNS topic
        ruleManager.applyRules(existingSaturdayRecurringRuleWithExclusion.getBooking().getDate(), false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_ApplicableNonRecurringRule() throws Exception {
        // A nonrecurring rule for the apply date should cause a booking to be made.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectBookingManagerCall(existingThursdayNonRecurringRule.getBooking());
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);

        // ACT
        // This should create a booking
        ruleManager.applyRules(existingThursdayNonRecurringRule.getBooking().getDate(), false);
    }

    @Test
    public void testApplyRulesReturnsBookingsItHasMade_SingleBooking() throws Exception {
        // applyRules should return a list of the rule-based bookings it has made -
        // so that, e.g., they can be backed up.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectBookingManagerCall(existingThursdayNonRecurringRule.getBooking());
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);

        // ACT
        // This should create a booking
        List<Booking> bookings = ruleManager.applyRules(existingThursdayNonRecurringRule.getBooking().getDate(),
                false);

        // ASSERT
        assertTrue("Unexpected bookings returned by applyRules",
                bookings.get(0).equals(existingThursdayNonRecurringRule.getBooking()) && bookings.size() == 1);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_NonRecurringRuleDoesNotRecur() throws Exception {
        // A nonrecurring rule should apply for its date only and should not recur
        // in later weeks.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);
        // Set apply date to one week after the non-recurring rule - it should be
        // ignored.
        String existingDate = existingThursdayNonRecurringRule.getBooking().getDate();
        String applyDate = LocalDate.parse(existingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusWeeks(1)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should not create a booking
        ruleManager.applyRules(applyDate, false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_ApplicableRecurringRuleNoExclusion() throws Exception {
        // A recurring rule with no exclusion for the apply date should cause a
        // booking to be made.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectBookingManagerCall(existingFridayRecurringRuleWithoutExclusions.getBooking());
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);

        // ACT
        // This should create a booking
        ruleManager.applyRules(existingFridayRecurringRuleWithoutExclusions.getBooking().getDate(), false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_ApplicableRecurringRuleNoExclusionRecurrentBooking()
            throws Exception {
        // A recurring rule with no exclusion for the apply date should cause a
        // booking to be made. This tests for an apply date corresponding to a later
        // recurrence of the recurrent rule rather than the rule's initial booking.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);

        // Set up booking for a recurrence of the rule's initial booking
        String initialDate = existingFridayRecurringRuleWithoutExclusions.getBooking().getDate();
        String newBookingDate = LocalDate.parse(initialDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .plusWeeks(10).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        Booking bookingToCreate = new Booking(existingFridayRecurringRuleWithoutExclusions.getBooking());
        bookingToCreate.setDate(newBookingDate);
        expectBookingManagerCall(bookingToCreate);
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);

        // ACT
        // This should create a booking
        ruleManager.applyRules(newBookingDate, false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_ApplicableRecurringRuleWithExclusion() throws Exception {
        // A recurring rule with an exclusion for the apply date should not cause a
        // booking to be made.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);
        mockery.checking(new Expectations() {
            {
                never(mockBookingManager).createBooking(with(anything()), with.booleanIs(anything()));
            }
        });

        // ACT
        // This should not create a booking
        ruleManager.applyRules(existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0], false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_FutureRecurringRuleNoExclusion() throws Exception {
        // A recurring rule with no exclusion for the apply date, but that does not
        // begin until after the apply date should not cause a booking to be made.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);
        mockery.checking(new Expectations() {
            {
                never(mockBookingManager).createBooking(with(anything()), with.booleanIs(anything()));
            }
        });

        // Get date before start date of rule
        String initialDate = existingFridayRecurringRuleWithoutExclusions.getBooking().getDate();
        String earlierDate = LocalDate.parse(initialDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")).minusWeeks(2)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should not create a booking
        ruleManager.applyRules(earlierDate, false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_NoRelevantRules() throws Exception {
        // An apply date with no relevant rules should not cause a booking to be
        // made, but should still purge expired rules and exclusions.

        // ARRANGE
        initialiseRuleManager();
        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);
        mockery.checking(new Expectations() {
            {
                never(mockBookingManager).createBooking(with(anything()), with.booleanIs(anything()));
            }
        });

        // Get date for day of week different to any rule's
        String friday = existingFridayRecurringRuleWithoutExclusions.getBooking().getDate();
        String monday = LocalDate.parse(friday, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusDays(3)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should not create a booking - none of our rules are for mondays
        ruleManager.applyRules(monday, false);
    }

    @Test
    public void testApplyRulesToleratesApplyDateInThePast() throws Exception {
        // An apply date in the past should not cause a booking to be made, but
        // should still purge expired rules and exclusions.

        // ARRANGE
        initialiseRuleManager();
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules);
        mockery.checking(new Expectations() {
            {
                never(mockBookingManager).createBooking(with(anything()), with.booleanIs(anything()));
            }
        });

        // Get date before our current date
        String pastDate = LocalDate.parse(fakeCurrentSaturdayDateString, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .minusWeeks(33).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

        // ACT
        // This should be tolerated even though the date is in the past
        ruleManager.applyRules(pastDate, false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_MultipleApplicableRules() throws Exception {
        // Tests that when there is more than one applicable rule, all applicable
        // rules cause a booking to be made.

        // ARRANGE
        initialiseRuleManager();

        List<BookingRule> existingRules = new ArrayList<>();
        existingRules.addAll(existingBookingRules);
        // Add second booking rule for the same day of the week as an existing one
        BookingRule sameDayRule = new BookingRule(existingFridayRecurringRuleWithoutExclusions);
        // Tweak so does not clash with existing rule
        sameDayRule.getBooking()
                .setCourt(sameDayRule.getBooking().getCourt() + sameDayRule.getBooking().getCourtSpan());
        sameDayRule.getBooking()
                .setSlot(sameDayRule.getBooking().getSlot() + sameDayRule.getBooking().getSlotSpan());
        existingRules.add(sameDayRule);
        expectOptimisticPersisterToReturnVersionedAttributes(2, existingRules);

        expectBookingManagerCall(existingFridayRecurringRuleWithoutExclusions.getBooking());
        expectBookingManagerCall(sameDayRule.getBooking());
        expectPurgeExpiredRulesAndRuleExclusions(42, existingRules);

        // ACT
        // This should create two bookings for the specified date
        ruleManager.applyRules(sameDayRule.getBooking().getDate(), false);
    }

    @Test
    public void testApplyRulesReturnsBookingsItHasMade_MultipleBookings() throws Exception {
        // applyRules should return a list of the rule-based bookings it has made -
        // so that, e.g., they can be backed up.

        // ARRANGE
        initialiseRuleManager();

        List<BookingRule> existingRules = new ArrayList<>();
        existingRules.addAll(existingBookingRules);
        // Add second booking rule for the same day of the week as an existing one
        BookingRule sameDayRule = new BookingRule(existingFridayRecurringRuleWithoutExclusions);
        // Tweak so does not clash with existing rule
        sameDayRule.getBooking()
                .setCourt(sameDayRule.getBooking().getCourt() + sameDayRule.getBooking().getCourtSpan());
        sameDayRule.getBooking()
                .setSlot(sameDayRule.getBooking().getSlot() + sameDayRule.getBooking().getSlotSpan());
        existingRules.add(sameDayRule);
        expectOptimisticPersisterToReturnVersionedAttributes(2, existingRules);

        expectBookingManagerCall(existingFridayRecurringRuleWithoutExclusions.getBooking());
        expectBookingManagerCall(sameDayRule.getBooking());
        expectPurgeExpiredRulesAndRuleExclusions(42, existingRules);

        List<Booking> expectedBookings = new ArrayList<>();
        expectedBookings.add(existingFridayRecurringRuleWithoutExclusions.getBooking());
        expectedBookings.add(sameDayRule.getBooking());

        // ACT
        // This should create two bookings for the specified date
        List<Booking> bookings = ruleManager.applyRules(sameDayRule.getBooking().getDate(), false);

        // ASSERT
        assertTrue("Unexpected bookings returned by applyRules", bookings.equals(expectedBookings));
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_PurgeExpiredNonRecurringRule() throws Exception {
        // applyRules should delete non-recurring rules for dates before the current
        // date.

        // ARRANGE
        initialiseRuleManager();

        // Set the current date to a date after an existing non-recurring rule, but
        // also for a day-of-the-week when no other rules apply (just to avoid extra
        // createBooking noise):
        String thursday = existingThursdayNonRecurringRule.getBooking().getDate();
        String dayAfterThursday = LocalDate.parse(thursday, DateTimeFormatter.ofPattern("yyyy-MM-dd")).plusDays(4)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        ruleManager
                .setCurrentLocalDate(LocalDate.parse(dayAfterThursday, DateTimeFormatter.ofPattern("yyyy-MM-dd")));

        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules,
                Optional.of(existingThursdayNonRecurringRule), Optional.empty());

        // ACT
        // This should delete the (expired) Thursday rule
        ruleManager.applyRules(dayAfterThursday, false);
    }

    @Test
    public void testApplyRulesCallsBookingManagerCorrectly_PurgeExpiredRuleExclusions() throws Exception {
        // applyRules should delete rule exclusions for dates before the current
        // day.

        // ARRANGE
        initialiseRuleManager();

        // Set the current date to a date after an exclusion, but also for a
        // day-of-the-week when no other rules apply (just to avoid extra
        // createBooking noise):
        String exclusionDate = existingSaturdayRecurringRuleWithExclusion.getDatesToExclude()[0];
        String daysAfterExclusionDate = LocalDate.parse(exclusionDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                .plusDays(2).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        ruleManager.setCurrentLocalDate(
                LocalDate.parse(daysAfterExclusionDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")));

        expectOptimisticPersisterToReturnVersionedAttributes(42);
        expectPurgeExpiredRulesAndRuleExclusions(42, existingBookingRules,
                Optional.of(existingThursdayNonRecurringRule),
                Optional.of(new ImmutablePair<>(existingSaturdayRecurringRuleWithExclusion, exclusionDate)));

        // ACT
        // This should delete the (expired) Thursday rule
        ruleManager.applyRules(daysAfterExclusionDate, false);
    }

    @Test
    public void testApplyRulesThrowsIfLifecycleManagerThrows() throws Exception {
        // The lifecycle manager signifies rule application is invalid in
        // current lifecycle state by throwing. This checks any such throw is thrown
        // on by the rule manager.

        // ARRANGE

        thrown.expect(Exception.class);
        String message = "Test lifecycle manager exception";
        thrown.expectMessage(message);

        // Set up mock lifecycle manager to throw
        mockLifecycleManager = mockery.mock(ILifecycleManager.class, "replacementLifecycleManagerMock");
        mockery.checking(new Expectations() {
            {
                oneOf(mockLifecycleManager).throwIfOperationInvalidForCurrentLifecycleState(
                        with.booleanIs(anything()), with.booleanIs(anything()));
                will(throwException(new Exception(message)));
            }
        });
        initialiseRuleManager();

        // ACT and ASSERT
        // N.B. The parameters are arbitrary here - as we should throw before using
        // them.
        ruleManager.applyRules("2020-03-03", true);
    }
}