diff --git a/briar-tests/src/org/briarproject/briar/privategroup/invitation/AbstractProtocolEngineTest.java b/briar-tests/src/org/briarproject/briar/privategroup/invitation/AbstractProtocolEngineTest.java
index 930941cd034dfae16a7c27a6b688150ea6022ab0..2332a8e931acc204f807122488b4313ffcf25623 100644
--- a/briar-tests/src/org/briarproject/briar/privategroup/invitation/AbstractProtocolEngineTest.java
+++ b/briar-tests/src/org/briarproject/briar/privategroup/invitation/AbstractProtocolEngineTest.java
@@ -24,7 +24,7 @@ import org.jmock.Expectations;
 
 import static org.briarproject.TestUtils.getRandomBytes;
 import static org.briarproject.TestUtils.getRandomId;
-import static org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager.CLIENT_ID;
+import static org.briarproject.briar.api.privategroup.PrivateGroupManager.*;
 import static org.briarproject.briar.privategroup.invitation.GroupInvitationConstants.GROUP_KEY_CONTACT_ID;
 import static org.briarproject.briar.privategroup.invitation.MessageType.ABORT;
 import static org.briarproject.briar.privategroup.invitation.MessageType.INVITE;
@@ -80,6 +80,21 @@ public abstract class AbstractProtocolEngineTest extends BriarMockTestCase {
 			BdfDictionary.of(new BdfEntry("me", "ta"));
 	protected final ContactId contactId = new ContactId(5);
 
+	protected final InviteMessage inviteMessage =
+			new InviteMessage(new MessageId(getRandomId()), contactGroupId,
+					privateGroupId, 0L, privateGroup.getName(),
+					privateGroup.getCreator(), privateGroup.getSalt(), "msg",
+					signature);
+	protected final JoinMessage joinMessage =
+			new JoinMessage(new MessageId(getRandomId()), contactGroupId,
+					privateGroupId, 0L, lastRemoteMessageId);
+	protected final LeaveMessage leaveMessage =
+			new LeaveMessage(new MessageId(getRandomId()), contactGroupId,
+					privateGroupId, 0L, lastRemoteMessageId);
+	protected final AbortMessage abortMessage =
+			new AbortMessage(messageId, contactGroupId, privateGroupId,
+					inviteTimestamp + 1);
+
 	protected void assertSessionConstantsUnchanged(Session s1, Session s2) {
 		assertEquals(s1.getRole(), s2.getRole());
 		assertEquals(s1.getContactGroupId(), s2.getContactGroupId());
@@ -112,7 +127,8 @@ public abstract class AbstractProtocolEngineTest extends BriarMockTestCase {
 		expectSendMessage(INVITE, true);
 	}
 
-	protected void expectSendJoinMessage(final JoinMessage m) throws Exception {
+	protected void expectSendJoinMessage(final JoinMessage m, boolean visible)
+			throws Exception {
 		expectGetLocalTimestamp(messageTimestamp);
 		context.checking(new Expectations() {{
 			oneOf(messageEncoder).encodeJoinMessage(m.getContactGroupId(),
@@ -120,10 +136,10 @@ public abstract class AbstractProtocolEngineTest extends BriarMockTestCase {
 					lastLocalMessageId);
 			will(returnValue(message));
 		}});
-		expectSendMessage(JOIN, false);
+		expectSendMessage(JOIN, visible);
 	}
 
-	protected void expectSendLeaveMessage() throws Exception {
+	protected void expectSendLeaveMessage(boolean visible) throws Exception {
 		expectGetLocalTimestamp(messageTimestamp);
 		context.checking(new Expectations() {{
 			oneOf(messageEncoder)
@@ -131,7 +147,7 @@ public abstract class AbstractProtocolEngineTest extends BriarMockTestCase {
 							messageTimestamp, lastLocalMessageId);
 			will(returnValue(message));
 		}});
-		expectSendMessage(LEAVE, false);
+		expectSendMessage(LEAVE, visible);
 	}
 
 	protected void expectSendAbortMessage() throws Exception {
diff --git a/briar-tests/src/org/briarproject/briar/privategroup/invitation/CreatorProtocolEngineTest.java b/briar-tests/src/org/briarproject/briar/privategroup/invitation/CreatorProtocolEngineTest.java
index d8bcdc3fd2573ce103e261c55d44465ca9a0fcf1..7bc8141cbd7977dd21272cdf77cf8f51c4f894c2 100644
--- a/briar-tests/src/org/briarproject/briar/privategroup/invitation/CreatorProtocolEngineTest.java
+++ b/briar-tests/src/org/briarproject/briar/privategroup/invitation/CreatorProtocolEngineTest.java
@@ -22,12 +22,6 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 			new CreatorProtocolEngine(db, clientHelper, privateGroupManager,
 					privateGroupFactory, groupMessageFactory, identityManager,
 					messageParser, messageEncoder, messageTracker, clock);
-	private final JoinMessage joinMessage =
-			new JoinMessage(new MessageId(getRandomId()), contactGroupId,
-					privateGroupId, 0L, lastRemoteMessageId);
-	private final LeaveMessage leaveMessage =
-			new LeaveMessage(new MessageId(getRandomId()), contactGroupId,
-					privateGroupId, 0L, lastRemoteMessageId);
 
 	private CreatorSession getDefaultSession(CreatorState state) {
 		return new CreatorSession(contactGroupId, privateGroupId,
@@ -185,7 +179,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 
 	private void expectOnLocalLeave() throws Exception {
 		expectSetPrivateGroupVisibility(INVISIBLE);
-		expectSendLeaveMessage();
+		expectSendLeaveMessage(false);
 	}
 
 	// onMemberAddedAction
@@ -215,12 +209,8 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 
 	@Test
 	public void testOnInviteMessageInAnyState() throws Exception {
-		InviteMessage inviteMessage =
-				new InviteMessage(new MessageId(getRandomId()), contactGroupId,
-						privateGroupId, inviteTimestamp, privateGroup.getName(),
-						author, privateGroup.getSalt(), null, signature);
-
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession session = getDefaultSession(LEFT);
 		CreatorSession newSession =
@@ -228,6 +218,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		assertEquals(ERROR, newSession.getState());
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		session = getDefaultSession(START);
 		newSession =
@@ -244,6 +235,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(START);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onJoinMessage(txn, session, joinMessage);
@@ -257,6 +249,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(JOINED);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onJoinMessage(txn, session, joinMessage);
@@ -270,6 +263,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(LEFT);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onJoinMessage(txn, session, joinMessage);
@@ -284,6 +278,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(INVITED);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onJoinMessage(txn, session, joinMessage);
@@ -301,6 +296,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 						inviteTimestamp + 1, messageId);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onJoinMessage(txn, session, invalidJoinMessage);
@@ -317,7 +313,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 						privateGroupId, inviteTimestamp + 1,
 						lastRemoteMessageId);
 
-		expectSendJoinMessage(properJoinMessage);
+		expectSendJoinMessage(properJoinMessage, false);
 		expectMarkMessageVisibleInUi(properJoinMessage.getId(), true);
 		context.checking(new Expectations() {{
 			oneOf(messageTracker)
@@ -359,6 +355,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(START);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onLeaveMessage(txn, session, leaveMessage);
@@ -372,6 +369,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(LEFT);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onLeaveMessage(txn, session, leaveMessage);
@@ -385,6 +383,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(INVITED);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onLeaveMessage(txn, session, leaveMessage);
@@ -402,6 +401,7 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 		CreatorSession session = getDefaultSession(INVITED);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onLeaveMessage(txn, session, invalidLeaveMessage);
@@ -455,9 +455,6 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 
 	@Test
 	public void testOnAbortMessageWhenNotSubscribed() throws Exception {
-		AbortMessage abortMessage =
-				new AbortMessage(messageId, contactGroupId, privateGroupId,
-						inviteTimestamp + 1);
 		CreatorSession session = getDefaultSession(START);
 
 		expectIsNotSubscribedPrivateGroup();
@@ -471,12 +468,10 @@ public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
 
 	@Test
 	public void testOnAbortMessageWhenSubscribed() throws Exception {
-		AbortMessage abortMessage =
-				new AbortMessage(messageId, contactGroupId, privateGroupId,
-						inviteTimestamp + 1);
 		CreatorSession session = getDefaultSession(START);
 
 		expectIsSubscribedPrivateGroup();
+		expectSetPrivateGroupVisibility(INVISIBLE);
 		expectSendAbortMessage();
 		CreatorSession newSession =
 				engine.onAbortMessage(txn, session, abortMessage);
diff --git a/briar-tests/src/org/briarproject/briar/privategroup/invitation/InviteeProtocolEngineTest.java b/briar-tests/src/org/briarproject/briar/privategroup/invitation/InviteeProtocolEngineTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..0822abaab2c62a240045af019232bc6b4ce151a8
--- /dev/null
+++ b/briar-tests/src/org/briarproject/briar/privategroup/invitation/InviteeProtocolEngineTest.java
@@ -0,0 +1,824 @@
+package org.briarproject.briar.privategroup.invitation;
+
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfEntry;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.ProtocolStateException;
+import org.briarproject.briar.api.privategroup.GroupMessage;
+import org.jmock.Expectations;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.Map;
+
+import static org.briarproject.TestUtils.getRandomBytes;
+import static org.briarproject.TestUtils.getRandomId;
+import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED;
+import static org.briarproject.bramble.api.sync.Group.Visibility.VISIBLE;
+import static org.briarproject.briar.privategroup.invitation.InviteeState.ACCEPTED;
+import static org.briarproject.briar.privategroup.invitation.InviteeState.DISSOLVED;
+import static org.briarproject.briar.privategroup.invitation.InviteeState.ERROR;
+import static org.briarproject.briar.privategroup.invitation.InviteeState.INVITED;
+import static org.briarproject.briar.privategroup.invitation.InviteeState.JOINED;
+import static org.briarproject.briar.privategroup.invitation.InviteeState.LEFT;
+import static org.briarproject.briar.privategroup.invitation.InviteeState.START;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class InviteeProtocolEngineTest extends AbstractProtocolEngineTest {
+
+	private final InviteeProtocolEngine engine =
+			new InviteeProtocolEngine(db, clientHelper, privateGroupManager,
+					privateGroupFactory, groupMessageFactory, identityManager,
+					messageParser, messageEncoder, messageTracker, clock);
+	private final LocalAuthor localAuthor =
+			new LocalAuthor(new AuthorId(getRandomId()), "Local Author",
+					getRandomBytes(12), getRandomBytes(12), 42L);
+
+	private InviteeSession getDefaultSession(InviteeState state) {
+		return new InviteeSession(contactGroupId, privateGroupId,
+				lastLocalMessageId, lastRemoteMessageId, localTimestamp,
+				inviteTimestamp, state);
+	}
+
+	// onInviteAction
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnInviteActionFromStart() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(START), null,
+				messageTimestamp, signature);
+	}
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnInviteActionFromLeft() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(ACCEPTED), null,
+				messageTimestamp, signature);
+	}
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnInviteActionFromInvited() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(INVITED), null,
+				messageTimestamp, signature);
+	}
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnInviteActionFromDissolved() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(DISSOLVED), null,
+				messageTimestamp, signature);
+	}
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnInviteActionFromAccepted() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(ACCEPTED), null,
+				messageTimestamp, signature);
+	}
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnInviteActionFromJoined() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(JOINED), null,
+				messageTimestamp, signature);
+	}
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnInviteActionFromError() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(ERROR), null,
+				messageTimestamp, signature);
+	}
+
+	// onJoinAction
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnJoinActionFromStart() throws Exception {
+		engine.onJoinAction(txn, getDefaultSession(START));
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnJoinActionFromAccepted() throws Exception {
+		engine.onJoinAction(txn, getDefaultSession(ACCEPTED));
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnJoinActionFromJoined() throws Exception {
+		engine.onJoinAction(txn, getDefaultSession(JOINED));
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnJoinActionFromLeft() throws Exception {
+		engine.onJoinAction(txn, getDefaultSession(LEFT));
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnJoinActionFromDissolved() throws Exception {
+		engine.onJoinAction(txn, getDefaultSession(DISSOLVED));
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnJoinActionFromError() throws Exception {
+		engine.onJoinAction(txn, getDefaultSession(ERROR));
+	}
+
+	@Test
+	public void testOnJoinActionFromInvited() throws Exception {
+		final JoinMessage properJoinMessage =
+				new JoinMessage(messageId, contactGroupId, privateGroupId,
+						messageTimestamp, lastRemoteMessageId);
+		final Message inviteMsg =
+				new Message(lastRemoteMessageId, contactGroupId, 1337L,
+						getRandomBytes(42));
+		final BdfList inviteList = BdfList.of("inviteMessage");
+		final long timestamp = 0L;
+		final GroupMessage joinGroupMessage =
+				new GroupMessage(message, null, localAuthor);
+
+		expectMarkMessageAvailableToAnswer(lastRemoteMessageId, false);
+		expectSendJoinMessage(properJoinMessage, true);
+		context.checking(new Expectations() {{
+			oneOf(messageTracker).trackOutgoingMessage(txn, message);
+			oneOf(clientHelper).getMessage(txn, lastRemoteMessageId);
+			will(returnValue(inviteMsg));
+			oneOf(clientHelper).toList(inviteMsg);
+			will(returnValue(inviteList));
+			oneOf(messageParser).parseInviteMessage(inviteMsg, inviteList);
+			will(returnValue(inviteMessage));
+			oneOf(privateGroupFactory)
+					.createPrivateGroup(inviteMessage.getGroupName(),
+							inviteMessage.getCreator(),
+							inviteMessage.getSalt());
+			will(returnValue(privateGroup));
+			oneOf(clock).currentTimeMillis();
+			will((returnValue(timestamp)));
+			oneOf(identityManager).getLocalAuthor(txn);
+			will(returnValue(localAuthor));
+			oneOf(groupMessageFactory).createJoinMessage(privateGroupId,
+					inviteMessage.getTimestamp() + 1, localAuthor,
+					inviteMessage.getTimestamp(), inviteMessage.getSignature());
+			will(returnValue(joinGroupMessage));
+			oneOf(privateGroupManager)
+					.addPrivateGroup(txn, privateGroup, joinGroupMessage,
+							false);
+		}});
+		expectSetPrivateGroupVisibility(VISIBLE);
+
+		InviteeSession session = getDefaultSession(INVITED);
+		InviteeSession newSession = engine.onJoinAction(txn, session);
+
+		assertEquals(ACCEPTED, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test(expected = IllegalStateException.class)
+	public void testOnJoinActionFromInvitedWithoutInvitationId()
+			throws Exception {
+		InviteeSession session =
+				new InviteeSession(contactGroupId, privateGroupId,
+						lastLocalMessageId, null, localTimestamp,
+						inviteTimestamp, INVITED);
+		engine.onJoinAction(txn, session);
+	}
+
+	// onLeaveAction
+
+	@Test
+	public void testOnLeaveActionFromStart() throws Exception {
+		InviteeSession session = getDefaultSession(START);
+		assertEquals(session, engine.onLeaveAction(txn, session));
+	}
+
+	@Test
+	public void testOnLeaveActionFromLeft() throws Exception {
+		InviteeSession session = getDefaultSession(LEFT);
+		assertEquals(session, engine.onLeaveAction(txn, session));
+	}
+
+	@Test
+	public void testOnLeaveActionFromDissolved() throws Exception {
+		InviteeSession session = getDefaultSession(DISSOLVED);
+		assertEquals(session, engine.onLeaveAction(txn, session));
+	}
+
+	@Test
+	public void testOnLeaveActionFromError() throws Exception {
+		InviteeSession session = getDefaultSession(ERROR);
+		assertEquals(session, engine.onLeaveAction(txn, session));
+	}
+
+	@Test
+	public void testOnLeaveActionFromInvited() throws Exception {
+		expectMarkMessageAvailableToAnswer(lastRemoteMessageId, false);
+		expectSendLeaveMessage(true);
+		context.checking(new Expectations() {{
+			oneOf(messageTracker).trackOutgoingMessage(txn, message);
+		}});
+
+		InviteeSession session = getDefaultSession(INVITED);
+		InviteeSession newSession = engine.onLeaveAction(txn, session);
+
+		assertEquals(START, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test(expected = IllegalStateException.class)
+	public void testOnLeaveActionFromInvitedWithoutInvitationId()
+			throws Exception {
+		InviteeSession session =
+				new InviteeSession(contactGroupId, privateGroupId,
+						lastLocalMessageId, null, localTimestamp,
+						inviteTimestamp, INVITED);
+		engine.onJoinAction(txn, session);
+	}
+
+	@Test
+	public void testOnLeaveActionFromAccepted() throws Exception {
+		expectSendLeaveMessage(false);
+		InviteeSession session = getDefaultSession(ACCEPTED);
+		InviteeSession newSession = engine.onLeaveAction(txn, session);
+
+		assertEquals(LEFT, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveActionFromJoined() throws Exception {
+		expectSendLeaveMessage(false);
+		InviteeSession session = getDefaultSession(JOINED);
+		InviteeSession newSession = engine.onLeaveAction(txn, session);
+
+		assertEquals(LEFT, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	// onMemberAddedAction
+
+	@Test
+	public void testOnMemberAddedFromStart() throws Exception {
+		InviteeSession session = getDefaultSession(START);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	@Test
+	public void testOnMemberAddedFromInvited() throws Exception {
+		InviteeSession session = getDefaultSession(INVITED);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	@Test
+	public void testOnMemberAddedFromAccepted() throws Exception {
+		InviteeSession session = getDefaultSession(ACCEPTED);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	@Test
+	public void testOnMemberAddedFromJoined() throws Exception {
+		InviteeSession session = getDefaultSession(JOINED);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	@Test
+	public void testOnMemberAddedFromLeft() throws Exception {
+		InviteeSession session = getDefaultSession(LEFT);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	@Test
+	public void testOnMemberAddedFromDissolved() throws Exception {
+		InviteeSession session = getDefaultSession(DISSOLVED);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	@Test
+	public void testOnMemberAddedFromError() throws Exception {
+		InviteeSession session = getDefaultSession(ERROR);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	// onInviteMessage
+
+	@Test
+	public void testOnInviteMessageFromStartWithLowerTimestamp()
+			throws Exception {
+		InviteeSession session = getDefaultSession(START);
+		assertTrue(
+				inviteMessage.getTimestamp() <= session.getInviteTimestamp());
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromStartButNotCreator() throws Exception {
+		InviteeSession session = getDefaultSession(START);
+		InviteMessage properInviteMessage =
+				new InviteMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1,
+						privateGroup.getName(), privateGroup.getCreator(),
+						privateGroup.getSalt(), "msg", signature);
+		final Contact contact =
+				new Contact(contactId, localAuthor, localAuthor.getId(), true,
+						true);
+
+		expectGetContactId();
+		context.checking(new Expectations() {{
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+		}});
+		expectAbort();
+
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, properInviteMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromStart() throws Exception {
+		InviteeSession session = getDefaultSession(START);
+		final InviteMessage properInviteMessage =
+				new InviteMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1,
+						privateGroup.getName(), privateGroup.getCreator(),
+						privateGroup.getSalt(), "msg", signature);
+		final Contact contact =
+				new Contact(contactId, author, localAuthor.getId(), true, true);
+		assertEquals(contact.getAuthor(), privateGroup.getCreator());
+
+		expectGetContactId();
+		context.checking(new Expectations() {{
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+		}});
+		expectMarkMessageVisibleInUi(properInviteMessage.getId(), true);
+		expectMarkMessageAvailableToAnswer(properInviteMessage.getId(), true);
+		context.checking(new Expectations() {{
+			oneOf(messageTracker).trackMessage(txn, contactGroupId,
+					properInviteMessage.getTimestamp(), false);
+			oneOf(privateGroupFactory)
+					.createPrivateGroup(properInviteMessage.getGroupName(),
+							properInviteMessage.getCreator(),
+							properInviteMessage.getSalt());
+			will(returnValue(privateGroup));
+		}});
+
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, properInviteMessage);
+
+		assertEquals(INVITED, newSession.getState());
+		assertEquals(session.getLastLocalMessageId(),
+				newSession.getLastLocalMessageId());
+		assertEquals(properInviteMessage.getId(),
+				newSession.getLastRemoteMessageId());
+		assertEquals(session.getLocalTimestamp(),
+				newSession.getLocalTimestamp());
+		assertEquals(properInviteMessage.getTimestamp(),
+				newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromInvited() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(INVITED);
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromAccepted() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(ACCEPTED);
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromJoined() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(JOINED);
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromLeft() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(LEFT);
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromDissolved() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(DISSOLVED);
+		InviteeSession newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteMessageFromError() throws Exception {
+		InviteeSession session = getDefaultSession(ERROR);
+		assertEquals(session,
+				engine.onInviteMessage(txn, session, inviteMessage));
+	}
+
+	// onJoinMessage
+
+	@Test
+	public void testOnJoinMessageFromStart() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(START);
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromInvited() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(INVITED);
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromJoined() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(JOINED);
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromLeft() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(LEFT);
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromDissolved() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(DISSOLVED);
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromAcceptedWithWrongTimestamp()
+			throws Exception {
+		InviteeSession session = getDefaultSession(ACCEPTED);
+		assertTrue(joinMessage.getTimestamp() <= session.getInviteTimestamp());
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromAcceptedWithInvalidDependency()
+			throws Exception {
+		InviteeSession session = getDefaultSession(ACCEPTED);
+		JoinMessage invalidJoinMessage =
+				new JoinMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1,
+						lastLocalMessageId);
+		assertFalse(invalidJoinMessage.getTimestamp() <=
+				session.getInviteTimestamp());
+		assertTrue(session.getLastRemoteMessageId() != null);
+		assertTrue(invalidJoinMessage.getPreviousMessageId() != null);
+		assertFalse(session.getLastRemoteMessageId()
+				.equals(invalidJoinMessage.getPreviousMessageId()));
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, invalidJoinMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromAccepted() throws Exception {
+		InviteeSession session = getDefaultSession(ACCEPTED);
+		JoinMessage properJoinMessage =
+				new JoinMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1,
+						lastRemoteMessageId);
+		assertFalse(properJoinMessage.getTimestamp() <=
+				session.getInviteTimestamp());
+		assertTrue(session.getLastRemoteMessageId() != null);
+		assertTrue(properJoinMessage.getPreviousMessageId() != null);
+		assertTrue(session.getLastRemoteMessageId()
+				.equals(properJoinMessage.getPreviousMessageId()));
+
+		expectSetPrivateGroupVisibility(SHARED);
+
+
+		InviteeSession newSession =
+				engine.onJoinMessage(txn, session, properJoinMessage);
+
+		assertEquals(JOINED, newSession.getState());
+		assertEquals(session.getLastLocalMessageId(),
+				newSession.getLastLocalMessageId());
+		assertEquals(properJoinMessage.getId(),
+				newSession.getLastRemoteMessageId());
+		assertEquals(session.getLocalTimestamp(),
+				newSession.getLocalTimestamp());
+		assertEquals(session.getInviteTimestamp(),
+				newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromFromError() throws Exception {
+		InviteeSession session = getDefaultSession(ERROR);
+		assertEquals(session, engine.onJoinMessage(txn, session, joinMessage));
+	}
+
+	// onLeaveMessage
+
+	@Test
+	public void testOnLeaveMessageFromStart() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(START);
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, leaveMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromDissolved() throws Exception {
+		expectAbort();
+		InviteeSession session = getDefaultSession(DISSOLVED);
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, leaveMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromInvitedWithWrongTimestamp()
+			throws Exception {
+		InviteeSession session = getDefaultSession(INVITED);
+		assertTrue(leaveMessage.getTimestamp() <= session.getInviteTimestamp());
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, leaveMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromLeftWithWrongTimestamp()
+			throws Exception {
+		InviteeSession session = getDefaultSession(LEFT);
+		assertTrue(leaveMessage.getTimestamp() <= session.getInviteTimestamp());
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, leaveMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromInvitedWithInvalidDependency()
+			throws Exception {
+		InviteeSession session = getDefaultSession(INVITED);
+		LeaveMessage invalidLeaveMessage =
+				new LeaveMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1, null);
+		assertFalse(invalidLeaveMessage.getTimestamp() <=
+				session.getInviteTimestamp());
+		assertTrue(invalidLeaveMessage.getPreviousMessageId() == null);
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, invalidLeaveMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromLeftWithInvalidDependency()
+			throws Exception {
+		InviteeSession session = getDefaultSession(LEFT);
+		LeaveMessage invalidLeaveMessage =
+				new LeaveMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1, null);
+		assertFalse(invalidLeaveMessage.getTimestamp() <=
+				session.getInviteTimestamp());
+		assertTrue(invalidLeaveMessage.getPreviousMessageId() == null);
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, invalidLeaveMessage);
+
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromInvited() throws Exception {
+		InviteeSession session = getDefaultSession(INVITED);
+		LeaveMessage properLeaveMessage =
+				new LeaveMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1,
+						lastRemoteMessageId);
+		assertFalse(properLeaveMessage.getTimestamp() <=
+				session.getInviteTimestamp());
+		assertTrue(session.getLastRemoteMessageId() != null);
+		assertTrue(properLeaveMessage.getPreviousMessageId() != null);
+		assertTrue(session.getLastRemoteMessageId()
+				.equals(properLeaveMessage.getPreviousMessageId()));
+
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, properLeaveMessage);
+
+		assertEquals(DISSOLVED, newSession.getState());
+		assertEquals(session.getLastLocalMessageId(),
+				newSession.getLastLocalMessageId());
+		assertEquals(properLeaveMessage.getId(),
+				newSession.getLastRemoteMessageId());
+		assertEquals(session.getLocalTimestamp(),
+				newSession.getLocalTimestamp());
+		assertEquals(session.getInviteTimestamp(),
+				newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromLeft() throws Exception {
+		InviteeSession session = getDefaultSession(LEFT);
+		LeaveMessage properLeaveMessage =
+				new LeaveMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, session.getInviteTimestamp() + 1,
+						lastRemoteMessageId);
+		assertFalse(properLeaveMessage.getTimestamp() <=
+				session.getInviteTimestamp());
+		assertTrue(session.getLastRemoteMessageId() != null);
+		assertTrue(properLeaveMessage.getPreviousMessageId() != null);
+		assertTrue(session.getLastRemoteMessageId()
+				.equals(properLeaveMessage.getPreviousMessageId()));
+
+		InviteeSession newSession =
+				engine.onLeaveMessage(txn, session, properLeaveMessage);
+
+		assertEquals(DISSOLVED, newSession.getState());
+		assertEquals(session.getLastLocalMessageId(),
+				newSession.getLastLocalMessageId());
+		assertEquals(properLeaveMessage.getId(),
+				newSession.getLastRemoteMessageId());
+		assertEquals(session.getLocalTimestamp(),
+				newSession.getLocalTimestamp());
+		assertEquals(session.getInviteTimestamp(),
+				newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	// onAbortMessage
+
+	@Test
+	public void testOnAbortMessageWhenNotSubscribed() throws Exception {
+		InviteeSession session = getDefaultSession(START);
+
+		expectAbort();
+		InviteeSession newSession =
+				engine.onAbortMessage(txn, session, abortMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnAbortMessageWhenSubscribed() throws Exception {
+		InviteeSession session = getDefaultSession(START);
+
+		expectAbortWhenNotSubscribedToGroup();
+		InviteeSession newSession =
+				engine.onAbortMessage(txn, session, abortMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	// helper methods
+
+	private void expectMarkMessageAvailableToAnswer(final MessageId id,
+			final boolean available) throws Exception {
+		final BdfDictionary meta = new BdfDictionary();
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder)
+					.setAvailableToAnswer(meta, available);
+			oneOf(clientHelper)
+					.mergeMessageMetadata(txn, id, meta);
+		}});
+	}
+
+	private void expectAbort() throws Exception {
+		expectAbort(true);
+	}
+
+	private void expectAbortWhenNotSubscribedToGroup() throws Exception {
+		expectAbort(false);
+	}
+
+	private void expectAbort(boolean subscribed) throws Exception {
+		final BdfDictionary query = BdfDictionary.of(new BdfEntry("query", ""));
+		final BdfDictionary meta = BdfDictionary.of(new BdfEntry("meta", ""));
+		final Map<MessageId, BdfDictionary> invites =
+				Collections.singletonMap(lastRemoteMessageId, meta);
+		context.checking(new Expectations() {{
+			oneOf(messageParser)
+					.getInvitesAvailableToAnswerQuery(privateGroupId);
+			will(returnValue(query));
+			oneOf(clientHelper)
+					.getMessageMetadataAsDictionary(txn, contactGroupId, query);
+			will(returnValue(invites));
+		}});
+		expectMarkMessageAvailableToAnswer(lastRemoteMessageId, false);
+		if (subscribed) {
+			expectIsSubscribedPrivateGroup();
+			expectSetPrivateGroupVisibility(INVISIBLE);
+		} else {
+			expectIsNotSubscribedPrivateGroup();
+		}
+		expectSendAbortMessage();
+	}
+
+}