diff --git a/briar-tests/src/org/briarproject/briar/privategroup/invitation/AbstractProtocolEngineTest.java b/briar-tests/src/org/briarproject/briar/privategroup/invitation/AbstractProtocolEngineTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..930941cd034dfae16a7c27a6b688150ea6022ab0
--- /dev/null
+++ b/briar-tests/src/org/briarproject/briar/privategroup/invitation/AbstractProtocolEngineTest.java
@@ -0,0 +1,204 @@
+package org.briarproject.briar.privategroup.invitation;
+
+import org.briarproject.BriarMockTestCase;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.contact.ContactId;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfEntry;
+import org.briarproject.bramble.api.db.DatabaseComponent;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.briar.api.client.MessageTracker;
+import org.briarproject.briar.api.privategroup.GroupMessageFactory;
+import org.briarproject.briar.api.privategroup.PrivateGroup;
+import org.briarproject.briar.api.privategroup.PrivateGroupFactory;
+import org.briarproject.briar.api.privategroup.PrivateGroupManager;
+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.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;
+import static org.briarproject.briar.privategroup.invitation.MessageType.JOIN;
+import static org.briarproject.briar.privategroup.invitation.MessageType.LEAVE;
+import static org.junit.Assert.assertEquals;
+
+public abstract class AbstractProtocolEngineTest extends BriarMockTestCase {
+
+	protected final DatabaseComponent db =
+			context.mock(DatabaseComponent.class);
+	protected final ClientHelper clientHelper =
+			context.mock(ClientHelper.class);
+	protected final PrivateGroupFactory privateGroupFactory =
+			context.mock(PrivateGroupFactory.class);
+	protected final PrivateGroupManager privateGroupManager =
+			context.mock(PrivateGroupManager.class);
+	protected final MessageParser messageParser =
+			context.mock(MessageParser.class);
+	protected final GroupMessageFactory groupMessageFactory =
+			context.mock(GroupMessageFactory.class);
+	protected final IdentityManager identityManager =
+			context.mock(IdentityManager.class);
+	protected final MessageEncoder messageEncoder =
+			context.mock(MessageEncoder.class);
+	protected final MessageTracker messageTracker =
+			context.mock(MessageTracker.class);
+	protected final Clock clock = context.mock(Clock.class);
+
+	protected final Transaction txn = new Transaction(null, false);
+	protected final GroupId contactGroupId = new GroupId(getRandomId());
+	protected final GroupId privateGroupId = new GroupId(getRandomId());
+	protected final Group privateGroupGroup =
+			new Group(privateGroupId, CLIENT_ID, getRandomBytes(5));
+	private final AuthorId authorId = new AuthorId(getRandomId());
+	protected final Author author =
+			new Author(authorId, "Author", getRandomBytes(12));
+	protected final PrivateGroup privateGroup =
+			new PrivateGroup(privateGroupGroup, "Private Group", author,
+					getRandomBytes(8));
+	protected final byte[] signature = getRandomBytes(42);
+	protected final MessageId lastLocalMessageId = new MessageId(getRandomId());
+	protected final MessageId lastRemoteMessageId =
+			new MessageId(getRandomId());
+	protected final long localTimestamp = 3L;
+	protected final long inviteTimestamp = 6L;
+	protected final long messageTimestamp = inviteTimestamp + 1;
+	protected final MessageId messageId = new MessageId(getRandomId());
+	protected final Message message =
+			new Message(messageId, contactGroupId, messageTimestamp,
+					getRandomBytes(42));
+	private final BdfDictionary meta =
+			BdfDictionary.of(new BdfEntry("me", "ta"));
+	protected final ContactId contactId = new ContactId(5);
+
+	protected void assertSessionConstantsUnchanged(Session s1, Session s2) {
+		assertEquals(s1.getRole(), s2.getRole());
+		assertEquals(s1.getContactGroupId(), s2.getContactGroupId());
+		assertEquals(s1.getPrivateGroupId(), s2.getPrivateGroupId());
+	}
+
+	protected void assertSessionRecordedSentMessage(Session s) {
+		assertEquals(messageId, s.getLastLocalMessageId());
+		assertEquals(lastRemoteMessageId, s.getLastRemoteMessageId());
+		assertEquals(messageTimestamp, s.getLocalTimestamp());
+		assertEquals(inviteTimestamp, s.getInviteTimestamp());
+	}
+
+	protected void expectGetLocalTimestamp(final long time) {
+		context.checking(new Expectations() {{
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(time));
+		}});
+	}
+
+	protected void expectSendInviteMessage(final String msg)
+			throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder)
+					.encodeInviteMessage(contactGroupId, privateGroupId,
+							inviteTimestamp, privateGroup.getName(), author,
+							privateGroup.getSalt(), msg, signature);
+			will(returnValue(message));
+		}});
+		expectSendMessage(INVITE, true);
+	}
+
+	protected void expectSendJoinMessage(final JoinMessage m) throws Exception {
+		expectGetLocalTimestamp(messageTimestamp);
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder).encodeJoinMessage(m.getContactGroupId(),
+					m.getPrivateGroupId(), m.getTimestamp(),
+					lastLocalMessageId);
+			will(returnValue(message));
+		}});
+		expectSendMessage(JOIN, false);
+	}
+
+	protected void expectSendLeaveMessage() throws Exception {
+		expectGetLocalTimestamp(messageTimestamp);
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder)
+					.encodeLeaveMessage(contactGroupId, privateGroupId,
+							messageTimestamp, lastLocalMessageId);
+			will(returnValue(message));
+		}});
+		expectSendMessage(LEAVE, false);
+	}
+
+	protected void expectSendAbortMessage() throws Exception {
+		expectGetLocalTimestamp(messageTimestamp);
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder)
+					.encodeAbortMessage(contactGroupId, privateGroupId,
+							messageTimestamp);
+			will(returnValue(message));
+		}});
+		expectSendMessage(ABORT, false);
+	}
+
+	private void expectSendMessage(final MessageType type,
+			final boolean visible) throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder).encodeMetadata(type, privateGroupId,
+					message.getTimestamp(), true, true, visible, false);
+			will(returnValue(meta));
+			oneOf(clientHelper).addLocalMessage(txn, message, meta, true);
+		}});
+	}
+
+	protected void expectSetPrivateGroupVisibility(final Group.Visibility v)
+			throws Exception {
+		expectGetContactId();
+		context.checking(new Expectations() {{
+			oneOf(db).setGroupVisibility(txn, contactId, privateGroupId, v);
+		}});
+	}
+
+	protected void expectGetContactId() throws Exception {
+		final BdfDictionary groupMeta = BdfDictionary
+				.of(new BdfEntry(GROUP_KEY_CONTACT_ID, contactId.getInt()));
+		context.checking(new Expectations() {{
+			oneOf(clientHelper)
+					.getGroupMetadataAsDictionary(txn, contactGroupId);
+			will(returnValue(groupMeta));
+		}});
+	}
+
+	protected void expectIsSubscribedPrivateGroup()
+			throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(db).containsGroup(txn, privateGroupId);
+			will(returnValue(true));
+			oneOf(db).getGroup(txn, privateGroupId);
+			will(returnValue(privateGroupGroup));
+		}});
+	}
+
+	protected void expectIsNotSubscribedPrivateGroup()
+			throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(db).containsGroup(txn, privateGroupId);
+			will(returnValue(false));
+		}});
+	}
+
+	protected void expectMarkMessageVisibleInUi(final MessageId m,
+			final boolean visible)
+			throws Exception {
+		final BdfDictionary d = new BdfDictionary();
+		context.checking(new Expectations() {{
+			oneOf(messageEncoder).setVisibleInUi(d, visible);
+			oneOf(clientHelper).mergeMessageMetadata(txn, m, d);
+		}});
+	}
+
+}
diff --git a/briar-tests/src/org/briarproject/briar/privategroup/invitation/CreatorProtocolEngineTest.java b/briar-tests/src/org/briarproject/briar/privategroup/invitation/CreatorProtocolEngineTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..d8bcdc3fd2573ce103e261c55d44465ca9a0fcf1
--- /dev/null
+++ b/briar-tests/src/org/briarproject/briar/privategroup/invitation/CreatorProtocolEngineTest.java
@@ -0,0 +1,488 @@
+package org.briarproject.briar.privategroup.invitation;
+
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.briar.api.client.ProtocolStateException;
+import org.jmock.Expectations;
+import org.junit.Test;
+
+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.briar.privategroup.invitation.CreatorState.DISSOLVED;
+import static org.briarproject.briar.privategroup.invitation.CreatorState.ERROR;
+import static org.briarproject.briar.privategroup.invitation.CreatorState.INVITED;
+import static org.briarproject.briar.privategroup.invitation.CreatorState.JOINED;
+import static org.briarproject.briar.privategroup.invitation.CreatorState.LEFT;
+import static org.briarproject.briar.privategroup.invitation.CreatorState.START;
+import static org.junit.Assert.assertEquals;
+
+public class CreatorProtocolEngineTest extends AbstractProtocolEngineTest {
+
+	private final CreatorProtocolEngine engine =
+			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,
+				lastLocalMessageId, lastRemoteMessageId, localTimestamp,
+				inviteTimestamp, state);
+	}
+
+	// onInviteAction
+
+	@Test
+	public void testOnInviteActionFromStart() throws Exception {
+		CreatorSession session =
+				new CreatorSession(contactGroupId, privateGroupId);
+		String message = "Invitation Message";
+
+		expectOnLocalInvite(message);
+		CreatorSession newSession =
+				engine.onInviteAction(txn, session, message, inviteTimestamp,
+						signature);
+		assertEquals(INVITED, newSession.getState());
+		assertEquals(messageId, newSession.getLastLocalMessageId());
+		assertEquals(null, newSession.getLastRemoteMessageId());
+		assertEquals(messageTimestamp, newSession.getLocalTimestamp());
+		assertEquals(inviteTimestamp, newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnInviteActionFromStartWithNullMessage() throws Exception {
+		CreatorSession session =
+				new CreatorSession(contactGroupId, privateGroupId);
+
+		expectOnLocalInvite(null);
+		CreatorSession newSession =
+				engine.onInviteAction(txn, session, null, inviteTimestamp,
+						signature);
+		assertEquals(INVITED, newSession.getState());
+		assertEquals(messageId, newSession.getLastLocalMessageId());
+		assertEquals(null, newSession.getLastRemoteMessageId());
+		assertEquals(messageTimestamp, newSession.getLocalTimestamp());
+		assertEquals(inviteTimestamp, newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	private void expectOnLocalInvite(final String msg) throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(db).getGroup(txn, privateGroupId);
+			will(returnValue(privateGroupGroup));
+			oneOf(privateGroupFactory).parsePrivateGroup(privateGroupGroup);
+			will(returnValue(privateGroup));
+			oneOf(messageTracker).trackOutgoingMessage(txn, message);
+		}});
+		expectSendInviteMessage(msg);
+		expectGetLocalTimestamp(messageTimestamp);
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnInviteActionFromInvited() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(INVITED), null,
+				inviteTimestamp, signature);
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnInviteActionFromJoined() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(JOINED), null,
+				inviteTimestamp, signature);
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnInviteActionFromLeft() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(LEFT), null,
+				inviteTimestamp, signature);
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnInviteActionFromDissolved() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(DISSOLVED), null,
+				inviteTimestamp, signature);
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testOnInviteActionFromError() throws Exception {
+		engine.onInviteAction(txn, getDefaultSession(ERROR), null,
+				inviteTimestamp, signature);
+	}
+
+	// onJoinAction
+
+	@Test(expected = UnsupportedOperationException.class)
+	public void testOnJoinActionFails() throws Exception {
+		engine.onJoinAction(txn, getDefaultSession(START));
+	}
+
+	// onLeaveAction
+
+	@Test
+	public void testOnLeaveActionFromStart() throws Exception {
+		CreatorSession session = getDefaultSession(START);
+		assertEquals(session, engine.onLeaveAction(txn, session));
+	}
+
+	@Test
+	public void testOnLeaveActionFromDissolved() throws Exception {
+		CreatorSession session = getDefaultSession(DISSOLVED);
+		assertEquals(session, engine.onLeaveAction(txn, session));
+	}
+
+	@Test
+	public void testOnLeaveActionFromError() throws Exception {
+		CreatorSession session = getDefaultSession(ERROR);
+		assertEquals(session, engine.onLeaveAction(txn, session));
+	}
+
+	@Test
+	public void testOnLeaveActionFromInvited() throws Exception {
+		CreatorSession session = getDefaultSession(INVITED);
+
+		expectOnLocalLeave();
+		CreatorSession newSession = engine.onLeaveAction(txn, session);
+		assertEquals(DISSOLVED, newSession.getState());
+		assertEquals(messageId, newSession.getLastLocalMessageId());
+		assertEquals(lastRemoteMessageId, newSession.getLastRemoteMessageId());
+		assertEquals(messageTimestamp, newSession.getLocalTimestamp());
+		assertEquals(inviteTimestamp, newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveActionFromJoined() throws Exception {
+		CreatorSession session = getDefaultSession(JOINED);
+
+		expectOnLocalLeave();
+		CreatorSession newSession = engine.onLeaveAction(txn, session);
+		assertEquals(DISSOLVED, newSession.getState());
+		assertEquals(messageId, newSession.getLastLocalMessageId());
+		assertEquals(lastRemoteMessageId, newSession.getLastRemoteMessageId());
+		assertEquals(messageTimestamp, newSession.getLocalTimestamp());
+		assertEquals(inviteTimestamp, newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveActionFromLeft() throws Exception {
+		CreatorSession session = getDefaultSession(LEFT);
+
+		expectOnLocalLeave();
+		CreatorSession newSession = engine.onLeaveAction(txn, session);
+		assertEquals(DISSOLVED, newSession.getState());
+		assertEquals(messageId, newSession.getLastLocalMessageId());
+		assertEquals(lastRemoteMessageId, newSession.getLastRemoteMessageId());
+		assertEquals(messageTimestamp, newSession.getLocalTimestamp());
+		assertEquals(inviteTimestamp, newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	private void expectOnLocalLeave() throws Exception {
+		expectSetPrivateGroupVisibility(INVISIBLE);
+		expectSendLeaveMessage();
+	}
+
+	// onMemberAddedAction
+
+	@Test
+	public void testOnMemberAddedAction() throws Exception {
+		CreatorSession session = getDefaultSession(START);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+
+		session = getDefaultSession(INVITED);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+
+		session = getDefaultSession(JOINED);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+
+		session = getDefaultSession(LEFT);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+
+		session = getDefaultSession(DISSOLVED);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+
+		session = getDefaultSession(ERROR);
+		assertEquals(session, engine.onMemberAddedAction(txn, session));
+	}
+
+	// onInviteMessage
+
+	@Test
+	public void testOnInviteMessageInAnyState() throws Exception {
+		InviteMessage inviteMessage =
+				new InviteMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, inviteTimestamp, privateGroup.getName(),
+						author, privateGroup.getSalt(), null, signature);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession session = getDefaultSession(LEFT);
+		CreatorSession newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+		assertEquals(ERROR, newSession.getState());
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		session = getDefaultSession(START);
+		newSession =
+				engine.onInviteMessage(txn, session, inviteMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	// onJoinMessage
+
+	@Test
+	public void testOnJoinMessageFromStart() throws Exception {
+		CreatorSession session = getDefaultSession(START);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromJoined() throws Exception {
+		CreatorSession session = getDefaultSession(JOINED);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromLeft() throws Exception {
+		CreatorSession session = getDefaultSession(LEFT);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromInvitedWithWrongTimestamp()
+			throws Exception {
+		CreatorSession session = getDefaultSession(INVITED);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onJoinMessage(txn, session, joinMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromInvitedWithInvalidDependency()
+			throws Exception {
+		CreatorSession session = getDefaultSession(INVITED);
+		JoinMessage invalidJoinMessage =
+				new JoinMessage(messageId, contactGroupId, privateGroupId,
+						inviteTimestamp + 1, messageId);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onJoinMessage(txn, session, invalidJoinMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromInvited() throws Exception {
+		CreatorSession session = getDefaultSession(INVITED);
+		JoinMessage properJoinMessage =
+				new JoinMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, inviteTimestamp + 1,
+						lastRemoteMessageId);
+
+		expectSendJoinMessage(properJoinMessage);
+		expectMarkMessageVisibleInUi(properJoinMessage.getId(), true);
+		context.checking(new Expectations() {{
+			oneOf(messageTracker)
+					.trackMessage(txn, contactGroupId, inviteTimestamp + 1,
+							false);
+		}});
+		expectGetContactId();
+		expectSetPrivateGroupVisibility(SHARED);
+		CreatorSession newSession =
+				engine.onJoinMessage(txn, session, properJoinMessage);
+		assertEquals(JOINED, newSession.getState());
+		assertEquals(messageId, newSession.getLastLocalMessageId());
+		assertEquals(properJoinMessage.getId(),
+				newSession.getLastRemoteMessageId());
+		assertEquals(messageTimestamp, newSession.getLocalTimestamp());
+		assertEquals(inviteTimestamp, newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnJoinMessageFromDissolved() throws Exception {
+		CreatorSession session = getDefaultSession(DISSOLVED);
+		assertEquals(session, engine.onJoinMessage(txn, session, joinMessage));
+	}
+
+	@Test
+	public void testOnJoinMessageFromError() throws Exception {
+		CreatorSession session = getDefaultSession(ERROR);
+		assertEquals(session, engine.onJoinMessage(txn, session, joinMessage));
+	}
+
+	// onLeaveMessage
+
+	@Test
+	public void testOnLeaveMessageFromStart() throws Exception {
+		LeaveMessage leaveMessage =
+				new LeaveMessage(messageId, contactGroupId, privateGroupId,
+						inviteTimestamp, lastLocalMessageId);
+		CreatorSession session = getDefaultSession(START);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onLeaveMessage(txn, session, leaveMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromLeft() throws Exception {
+		CreatorSession session = getDefaultSession(LEFT);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onLeaveMessage(txn, session, leaveMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromInvitedWithWrongTime() throws Exception {
+		CreatorSession session = getDefaultSession(INVITED);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onLeaveMessage(txn, session, leaveMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromInvitedWithWrongDependency()
+			throws Exception {
+		LeaveMessage invalidLeaveMessage =
+				new LeaveMessage(messageId, contactGroupId, privateGroupId,
+						inviteTimestamp + 1, lastLocalMessageId);
+		CreatorSession session = getDefaultSession(INVITED);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onLeaveMessage(txn, session, invalidLeaveMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromInvited()
+			throws Exception {
+		LeaveMessage properLeaveMessage =
+				new LeaveMessage(new MessageId(getRandomId()), contactGroupId,
+						privateGroupId, inviteTimestamp + 1,
+						lastRemoteMessageId);
+		CreatorSession session = getDefaultSession(INVITED);
+
+		expectMarkMessageVisibleInUi(properLeaveMessage.getId(), true);
+		context.checking(new Expectations() {{
+			oneOf(messageTracker)
+					.trackMessage(txn, contactGroupId, inviteTimestamp + 1,
+							false);
+		}});
+		expectGetContactId();
+		CreatorSession newSession =
+				engine.onLeaveMessage(txn, session, properLeaveMessage);
+		assertEquals(START, newSession.getState());
+		assertEquals(lastLocalMessageId, newSession.getLastLocalMessageId());
+		assertEquals(properLeaveMessage.getId(),
+				newSession.getLastRemoteMessageId());
+		assertEquals(localTimestamp, newSession.getLocalTimestamp());
+		assertEquals(inviteTimestamp, newSession.getInviteTimestamp());
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnLeaveMessageFromDissolved() throws Exception {
+		CreatorSession session = getDefaultSession(DISSOLVED);
+		assertEquals(session,
+				engine.onLeaveMessage(txn, session, leaveMessage));
+	}
+
+	@Test
+	public void testOnLeaveMessageFromError() throws Exception {
+		CreatorSession session = getDefaultSession(ERROR);
+		assertEquals(session,
+				engine.onLeaveMessage(txn, session, leaveMessage));
+	}
+
+	// onAbortMessage
+
+	@Test
+	public void testOnAbortMessageWhenNotSubscribed() throws Exception {
+		AbortMessage abortMessage =
+				new AbortMessage(messageId, contactGroupId, privateGroupId,
+						inviteTimestamp + 1);
+		CreatorSession session = getDefaultSession(START);
+
+		expectIsNotSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onAbortMessage(txn, session, abortMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+	@Test
+	public void testOnAbortMessageWhenSubscribed() throws Exception {
+		AbortMessage abortMessage =
+				new AbortMessage(messageId, contactGroupId, privateGroupId,
+						inviteTimestamp + 1);
+		CreatorSession session = getDefaultSession(START);
+
+		expectIsSubscribedPrivateGroup();
+		expectSendAbortMessage();
+		CreatorSession newSession =
+				engine.onAbortMessage(txn, session, abortMessage);
+		assertEquals(ERROR, newSession.getState());
+		assertSessionRecordedSentMessage(newSession);
+		assertSessionConstantsUnchanged(session, newSession);
+	}
+
+}