needs = subscriberHub
.ids()
.stream()
@@ -279,16 +279,17 @@ private void notifyOfUpdatedNeeds() {
.setWrapperTypeUrl(EVENT.value())
.buildPartial())
.collect(toImmutableSet());
- configurationBroadcast.onNeedsUpdated(needs);
+ configurationBroadcast.onTypesChanged(needs);
}
/**
- * Notifies other parts of the application about the types requested by this integration bus.
+ * Notifies other Bounded Contexts of the application about the types requested by this Context.
*
- * Sends out an instance of {@linkplain RequestForExternalMessages
- * request for external messages} for that purpose.
+ *
The {@code IntegrationBus} sends a {@link RequestForExternalMessages}. The request
+ * triggers other Contexts to send their requests. As the result, all the Contexts know about
+ * the needs of all the Contexts.
*/
- void notifyOfCurrentNeeds() {
+ void notifyOthers() {
configurationBroadcast.send();
}
@@ -322,7 +323,7 @@ public void close() throws Exception {
super.close();
configurationChangeObserver.close();
- notifyOfUpdatedNeeds();
+ notifyTypesChanged();
subscriberHub.close();
publisherHub.close();
diff --git a/server/src/main/java/io/spine/server/integration/ThirdPartyContext.java b/server/src/main/java/io/spine/server/integration/ThirdPartyContext.java
new file mode 100644
index 00000000000..a4b3dd4c3e2
--- /dev/null
+++ b/server/src/main/java/io/spine/server/integration/ThirdPartyContext.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2019, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.server.integration;
+
+import com.google.protobuf.Any;
+import io.spine.base.EventMessage;
+import io.spine.core.ActorContext;
+import io.spine.core.Event;
+import io.spine.core.UserId;
+import io.spine.server.BoundedContext;
+import io.spine.server.event.EventFactory;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.base.Time.currentTime;
+import static io.spine.protobuf.AnyPacker.pack;
+import static io.spine.server.BoundedContextBuilder.notStoringEvents;
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * An external non-Spine based upstream system.
+ *
+ *
{@code ThirdPartyContext} helps to represent an external system as a Bounded Context. Events
+ * which occur in the external system are converted into domain events of the user's
+ * Bounded Contexts and dispatched via {@link IntegrationBus}.
+ *
+ * @implSpec Note that a {@code ThirdPartyContext} sends a request for external messages to
+ * other contexts. The {@code ThirdPartyContext} never consumes external messages itself,
+ * but requires the other Bounded Contexts to send their requests, so that the publishing
+ * channels are open. Depending of the implementation of
+ * {@link io.spine.server.transport.TransportFactory transport}, creating
+ * a {@code ThirdPartyContext} may be an expensive operation. Thus, it is recommended that
+ * the instances of this class are reused and {@linkplain #close() closed} when they are
+ * no longer needed.
+ */
+public final class ThirdPartyContext implements AutoCloseable {
+
+ private final BoundedContext context;
+ private final Any producerId;
+
+ /**
+ * Creates a new single-tenant instance of {@code ThirdPartyContext} with the given name.
+ *
+ * @param name
+ * name of the Bounded Context representing a part of a third-party system
+ */
+ public static ThirdPartyContext singleTenant(String name) {
+ return newContext(name, false);
+ }
+
+ /**
+ * Creates a new multitenant instance of {@code ThirdPartyContext} with the given name.
+ *
+ * @param name
+ * name of the Bounded Context representing a part of a third-party system
+ */
+ public static ThirdPartyContext multitenant(String name) {
+ return newContext(name, true);
+ }
+
+ private static ThirdPartyContext newContext(String name, boolean multitenant) {
+ checkNotEmptyOrBlank(name);
+ BoundedContext context = notStoringEvents(name, multitenant).build();
+ context.integrationBus()
+ .notifyOthers();
+ return new ThirdPartyContext(context);
+ }
+
+ private ThirdPartyContext(BoundedContext context) {
+ this.context = context;
+ this.producerId = pack(context.name());
+ }
+
+ /**
+ * Emits an event from the third-party system.
+ *
+ *
If the event is required by another Context, posts the event into
+ * the {@link IntegrationBus} of the respective Context. Does nothing if the event is not
+ * required by any Context.
+ *
+ *
The caller is required to supply the tenant ID via the {@code ActorContext.tenant_id} if
+ * this Context is multitenant.
+ *
+ * @param eventMessage
+ * the event
+ * @param actorContext
+ * the info about the actor, a user or a software component, who emits the event
+ */
+ public void emittedEvent(EventMessage eventMessage, ActorContext actorContext) {
+ checkNotNull(actorContext);
+ checkNotNull(eventMessage);
+ checkTenant(actorContext, eventMessage);
+
+ EventFactory eventFactory = EventFactory.forImport(actorContext, producerId);
+ Event event = eventFactory.createEvent(eventMessage, null);
+ context.eventBus()
+ .post(event);
+ }
+
+ /**
+ * Emits an event from the third-party system.
+ *
+ *
If the event is required by another Context, posts the event into
+ * the {@link IntegrationBus} of the respective Context. Does nothing if the event is not
+ * required by any Context.
+ *
+ *
This overload may only be used for single-tenant third-party contexts. If this Context is
+ * multitenant, this method throws an exception.
+ *
+ * @param eventMessage
+ * the event
+ * @param userId
+ * the ID of the actor, a user or a software component, who emits the event
+ */
+ public void emittedEvent(EventMessage eventMessage, UserId userId) {
+ checkNotNull(userId);
+ checkNotNull(eventMessage);
+ ActorContext context = ActorContext
+ .newBuilder()
+ .setActor(userId)
+ .setTimestamp(currentTime())
+ .vBuild();
+ emittedEvent(eventMessage, context);
+ }
+
+ private void checkTenant(ActorContext actorContext, EventMessage event) {
+ boolean tenantSupplied = actorContext.hasTenantId();
+ if (context.isMultitenant()) {
+ checkArgument(tenantSupplied,
+ "Cannot post `%s` into a third-party multitenant context %s." +
+ " No tenant ID supplied.",
+ event.getClass().getSimpleName(),
+ context.name().getValue());
+ } else {
+ checkArgument(!tenantSupplied,
+ "Cannot post `%s` into a third-party single-tenant context %s." +
+ " Tenant ID must NOT be supplied.",
+ event.getClass().getSimpleName(),
+ context.name().getValue());
+ }
+ }
+
+ /**
+ * Closes this Context and clean up underlying resources.
+ *
+ *
Attempts of emitting an event from a closed Context result in an exception.
+ *
+ * @throws Exception
+ * if the underlying {@link BoundedContext} fails to close
+ */
+ @Override
+ public void close() throws Exception {
+ context.close();
+ }
+}
diff --git a/server/src/main/java/io/spine/server/model/DuplicateHandlerMethodError.java b/server/src/main/java/io/spine/server/model/DuplicateHandlerMethodError.java
index bda7bd56747..ab10e0935f6 100644
--- a/server/src/main/java/io/spine/server/model/DuplicateHandlerMethodError.java
+++ b/server/src/main/java/io/spine/server/model/DuplicateHandlerMethodError.java
@@ -19,6 +19,10 @@
*/
package io.spine.server.model;
+import com.google.common.base.Joiner;
+
+import java.util.Collection;
+
import static java.lang.String.format;
/**
@@ -27,16 +31,23 @@
*/
public final class DuplicateHandlerMethodError extends ModelError {
+ private static final Joiner METHOD_JOINER = Joiner.on(", ");
+
private static final long serialVersionUID = 0L;
- DuplicateHandlerMethodError(Class> targetClass,
+ DuplicateHandlerMethodError(Class> declaringClass,
DispatchKey key,
String firstMethodName,
String secondMethodName) {
-
super(format("The `%s` class defines more than one method with parameters `%s`." +
" Methods encountered: `%s`, `%s`.",
- targetClass.getName(), key,
+ declaringClass.getName(), key,
firstMethodName, secondMethodName));
}
+
+ DuplicateHandlerMethodError(Collection extends HandlerMethod, ?, ?, ?>> handlers) {
+ super(format("Handler methods %s are clashing.%n" +
+ "Only one of them should handle this message type.",
+ METHOD_JOINER.join(handlers)));
+ }
}
diff --git a/server/src/main/java/io/spine/server/model/HandlerMap.java b/server/src/main/java/io/spine/server/model/HandlerMap.java
index 20a18ff8780..e0aad6ec348 100644
--- a/server/src/main/java/io/spine/server/model/HandlerMap.java
+++ b/server/src/main/java/io/spine/server/model/HandlerMap.java
@@ -20,11 +20,13 @@
package io.spine.server.model;
+import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.errorprone.annotations.Immutable;
+import io.spine.logging.Logging;
import io.spine.server.type.EmptyClass;
import io.spine.type.MessageClass;
@@ -36,7 +38,9 @@
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.Iterables.getOnlyElement;
import static io.spine.server.model.MethodScan.findMethodsBy;
+import static io.spine.util.Exceptions.newIllegalStateException;
/**
* Provides mapping from a class of messages to methods which handle such messages.
@@ -52,8 +56,9 @@
public final class HandlerMap,
R extends MessageClass>,
H extends HandlerMethod, M, ?, R>>
- implements Serializable {
+ implements Serializable, Logging {
+ private static final Joiner METHOD_LIST_JOINER = Joiner.on(System.lineSeparator() + ',');
private static final long serialVersionUID = 0L;
private final ImmutableSetMultimap map;
@@ -163,8 +168,8 @@ public ImmutableSet handlersOf(M messageClass, MessageClass> originClass) {
// If we have a handler with origin type, use the key. Otherwise, find handlers only
// by the first parameter.
DispatchKey presentKey = map.containsKey(key)
- ? key
- : new DispatchKey(messageClass.value(), null, null);
+ ? key
+ : new DispatchKey(messageClass.value(), null, null);
return handlersOf(presentKey);
}
@@ -185,7 +190,7 @@ public ImmutableSet handlersOf(M messageClass, MessageClass> originClass) {
*/
public H handlerOf(M messageClass, MessageClass originClass) {
ImmutableSet methods = handlersOf(messageClass, originClass);
- return checkSingle(methods, messageClass);
+ return singleMethod(methods, messageClass);
}
/**
@@ -215,19 +220,36 @@ public ImmutableSet handlersOf(M messageClass) {
*/
public H handlerOf(M messageClass) {
ImmutableSet methods = handlersOf(messageClass);
- return checkSingle(methods, messageClass);
+ return singleMethod(methods, messageClass);
}
- private H checkSingle(Collection handlers, M targetType) {
+ private H singleMethod(Collection handlers, M targetType) {
+ checkSingle(handlers, targetType);
+ H handler = getOnlyElement(handlers);
+ return handler;
+ }
+
+ private void checkSingle(Collection handlers, M targetType) {
int count = handlers.size();
- checkState(count == 1,
- "Unexpected number of handlers for messages of class %s: %s.%n%s",
- targetType, count, handlers);
- H result = handlers
- .stream()
- .findFirst()
- .get();
- return result;
+ if (count == 0) {
+ _error().log("No handler method found for the type `%s`.", targetType);
+ throw newIllegalStateException(
+ "Unexpected number of handlers for messages of class %s: %d.%n%s",
+ targetType, count, handlers
+ );
+ } else if (count > 1) {
+ /*
+ The map should have found all the duplicates during construction.
+ This is a fail-safe execution branch which ensures that no changes in the `HandlerMap`
+ implementation corrupt the model.
+ */
+ _error().log(
+ "There are %d handler methods found for the type `%s`." +
+ "Please remove all but one method:%n%s",
+ count, targetType, METHOD_LIST_JOINER.join(handlers)
+ );
+ throw new DuplicateHandlerMethodError(handlers);
+ }
}
private static , H extends HandlerMethod, M, ?, ?>>
diff --git a/server/src/main/java/io/spine/system/server/SystemEventFactory.java b/server/src/main/java/io/spine/system/server/SystemEventFactory.java
index aa5f55c45cf..c4646a494cf 100644
--- a/server/src/main/java/io/spine/system/server/SystemEventFactory.java
+++ b/server/src/main/java/io/spine/system/server/SystemEventFactory.java
@@ -34,7 +34,7 @@
import java.util.Set;
import static com.google.common.base.Preconditions.checkArgument;
-import static io.spine.system.server.SystemCommandFactory.requestFactory;
+import static io.spine.system.server.SystemRequestFactory.requestFactory;
import static io.spine.validate.Validate.isNotDefault;
/**
diff --git a/server/src/main/java/io/spine/system/server/SystemCommandFactory.java b/server/src/main/java/io/spine/system/server/SystemRequestFactory.java
similarity index 80%
rename from server/src/main/java/io/spine/system/server/SystemCommandFactory.java
rename to server/src/main/java/io/spine/system/server/SystemRequestFactory.java
index 56e3f73623e..b73b5e1460c 100644
--- a/server/src/main/java/io/spine/system/server/SystemCommandFactory.java
+++ b/server/src/main/java/io/spine/system/server/SystemRequestFactory.java
@@ -21,7 +21,6 @@
package io.spine.system.server;
import io.spine.client.ActorRequestFactory;
-import io.spine.client.CommandFactory;
import io.spine.core.TenantId;
import io.spine.server.tenant.TenantFunction;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -30,27 +29,15 @@
import static io.spine.system.server.DefaultSystemWriteSide.SYSTEM_USER;
/**
- * Creates a command factory for producing commands under the context of specified tenant.
+ * Creates an actor request factory for producing requests under the context of specified tenant.
*/
-final class SystemCommandFactory {
+final class SystemRequestFactory {
private static final ActorRequestFactory SINGLE_TENANT =
newFactoryFor(TenantId.getDefaultInstance());
/** Prevents instantiation of this utility class. */
- private SystemCommandFactory() {
- }
-
- /**
- * Obtains a {@code CommandFactory} for creating system commands.
- *
- * @param multitenant
- * pass {@code true} if the System Context works in the multi-tenant mode,
- * {@code false} otherwise
- */
- static CommandFactory newInstance(boolean multitenant) {
- ActorRequestFactory requestFactory = requestFactory(multitenant);
- return requestFactory.command();
+ private SystemRequestFactory() {
}
static ActorRequestFactory requestFactory(boolean multitenant) {
diff --git a/server/src/test/java/io/spine/server/event/EventBusTest.java b/server/src/test/java/io/spine/server/event/EventBusTest.java
index 0f1cf720430..390ff48a634 100644
--- a/server/src/test/java/io/spine/server/event/EventBusTest.java
+++ b/server/src/test/java/io/spine/server/event/EventBusTest.java
@@ -20,10 +20,10 @@
package io.spine.server.event;
-import com.google.protobuf.Int32Value;
+import io.grpc.stub.StreamObserver;
+import io.spine.core.Ack;
import io.spine.core.Command;
import io.spine.core.Event;
-import io.spine.core.EventId;
import io.spine.grpc.StreamObservers;
import io.spine.server.BoundedContext;
import io.spine.server.BoundedContextBuilder;
@@ -45,11 +45,11 @@
import io.spine.test.event.ProjectId;
import io.spine.test.event.Task;
import io.spine.testdata.Sample;
+import io.spine.testing.SlowTest;
import io.spine.testing.server.TestEventFactory;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -61,7 +61,6 @@
import java.util.concurrent.Executors;
import static com.google.common.truth.Truth.assertThat;
-import static io.spine.protobuf.AnyPacker.pack;
import static io.spine.protobuf.AnyPacker.unpack;
import static io.spine.server.BoundedContextBuilder.assumingTests;
import static io.spine.server.event.given.bus.EventBusTestEnv.addTasks;
@@ -445,31 +444,25 @@ void domesticEventToExternalMethod() {
* initialization multiple times from several threads.
*/
@SuppressWarnings({"MethodWithMultipleLoops", "BusyWait"}) // OK for such test case.
- @Disabled // This test is used only to diagnose EventBus malfunctions in concurrent environment.
- // It's too long to execute this test per each build, so we leave it as is for now.
- // Please see build log to find out if there were some errors during the test execution.
+ @SlowTest
@Test
@DisplayName("store filters regarding possible concurrent modifications")
- void storeFiltersInConcurrentEnv() throws InterruptedException {
+ void storeFiltersInConcurrentEnv() throws Exception {
int threadCount = 50;
-
- // "Random" more or less valid Event.
- Event event = Event
- .newBuilder()
- .setId(EventId.newBuilder()
- .setValue("123-1"))
- .setMessage(pack(Int32Value.newBuilder()
- .setValue(42)
- .build()))
- .build();
+ Event event = eventFactory.createEvent(DonationMade
+ .newBuilder()
+ .setUsdsDonated(3.14)
+ .vBuild());
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
// Catch non-easily reproducible bugs.
for (int i = 0; i < 300; i++) {
- EventBus eventBus = EventBus
- .newBuilder()
+ BoundedContext context = BoundedContextBuilder
+ .assumingTests()
.build();
+ EventBus eventBus = context.eventBus();
+ StreamObserver observer = StreamObservers.noOpObserver();
for (int j = 0; j < threadCount; j++) {
- executor.execute(() -> eventBus.post(event));
+ executor.execute(() -> eventBus.post(event, observer));
}
// Let the system destroy all the native threads, clean up, etc.
Thread.sleep(100);
diff --git a/server/src/test/java/io/spine/server/integration/ThirdPartyContextTest.java b/server/src/test/java/io/spine/server/integration/ThirdPartyContextTest.java
new file mode 100644
index 00000000000..84532ebc9b6
--- /dev/null
+++ b/server/src/test/java/io/spine/server/integration/ThirdPartyContextTest.java
@@ -0,0 +1,273 @@
+/*
+ * Copyright 2019, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.server.integration;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.testing.NullPointerTester;
+import io.spine.base.EventMessage;
+import io.spine.base.Time;
+import io.spine.core.ActorContext;
+import io.spine.core.TenantId;
+import io.spine.core.UserId;
+import io.spine.net.InternetDomain;
+import io.spine.server.BoundedContext;
+import io.spine.server.BoundedContextBuilder;
+import io.spine.server.ServerEnvironment;
+import io.spine.server.integration.given.DocumentAggregate;
+import io.spine.server.integration.given.DocumentRepository;
+import io.spine.server.integration.given.EditHistoryProjection;
+import io.spine.server.integration.given.EditHistoryRepository;
+import io.spine.server.tenant.TenantAwareRunner;
+import io.spine.server.type.given.GivenEvent;
+import io.spine.testing.client.TestActorRequestFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static io.spine.base.Identifier.newUuid;
+import static io.spine.base.Time.currentTime;
+import static io.spine.grpc.StreamObservers.noOpObserver;
+import static io.spine.testing.DisplayNames.NOT_ACCEPT_NULLS;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+@DisplayName("ThirdPartyContext should")
+class ThirdPartyContextTest {
+
+ private BoundedContext context;
+ private DocumentRepository documentRepository;
+ private EditHistoryRepository editHistoryRepository;
+
+ @BeforeEach
+ void prepareContext() {
+ documentRepository = new DocumentRepository();
+ editHistoryRepository = new EditHistoryRepository();
+ context = BoundedContextBuilder
+ .assumingTests()
+ .add(documentRepository)
+ .add(editHistoryRepository)
+ .build();
+ }
+
+ @AfterEach
+ void closeContext() throws Exception {
+ context.close();
+ ServerEnvironment
+ .instance()
+ .reset();
+ }
+
+ @Test
+ @DisplayName("not accept nulls in factory methods")
+ void nullsOnConstruction() {
+ new NullPointerTester()
+ .testAllPublicStaticMethods(ThirdPartyContext.class);
+ }
+
+ @Test
+ @DisplayName(NOT_ACCEPT_NULLS)
+ void nulls() {
+ new NullPointerTester()
+ .setDefault(UserId.class, UserId.getDefaultInstance())
+ .setDefault(ActorContext.class, ActorContext.getDefaultInstance())
+ .testAllPublicInstanceMethods(ThirdPartyContext.singleTenant("Directory"));
+ }
+
+ @Test
+ @DisplayName("if multitenant, require a tenant ID for each event")
+ void requireTenant() {
+ ActorContext noTenantContext = ActorContext
+ .newBuilder()
+ .setActor(UserId.newBuilder().setValue("42"))
+ .setTimestamp(Time.currentTime())
+ .vBuild();
+ ThirdPartyContext calendar = ThirdPartyContext.multitenant("Calendar");
+ assertThrows(IllegalArgumentException.class,
+ () -> calendar.emittedEvent(GivenEvent.message(), noTenantContext));
+ }
+
+ @Test
+ @DisplayName("if single-tenant, fail if a tenant ID is supplied")
+ void noTenant() {
+ ActorContext actorWithTenant = ActorContext
+ .newBuilder()
+ .setActor(UserId.newBuilder().setValue("42"))
+ .setTimestamp(Time.currentTime())
+ .setTenantId(TenantId.newBuilder().setValue("AcmeCorp"))
+ .vBuild();
+ ThirdPartyContext calendar = ThirdPartyContext.singleTenant("Notes");
+ assertThrows(IllegalArgumentException.class,
+ () -> calendar.emittedEvent(GivenEvent.message(), actorWithTenant));
+ }
+
+ @Test
+ @DisplayName("deliver to external reactors")
+ void externalReactor() {
+ UserId johnDoe = userId();
+ OpenOfficeDocumentUploaded importEvent = OpenOfficeDocumentUploaded
+ .newBuilder()
+ .setId(DocumentId.generate())
+ .setText("The scary truth about gluten")
+ .build();
+ postForSingleTenant(johnDoe, importEvent);
+ Optional foundDoc = documentRepository.find(importEvent.getId());
+ assertThat(foundDoc).isPresent();
+ assertThat(foundDoc.get()
+ .state()
+ .getText())
+ .isEqualTo(importEvent.getText());
+ }
+
+ @Test
+ @DisplayName("not deliver to domestic reactors")
+ void domesticReactor() {
+ UserId johnDoe = userId();
+ DocumentImported importEvent = DocumentImported
+ .newBuilder()
+ .setId(DocumentId.generate())
+ .setText("Annual report")
+ .build();
+ postForSingleTenant(johnDoe, importEvent);
+ Optional foundDoc = documentRepository.find(importEvent.getId());
+ assertThat(foundDoc).isEmpty();
+ }
+
+ @Test
+ @DisplayName("and deliver to external subscribers")
+ void externalSubscriber() {
+ UserId johnDoe = userId();
+ TestActorRequestFactory requests =
+ new TestActorRequestFactory(johnDoe);
+ DocumentId documentId = DocumentId.generate();
+ CreateDocument crete = CreateDocument
+ .newBuilder()
+ .setId(documentId)
+ .vBuild();
+ EditText edit = EditText
+ .newBuilder()
+ .setId(documentId)
+ .setPosition(0)
+ .setNewText("Fresh new document")
+ .vBuild();
+ context.commandBus()
+ .post(ImmutableList.of(requests.createCommand(crete), requests.createCommand(edit)),
+ noOpObserver());
+ EditHistoryProjection historyAfterEdit = editHistoryRepository
+ .find(documentId)
+ .orElseGet(Assertions::fail);
+ assertThat(historyAfterEdit.state().getEditList())
+ .isNotEmpty();
+ postForSingleTenant(johnDoe, UserDeleted
+ .newBuilder()
+ .setUser(johnDoe)
+ .vBuild());
+ EditHistoryProjection historyAfterDeleted = editHistoryRepository
+ .find(documentId)
+ .orElseGet(Assertions::fail);
+ assertThat(historyAfterDeleted.state().getEditList())
+ .isEmpty();
+ }
+
+ @Test
+ @DisplayName("and ignore domestic subscribers")
+ void domesticSubscriber() {
+ DocumentId documentId = DocumentId.generate();
+ TextEdited event = TextEdited
+ .newBuilder()
+ .setId(documentId)
+ .vBuild();
+ postForSingleTenant(userId(), event);
+ assertThat(editHistoryRepository.find(documentId)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("in a multitenant environment")
+ void multitenant() {
+ DocumentRepository documentRepository = new DocumentRepository();
+ BoundedContextBuilder
+ .assumingTests(true)
+ .add(documentRepository)
+ .build();
+ UserId johnDoe = userId();
+ TenantId acmeCorp = TenantId
+ .newBuilder()
+ .setDomain(InternetDomain.newBuilder()
+ .setValue("acme.com"))
+ .build();
+ TenantId cyberdyne = TenantId
+ .newBuilder()
+ .setDomain(InternetDomain.newBuilder()
+ .setValue("cyberdyne.com"))
+ .build();
+ DocumentId documentId = DocumentId.generate();
+ OpenOfficeDocumentUploaded importEvent = OpenOfficeDocumentUploaded
+ .newBuilder()
+ .setId(documentId)
+ .setText("Daily report")
+ .build();
+ postForTenant(acmeCorp, johnDoe, importEvent);
+
+ Optional acmeDailyReport = TenantAwareRunner
+ .with(acmeCorp)
+ .evaluate(() -> documentRepository.find(documentId));
+ assertThat(acmeDailyReport).isPresent();
+
+ Optional cyberdyneDailyReport = TenantAwareRunner
+ .with(cyberdyne)
+ .evaluate(() -> documentRepository.find(documentId));
+ assertThat(cyberdyneDailyReport).isEmpty();
+ }
+
+ private static void postForSingleTenant(UserId actor, EventMessage event) {
+ try (ThirdPartyContext uploads = ThirdPartyContext.singleTenant("Imports")) {
+ uploads.emittedEvent(event, actor);
+ } catch (Exception e) {
+ fail(e);
+ }
+ }
+
+ private static void postForTenant(TenantId tenantId, UserId actor, EventMessage event) {
+ try (ThirdPartyContext uploads = ThirdPartyContext.multitenant("Exports")) {
+ ActorContext actorContext = ActorContext
+ .newBuilder()
+ .setActor(actor)
+ .setTenantId(tenantId)
+ .setTimestamp(currentTime())
+ .vBuild();
+ uploads.emittedEvent(event, actorContext);
+ } catch (Exception e) {
+ fail(e);
+ }
+ }
+
+ private static UserId userId() {
+ return UserId
+ .newBuilder()
+ .setValue(newUuid())
+ .build();
+ }
+}
diff --git a/server/src/test/java/io/spine/server/integration/given/DocumentAggregate.java b/server/src/test/java/io/spine/server/integration/given/DocumentAggregate.java
new file mode 100644
index 00000000000..9543a55eb73
--- /dev/null
+++ b/server/src/test/java/io/spine/server/integration/given/DocumentAggregate.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2019, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.server.integration.given;
+
+import io.spine.core.CommandContext;
+import io.spine.core.EventContext;
+import io.spine.core.UserId;
+import io.spine.server.aggregate.Aggregate;
+import io.spine.server.aggregate.Apply;
+import io.spine.server.command.Assign;
+import io.spine.server.event.React;
+import io.spine.server.integration.CreateDocument;
+import io.spine.server.integration.Document;
+import io.spine.server.integration.DocumentCreated;
+import io.spine.server.integration.DocumentId;
+import io.spine.server.integration.DocumentImported;
+import io.spine.server.integration.Edit;
+import io.spine.server.integration.EditText;
+import io.spine.server.integration.OpenOfficeDocumentUploaded;
+import io.spine.server.integration.PaperDocumentScanned;
+import io.spine.server.integration.TextEdited;
+import io.spine.server.tuple.Pair;
+import io.spine.time.LocalDateTime;
+import io.spine.time.Now;
+
+/**
+ * A test aggregate representing a {@code Document}.
+ *
+ * Used for testing event posting via {@link io.spine.server.integration.EventFunnel}.
+ */
+public class DocumentAggregate extends Aggregate {
+
+ @Assign
+ DocumentCreated handle(CreateDocument command, CommandContext context) {
+ return DocumentCreated
+ .newBuilder()
+ .setId(command.getId())
+ .setOwner(context.getActorContext().getActor())
+ .setWhenCreated(Now.get().asLocalDateTime())
+ .vBuild();
+ }
+
+ @Assign
+ TextEdited handle(EditText command, CommandContext context) {
+ Edit edit = Edit
+ .newBuilder()
+ .setEditor(context.getActorContext().getActor())
+ .setPosition(command.getPosition())
+ .setTextAdded(command.getNewText())
+ .setCharsDeleted(command.getCharsToDelete())
+ .build();
+ return TextEdited
+ .newBuilder()
+ .setId(command.getId())
+ .setEdit(edit)
+ .vBuild();
+ }
+
+ /**
+ * Reacts on an external {@code OpenOfficeDocumentUploaded} event with
+ * a {@code DocumentImported} event.
+ *
+ * This flow is intentionally complex so that the aggregate reacts to both external and
+ * domestic events.
+ */
+ @React(external = true)
+ DocumentImported on(OpenOfficeDocumentUploaded event, EventContext context) {
+ return DocumentImported
+ .newBuilder()
+ .setId(event.getId())
+ .setOwner(context.actor())
+ .setText(event.getText())
+ .setWhenUploaded(Now.get().asLocalDateTime())
+ .vBuild();
+ }
+
+ @React
+ Pair on(DocumentImported event) {
+ DocumentId documentId = event.getId();
+ UserId user = event.getOwner();
+ LocalDateTime when = event.getWhenUploaded();
+ DocumentCreated created = DocumentCreated
+ .newBuilder()
+ .setId(documentId)
+ .setWhenCreated(when)
+ .setOwner(user)
+ .vBuild();
+ Edit edit = Edit
+ .newBuilder()
+ .setEditor(user)
+ .setPosition(0)
+ .setTextAdded(event.getText())
+ .build();
+ TextEdited edited = TextEdited
+ .newBuilder()
+ .setId(documentId)
+ .setEdit(edit)
+ .vBuild();
+ return Pair.of(created, edited);
+ }
+
+ @Apply
+ private void event(DocumentImported event) {
+ // Do nothing. As the event is produced, it must be applied.
+ }
+
+ @Apply
+ private void event(DocumentCreated e) {
+ builder()
+ .setOwner(e.getOwner())
+ .setLastEdit(e.getWhenCreated());
+ }
+
+ @Apply
+ private void event(TextEdited e) {
+ Edit edit = e.getEdit();
+ String text = builder().getText();
+ int position = edit.getPosition();
+ String start = text.substring(0, position);
+ String end = text.substring(position);
+ int deletedCount = edit.getCharsDeleted();
+ if (deletedCount > 0) {
+ end = end.substring(deletedCount);
+ }
+ String resultText = start + edit.getTextAdded() + end;
+ builder()
+ .setText(resultText);
+ }
+
+ @Apply(allowImport = true)
+ private void event(PaperDocumentScanned e) {
+ builder()
+ .setText(e.getText())
+ .setOwner(e.getOwner())
+ .setLastEdit(e.getWhenCreated());
+ }
+}
diff --git a/server/src/test/java/io/spine/server/integration/given/DocumentRepository.java b/server/src/test/java/io/spine/server/integration/given/DocumentRepository.java
new file mode 100644
index 00000000000..87bee88ee3d
--- /dev/null
+++ b/server/src/test/java/io/spine/server/integration/given/DocumentRepository.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2019, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.server.integration.given;
+
+import io.spine.server.aggregate.AggregateRepository;
+import io.spine.server.integration.DocumentId;
+import io.spine.server.integration.OpenOfficeDocumentUploaded;
+import io.spine.server.integration.PaperDocumentScanned;
+import io.spine.server.route.EventRouting;
+
+import static io.spine.server.route.EventRoute.withId;
+
+public class DocumentRepository extends AggregateRepository {
+
+ @Override
+ protected void setupImportRouting(EventRouting routing) {
+ super.setupImportRouting(routing);
+ routing.route(PaperDocumentScanned.class, (message, context) -> withId(message.getId()));
+ }
+
+ @Override
+ protected void setupEventRouting(EventRouting routing) {
+ super.setupEventRouting(routing);
+ routing.route(OpenOfficeDocumentUploaded.class,
+ (message, context) -> withId(message.getId()));
+ }
+}
diff --git a/server/src/test/java/io/spine/server/integration/given/EditHistoryProjection.java b/server/src/test/java/io/spine/server/integration/given/EditHistoryProjection.java
new file mode 100644
index 00000000000..4db5ed178f8
--- /dev/null
+++ b/server/src/test/java/io/spine/server/integration/given/EditHistoryProjection.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2019, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.server.integration.given;
+
+import io.spine.core.Subscribe;
+import io.spine.server.integration.DocumentId;
+import io.spine.server.integration.Edit;
+import io.spine.server.integration.EditHistory;
+import io.spine.server.integration.TextEdited;
+import io.spine.server.integration.UserDeleted;
+import io.spine.server.projection.Projection;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class EditHistoryProjection
+ extends Projection {
+
+ @Subscribe
+ void on(TextEdited event) {
+ builder()
+ .addEdit(event.getEdit());
+ }
+
+ @Subscribe(external = true)
+ void on(UserDeleted event) {
+ List list = new ArrayList<>(builder().getEditList());
+ list.removeIf(edit -> edit.getEditor().equals(event.getUser()));
+ builder()
+ .clearEdit()
+ .addAllEdit(list);
+ }
+}
diff --git a/server/src/test/java/io/spine/server/integration/given/EditHistoryRepository.java b/server/src/test/java/io/spine/server/integration/given/EditHistoryRepository.java
new file mode 100644
index 00000000000..772948a5935
--- /dev/null
+++ b/server/src/test/java/io/spine/server/integration/given/EditHistoryRepository.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2019, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.server.integration.given;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper;
+import io.spine.server.integration.DocumentId;
+import io.spine.server.integration.EditHistory;
+import io.spine.server.integration.TextEdited;
+import io.spine.server.integration.UserDeleted;
+import io.spine.server.projection.ProjectionRepository;
+import io.spine.server.route.EventRouting;
+
+import static io.spine.server.route.EventRoute.withId;
+
+public class EditHistoryRepository
+ extends ProjectionRepository {
+
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ protected void setupEventRouting(EventRouting routing) {
+ super.setupEventRouting(routing);
+ routing.route(TextEdited.class,
+ (message, context) -> withId(message.getId()));
+ // Routing an event to all projections of a type is generally a bad idea.
+ // However, it is good enough for the sake of testing.
+ routing.route(UserDeleted.class,
+ (message, context) -> ImmutableSet.copyOf(storage().index()));
+ }
+}
diff --git a/server/src/test/java/io/spine/server/model/HandlerMapTest.java b/server/src/test/java/io/spine/server/model/HandlerMapTest.java
index 4839bb94f9c..caf428a8521 100644
--- a/server/src/test/java/io/spine/server/model/HandlerMapTest.java
+++ b/server/src/test/java/io/spine/server/model/HandlerMapTest.java
@@ -22,10 +22,14 @@
import io.spine.server.command.model.CommandHandlerSignature;
import io.spine.server.model.given.map.DupEventFilterValue;
-import io.spine.server.model.given.map.DuplicatingCommandHandlers;
+import io.spine.server.model.given.map.DuplicateCommandHandlers;
import io.spine.server.model.given.map.TwoFieldsInSubscription;
+import io.spine.server.model.given.method.OneParamSignature;
+import io.spine.server.model.given.method.StubHandler;
+import io.spine.server.type.EventClass;
import io.spine.string.StringifierRegistry;
import io.spine.string.Stringifiers;
+import io.spine.test.event.ProjectStarred;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -52,17 +56,17 @@ static void prepare() {
class DuplicateHandler {
@Test
- @DisplayName("duplicating message classes in handlers")
+ @DisplayName("duplicate message classes in handlers")
void rejectDuplicateHandlers() {
- assertDuplication(
- () -> create(DuplicatingCommandHandlers.class, new CommandHandlerSignature())
+ assertDuplicate(
+ () -> create(DuplicateCommandHandlers.class, new CommandHandlerSignature())
);
}
@Test
@DisplayName("the same value of the filtered event field")
void rejectFilterFieldDuplication() {
- assertDuplication(() -> asProjectionClass(DupEventFilterValue.class));
+ assertDuplicate(() -> asProjectionClass(DupEventFilterValue.class));
}
@Test
@@ -74,8 +78,17 @@ void failToSubscribeByDifferentFields() {
);
}
- void assertDuplication(Runnable runnable) {
+ void assertDuplicate(Runnable runnable) {
assertThrows(DuplicateHandlerMethodError.class, runnable::run);
}
}
+
+ @Test
+ @DisplayName("fail if no method found")
+ void failIfNotFound() {
+ HandlerMap map = create(StubHandler.class,
+ new OneParamSignature());
+ assertThrows(IllegalStateException.class,
+ () -> map.handlerOf(EventClass.from(ProjectStarred.class)));
+ }
}
diff --git a/server/src/test/java/io/spine/server/model/given/map/DuplicatingCommandHandlers.java b/server/src/test/java/io/spine/server/model/given/map/DuplicateCommandHandlers.java
similarity index 94%
rename from server/src/test/java/io/spine/server/model/given/map/DuplicatingCommandHandlers.java
rename to server/src/test/java/io/spine/server/model/given/map/DuplicateCommandHandlers.java
index 1cc5bf4d71b..27bafc6f4de 100644
--- a/server/src/test/java/io/spine/server/model/given/map/DuplicatingCommandHandlers.java
+++ b/server/src/test/java/io/spine/server/model/given/map/DuplicateCommandHandlers.java
@@ -25,10 +25,10 @@
import io.spine.test.event.command.CreateProject;
/**
- * This class is not valid because it declares two command handlers that
- * accept the same command type.
+ * This class is not valid because it declares two command handlers which accept the same command
+ * type.
*/
-public class DuplicatingCommandHandlers {
+public class DuplicateCommandHandlers {
@Assign
ProjectCreated on(CreateProject cmd) {
diff --git a/server/src/test/java/io/spine/server/model/given/map/ProjectCreatedEventReactor.java b/server/src/test/java/io/spine/server/model/given/map/ProjectCreatedEventReactor.java
new file mode 100644
index 00000000000..a449d761732
--- /dev/null
+++ b/server/src/test/java/io/spine/server/model/given/map/ProjectCreatedEventReactor.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2019, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.server.model.given.map;
+
+import io.spine.server.event.React;
+import io.spine.test.event.ProjectCreated;
+import io.spine.test.event.ProjectStarred;
+
+/**
+ * This class is not valid because it declares two event reactors which accept the same event type.
+ */
+public class ProjectCreatedEventReactor {
+
+ @React
+ ProjectStarred on(ProjectCreated cmd) {
+ return ProjectStarred.getDefaultInstance();
+ }
+}
diff --git a/server/src/test/java/io/spine/system/server/ConstraintViolatedTest.java b/server/src/test/java/io/spine/system/server/ConstraintViolatedTest.java
index 35b8e210aef..67bb62a2756 100644
--- a/server/src/test/java/io/spine/system/server/ConstraintViolatedTest.java
+++ b/server/src/test/java/io/spine/system/server/ConstraintViolatedTest.java
@@ -33,7 +33,6 @@
import io.spine.testing.logging.MuteLogging;
import io.spine.testing.server.blackbox.BlackBoxBoundedContext;
import io.spine.testing.server.blackbox.SingleTenantBlackBoxContext;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -45,8 +44,6 @@ class ConstraintViolatedTest {
@MuteLogging
@Test
- @Disabled // due to an exception which should not be thrown in parallel with the event
- // see https://github.com/SpineEventEngine/core-java/issues/1094
@DisplayName("an entity state is set to an invalid value as a result of an event")
void afterEvent() {
String invalidText = "123-non numerical";
@@ -70,9 +67,6 @@ void afterEvent() {
@MuteLogging
@Test
- @Disabled // due to an exception which should not be thrown in parallel with the event
- // see https://github.com/SpineEventEngine/core-java/issues/1094
-
@DisplayName("an entity state is set to an invalid value as a result of a command")
void afterCommand() {
SingleTenantBlackBoxContext context = BlackBoxBoundedContext
diff --git a/server/src/test/proto/spine/test/integration/doc_commands.proto b/server/src/test/proto/spine/test/integration/doc_commands.proto
new file mode 100644
index 00000000000..ad7e005de2a
--- /dev/null
+++ b/server/src/test/proto/spine/test/integration/doc_commands.proto
@@ -0,0 +1,28 @@
+syntax = "proto3";
+
+package spine.test.server.integration;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io";
+option java_package = "io.spine.server.integration";
+option java_outer_classname = "DocCommandsProto";
+option java_multiple_files = true;
+
+import "spine/test/integration/docs.proto";
+
+message CreateDocument {
+
+ DocumentId id = 1;
+}
+
+message EditText {
+
+ DocumentId id = 1;
+
+ uint32 position = 2;
+
+ uint32 chars_to_delete = 3;
+
+ string new_text = 4;
+}
diff --git a/server/src/test/proto/spine/test/integration/doc_events.proto b/server/src/test/proto/spine/test/integration/doc_events.proto
new file mode 100644
index 00000000000..d95ac30707a
--- /dev/null
+++ b/server/src/test/proto/spine/test/integration/doc_events.proto
@@ -0,0 +1,65 @@
+syntax = "proto3";
+
+package spine.test.server.integration;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io";
+option java_package = "io.spine.server.integration";
+option java_outer_classname = "DocEventsProto";
+option java_multiple_files = true;
+
+import "spine/test/integration/docs.proto";
+
+import "spine/core/user_id.proto";
+import "spine/time/time.proto";
+
+message DocumentCreated {
+
+ DocumentId id = 1;
+
+ core.UserId owner = 2;
+
+ time.LocalDateTime when_created = 3;
+}
+
+message TextEdited {
+
+ DocumentId id = 1;
+
+ Edit edit = 2;
+}
+
+message PaperDocumentScanned {
+
+ DocumentId id = 1;
+
+ string text = 2;
+
+ core.UserId owner = 3;
+
+ time.LocalDateTime when_created = 4;
+}
+
+message OpenOfficeDocumentUploaded {
+
+ DocumentId id = 1;
+
+ string text = 2;
+}
+
+message DocumentImported {
+
+ DocumentId id = 1;
+
+ string text = 2;
+
+ core.UserId owner = 3;
+
+ time.LocalDateTime when_uploaded = 4;
+}
+
+message UserDeleted {
+
+ core.UserId user = 1;
+}
diff --git a/server/src/test/proto/spine/test/integration/docs.proto b/server/src/test/proto/spine/test/integration/docs.proto
new file mode 100644
index 00000000000..25f507fa644
--- /dev/null
+++ b/server/src/test/proto/spine/test/integration/docs.proto
@@ -0,0 +1,52 @@
+syntax = "proto3";
+
+package spine.test.server.integration;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io";
+option java_package = "io.spine.server.integration";
+option java_outer_classname = "DocsProto";
+option java_multiple_files = true;
+
+import "spine/core/user_id.proto";
+import "spine/time/time.proto";
+
+message DocumentId {
+ string uuid = 1;
+}
+
+message Document {
+ option (entity) = {kind: AGGREGATE visibility: QUERY};
+
+ DocumentId id = 1;
+
+ string text = 2;
+
+ core.UserId owner = 3;
+
+ repeated core.UserId editor = 4;
+
+ repeated core.UserId viewer = 5;
+
+ time.LocalDateTime last_edit = 6;
+}
+
+message EditHistory {
+ option (entity).kind = PROJECTION;
+
+ DocumentId id = 1;
+
+ repeated Edit edit = 2;
+}
+
+message Edit {
+
+ core.UserId editor = 1;
+
+ uint32 position = 2;
+
+ uint32 chars_deleted = 3;
+
+ string text_added = 4;
+}
diff --git a/testutil-server/src/main/java/io/spine/testing/server/TestEventFactory.java b/testutil-server/src/main/java/io/spine/testing/server/TestEventFactory.java
index e9676dd87d1..673e687af8e 100644
--- a/testutil-server/src/main/java/io/spine/testing/server/TestEventFactory.java
+++ b/testutil-server/src/main/java/io/spine/testing/server/TestEventFactory.java
@@ -20,6 +20,7 @@
package io.spine.testing.server;
+import com.google.common.annotations.VisibleForTesting;
import com.google.errorprone.annotations.CheckReturnValue;
import com.google.protobuf.Any;
import com.google.protobuf.Message;
@@ -42,6 +43,7 @@
/**
* The factory or producing events for tests.
*/
+@VisibleForTesting
@CheckReturnValue
public class TestEventFactory extends EventFactory {
diff --git a/version.gradle b/version.gradle
index 9aabf3de2da..23edcd51498 100644
--- a/version.gradle
+++ b/version.gradle
@@ -25,15 +25,15 @@
* as we want to manage the versions in a single source.
*/
-def final SPINE_VERSION = '1.0.3'
+def final SPINE_VERSION = '1.0.6-SNAPSHOT'
ext {
// The version of the modules in this project.
versionToPublish = SPINE_VERSION
// Depend on `base` for the general definitions and a model compiler.
- spineBaseVersion = SPINE_VERSION
+ spineBaseVersion = '1.0.3'
// Depend on `time` for `ZoneId`, `ZoneOffset` and other date/time types and utilities.
- spineTimeVersion = SPINE_VERSION
+ spineTimeVersion = '1.0.3'
}