diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/mailbox/MailboxUpdateManager.java b/bramble-api/src/main/java/org/briarproject/bramble/api/mailbox/MailboxUpdateManager.java index d77129baec1aa84122856125ab32d951b8b16425..e9c81237e48f9d4739b620914a29f67d2feb784a 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/api/mailbox/MailboxUpdateManager.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/mailbox/MailboxUpdateManager.java @@ -63,9 +63,26 @@ public interface MailboxUpdateManager { */ String GROUP_KEY_SENT_CLIENT_SUPPORTS = "sentClientSupports"; + /** + * Returns the latest {@link MailboxUpdate} sent to the given contact. + * <p> + * If we have our own mailbox then the update will be a + * {@link MailboxUpdateWithMailbox} containing the + * {@link MailboxProperties} the contact should use for communicating with + * our mailbox. + */ MailboxUpdate getLocalUpdate(Transaction txn, ContactId c) throws DbException; + /** + * Returns the latest {@link MailboxUpdate} received from the given + * contact, or null if no update has been received. + * <p> + * If the contact has a mailbox then the update will be a + * {@link MailboxUpdateWithMailbox} containing the + * {@link MailboxProperties} we should use for communicating with the + * contact's mailbox. + */ @Nullable MailboxUpdate getRemoteUpdate(Transaction txn, ContactId c) throws DbException; diff --git a/bramble-core/src/main/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityChecker.java b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityChecker.java index 6abe9e6076ae853767f338acbe0e5464194000d8..54fcbfea310cd8444f01a9b75f021f8a7ed23278 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityChecker.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityChecker.java @@ -4,11 +4,13 @@ import org.briarproject.bramble.api.db.DbException; import org.briarproject.bramble.api.db.TransactionManager; import org.briarproject.bramble.api.mailbox.MailboxProperties; import org.briarproject.bramble.api.mailbox.MailboxSettingsManager; +import org.briarproject.bramble.api.mailbox.MailboxVersion; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.system.Clock; import org.briarproject.bramble.mailbox.MailboxApi.ApiException; import java.io.IOException; +import java.util.List; import java.util.logging.Logger; import javax.annotation.concurrent.ThreadSafe; @@ -55,11 +57,12 @@ class OwnMailboxConnectivityChecker extends ConnectivityCheckerImpl { private boolean checkConnectivityAndStoreResult( MailboxProperties properties) throws DbException { try { - if (!mailboxApi.checkStatus(properties)) throw new ApiException(); + List<MailboxVersion> serverSupports = + mailboxApi.getServerSupports(properties); LOG.info("Own mailbox is reachable"); long now = clock.currentTimeMillis(); db.transaction(false, txn -> mailboxSettingsManager - .recordSuccessfulConnection(txn, now)); + .recordSuccessfulConnection(txn, now, serverSupports)); // Call the observers and cache the result onConnectivityCheckSucceeded(now); return false; // Don't retry diff --git a/bramble-core/src/main/java/org/briarproject/bramble/mailbox/OwnMailboxContactListWorker.java b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/OwnMailboxContactListWorker.java new file mode 100644 index 0000000000000000000000000000000000000000..40fe296d6c57a7241b875cab7dcc17b9f85b1d7e --- /dev/null +++ b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/OwnMailboxContactListWorker.java @@ -0,0 +1,369 @@ +package org.briarproject.bramble.mailbox; + +import org.briarproject.bramble.api.Cancellable; +import org.briarproject.bramble.api.contact.Contact; +import org.briarproject.bramble.api.contact.ContactId; +import org.briarproject.bramble.api.contact.event.ContactAddedEvent; +import org.briarproject.bramble.api.contact.event.ContactRemovedEvent; +import org.briarproject.bramble.api.db.DatabaseComponent; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.db.NoSuchContactException; +import org.briarproject.bramble.api.event.Event; +import org.briarproject.bramble.api.event.EventBus; +import org.briarproject.bramble.api.event.EventExecutor; +import org.briarproject.bramble.api.event.EventListener; +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.bramble.api.mailbox.MailboxProperties; +import org.briarproject.bramble.api.mailbox.MailboxUpdate; +import org.briarproject.bramble.api.mailbox.MailboxUpdateManager; +import org.briarproject.bramble.api.mailbox.MailboxUpdateWithMailbox; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.mailbox.ConnectivityChecker.ConnectivityObserver; +import org.briarproject.bramble.mailbox.MailboxApi.ApiException; +import org.briarproject.bramble.mailbox.MailboxApi.MailboxContact; +import org.briarproject.bramble.mailbox.MailboxApi.TolerableFailureException; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.GuardedBy; +import javax.annotation.concurrent.ThreadSafe; + +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNonNull; +import static org.briarproject.bramble.util.LogUtils.logException; + +@ThreadSafe +@NotNullByDefault +class OwnMailboxContactListWorker + implements MailboxWorker, ConnectivityObserver, EventListener { + + /** + * When the worker is started it waits for a connectivity check, then + * fetches the remote contact list and compares it to the local contact + * list. + * <p> + * Any contacts that are missing from the remote list are added to the + * mailbox's contact list, while any contacts that are missing from the + * local list are removed from the mailbox's contact list. + * <p> + * Once the remote contact list has been brought up to date, the worker + * waits for events indicating that contacts have been added or removed. + * Each time an event is received, the worker updates the mailbox's + * contact list and then goes back to waiting. + */ + private enum State { + CREATED, + CONNECTIVITY_CHECK, + FETCHING_CONTACT_LIST, + UPDATING_CONTACT_LIST, + WAITING_FOR_CHANGES, + DESTROYED + } + + private static final Logger LOG = + getLogger(OwnMailboxContactListWorker.class.getName()); + + private final Executor ioExecutor; + private final DatabaseComponent db; + private final EventBus eventBus; + private final ConnectivityChecker connectivityChecker; + private final MailboxApiCaller mailboxApiCaller; + private final MailboxApi mailboxApi; + private final MailboxUpdateManager mailboxUpdateManager; + private final MailboxProperties mailboxProperties; + private final Object lock = new Object(); + + @GuardedBy("lock") + private State state = State.CREATED; + + @GuardedBy("lock") + @Nullable + private Cancellable apiCall = null; + + /** + * A queue of updates waiting to be applied to the remote contact list. + */ + @GuardedBy("lock") + private final Queue<Update> updates = new LinkedList<>(); + + OwnMailboxContactListWorker(@IoExecutor Executor ioExecutor, + DatabaseComponent db, + EventBus eventBus, + ConnectivityChecker connectivityChecker, + MailboxApiCaller mailboxApiCaller, + MailboxApi mailboxApi, + MailboxUpdateManager mailboxUpdateManager, + MailboxProperties mailboxProperties) { + if (!mailboxProperties.isOwner()) throw new IllegalArgumentException(); + this.ioExecutor = ioExecutor; + this.db = db; + this.connectivityChecker = connectivityChecker; + this.mailboxApiCaller = mailboxApiCaller; + this.mailboxApi = mailboxApi; + this.mailboxUpdateManager = mailboxUpdateManager; + this.mailboxProperties = mailboxProperties; + this.eventBus = eventBus; + } + + @Override + public void start() { + LOG.info("Started"); + synchronized (lock) { + if (state != State.CREATED) return; + state = State.CONNECTIVITY_CHECK; + } + // Avoid leaking observer in case destroy() is called concurrently + // before observer is added + connectivityChecker.checkConnectivity(mailboxProperties, this); + boolean destroyed; + synchronized (lock) { + destroyed = state == State.DESTROYED; + } + if (destroyed) connectivityChecker.removeObserver(this); + } + + @Override + public void destroy() { + LOG.info("Destroyed"); + Cancellable apiCall; + synchronized (lock) { + state = State.DESTROYED; + apiCall = this.apiCall; + this.apiCall = null; + } + if (apiCall != null) apiCall.cancel(); + connectivityChecker.removeObserver(this); + eventBus.removeListener(this); + } + + @Override + public void onConnectivityCheckSucceeded() { + LOG.info("Connectivity check succeeded"); + synchronized (lock) { + if (state != State.CONNECTIVITY_CHECK) return; + state = State.FETCHING_CONTACT_LIST; + apiCall = mailboxApiCaller.retryWithBackoff( + new SimpleApiCall(this::apiCallFetchContactList)); + } + } + + @IoExecutor + private void apiCallFetchContactList() throws IOException, ApiException { + synchronized (lock) { + if (state != State.FETCHING_CONTACT_LIST) return; + } + LOG.info("Fetching remote contact list"); + Collection<ContactId> remote = + mailboxApi.getContacts(mailboxProperties); + ioExecutor.execute(() -> loadLocalContactList(remote)); + } + + @IoExecutor + private void loadLocalContactList(Collection<ContactId> remote) { + synchronized (lock) { + if (state != State.FETCHING_CONTACT_LIST) return; + apiCall = null; + } + LOG.info("Loading local contact list"); + try { + db.transaction(true, txn -> { + Collection<Contact> local = db.getContacts(txn); + // Handle the result on the event executor to avoid races with + // incoming events + txn.attach(() -> reconcileContactLists(local, remote)); + }); + } catch (DbException e) { + logException(LOG, WARNING, e); + } + } + + @EventExecutor + private void reconcileContactLists(Collection<Contact> local, + Collection<ContactId> remote) { + Set<ContactId> localIds = new HashSet<>(); + for (Contact c : local) localIds.add(c.getId()); + remote = new HashSet<>(remote); + synchronized (lock) { + if (state != State.FETCHING_CONTACT_LIST) return; + for (ContactId c : localIds) { + if (!remote.contains(c)) updates.add(new Update(true, c)); + } + for (ContactId c : remote) { + if (!localIds.contains(c)) updates.add(new Update(false, c)); + } + if (updates.isEmpty()) { + LOG.info("Contact list is up to date"); + state = State.WAITING_FOR_CHANGES; + } else { + if (LOG.isLoggable(INFO)) { + LOG.info(updates.size() + " updates to apply"); + } + state = State.UPDATING_CONTACT_LIST; + ioExecutor.execute(this::updateContactList); + } + } + } + + @IoExecutor + private void updateContactList() { + Update update; + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST) return; + update = updates.poll(); + if (update == null) { + LOG.info("No more updates to process"); + state = State.WAITING_FOR_CHANGES; + apiCall = null; + return; + } + } + if (update.add) loadMailboxProperties(update.contactId); + else removeContact(update.contactId); + } + + @IoExecutor + private void loadMailboxProperties(ContactId c) { + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST) return; + } + LOG.info("Loading mailbox properties for contact"); + try { + MailboxUpdate mailboxUpdate = db.transactionWithResult(true, txn -> + mailboxUpdateManager.getLocalUpdate(txn, c)); + if (mailboxUpdate instanceof MailboxUpdateWithMailbox) { + addContact(c, (MailboxUpdateWithMailbox) mailboxUpdate); + } else { + // Our own mailbox was concurrently unpaired. This worker will + // be destroyed soon, so we can stop here + LOG.info("Own mailbox was unpaired"); + } + } catch (NoSuchContactException e) { + // Contact was removed concurrently. Move on to the next update. + // Later we may process a removal update for this contact, which + // was never added to the mailbox's contact list. The removal API + // call should fail safely with a TolerableFailureException + LOG.info("No such contact"); + updateContactList(); + } catch (DbException e) { + logException(LOG, WARNING, e); + } + } + + @IoExecutor + private void addContact(ContactId c, MailboxUpdateWithMailbox withMailbox) { + MailboxProperties props = withMailbox.getMailboxProperties(); + MailboxContact contact = new MailboxContact(c, props.getAuthToken(), + requireNonNull(props.getInboxId()), + requireNonNull(props.getOutboxId())); + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST) return; + apiCall = mailboxApiCaller.retryWithBackoff(new SimpleApiCall(() -> + apiCallAddContact(contact))); + } + } + + @IoExecutor + private void apiCallAddContact(MailboxContact contact) + throws IOException, ApiException, TolerableFailureException { + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST) return; + } + LOG.info("Adding contact to remote contact list"); + mailboxApi.addContact(mailboxProperties, contact); + updateContactList(); + } + + @IoExecutor + private void removeContact(ContactId c) { + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST) return; + apiCall = mailboxApiCaller.retryWithBackoff(new SimpleApiCall(() -> + apiCallRemoveContact(c))); + } + } + + @IoExecutor + private void apiCallRemoveContact(ContactId c) + throws IOException, ApiException { + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST) return; + } + LOG.info("Removing contact from remote contact list"); + try { + mailboxApi.deleteContact(mailboxProperties, c); + } catch (TolerableFailureException e) { + // Catch this so we can continue to the next update + logException(LOG, INFO, e); + } + updateContactList(); + } + + @Override + public void eventOccurred(Event e) { + if (e instanceof ContactAddedEvent) { + LOG.info("Contact added"); + onContactAdded(((ContactAddedEvent) e).getContactId()); + } else if (e instanceof ContactRemovedEvent) { + LOG.info("Contact removed"); + onContactRemoved(((ContactRemovedEvent) e).getContactId()); + } + } + + @EventExecutor + private void onContactAdded(ContactId c) { + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST && + state != State.WAITING_FOR_CHANGES) { + return; + } + updates.add(new Update(true, c)); + if (state == State.WAITING_FOR_CHANGES) { + state = State.UPDATING_CONTACT_LIST; + ioExecutor.execute(this::updateContactList); + } + } + } + + @EventExecutor + private void onContactRemoved(ContactId c) { + synchronized (lock) { + if (state != State.UPDATING_CONTACT_LIST && + state != State.WAITING_FOR_CHANGES) { + return; + } + updates.add(new Update(false, c)); + if (state == State.WAITING_FOR_CHANGES) { + state = State.UPDATING_CONTACT_LIST; + ioExecutor.execute(this::updateContactList); + } + } + } + + /** + * An update that should be applied to the remote contact list. + */ + private static class Update { + + /** + * True if the contact should be added, false if the contact should be + * removed. + */ + private final boolean add; + private final ContactId contactId; + + private Update(boolean add, ContactId contactId) { + this.add = add; + this.contactId = contactId; + } + } +} diff --git a/bramble-core/src/test/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityCheckerTest.java b/bramble-core/src/test/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityCheckerTest.java index 159d0262fd6dd893994c4c38d285173c5358d74a..7c2dd18813f3c6ec1bc55efb97ddb0f5105d536e 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityCheckerTest.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/mailbox/OwnMailboxConnectivityCheckerTest.java @@ -5,6 +5,7 @@ import org.briarproject.bramble.api.db.Transaction; import org.briarproject.bramble.api.db.TransactionManager; import org.briarproject.bramble.api.mailbox.MailboxProperties; import org.briarproject.bramble.api.mailbox.MailboxSettingsManager; +import org.briarproject.bramble.api.mailbox.MailboxVersion; import org.briarproject.bramble.api.system.Clock; import org.briarproject.bramble.mailbox.ConnectivityChecker.ConnectivityObserver; import org.briarproject.bramble.test.BrambleMockTestCase; @@ -15,8 +16,10 @@ import org.jmock.lib.action.DoAllAction; import org.junit.Test; import java.io.IOException; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; +import static java.util.Collections.singletonList; import static org.briarproject.bramble.api.mailbox.MailboxConstants.CLIENT_SUPPORTS; import static org.briarproject.bramble.test.TestUtils.getMailboxProperties; import static org.junit.Assert.assertFalse; @@ -39,6 +42,8 @@ public class OwnMailboxConnectivityCheckerTest extends BrambleMockTestCase { private final MailboxProperties properties = getMailboxProperties(true, CLIENT_SUPPORTS); private final long now = System.currentTimeMillis(); + private final List<MailboxVersion> serverSupports = + singletonList(new MailboxVersion(123, 456)); @Test public void testObserverIsCalledWhenCheckSucceeds() throws Exception { @@ -62,12 +67,13 @@ public class OwnMailboxConnectivityCheckerTest extends BrambleMockTestCase { // When the check succeeds, the success should be recorded in the DB // and the observer should be called context.checking(new DbExpectations() {{ - oneOf(mailboxApi).checkStatus(properties); - will(returnValue(true)); + oneOf(mailboxApi).getServerSupports(properties); + will(returnValue(serverSupports)); oneOf(clock).currentTimeMillis(); will(returnValue(now)); oneOf(db).transaction(with(false), withDbRunnable(txn)); - oneOf(mailboxSettingsManager).recordSuccessfulConnection(txn, now); + oneOf(mailboxSettingsManager).recordSuccessfulConnection(txn, now, + serverSupports); oneOf(observer).onConnectivityCheckSucceeded(); }}); @@ -97,7 +103,7 @@ public class OwnMailboxConnectivityCheckerTest extends BrambleMockTestCase { // When the check fails, the failure should be recorded in the DB and // the observer should not be called context.checking(new DbExpectations() {{ - oneOf(mailboxApi).checkStatus(properties); + oneOf(mailboxApi).getServerSupports(properties); will(throwException(new IOException())); oneOf(clock).currentTimeMillis(); will(returnValue(now)); diff --git a/bramble-core/src/test/java/org/briarproject/bramble/mailbox/OwnMailboxContactListWorkerTest.java b/bramble-core/src/test/java/org/briarproject/bramble/mailbox/OwnMailboxContactListWorkerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..12232122e18a44a1ca9fe49bd401ea270b608dc4 --- /dev/null +++ b/bramble-core/src/test/java/org/briarproject/bramble/mailbox/OwnMailboxContactListWorkerTest.java @@ -0,0 +1,406 @@ +package org.briarproject.bramble.mailbox; + +import org.briarproject.bramble.api.Cancellable; +import org.briarproject.bramble.api.contact.Contact; +import org.briarproject.bramble.api.contact.ContactId; +import org.briarproject.bramble.api.contact.event.ContactAddedEvent; +import org.briarproject.bramble.api.contact.event.ContactRemovedEvent; +import org.briarproject.bramble.api.db.DatabaseComponent; +import org.briarproject.bramble.api.db.NoSuchContactException; +import org.briarproject.bramble.api.db.Transaction; +import org.briarproject.bramble.api.event.EventBus; +import org.briarproject.bramble.api.mailbox.MailboxProperties; +import org.briarproject.bramble.api.mailbox.MailboxUpdate; +import org.briarproject.bramble.api.mailbox.MailboxUpdateManager; +import org.briarproject.bramble.api.mailbox.MailboxUpdateWithMailbox; +import org.briarproject.bramble.mailbox.MailboxApi.MailboxContact; +import org.briarproject.bramble.mailbox.MailboxApi.TolerableFailureException; +import org.briarproject.bramble.test.BrambleMockTestCase; +import org.briarproject.bramble.test.CaptureArgumentAction; +import org.briarproject.bramble.test.DbExpectations; +import org.briarproject.bramble.test.RunAction; +import org.jmock.Expectations; +import org.jmock.lib.action.DoAllAction; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.briarproject.bramble.api.mailbox.MailboxConstants.CLIENT_SUPPORTS; +import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNonNull; +import static org.briarproject.bramble.test.TestUtils.getContact; +import static org.briarproject.bramble.test.TestUtils.getMailboxProperties; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class OwnMailboxContactListWorkerTest extends BrambleMockTestCase { + + private final Executor ioExecutor = context.mock(Executor.class); + private final DatabaseComponent db = context.mock(DatabaseComponent.class); + private final EventBus eventBus = context.mock(EventBus.class); + private final ConnectivityChecker connectivityChecker = + context.mock(ConnectivityChecker.class); + private final MailboxApiCaller mailboxApiCaller = + context.mock(MailboxApiCaller.class); + private final MailboxApi mailboxApi = context.mock(MailboxApi.class); + private final MailboxUpdateManager mailboxUpdateManager = + context.mock(MailboxUpdateManager.class); + private final Cancellable apiCall = context.mock(Cancellable.class); + + private final MailboxProperties mailboxProperties = + getMailboxProperties(true, CLIENT_SUPPORTS); + private final Contact contact1 = getContact(), contact2 = getContact(); + private final MailboxProperties contactProperties1 = + getMailboxProperties(false, CLIENT_SUPPORTS); + private final MailboxProperties contactProperties2 = + getMailboxProperties(false, CLIENT_SUPPORTS); + private final MailboxUpdateWithMailbox update1 = + new MailboxUpdateWithMailbox(CLIENT_SUPPORTS, contactProperties1); + private final MailboxUpdateWithMailbox update2 = + new MailboxUpdateWithMailbox(CLIENT_SUPPORTS, contactProperties2); + + private final OwnMailboxContactListWorker worker = + new OwnMailboxContactListWorker(ioExecutor, db, eventBus, + connectivityChecker, mailboxApiCaller, mailboxApi, + mailboxUpdateManager, mailboxProperties); + + @Test + public void testChecksConnectivityWhenStartedAndRemovesObserverWhenDestroyed() { + // When the worker is started it should start a connectivity check + expectStartConnectivityCheck(); + + worker.start(); + + // When the worker is destroyed it should remove the connectivity + // observer and event listener + expectRemoveConnectivityObserverAndEventListener(); + + worker.destroy(); + } + + @Test + public void testUpdatesContactListWhenConnectivityCheckSucceeds() + throws Exception { + // When the worker is started it should start a connectivity check + expectStartConnectivityCheck(); + + worker.start(); + + // When the connectivity check succeeds, the worker should start a + // task to fetch the remote contact list + AtomicReference<ApiCall> fetchList = new AtomicReference<>(); + expectStartTaskToFetchRemoteContactList(fetchList); + + worker.onConnectivityCheckSucceeded(); + + // When the fetch task runs it should fetch the remote contact list, + // load the local contact list, and find the differences. Contact 2 + // needs to be added and contact 1 needs to be removed. The worker + // should load the mailbox update for contact 2 and start a task to + // add contact 2 to the mailbox + expectFetchRemoteContactList(singletonList(contact1.getId())); + expectRunTaskOnIoExecutor(); + expectLoadLocalContactList(singletonList(contact2)); + expectRunTaskOnIoExecutor(); + expectLoadMailboxUpdate(contact2, update2); + AtomicReference<ApiCall> addContact = new AtomicReference<>(); + expectStartTaskToAddContact(addContact); + + assertFalse(fetchList.get().callApi()); + + // When the add-contact task runs it should add contact 2 to the + // mailbox, then continue with the next update + AtomicReference<MailboxContact> added = new AtomicReference<>(); + expectAddContactToMailbox(added); + AtomicReference<ApiCall> removeContact = new AtomicReference<>(); + expectStartTaskToRemoveContact(removeContact); + + assertFalse(addContact.get().callApi()); + + // Check that the added contact has the expected properties + MailboxContact expected = new MailboxContact(contact2.getId(), + contactProperties2.getAuthToken(), + requireNonNull(contactProperties2.getInboxId()), + requireNonNull(contactProperties2.getOutboxId())); + assertMailboxContactEquals(expected, added.get()); + + // When the remove-contact task runs it should remove contact 1 from + // the mailbox + expectRemoveContactFromMailbox(contact1); + + assertFalse(removeContact.get().callApi()); + + // When the worker is destroyed it should remove the connectivity + // observer and event listener + expectRemoveConnectivityObserverAndEventListener(); + + worker.destroy(); + } + + @Test + public void testHandlesEventsAfterMakingInitialUpdates() throws Exception { + // When the worker is started it should start a connectivity check + expectStartConnectivityCheck(); + + worker.start(); + + // When the connectivity check succeeds, the worker should start a + // task to fetch the remote contact list + AtomicReference<ApiCall> fetchList = new AtomicReference<>(); + expectStartTaskToFetchRemoteContactList(fetchList); + + worker.onConnectivityCheckSucceeded(); + + // When the fetch task runs it should fetch the remote contact list, + // load the local contact list, and find the differences. The lists + // are the same, so the worker should wait for changes + expectFetchRemoteContactList(emptyList()); + expectRunTaskOnIoExecutor(); + expectLoadLocalContactList(emptyList()); + + assertFalse(fetchList.get().callApi()); + + // When a contact is added, the worker should load the contact's + // mailbox update and start a task to add the contact to the mailbox + expectRunTaskOnIoExecutor(); + expectLoadMailboxUpdate(contact1, update1); + AtomicReference<ApiCall> addContact = new AtomicReference<>(); + expectStartTaskToAddContact(addContact); + + worker.eventOccurred(new ContactAddedEvent(contact1.getId(), true)); + + // When the add-contact task runs it should add contact 1 to the + // mailbox + AtomicReference<MailboxContact> added = new AtomicReference<>(); + expectAddContactToMailbox(added); + + assertFalse(addContact.get().callApi()); + + // Check that the added contact has the expected properties + MailboxContact expected = new MailboxContact(contact1.getId(), + contactProperties1.getAuthToken(), + requireNonNull(contactProperties1.getInboxId()), + requireNonNull(contactProperties1.getOutboxId())); + assertMailboxContactEquals(expected, added.get()); + + // When the contact is removed again, the worker should start a task + // to remove the contact from the mailbox + expectRunTaskOnIoExecutor(); + AtomicReference<ApiCall> removeContact = new AtomicReference<>(); + expectStartTaskToRemoveContact(removeContact); + + worker.eventOccurred(new ContactRemovedEvent(contact1.getId())); + + // When the remove-contact task runs it should remove the contact from + // the mailbox + expectRemoveContactFromMailbox(contact1); + + assertFalse(removeContact.get().callApi()); + + // When the worker is destroyed it should remove the connectivity + // observer and event listener + expectRemoveConnectivityObserverAndEventListener(); + + worker.destroy(); + } + + + @Test + public void testHandlesNoSuchContactException() throws Exception { + // When the worker is started it should start a connectivity check + expectStartConnectivityCheck(); + + worker.start(); + + // When the connectivity check succeeds, the worker should start a + // task to fetch the remote contact list + AtomicReference<ApiCall> fetchList = new AtomicReference<>(); + expectStartTaskToFetchRemoteContactList(fetchList); + + worker.onConnectivityCheckSucceeded(); + + // When the fetch task runs it should fetch the remote contact list, + // load the local contact list, and find the differences. Contact 1 + // needs to be added, so the worker should submit a task to the + // IO executor to load the contact's mailbox update + expectFetchRemoteContactList(emptyList()); + expectRunTaskOnIoExecutor(); + expectLoadLocalContactList(singletonList(contact1)); + AtomicReference<Runnable> loadUpdate = new AtomicReference<>(); + context.checking(new Expectations() {{ + oneOf(ioExecutor).execute(with(any(Runnable.class))); + will(new CaptureArgumentAction<>(loadUpdate, Runnable.class, 0)); + }}); + + assertFalse(fetchList.get().callApi()); + + // Before the contact's mailbox update can be loaded, the contact + // is removed + worker.eventOccurred(new ContactRemovedEvent(contact1.getId())); + + // When the load-update task runs, a NoSuchContactException is thrown. + // The worker should abandon adding the contact and move on to the + // next update, which is the removal of the same contact. The worker + // should start a task to remove the contact from the mailbox + Transaction txn = new Transaction(null, false); + context.checking(new DbExpectations() {{ + oneOf(db).transactionWithResult(with(true), withDbCallable(txn)); + oneOf(mailboxUpdateManager).getLocalUpdate(txn, contact1.getId()); + will(throwException(new NoSuchContactException())); + }}); + AtomicReference<ApiCall> removeContact = new AtomicReference<>(); + expectStartTaskToRemoveContact(removeContact); + + loadUpdate.get().run(); + + // When the remove-contact task runs it should remove the contact from + // the mailbox. The contact was never added, so the call throws a + // TolerableFailureException + context.checking(new Expectations() {{ + oneOf(mailboxApi).deleteContact(mailboxProperties, + contact1.getId()); + will(throwException(new TolerableFailureException())); + }}); + + assertFalse(removeContact.get().callApi()); + + // When the worker is destroyed it should remove the connectivity + // observer and event listener + expectRemoveConnectivityObserverAndEventListener(); + + worker.destroy(); + } + + @Test + public void testCancelsApiCallWhenDestroyed() { + // When the worker is started it should start a connectivity check + expectStartConnectivityCheck(); + + worker.start(); + + // When the connectivity check succeeds, the worker should start a + // task to fetch the remote contact list + AtomicReference<ApiCall> fetchList = new AtomicReference<>(); + expectStartTaskToFetchRemoteContactList(fetchList); + + worker.onConnectivityCheckSucceeded(); + + // The worker is destroyed before the task runs. The worker should + // cancel the task remove the connectivity observer and event listener + context.checking(new Expectations() {{ + oneOf(apiCall).cancel(); + }}); + expectRemoveConnectivityObserverAndEventListener(); + + worker.destroy(); + } + + private void expectStartConnectivityCheck() { + context.checking(new Expectations() {{ + oneOf(connectivityChecker).checkConnectivity( + with(mailboxProperties), with(worker)); + }}); + } + + private void expectRemoveConnectivityObserverAndEventListener() { + context.checking(new Expectations() {{ + oneOf(connectivityChecker).removeObserver(worker); + oneOf(eventBus).removeListener(worker); + }}); + } + + private void expectStartTaskToFetchRemoteContactList( + AtomicReference<ApiCall> task) { + context.checking(new Expectations() {{ + oneOf(mailboxApiCaller).retryWithBackoff(with(any(ApiCall.class))); + will(new DoAllAction( + new CaptureArgumentAction<>(task, ApiCall.class, 0), + returnValue(apiCall) + )); + }}); + } + + private void expectFetchRemoteContactList(List<ContactId> remote) + throws Exception { + context.checking(new Expectations() {{ + oneOf(mailboxApi).getContacts(mailboxProperties); + will(returnValue(remote)); + }}); + } + + private void expectLoadLocalContactList(List<Contact> local) + throws Exception { + Transaction txn = new Transaction(null, true); + + context.checking(new DbExpectations() {{ + oneOf(db).transaction(with(true), withDbRunnable(txn)); + oneOf(db).getContacts(txn); + will(returnValue(local)); + }}); + } + + private void expectLoadMailboxUpdate(Contact c, MailboxUpdate update) + throws Exception { + Transaction txn = new Transaction(null, true); + + context.checking(new DbExpectations() {{ + oneOf(db).transactionWithResult(with(true), + withDbCallable(txn)); + oneOf(mailboxUpdateManager).getLocalUpdate(txn, c.getId()); + will(returnValue(update)); + }}); + } + + private void expectStartTaskToAddContact(AtomicReference<ApiCall> task) { + context.checking(new Expectations() {{ + oneOf(mailboxApiCaller).retryWithBackoff(with(any(ApiCall.class))); + will(new DoAllAction( + new CaptureArgumentAction<>(task, ApiCall.class, 0), + returnValue(apiCall) + )); + }}); + } + + private void expectAddContactToMailbox( + AtomicReference<MailboxContact> added) throws Exception { + context.checking(new DbExpectations() {{ + oneOf(mailboxApi).addContact(with(mailboxProperties), + with(any(MailboxContact.class))); + will(new CaptureArgumentAction<>(added, MailboxContact.class, 1)); + }}); + } + + private void expectStartTaskToRemoveContact(AtomicReference<ApiCall> task) { + context.checking(new DbExpectations() {{ + oneOf(mailboxApiCaller).retryWithBackoff(with(any(ApiCall.class))); + will(new DoAllAction( + new CaptureArgumentAction<>(task, ApiCall.class, 0), + returnValue(apiCall) + )); + }}); + } + + private void expectRemoveContactFromMailbox(Contact c) throws Exception { + context.checking(new Expectations() {{ + oneOf(mailboxApi).deleteContact(mailboxProperties, c.getId()); + }}); + } + + private void expectRunTaskOnIoExecutor() { + context.checking(new Expectations() {{ + oneOf(ioExecutor).execute(with(any(Runnable.class))); + will(new RunAction()); + }}); + } + + private void assertMailboxContactEquals(MailboxContact expected, + MailboxContact actual) { + assertEquals(expected.contactId, actual.contactId); + assertEquals(expected.token, actual.token); + assertEquals(expected.inboxId, actual.inboxId); + assertEquals(expected.outboxId, actual.outboxId); + } +}