diff --git a/briar-tests/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImplTest.java b/briar-tests/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..93ffeb3bf813ebf2e23c1739db86aafc68b653fd
--- /dev/null
+++ b/briar-tests/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImplTest.java
@@ -0,0 +1,924 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.BriarMockTestCase;
+import org.briarproject.TestUtils;
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.ContactGroupFactory;
+import org.briarproject.api.clients.MessageTracker;
+import org.briarproject.api.clients.SessionId;
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.data.MetadataParser;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationItem;
+import org.briarproject.api.privategroup.invitation.GroupInvitationRequest;
+import org.briarproject.api.privategroup.invitation.GroupInvitationResponse;
+import org.briarproject.api.sharing.InvitationMessage;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.jetbrains.annotations.Nullable;
+import org.jmock.AbstractExpectations;
+import org.jmock.Expectations;
+import org.jmock.lib.legacy.ClassImposteriser;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static junit.framework.TestCase.fail;
+import static org.briarproject.TestUtils.getRandomBytes;
+import static org.briarproject.TestUtils.getRandomId;
+import static org.briarproject.TestUtils.getRandomString;
+import static org.briarproject.api.privategroup.invitation.GroupInvitationManager.CLIENT_ID;
+import static org.briarproject.api.sync.Group.Visibility.SHARED;
+import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.GROUP_KEY_CONTACT_ID;
+import static org.briarproject.privategroup.invitation.MessageType.ABORT;
+import static org.briarproject.privategroup.invitation.MessageType.INVITE;
+import static org.briarproject.privategroup.invitation.MessageType.JOIN;
+import static org.briarproject.privategroup.invitation.MessageType.LEAVE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class GroupInvitationManagerImplTest extends BriarMockTestCase {
+
+	private final GroupInvitationManagerImpl groupInvitationManager;
+	private final DatabaseComponent db = context.mock(DatabaseComponent.class);
+	private final ClientHelper clientHelper = context.mock(ClientHelper.class);
+	private final ContactGroupFactory contactGroupFactory =
+			context.mock(ContactGroupFactory.class);
+	private final PrivateGroupFactory privateGroupFactory =
+			context.mock(PrivateGroupFactory.class);
+	private final PrivateGroupManager privateGroupManager =
+			context.mock(PrivateGroupManager.class);
+	private final MessageParser messageParser =
+			context.mock(MessageParser.class);
+	private final SessionParser sessionParser =
+			context.mock(SessionParser.class);
+	private final SessionEncoder sessionEncoder =
+			context.mock(SessionEncoder.class);
+	private final ProtocolEngineFactory engineFactory =
+			context.mock(ProtocolEngineFactory.class);
+	private final CreatorProtocolEngine creatorEngine;
+	private final InviteeProtocolEngine inviteeEngine;
+	private final PeerProtocolEngine peerEngine;
+	private final CreatorSession creatorSession;
+	private final InviteeSession inviteeSession;
+	private final PeerSession peerSession;
+	private final Group localGroup =
+			new Group(new GroupId(getRandomId()), CLIENT_ID, getRandomBytes(5));
+	private final Transaction txn = new Transaction(null, false);
+	private final ContactId contactId = new ContactId(0);
+	private final Author author =
+			new Author(new AuthorId(getRandomId()), getRandomString(5),
+					getRandomBytes(5));
+	private final Contact contact =
+			new Contact(contactId, author, new AuthorId(getRandomId()), true,
+					true);
+	private final Group contactGroup =
+			new Group(new GroupId(getRandomId()), CLIENT_ID, getRandomBytes(5));
+	private final Group privateGroup =
+			new Group(new GroupId(getRandomId()), CLIENT_ID, getRandomBytes(5));
+	private final BdfDictionary meta = BdfDictionary.of(new BdfEntry("f", "o"));
+	private final Message message =
+			new Message(new MessageId(getRandomId()), contactGroup.getId(),
+					0L, getRandomBytes(MESSAGE_HEADER_LENGTH + 1));
+	private final BdfList body = new BdfList();
+	private final Message storageMessage =
+			new Message(new MessageId(getRandomId()), contactGroup.getId(),
+					0L, getRandomBytes(MESSAGE_HEADER_LENGTH + 1));
+
+	public GroupInvitationManagerImplTest() {
+		context.setImposteriser(ClassImposteriser.INSTANCE);
+		creatorEngine = context.mock(CreatorProtocolEngine.class);
+		inviteeEngine = context.mock(InviteeProtocolEngine.class);
+		peerEngine = context.mock(PeerProtocolEngine.class);
+
+		creatorSession = context.mock(CreatorSession.class);
+		inviteeSession = context.mock(InviteeSession.class);
+		peerSession = context.mock(PeerSession.class);
+
+		context.checking(new Expectations() {{
+			oneOf(engineFactory).createCreatorEngine();
+			will(returnValue(creatorEngine));
+			oneOf(engineFactory).createInviteeEngine();
+			will(returnValue(inviteeEngine));
+			oneOf(engineFactory).createPeerEngine();
+			will(returnValue(peerEngine));
+			oneOf(contactGroupFactory).createLocalGroup(CLIENT_ID);
+			will(returnValue(localGroup));
+		}});
+		MetadataParser metadataParser = context.mock(MetadataParser.class);
+		MessageTracker messageTracker = context.mock(MessageTracker.class);
+		groupInvitationManager =
+				new GroupInvitationManagerImpl(db, clientHelper, metadataParser,
+						messageTracker, contactGroupFactory,
+						privateGroupFactory, privateGroupManager, messageParser,
+						sessionParser, sessionEncoder, engineFactory);
+	}
+
+	@Test
+	public void testCreateLocalState() throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(db).addGroup(txn, localGroup);
+			oneOf(db).getContacts(txn);
+			will(returnValue(Collections.singletonList(contact)));
+		}});
+		expectAddingContact(contact, true);
+		groupInvitationManager.createLocalState(txn);
+	}
+
+	private void expectAddingContact(final Contact c,
+			final boolean contactExists) throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, c);
+			will(returnValue(contactGroup));
+			oneOf(db).containsGroup(txn, contactGroup.getId());
+			will(returnValue(contactExists));
+		}});
+		if (contactExists) return;
+
+		final BdfDictionary meta = BdfDictionary
+				.of(new BdfEntry(GROUP_KEY_CONTACT_ID, c.getId().getInt()));
+		context.checking(new Expectations() {{
+			oneOf(db).addGroup(txn, contactGroup);
+			oneOf(db).setGroupVisibility(txn, c.getId(), contactGroup.getId(),
+					SHARED);
+			oneOf(clientHelper)
+					.mergeGroupMetadata(txn, contactGroup.getId(), meta);
+			oneOf(db).getGroups(txn, PrivateGroupManager.CLIENT_ID);
+			will(returnValue(Collections.singletonList(privateGroup)));
+			oneOf(privateGroupManager)
+					.isMember(txn, privateGroup.getId(), c.getAuthor());
+			will(returnValue(true));
+		}});
+		expectAddingMember(privateGroup.getId(), c);
+	}
+
+	private void expectAddingMember(final GroupId g, final Contact c)
+			throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, c);
+			will(returnValue(contactGroup));
+		}});
+		expectGetSession(Collections.<MessageId, BdfDictionary>emptyMap(),
+				new SessionId(g.getBytes()));
+
+		context.checking(new Expectations() {{
+			oneOf(peerEngine).onMemberAddedAction(with(equal(txn)),
+					with(any(PeerSession.class)));
+			will(returnValue(peerSession));
+		}});
+		expectStoreSession(peerSession, storageMessage.getId());
+		expectCreateStorageId();
+	}
+
+	private void expectCreateStorageId() throws DbException {
+		context.checking(new Expectations() {{
+			oneOf(clientHelper)
+					.createMessageForStoringMetadata(contactGroup.getId());
+			will(returnValue(storageMessage));
+			oneOf(db).addLocalMessage(txn, storageMessage, new Metadata(),
+					false);
+		}});
+	}
+
+	private void expectStoreSession(final Session session,
+			final MessageId storageId) throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(sessionEncoder).encodeSession(session);
+			will(returnValue(meta));
+			oneOf(clientHelper).mergeMessageMetadata(txn, storageId, meta);
+		}});
+	}
+
+	private void expectGetSession(final Map<MessageId, BdfDictionary> result,
+			final SessionId sessionId) throws Exception {
+		final BdfDictionary query = BdfDictionary.of(new BdfEntry("q", "u"));
+		context.checking(new Expectations() {{
+			oneOf(sessionParser).getSessionQuery(sessionId);
+			will(returnValue(query));
+			oneOf(clientHelper)
+					.getMessageMetadataAsDictionary(txn, contactGroup.getId(),
+							query);
+			will(returnValue(result));
+		}});
+	}
+
+	@Test
+	public void testAddingContact() throws Exception {
+		expectAddingContact(contact, false);
+		groupInvitationManager.addingContact(txn, contact);
+	}
+
+	@Test
+	public void testRemovingContact() throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(db).removeGroup(txn, contactGroup);
+		}});
+		groupInvitationManager.removingContact(txn, contact);
+	}
+
+	@Test(expected = FormatException.class)
+	public void testIncomingUnknownMessage() throws Exception {
+		expectFirstIncomingMessage(Role.INVITEE, ABORT);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	@Test
+	public void testIncomingFirstInviteMessage() throws Exception {
+		expectFirstIncomingMessage(Role.INVITEE, INVITE);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	@Test
+	public void testIncomingFirstJoinMessage() throws Exception {
+		expectFirstIncomingMessage(Role.PEER, JOIN);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	@Test
+	public void testIncomingInviteMessage() throws Exception {
+		expectIncomingMessage(Role.INVITEE, INVITE);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	@Test
+	public void testIncomingJoinMessage() throws Exception {
+		expectIncomingMessage(Role.INVITEE, JOIN);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	@Test
+	public void testIncomingJoinMessageForCreator() throws Exception {
+		expectIncomingMessage(Role.CREATOR, JOIN);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	@Test
+	public void testIncomingLeaveMessage() throws Exception {
+		expectIncomingMessage(Role.INVITEE, LEAVE);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	@Test
+	public void testIncomingAbortMessage() throws Exception {
+		expectIncomingMessage(Role.INVITEE, ABORT);
+		groupInvitationManager.incomingMessage(txn, message, body, meta);
+	}
+
+	private void expectFirstIncomingMessage(Role role, MessageType type)
+			throws Exception {
+		expectIncomingMessage(role, type,
+				Collections.<MessageId, BdfDictionary>emptyMap());
+	}
+
+	private void expectIncomingMessage(Role role, MessageType type)
+			throws Exception {
+		BdfDictionary state = BdfDictionary.of(new BdfEntry("state", "test"));
+		Map<MessageId, BdfDictionary> states =
+				Collections.singletonMap(storageMessage.getId(), state);
+		expectIncomingMessage(role, type, states);
+	}
+
+	private void expectIncomingMessage(final Role role,
+			final MessageType type, final Map<MessageId, BdfDictionary> states)
+			throws Exception {
+		final MessageMetadata messageMetadata =
+				context.mock(MessageMetadata.class);
+		context.checking(new Expectations() {{
+			oneOf(messageParser).parseMetadata(meta);
+			will(returnValue(messageMetadata));
+			oneOf(messageMetadata).getPrivateGroupId();
+			will(returnValue(privateGroup.getId()));
+		}});
+		expectGetSession(states,
+				new SessionId(privateGroup.getId().getBytes()));
+
+		Session session;
+		if (states.isEmpty()) {
+			session = expectHandleFirstMessage(role, messageMetadata, type);
+			if (session != null) {
+				expectCreateStorageId();
+				expectStoreSession(session, storageMessage.getId());
+			}
+		} else {
+			assertEquals(1, states.size());
+			session = expectHandleMessage(role, messageMetadata,
+					states.values().iterator().next(), type);
+			expectStoreSession(session, storageMessage.getId());
+		}
+	}
+
+	@Nullable
+	private Session expectHandleFirstMessage(Role role,
+			final MessageMetadata messageMetadata, final MessageType type)
+			throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(messageMetadata).getPrivateGroupId();
+			will(returnValue(privateGroup.getId()));
+			oneOf(messageMetadata).getMessageType();
+			will(returnValue(type));
+		}});
+		if (type == ABORT || type == LEAVE) return null;
+
+		AbstractProtocolEngine engine;
+		Session session;
+		if (type == INVITE) {
+			assertEquals(Role.INVITEE, role);
+			engine = inviteeEngine;
+			session = inviteeSession;
+		} else if (type == JOIN) {
+			assertEquals(Role.PEER, role);
+			engine = peerEngine;
+			session = peerSession;
+		} else {
+			throw new RuntimeException();
+		}
+		expectIndividualMessage(type, engine, session);
+		return session;
+	}
+
+	@Nullable
+	private Session expectHandleMessage(final Role role,
+			final MessageMetadata messageMetadata, final BdfDictionary state,
+			final MessageType type) throws Exception {
+		context.checking(new Expectations() {{
+			oneOf(messageMetadata).getMessageType();
+			will(returnValue(type));
+			oneOf(sessionParser).getRole(state);
+			will(returnValue(role));
+		}});
+		if (role == Role.CREATOR) {
+			context.checking(new Expectations() {{
+				oneOf(sessionParser)
+						.parseCreatorSession(contactGroup.getId(), state);
+				will(returnValue(creatorSession));
+			}});
+			expectIndividualMessage(type, creatorEngine, creatorSession);
+			return creatorSession;
+		} else if (role == Role.INVITEE) {
+			context.checking(new Expectations() {{
+				oneOf(sessionParser)
+						.parseInviteeSession(contactGroup.getId(), state);
+				will(returnValue(inviteeSession));
+			}});
+			expectIndividualMessage(type, inviteeEngine, inviteeSession);
+			return inviteeSession;
+		} else if (role == Role.PEER) {
+			context.checking(new Expectations() {{
+				oneOf(sessionParser)
+						.parsePeerSession(contactGroup.getId(), state);
+				will(returnValue(peerSession));
+			}});
+			expectIndividualMessage(type, peerEngine, peerSession);
+			return peerSession;
+		} else {
+			fail();
+			throw new RuntimeException();
+		}
+	}
+
+	private <S extends Session> void expectIndividualMessage(
+			final MessageType type, final ProtocolEngine<S> engine,
+			final S session) throws Exception {
+		if (type == INVITE) {
+			final InviteMessage msg = context.mock(InviteMessage.class);
+			context.checking(new Expectations() {{
+				oneOf(messageParser).parseInviteMessage(message, body);
+				will(returnValue(msg));
+				oneOf(engine).onInviteMessage(with(equal(txn)),
+						with(AbstractExpectations.<S>anything()),
+						with(equal(msg)));
+				will(returnValue(session));
+			}});
+		} else if (type == JOIN) {
+			final JoinMessage msg = context.mock(JoinMessage.class);
+			context.checking(new Expectations() {{
+				oneOf(messageParser).parseJoinMessage(message, body);
+				will(returnValue(msg));
+				oneOf(engine).onJoinMessage(with(equal(txn)),
+						with(AbstractExpectations.<S>anything()),
+						with(equal(msg)));
+				will(returnValue(session));
+			}});
+		} else if (type == LEAVE) {
+			final LeaveMessage msg = context.mock(LeaveMessage.class);
+			context.checking(new Expectations() {{
+				oneOf(messageParser).parseLeaveMessage(message, body);
+				will(returnValue(msg));
+				oneOf(engine).onLeaveMessage(with(equal(txn)),
+						with(AbstractExpectations.<S>anything()),
+						with(equal(msg)));
+				will(returnValue(session));
+			}});
+		} else if (type == ABORT) {
+			final AbortMessage msg = context.mock(AbortMessage.class);
+			context.checking(new Expectations() {{
+				oneOf(messageParser).parseAbortMessage(message, body);
+				will(returnValue(msg));
+				oneOf(engine).onAbortMessage(with(equal(txn)),
+						with(AbstractExpectations.<S>anything()),
+						with(equal(msg)));
+				will(returnValue(session));
+			}});
+		} else {
+			fail();
+		}
+	}
+
+	@Test
+	public void testSendFirstInvitation() throws Exception {
+		final String msg = "Invitation text for first invitation";
+		final long time = 42L;
+		final byte[] signature = TestUtils.getRandomBytes(42);
+		Map<MessageId, BdfDictionary> states = Collections.emptyMap();
+
+		expectGetSession(states,
+				new SessionId(privateGroup.getId().getBytes()));
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+		}});
+		expectCreateStorageId();
+		context.checking(new Expectations() {{
+			oneOf(creatorEngine).onInviteAction(with(same(txn)),
+					with(any(CreatorSession.class)), with(equal(msg)),
+					with(equal(time)), with(equal(signature)));
+			will(returnValue(creatorSession));
+		}});
+		expectStoreSession(creatorSession, storageMessage.getId());
+		context.checking(new Expectations() {{
+			oneOf(db).commitTransaction(txn);
+			oneOf(db).endTransaction(txn);
+		}});
+		groupInvitationManager.sendInvitation(privateGroup.getId(), contactId,
+				msg, time, signature);
+	}
+
+	@Test
+	public void testSendSubsequentInvitation() throws Exception {
+		final String msg = "Invitation text for subsequent invitation";
+		final long time = 43L;
+		final byte[] signature = TestUtils.getRandomBytes(43);
+		final BdfDictionary state =
+				BdfDictionary.of(new BdfEntry("state", "test"));
+		Map<MessageId, BdfDictionary> states =
+				Collections.singletonMap(storageMessage.getId(), state);
+
+		expectGetSession(states,
+				new SessionId(privateGroup.getId().getBytes()));
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(sessionParser)
+					.parseCreatorSession(contactGroup.getId(), state);
+			will(returnValue(creatorSession));
+			oneOf(creatorEngine).onInviteAction(with(same(txn)),
+					with(any(CreatorSession.class)), with(equal(msg)),
+					with(equal(time)), with(equal(signature)));
+			will(returnValue(creatorSession));
+		}});
+		expectStoreSession(creatorSession, storageMessage.getId());
+		context.checking(new Expectations() {{
+			oneOf(db).commitTransaction(txn);
+			oneOf(db).endTransaction(txn);
+		}});
+		groupInvitationManager.sendInvitation(privateGroup.getId(), contactId,
+				msg, time, signature);
+	}
+
+	@Test(expected = IllegalArgumentException.class)
+	public void testRespondToInvitationWithoutSession() throws Exception {
+		final SessionId sessionId = new SessionId(getRandomId());
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(db).endTransaction(txn);
+		}});
+		expectGetSession(Collections.<MessageId, BdfDictionary>emptyMap(),
+				sessionId);
+
+		groupInvitationManager.respondToInvitation(contactId, sessionId, true);
+	}
+
+	@Test
+	public void testAcceptInvitationWithSession() throws Exception {
+		final boolean accept = true;
+		SessionId sessionId = new SessionId(getRandomId());
+		BdfDictionary state = BdfDictionary.of(new BdfEntry("f", "o"));
+		Map<MessageId, BdfDictionary> states =
+				Collections.singletonMap(storageMessage.getId(), state);
+
+		expectRespondToInvitation(states, sessionId, accept);
+		groupInvitationManager
+				.respondToInvitation(contactId, sessionId, accept);
+	}
+
+	@Test
+	public void testDeclineInvitationWithSession() throws Exception {
+		final boolean accept = false;
+		SessionId sessionId = new SessionId(getRandomId());
+		BdfDictionary state = BdfDictionary.of(new BdfEntry("f", "o"));
+		Map<MessageId, BdfDictionary> states =
+				Collections.singletonMap(storageMessage.getId(), state);
+
+		expectRespondToInvitation(states, sessionId, accept);
+		groupInvitationManager
+				.respondToInvitation(contactId, sessionId, accept);
+	}
+
+	@Test
+	public void testRespondToInvitationWithGroupId() throws Exception {
+		final boolean accept = true;
+		final PrivateGroup g = context.mock(PrivateGroup.class);
+		SessionId sessionId = new SessionId(privateGroup.getId().getBytes());
+		BdfDictionary state = BdfDictionary.of(new BdfEntry("f", "o"));
+		Map<MessageId, BdfDictionary> states =
+				Collections.singletonMap(storageMessage.getId(), state);
+
+		context.checking(new Expectations() {{
+			oneOf(g).getId();
+			will(returnValue(privateGroup.getId()));
+		}});
+		expectRespondToInvitation(states, sessionId, accept);
+		groupInvitationManager.respondToInvitation(contactId, g, accept);
+	}
+
+	private void expectRespondToInvitation(
+			final Map<MessageId, BdfDictionary> states,
+			final SessionId sessionId, final boolean accept) throws Exception {
+		expectGetSession(states, sessionId);
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+		}});
+
+		if (states.isEmpty()) return;
+		assertEquals(1, states.size());
+
+		final BdfDictionary state = states.values().iterator().next();
+		context.checking(new Expectations() {{
+			oneOf(sessionParser)
+					.parseInviteeSession(contactGroup.getId(), state);
+			will(returnValue(inviteeSession));
+			if (accept) oneOf(inviteeEngine).onJoinAction(txn, inviteeSession);
+			else oneOf(inviteeEngine).onLeaveAction(txn, inviteeSession);
+			will(returnValue(inviteeSession));
+			oneOf(db).commitTransaction(txn);
+			oneOf(db).endTransaction(txn);
+		}});
+		expectStoreSession(inviteeSession, storageMessage.getId());
+	}
+
+	@Test
+	public void testRevealRelationship() throws Exception {
+		SessionId sessionId = new SessionId(privateGroup.getId().getBytes());
+		final BdfDictionary state = BdfDictionary.of(new BdfEntry("f", "o"));
+		Map<MessageId, BdfDictionary> states =
+				Collections.singletonMap(storageMessage.getId(), state);
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(sessionParser).parsePeerSession(contactGroup.getId(), state);
+			will(returnValue(peerSession));
+			oneOf(peerEngine).onJoinAction(txn, peerSession);
+			will(returnValue(peerSession));
+			oneOf(db).commitTransaction(txn);
+			oneOf(db).endTransaction(txn);
+		}});
+		expectGetSession(states, sessionId);
+		expectStoreSession(peerSession, storageMessage.getId());
+
+		groupInvitationManager
+				.revealRelationship(contactId, privateGroup.getId());
+	}
+
+	@Test(expected = IllegalArgumentException.class)
+	public void testRevealRelationshipWithoutSession() throws Exception {
+		SessionId sessionId = new SessionId(privateGroup.getId().getBytes());
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(false);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(db).endTransaction(txn);
+		}});
+		expectGetSession(Collections.<MessageId, BdfDictionary>emptyMap(),
+				sessionId);
+
+		groupInvitationManager
+				.revealRelationship(contactId, privateGroup.getId());
+	}
+
+	@Test
+	public void testGetInvitationMessages() throws Exception {
+		final BdfDictionary query = new BdfDictionary();
+		final BdfDictionary d1 = BdfDictionary.of(new BdfEntry("m1", "d"));
+		final BdfDictionary d2 = BdfDictionary.of(new BdfEntry("m2", "d"));
+		final Map<MessageId, BdfDictionary> results =	new HashMap<>();
+		results.put(message.getId(), d1);
+		results.put(storageMessage.getId(), d2);
+		long time1 = 1L, time2 = 2L;
+		final MessageMetadata meta1 =
+				new MessageMetadata(INVITE, privateGroup.getId(), time1, true,
+						true, true, true);
+		final MessageMetadata meta2 =
+				new MessageMetadata(JOIN, privateGroup.getId(), time2, true,
+						true, true, true);
+		final BdfList list1 = BdfList.of(1);
+
+		context.checking(new Expectations() {{
+			oneOf(db).startTransaction(true);
+			will(returnValue(txn));
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(messageParser).getMessagesVisibleInUiQuery();
+			will(returnValue(query));
+			oneOf(clientHelper)
+					.getMessageMetadataAsDictionary(txn, contactGroup.getId(),
+							query);
+			will(returnValue(results));
+			// first message
+			oneOf(messageParser).parseMetadata(d1);
+			will(returnValue(meta1));
+			oneOf(db).getMessageStatus(txn, contactId, message.getId());
+			oneOf(clientHelper).getMessage(txn, message.getId());
+			will(returnValue(message));
+			oneOf(clientHelper).toList(message);
+			will(returnValue(list1));
+			oneOf(messageParser).parseInviteMessage(message, list1);
+			// second message
+			oneOf(messageParser).parseMetadata(d2);
+			will(returnValue(meta2));
+			oneOf(db).getMessageStatus(txn, contactId, storageMessage.getId());
+			// end transaction
+			oneOf(db).commitTransaction(txn);
+			oneOf(db).endTransaction(txn);
+		}});
+
+		Collection<InvitationMessage> messages =
+				groupInvitationManager.getInvitationMessages(contactId);
+		assertEquals(2, messages.size());
+		for (InvitationMessage m : messages) {
+			assertEquals(contactGroup.getId(), m.getGroupId());
+			assertEquals(contactId, m.getContactId());
+			if (m.getId().equals(message.getId())) {
+				assertTrue(m instanceof GroupInvitationRequest);
+				assertEquals(time1, m.getTimestamp());
+			} else if (m.getId().equals(storageMessage.getId())) {
+				assertTrue(m instanceof GroupInvitationResponse);
+				assertEquals(time2, m.getTimestamp());
+			}
+		}
+	}
+
+	@Test
+	public void testGetInvitations() throws Exception {
+		final BdfDictionary query = new BdfDictionary();
+		BdfDictionary d1 = BdfDictionary.of(new BdfEntry("m1", "d"));
+		BdfDictionary d2 = BdfDictionary.of(new BdfEntry("m2", "d"));
+		final Map<MessageId, BdfDictionary> results =	new HashMap<>();
+		results.put(message.getId(), d1);
+		results.put(storageMessage.getId(), d2);
+		final BdfList list1 = BdfList.of(1);
+		final BdfList list2 = BdfList.of(2);
+		long time1 = 1L, time2 = 2L;
+		final InviteMessage m1 =
+				new InviteMessage(message.getId(), contactGroup.getId(),
+						privateGroup.getId(), time1, "test", author,
+						getRandomBytes(5), null, getRandomBytes(5));
+		final InviteMessage m2 =
+				new InviteMessage(message.getId(), contactGroup.getId(),
+						privateGroup.getId(), time2, "test", author,
+						getRandomBytes(5), null, getRandomBytes(5));
+		final PrivateGroup g = context.mock(PrivateGroup.class);
+
+		context.checking(new Expectations() {{
+			oneOf(messageParser).getInvitesAvailableToAnswerQuery();
+			will(returnValue(query));
+			oneOf(db).startTransaction(true);
+			will(returnValue(txn));
+			oneOf(db).getContacts(txn);
+			will(returnValue(Collections.singletonList(contact)));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(clientHelper)
+					.getMessageMetadataAsDictionary(txn, contactGroup.getId(),
+							query);
+			will(returnValue(results));
+			// message 1
+			oneOf(clientHelper).getMessage(txn, message.getId());
+			will(returnValue(message));
+			oneOf(clientHelper).toList(message);
+			will(returnValue(list1));
+			oneOf(messageParser).parseInviteMessage(message, list1);
+			will(returnValue(m1));
+			oneOf(privateGroupFactory)
+					.createPrivateGroup(m1.getGroupName(), m1.getCreator(),
+							m1.getSalt());
+			will(returnValue(g));
+			// message 2
+			oneOf(clientHelper).getMessage(txn, storageMessage.getId());
+			will(returnValue(storageMessage));
+			oneOf(clientHelper).toList(storageMessage);
+			will(returnValue(list2));
+			oneOf(messageParser).parseInviteMessage(storageMessage, list2);
+			will(returnValue(m2));
+			oneOf(privateGroupFactory)
+					.createPrivateGroup(m2.getGroupName(), m2.getCreator(),
+							m2.getSalt());
+			will(returnValue(g));
+			// end transaction
+			oneOf(db).commitTransaction(txn);
+			oneOf(db).endTransaction(txn);
+		}});
+
+		Collection<GroupInvitationItem> items =
+				groupInvitationManager.getInvitations();
+		assertEquals(2, items.size());
+		for (GroupInvitationItem i : items) {
+			assertEquals(contact, i.getCreator());
+			assertEquals(author, i.getCreator().getAuthor());
+		}
+	}
+
+	@Test
+	public void testIsInvitationAllowed() throws Exception {
+		expectIsInvitationAllowed(CreatorState.START);
+		assertTrue(groupInvitationManager
+				.isInvitationAllowed(contact, privateGroup.getId()));
+	}
+
+	@Test
+	public void testIsNotInvitationAllowed() throws Exception {
+		expectIsInvitationAllowed(CreatorState.DISSOLVED);
+		assertFalse(groupInvitationManager
+				.isInvitationAllowed(contact, privateGroup.getId()));
+
+		expectIsInvitationAllowed(CreatorState.ERROR);
+		assertFalse(groupInvitationManager
+				.isInvitationAllowed(contact, privateGroup.getId()));
+
+		expectIsInvitationAllowed(CreatorState.INVITED);
+		assertFalse(groupInvitationManager
+				.isInvitationAllowed(contact, privateGroup.getId()));
+
+		expectIsInvitationAllowed(CreatorState.JOINED);
+		assertFalse(groupInvitationManager
+				.isInvitationAllowed(contact, privateGroup.getId()));
+
+		expectIsInvitationAllowed(CreatorState.LEFT);
+		assertFalse(groupInvitationManager
+				.isInvitationAllowed(contact, privateGroup.getId()));
+	}
+
+	private void expectIsInvitationAllowed(final CreatorState state)
+			throws Exception {
+		final SessionId sessionId =
+				new SessionId(privateGroup.getId().getBytes());
+		final BdfDictionary meta = BdfDictionary.of(new BdfEntry("m", "d"));
+		final Map<MessageId, BdfDictionary> results = new HashMap<>();
+		results.put(message.getId(), meta);
+
+		expectGetSession(results, sessionId);
+		context.checking(new Expectations() {{
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(db).startTransaction(true);
+			will(returnValue(txn));
+			oneOf(sessionParser)
+					.parseCreatorSession(contactGroup.getId(), meta);
+			will(returnValue(creatorSession));
+			oneOf(creatorSession).getState();
+			will(returnValue(state));
+			oneOf(db).commitTransaction(txn);
+			oneOf(db).endTransaction(txn);
+		}});
+	}
+
+	@Test
+	public void testAddingMember() throws Exception {
+		expectAddingMember(privateGroup.getId(), contact);
+		context.checking(new Expectations() {{
+			oneOf(db).getContactsByAuthorId(txn, author.getId());
+			will(returnValue(Collections.singletonList(contact)));
+		}});
+		groupInvitationManager.addingMember(txn, privateGroup.getId(), author);
+	}
+
+	@Test
+	public void testRemovingGroupEndsSessions() throws Exception {
+		final Contact contact2 =
+				new Contact(new ContactId(2), author, author.getId(), true,
+						true);
+		final Contact contact3 =
+				new Contact(new ContactId(3), author, author.getId(), true,
+						true);
+		final Collection<Contact> contacts = new ArrayList<>();
+		contacts.add(contact);
+		contacts.add(contact2);
+		contacts.add(contact3);
+		final MessageId mId2 = new MessageId(getRandomId());
+		final MessageId mId3 = new MessageId(getRandomId());
+		final BdfDictionary meta1 = BdfDictionary.of(new BdfEntry("m1", "d"));
+		final BdfDictionary meta2 = BdfDictionary.of(new BdfEntry("m2", "d"));
+		final BdfDictionary meta3 = BdfDictionary.of(new BdfEntry("m3", "d"));
+
+		expectGetSession(
+				Collections.singletonMap(storageMessage.getId(), meta1),
+				new SessionId(privateGroup.getId().getBytes()));
+		expectGetSession(
+				Collections.singletonMap(mId2, meta2),
+				new SessionId(privateGroup.getId().getBytes()));
+		expectGetSession(
+				Collections.singletonMap(mId3, meta3),
+				new SessionId(privateGroup.getId().getBytes()));
+		context.checking(new Expectations() {{
+			oneOf(db).getContacts(txn);
+			will(returnValue(contacts));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact);
+			will(returnValue(contactGroup));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact2);
+			will(returnValue(contactGroup));
+			oneOf(contactGroupFactory).createContactGroup(CLIENT_ID, contact3);
+			will(returnValue(contactGroup));
+			// session 1
+			oneOf(sessionParser).getRole(meta1);
+			will(returnValue(Role.CREATOR));
+			oneOf(sessionParser)
+					.parseCreatorSession(contactGroup.getId(), meta1);
+			will(returnValue(creatorSession));
+			oneOf(creatorEngine).onLeaveAction(txn, creatorSession);
+			will(returnValue(creatorSession));
+			// session 2
+			oneOf(sessionParser).getRole(meta2);
+			will(returnValue(Role.INVITEE));
+			oneOf(sessionParser)
+					.parseInviteeSession(contactGroup.getId(), meta2);
+			will(returnValue(inviteeSession));
+			oneOf(inviteeEngine).onLeaveAction(txn, inviteeSession);
+			will(returnValue(inviteeSession));
+			// session 3
+			oneOf(sessionParser).getRole(meta3);
+			will(returnValue(Role.PEER));
+			oneOf(sessionParser)
+					.parsePeerSession(contactGroup.getId(), meta3);
+			will(returnValue(peerSession));
+			oneOf(peerEngine).onLeaveAction(txn, peerSession);
+			will(returnValue(peerSession));
+		}});
+		expectStoreSession(creatorSession, storageMessage.getId());
+		expectStoreSession(inviteeSession, mId2);
+		expectStoreSession(peerSession, mId3);
+		groupInvitationManager.removingGroup(txn, privateGroup.getId());
+	}
+
+}