net.sourceforge.pmd
diff --git a/server/src/main/java/io/spine/server/entity/AbstractEntity.java b/server/src/main/java/io/spine/server/entity/AbstractEntity.java
index b2d732854b3..42c87d48e75 100644
--- a/server/src/main/java/io/spine/server/entity/AbstractEntity.java
+++ b/server/src/main/java/io/spine/server/entity/AbstractEntity.java
@@ -22,6 +22,8 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper;
import com.google.errorprone.annotations.concurrent.LazyInit;
import com.google.protobuf.Any;
import com.google.protobuf.Message;
@@ -34,17 +36,23 @@
import io.spine.server.entity.model.EntityClass;
import io.spine.server.entity.rejection.CannotModifyArchivedEntity;
import io.spine.server.entity.rejection.CannotModifyDeletedEntity;
+import io.spine.server.log.HandlerLifecycle;
+import io.spine.server.log.HandlerLog;
+import io.spine.server.model.HandlerMethod;
import io.spine.string.Stringifiers;
import io.spine.validate.ConstraintViolation;
import io.spine.validate.Validate;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
+import java.util.logging.Level;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
+import static io.spine.logging.Logging.loggerFor;
import static io.spine.util.Exceptions.newIllegalArgumentException;
import static io.spine.validate.Validate.checkValid;
@@ -59,9 +67,9 @@
@SuppressWarnings({
"SynchronizeOnThis" /* This class uses double-check idiom for lazy init of some
fields. See Effective Java 2nd Ed. Item #71. */,
- "AbstractClassWithoutAbstractMethods",
"ClassWithTooManyMethods"})
-public abstract class AbstractEntity implements Entity {
+public abstract class AbstractEntity
+ implements Entity, HandlerLifecycle {
/**
* Lazily initialized reference to the model class of this entity.
@@ -108,6 +116,8 @@ public abstract class AbstractEntity implements Entity
*/
private volatile boolean lifecycleFlagsChanged;
+ private @Nullable HandlerLog handlerLog;
+
/**
* Creates a new instance with the zero version and cleared lifecycle flags.
*
@@ -508,6 +518,42 @@ public Timestamp whenModified() {
return version.getTimestamp();
}
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ public void beforeInvoke(HandlerMethod, ?, ?, ?> method) {
+ checkNotNull(method);
+ FluentLogger logger = loggerFor(getClass());
+ this.handlerLog = new HandlerLog(logger, method);
+ }
+
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ public void afterInvoke(HandlerMethod, ?, ?, ?> method) {
+ this.handlerLog = null;
+ }
+
+ /**
+ * Obtains a new fluent logging API at the given level.
+ *
+ * If called from within a handler method, the resulting log will reference the handler
+ * method as the log site. Otherwise, equivalent to
+ * {@code Logging.loggerFor(getClass()).at(logLevel)}.
+ *
+ * @param logLevel
+ * the log level
+ * @return new fluent logging API
+ * @apiNote This method mirrors the declaration of
+ * {@link io.spine.server.log.LoggingEntity#at(Level)}. It is recommended to implement
+ * the {@link io.spine.server.log.LoggingEntity} interface and use the underscore
+ * logging methods instead of calling {@code at(..)} directly.
+ * @see io.spine.server.log.LoggingEntity
+ */
+ public final FluentLogger.Api at(Level logLevel) {
+ return handlerLog != null
+ ? handlerLog.at(logLevel)
+ : loggerFor(getClass()).at(logLevel);
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git a/server/src/main/java/io/spine/server/log/HandlerLifecycle.java b/server/src/main/java/io/spine/server/log/HandlerLifecycle.java
new file mode 100644
index 00000000000..bfeec8efeb2
--- /dev/null
+++ b/server/src/main/java/io/spine/server/log/HandlerLifecycle.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020, 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.log;
+
+import io.spine.server.model.HandlerMethod;
+
+/**
+ * Callbacks for a {@link HandlerMethod} invocation.
+ *
+ *
If the target of the method implements {@code HandlerLifecycle}, it is invoked alongside
+ * handler method. For example:
+ *
+ * class SignUpSubscriber extends AbstractEventSubscriber implements HandlerLifecycle {
+ *
+ * {@literal @Subscribe}
+ * void on(UserSignedUp event) {
+ * // ...
+ * }
+ *
+ * {@literal @Override}
+ * public void beforeInvoke(HandlerMethod, ?, ?, ?> method) {
+ * // ...
+ * }
+ *
+ * {@literal @Override}
+ * public void afterInvoke(HandlerMethod, ?, ?, ?> method) {
+ * // ...
+ * }
+ * }
+ *
+ *
+ * When a {@code UserSignedUp} event is dispatched to the {@code SignUpSubscriber},
+ * the invocation order goes as follows:
+ *
+ * - {@code beforeInvoke([instance representing on(UserSignedUp) method])}.
+ *
- {@code on(UserSignedUp)}.
+ *
- {@code afterInvoke([instance representing on(UserSignedUp) method])}.
+ *
+ */
+public interface HandlerLifecycle {
+
+ /**
+ * A callback for a handler method invocation start.
+ *
+ * The handler method is invoked immediately after this method.
+ */
+ void beforeInvoke(HandlerMethod, ?, ?, ?> method);
+
+ /**
+ * A callback for a handler method invocation end.
+ *
+ *
This method is invoked immediately after the handler method, even if it has thrown
+ * an exception.
+ */
+ void afterInvoke(HandlerMethod, ?, ?, ?> method);
+}
diff --git a/server/src/main/java/io/spine/server/log/HandlerLog.java b/server/src/main/java/io/spine/server/log/HandlerLog.java
new file mode 100644
index 00000000000..e48caea7017
--- /dev/null
+++ b/server/src/main/java/io/spine/server/log/HandlerLog.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020, 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.log;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.flogger.LogSite;
+import io.spine.server.model.HandlerMethod;
+
+import java.util.logging.Level;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * A log for handler methods.
+ *
+ *
The log is set up with a {@link HandlerMethod} from which it should be accessed. By default,
+ * the log will include the given method as the logging {@linkplain LogSite site}.
+ */
+public final class HandlerLog {
+
+ private final FluentLogger logger;
+ private final LogSite logSite;
+
+ public HandlerLog(FluentLogger logger, HandlerMethod, ?, ?, ?> method) {
+ this.logger = checkNotNull(logger);
+ checkNotNull(method);
+ this.logSite = new HandlerMethodSite(method);
+ }
+
+ /**
+ * Returns a fluent logging API appropriate for the specified log level.
+ *
+ *
By default, the log produced by this API will include the name of the handler method, as
+ * well as its parameter types.
+ */
+ public FluentLogger.Api at(Level level) {
+ return logger.at(level)
+ .withInjectedLogSite(logSite);
+ }
+}
diff --git a/server/src/main/java/io/spine/server/log/HandlerMethodSite.java b/server/src/main/java/io/spine/server/log/HandlerMethodSite.java
new file mode 100644
index 00000000000..fa9d0416e50
--- /dev/null
+++ b/server/src/main/java/io/spine/server/log/HandlerMethodSite.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020, 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.log;
+
+import com.google.common.base.Objects;
+import com.google.common.flogger.LogSite;
+import com.google.errorprone.annotations.Immutable;
+import io.spine.code.java.ClassName;
+import io.spine.code.java.SimpleClassName;
+import io.spine.server.model.HandlerMethod;
+
+import java.lang.reflect.Method;
+import java.util.stream.Stream;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.lang.String.format;
+import static java.util.stream.Collectors.joining;
+
+/**
+ * A {@link LogSite} of a signal handler method.
+ *
+ *
The site provides the name of the class and the name of the method with its parameter types.
+ * The site never provides a line number or a file name.
+ */
+@Immutable
+final class HandlerMethodSite extends LogSite {
+
+ @SuppressWarnings("Immutable")
+ private final Method method;
+
+ HandlerMethodSite(HandlerMethod, ?, ?, ?> method) {
+ super();
+ checkNotNull(method);
+ this.method = method.rawMethod();
+ }
+
+ @Override
+ public String getClassName() {
+ return method.getDeclaringClass()
+ .getName();
+ }
+
+ @Override
+ public String getMethodName() {
+ String params = Stream.of(method.getParameterTypes())
+ .map(Class::getSimpleName)
+ .collect(joining(", "));
+ String methodName = format("%s(%s)", method.getName(), params);
+ return methodName;
+ }
+
+ @Override
+ public int getLineNumber() {
+ return UNKNOWN_LINE;
+ }
+
+ @Override
+ public String getFileName() {
+ SimpleClassName className = ClassName.of(method.getDeclaringClass())
+ .topLevelClass();
+ return className.value() + ".java";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof HandlerMethodSite)) {
+ return false;
+ }
+ HandlerMethodSite site = (HandlerMethodSite) o;
+ return Objects.equal(method, site.method);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(method);
+ }
+}
diff --git a/server/src/main/java/io/spine/server/log/LoggingEntity.java b/server/src/main/java/io/spine/server/log/LoggingEntity.java
new file mode 100644
index 00000000000..1b330fbd66d
--- /dev/null
+++ b/server/src/main/java/io/spine/server/log/LoggingEntity.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020, 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.log;
+
+import com.google.common.flogger.FluentLogger;
+import io.spine.logging.Logging;
+
+import java.util.logging.Level;
+
+/**
+ * A {@link Logging} trait for entities.
+ *
+ *
When adding logs in an entity, use this interface over {@link Logging}.
+ */
+public interface LoggingEntity extends Logging {
+
+ /**
+ * Creates a {@code FluentLogger.Api} with the given level.
+ *
+ * @param level
+ * the log level
+ * @return new fluent logging API
+ * @apiNote This method mirrors the declaration of
+ * {@link io.spine.server.entity.AbstractEntity#at(Level)}. When a concrete entity
+ * implements this interface, the underscore logging methods will have the same
+ * behaviour regarding the log site as does the {@code AbstractEntity.at(Level)} method.
+ */
+ FluentLogger.Api at(Level level);
+
+ @Override
+ default FluentLogger.Api _severe() {
+ return at(Level.SEVERE);
+ }
+
+ @Override
+ default FluentLogger.Api _warn() {
+ return at(Level.WARNING);
+ }
+
+ @Override
+ default FluentLogger.Api _info() {
+ return at(Level.INFO);
+ }
+
+ @Override
+ default FluentLogger.Api _config() {
+ return at(Level.CONFIG);
+ }
+
+ @Override
+ default FluentLogger.Api _fine() {
+ return at(Level.FINE);
+ }
+
+ @Override
+ default FluentLogger.Api _finer() {
+ return at(Level.FINER);
+ }
+
+ @Override
+ default FluentLogger.Api _finest() {
+ return at(Level.FINEST);
+ }
+}
diff --git a/server/src/main/java/io/spine/server/log/package-info.java b/server/src/main/java/io/spine/server/log/package-info.java
new file mode 100644
index 00000000000..f3998d0f18c
--- /dev/null
+++ b/server/src/main/java/io/spine/server/log/package-info.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020, 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.
+ */
+
+/**
+ * Contains utilities which work with server-side logs.
+ */
+
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.server.log;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/src/main/java/io/spine/server/model/AbstractHandlerMethod.java b/server/src/main/java/io/spine/server/model/AbstractHandlerMethod.java
index 4645ea0a2e6..f5cd37b4189 100644
--- a/server/src/main/java/io/spine/server/model/AbstractHandlerMethod.java
+++ b/server/src/main/java/io/spine/server/model/AbstractHandlerMethod.java
@@ -29,6 +29,7 @@
import io.spine.core.Signal;
import io.spine.server.dispatch.DispatchOutcome;
import io.spine.server.dispatch.Success;
+import io.spine.server.log.HandlerLifecycle;
import io.spine.server.type.MessageEnvelope;
import io.spine.type.MessageClass;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -253,10 +254,14 @@ public DispatchOutcome invoke(T target, E envelope) {
DispatchOutcome.Builder outcome = DispatchOutcome
.newBuilder()
.setPropagatedSignal(signal);
+ HandlerLifecycle lifecycle = target instanceof HandlerLifecycle
+ ? (HandlerLifecycle) target
+ : null;
+ if (lifecycle != null) {
+ lifecycle.beforeInvoke(this);
+ }
try {
- Object[] arguments = parameterSpec.extractArguments(envelope);
- Object rawOutput = method.invoke(target, arguments);
- Success success = toSuccessfulOutcome(rawOutput, target, envelope);
+ Success success = doInvoke(target, envelope);
outcome.setSuccess(success);
} catch (IllegalOutcomeException e) {
Error error = fromThrowable(e);
@@ -265,9 +270,7 @@ public DispatchOutcome invoke(T target, E envelope) {
Throwable cause = e.getCause();
checkNotNull(cause);
if (cause instanceof ThrowableMessage) {
- ThrowableMessage throwable = (ThrowableMessage) cause;
- Optional maybeSuccess = handleRejection(throwable, target, envelope);
- Success success = maybeSuccess.orElseThrow(this::cannotThrowRejections);
+ Success success = asRejection(target, envelope, cause);
outcome.setSuccess(success);
} else {
Error error = causeOf(cause);
@@ -275,10 +278,27 @@ public DispatchOutcome invoke(T target, E envelope) {
}
} catch (IllegalArgumentException | IllegalAccessException e) {
throw illegalStateWithCauseOf(e);
+ } finally {
+ if (lifecycle != null) {
+ lifecycle.afterInvoke(this);
+ }
}
return outcome.build();
}
+ private Success doInvoke(T target, E envelope)
+ throws IllegalAccessException, InvocationTargetException {
+ Object[] arguments = parameterSpec.extractArguments(envelope);
+ Object rawOutput = method.invoke(target, arguments);
+ return toSuccessfulOutcome(rawOutput, target, envelope);
+ }
+
+ private Success asRejection(T target, E envelope, Throwable cause) {
+ ThrowableMessage throwable = (ThrowableMessage) cause;
+ Optional maybeSuccess = handleRejection(throwable, target, envelope);
+ return maybeSuccess.orElseThrow(this::cannotThrowRejections);
+ }
+
private RuntimeException cannotThrowRejections() {
String errorMessage = format("`%s` may not throw rejections.", this);
return new IllegalOutcomeException(errorMessage);
diff --git a/server/src/test/java/io/spine/server/log/LoggingEntityTest.java b/server/src/test/java/io/spine/server/log/LoggingEntityTest.java
new file mode 100644
index 00000000000..95e8e81cc22
--- /dev/null
+++ b/server/src/test/java/io/spine/server/log/LoggingEntityTest.java
@@ -0,0 +1,273 @@
+/*
+ * Copyright 2020, 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.log;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.flogger.LoggerConfig;
+import com.google.common.testing.TestLogHandler;
+import io.spine.core.UserId;
+import io.spine.logging.Logging;
+import io.spine.server.DefaultRepository;
+import io.spine.server.log.given.Books;
+import io.spine.server.log.given.CardAggregate;
+import io.spine.testing.core.given.GivenUserId;
+import io.spine.testing.logging.LogRecordSubject;
+import io.spine.testing.logging.MuteLogging;
+import io.spine.testing.server.blackbox.BlackBoxBoundedContext;
+import io.spine.testing.server.blackbox.SingleTenantBlackBoxContext;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+
+import static com.google.common.truth.Truth.assertThat;
+import static io.spine.base.Identifier.newUuid;
+import static io.spine.server.log.given.Books.THE_HOBBIT;
+import static io.spine.testing.logging.LogTruth.assertThat;
+import static java.util.logging.Level.ALL;
+import static java.util.logging.Level.CONFIG;
+import static java.util.logging.Level.FINE;
+import static java.util.logging.Level.FINER;
+import static java.util.logging.Level.FINEST;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.SEVERE;
+import static java.util.logging.Level.WARNING;
+
+@MuteLogging
+@DisplayName("`LoggingEntity` should")
+class LoggingEntityTest {
+
+ private FluentLogger logger;
+ private TestLogHandler handler;
+ private Handler[] defaultHandlers;
+ private @Nullable Level defaultLevel;
+
+ @BeforeEach
+ void setUpLog() {
+ logger = Logging.loggerFor(CardAggregate.class);
+ handler = new TestLogHandler();
+ LoggerConfig config = LoggerConfig.of(logger);
+ defaultHandlers = config.getHandlers();
+ for (Handler defaultHandler : defaultHandlers) {
+ config.removeHandler(defaultHandler);
+ }
+ config.addHandler(handler);
+ defaultLevel = config.getLevel();
+ config.setLevel(ALL);
+ }
+
+ @AfterEach
+ void resetLog() {
+ LoggerConfig config = LoggerConfig.of(logger);
+ config.removeHandler(handler);
+ handler.close();
+ for (Handler defaultHandler : defaultHandlers) {
+ config.addHandler(defaultHandler);
+ }
+ config.setLevel(defaultLevel);
+ }
+
+ @Test
+ @DisplayName("log handler method name and parameters")
+ void includeSignalName() {
+ UserId user = GivenUserId.generated();
+ BorrowBooks command = borrowBooks(user);
+ context().receivesCommand(command);
+ List records = handler.getStoredLogRecords();
+
+ assertThat(records)
+ .hasSize(2);
+
+ LogRecordSubject assertFirstLog = assertThat(records.get(0));
+ assertFirstLog.hasLevelThat()
+ .isEqualTo(FINE);
+ assertFirstLog.hasMessageThat()
+ .contains(Books.implementingDdd()
+ .getTitle());
+ assertFirstLog.hasMethodNameThat()
+ .contains(command.getClass()
+ .getSimpleName());
+
+ LogRecordSubject assertSecondLog = assertThat(records.get(1));
+ assertSecondLog.hasLevelThat()
+ .isEqualTo(FINE);
+ assertSecondLog.hasMessageThat()
+ .contains(Books.domainDrivenDesign()
+ .getTitle());
+ assertSecondLog.hasMethodNameThat()
+ .contains(command.getClass()
+ .getSimpleName());
+ }
+
+ @Test
+ @DisplayName("log handler class name")
+ void includeClassName() {
+ UserId user = GivenUserId.generated();
+ BorrowBooks command = borrowBooks(user);
+ context().receivesCommand(command);
+ List records = handler.getStoredLogRecords();
+
+ assertThat(records)
+ .hasSize(2);
+ for (LogRecord record : records) {
+ assertThat(record).hasClassNameThat()
+ .isEqualTo(CardAggregate.class.getName());
+ }
+ }
+
+ @Test
+ @DisplayName("pass the throwable unchanged")
+ void withCause() {
+ UserId user = GivenUserId.generated();
+ ReturnBook command = ReturnBook
+ .newBuilder()
+ .setCard(cardId(user))
+ .setBook(THE_HOBBIT)
+ .vBuild();
+ context().receivesCommand(command);
+ List records = handler.getStoredLogRecords();
+ assertThat(records)
+ .hasSize(1);
+ LogRecord record = records.get(0);
+ LogRecordSubject assertRecord = assertThat(record);
+ assertRecord.isError();
+ assertRecord.hasThrowableThat()
+ .isInstanceOf(UnknownBook.class);
+ }
+
+ private static SingleTenantBlackBoxContext context() {
+ return BlackBoxBoundedContext
+ .singleTenant()
+ .with(DefaultRepository.of(CardAggregate.class));
+ }
+
+ private static BorrowBooks borrowBooks(UserId reader) {
+ LibraryCardId id = cardId(reader);
+ BorrowBooks command = BorrowBooks
+ .newBuilder()
+ .setCard(id)
+ .addBookId(Books.BIG_RED_BOOK)
+ .addBookId(Books.BIG_BLUE_BOOK)
+ .vBuild();
+ return command;
+ }
+
+ private static LibraryCardId cardId(UserId reader) {
+ return LibraryCardId
+ .newBuilder()
+ .setReader(reader)
+ .build();
+ }
+
+ @Nested
+ @DisplayName("support method")
+ class Support {
+
+ private String message;
+
+ @BeforeEach
+ void randomizeMessage() {
+ message = newUuid();
+ }
+
+ @Test
+ @DisplayName("_severe")
+ void severe() {
+ testLevel(Logging::_severe, SEVERE);
+ }
+
+ @Test
+ @DisplayName("_warn")
+ void warn() {
+ testLevel(Logging::_warn, WARNING);
+ }
+
+ @Test
+ @DisplayName("_info")
+ void info() {
+ testLevel(Logging::_info, INFO);
+ }
+
+ @Test
+ @DisplayName("_config")
+ void config() {
+ testLevel(Logging::_config, CONFIG);
+ }
+
+ @Test
+ @DisplayName("_fine")
+ void fine() {
+ testLevel(Logging::_fine, FINE);
+ }
+
+ @Test
+ @DisplayName("_finer")
+ void finer() {
+ testLevel(Logging::_finer, FINER);
+ }
+
+ @Test
+ @DisplayName("_finest")
+ void finest() {
+ testLevel(Logging::_finest, FINEST);
+ }
+
+ @Test
+ @DisplayName("_error")
+ void error() {
+ testLevel(Logging::_error, Logging.errorLevel());
+ }
+
+ @Test
+ @DisplayName("_debug")
+ void debug() {
+ testLevel(Logging::_debug, Logging.debugLevel());
+ }
+
+ @Test
+ @DisplayName("_trace")
+ void trace() {
+ testLevel(Logging::_trace, FINEST);
+ }
+
+ private void testLevel(Function underscoreFunc,
+ Level expectedLevel) {
+ CardAggregate aggregate = new CardAggregate();
+ underscoreFunc.apply(aggregate).log(message);
+ List records = handler.getStoredLogRecords();
+ assertThat(records)
+ .hasSize(1);
+ LogRecordSubject assertLog = assertThat(records.get(0));
+ assertLog.hasLevelThat()
+ .isEqualTo(expectedLevel);
+ assertLog.hasMessageThat()
+ .isEqualTo(message);
+ }
+ }
+}
diff --git a/server/src/test/java/io/spine/server/log/given/Books.java b/server/src/test/java/io/spine/server/log/given/Books.java
new file mode 100644
index 00000000000..58a2a9294f1
--- /dev/null
+++ b/server/src/test/java/io/spine/server/log/given/Books.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020, 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.log.given;
+
+import io.spine.people.PersonName;
+import io.spine.server.log.Book;
+import io.spine.server.log.Isbn;
+
+/**
+ * A test factory for instances of {@code Book} and {@code Isbn}.
+ */
+public final class Books {
+
+ public static final Isbn BIG_BLUE_BOOK = isbn("978-0321125217");
+ public static final Isbn BIG_RED_BOOK = isbn("978-0321834577");
+ public static final Isbn SMALL_GREEN_BOOK = isbn("978-0134434421");
+
+ public static final Isbn THE_HOBBIT = isbn("978-0547928241");
+
+ private static final String PUBLISHER = "A-W";
+
+ /**
+ * Prevents the utility class instantiation.
+ */
+ private Books() {
+ }
+
+ public static Book domainDrivenDesign() {
+ return Book
+ .newBuilder()
+ .setIsbn(BIG_BLUE_BOOK)
+ .setTitle("Domain-Driven Design")
+ .addAuthor(PersonName.newBuilder().setGivenName("Eric"))
+ .setPublisher(PUBLISHER)
+ .vBuild();
+ }
+
+ public static Book implementingDdd() {
+ return Book
+ .newBuilder()
+ .setIsbn(BIG_BLUE_BOOK)
+ .setTitle("Implementing DDD")
+ .addAuthor(PersonName.newBuilder().setGivenName("Vaughn"))
+ .setPublisher(PUBLISHER)
+ .vBuild();
+ }
+
+ public static Book dddDistilled() {
+ return Book
+ .newBuilder()
+ .setIsbn(BIG_BLUE_BOOK)
+ .setTitle("DDD Distilled")
+ .addAuthor(PersonName.newBuilder().setGivenName("Vaughn"))
+ .setPublisher(PUBLISHER)
+ .vBuild();
+ }
+
+ private static Isbn isbn(String value) {
+ return Isbn
+ .newBuilder()
+ .setValue(value)
+ .build();
+ }
+}
diff --git a/server/src/test/java/io/spine/server/log/given/CardAggregate.java b/server/src/test/java/io/spine/server/log/given/CardAggregate.java
new file mode 100644
index 00000000000..22594b74545
--- /dev/null
+++ b/server/src/test/java/io/spine/server/log/given/CardAggregate.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2020, 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.log.given;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import io.spine.people.PersonName;
+import io.spine.server.aggregate.Aggregate;
+import io.spine.server.aggregate.Apply;
+import io.spine.server.command.Assign;
+import io.spine.server.log.Book;
+import io.spine.server.log.BookReturned;
+import io.spine.server.log.BooksBorrowed;
+import io.spine.server.log.BorrowBooks;
+import io.spine.server.log.Isbn;
+import io.spine.server.log.LibraryCard;
+import io.spine.server.log.LibraryCardId;
+import io.spine.server.log.LoggingEntity;
+import io.spine.server.log.ReturnBook;
+import io.spine.server.log.UnknownBook;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static io.spine.server.log.given.Books.BIG_BLUE_BOOK;
+import static io.spine.server.log.given.Books.BIG_RED_BOOK;
+import static io.spine.server.log.given.Books.SMALL_GREEN_BOOK;
+import static io.spine.server.log.given.Books.dddDistilled;
+import static io.spine.server.log.given.Books.domainDrivenDesign;
+import static io.spine.server.log.given.Books.implementingDdd;
+
+public final class CardAggregate
+ extends Aggregate
+ implements LoggingEntity {
+
+ private static final ImmutableMap knownBooks = ImmutableMap.of(
+ BIG_BLUE_BOOK, domainDrivenDesign(),
+ BIG_RED_BOOK, implementingDdd(),
+ SMALL_GREEN_BOOK, dddDistilled()
+ );
+
+ @Assign
+ BooksBorrowed handle(BorrowBooks command) throws UnknownBook {
+ BooksBorrowed.Builder event = BooksBorrowed
+ .newBuilder()
+ .setCard(id());
+ List unknownBooks = new ArrayList<>();
+ for (Isbn bookId : command.getBookIdList()) {
+ Book book = knownBooks.get(bookId);
+ if (book != null) {
+ event.addBook(book);
+ List authors = book.getAuthorList();
+ PersonName firstAuthor = authors.get(0);
+ _fine().log("Adding to order: %s by %s %s",
+ book.getTitle(),
+ firstAuthor.getGivenName(),
+ firstAuthor.getFamilyName());
+ } else {
+ _warn().log("Cannot lend an unknown book. ISBN: `%s`", bookId.getValue());
+ unknownBooks.add(bookId);
+ }
+ }
+ if (!unknownBooks.isEmpty()) {
+ throw UnknownBook
+ .newBuilder()
+ .addAllBook(unknownBooks)
+ .build();
+ } else {
+ return event.vBuild();
+ }
+ }
+
+ @Assign
+ BookReturned handle(ReturnBook command) throws UnknownBook {
+ Isbn isbn = command.getBook();
+ Book book = knownBooks.get(isbn);
+ if (book == null) {
+ UnknownBook rejection = UnknownBook
+ .newBuilder()
+ .addAllBook(ImmutableList.of(isbn))
+ .build();
+ _error().withCause(rejection)
+ .log("Cannot return an unknown book. ISBN: `%s`", isbn.getValue());
+ throw rejection;
+ } else {
+ return BookReturned
+ .newBuilder()
+ .setCard(id())
+ .setBook(book)
+ .vBuild();
+ }
+ }
+
+ @Apply
+ private void on(BooksBorrowed event) {
+ builder().addAllBook(event.getBookList());
+ }
+
+ @Apply
+ private void on(BookReturned event) {
+ List list = builder().getBookList();
+ Book book = event.getBook();
+ int bookIndex = list.indexOf(book);
+ builder().removeBook(bookIndex);
+ }
+}
diff --git a/server/src/test/java/io/spine/server/log/given/package-info.java b/server/src/test/java/io/spine/server/log/given/package-info.java
new file mode 100644
index 00000000000..dd802753176
--- /dev/null
+++ b/server/src/test/java/io/spine/server/log/given/package-info.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020, 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.
+ */
+
+/**
+ * Contains a small test domain and utilities for testing entity logs.
+ */
+
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.server.log.given;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/src/test/proto/spine/test/library/card.proto b/server/src/test/proto/spine/test/library/card.proto
new file mode 100644
index 00000000000..d8b40285f05
--- /dev/null
+++ b/server/src/test/proto/spine/test/library/card.proto
@@ -0,0 +1,40 @@
+syntax = "proto3";
+
+package spine.test.server.log;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io";
+option java_package = "io.spine.server.log";
+option java_outer_classname = "LibraryProto";
+option java_multiple_files = true;
+
+import "spine/people/person_name.proto";
+import "spine/time/time.proto";
+import "spine/core/user_id.proto";
+
+message Isbn {
+ string value = 1 [(required) = true];
+}
+
+message Book {
+
+ Isbn isbn = 1;
+ string title = 2;
+ repeated people.PersonName author = 3;
+ int32 year_when_published = 4;
+ string publisher = 5;
+}
+
+message LibraryCardId {
+
+ core.UserId reader = 1 [(required) = true];
+}
+
+message LibraryCard {
+ option (entity).kind = AGGREGATE;
+
+ LibraryCardId id = 1;
+
+ repeated Book book = 2;
+}
diff --git a/server/src/test/proto/spine/test/library/commands.proto b/server/src/test/proto/spine/test/library/commands.proto
new file mode 100644
index 00000000000..240948da4c6
--- /dev/null
+++ b/server/src/test/proto/spine/test/library/commands.proto
@@ -0,0 +1,22 @@
+syntax = "proto3";
+
+package spine.test.server.log;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io";
+option java_package = "io.spine.server.log";
+option java_outer_classname = "LibraryCommandsProto";
+option java_multiple_files = true;
+
+import "spine/test/library/card.proto";
+
+message BorrowBooks {
+ LibraryCardId card = 1 [(required) = true, (validate) = true];
+ repeated Isbn book_id = 2 [(required) = true, (validate) = true];
+}
+
+message ReturnBook {
+ LibraryCardId card = 1 [(required) = true, (validate) = true];
+ Isbn book = 2 [(required) = true, (validate) = true];
+}
diff --git a/server/src/test/proto/spine/test/library/events.proto b/server/src/test/proto/spine/test/library/events.proto
new file mode 100644
index 00000000000..83b80f70d72
--- /dev/null
+++ b/server/src/test/proto/spine/test/library/events.proto
@@ -0,0 +1,22 @@
+syntax = "proto3";
+
+package spine.test.server.log;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io";
+option java_package = "io.spine.server.log";
+option java_outer_classname = "LibraryEventsProto";
+option java_multiple_files = true;
+
+import "spine/test/library/card.proto";
+
+message BooksBorrowed {
+ LibraryCardId card = 1;
+ repeated Book book = 2 [(required) = true];
+}
+
+message BookReturned {
+ LibraryCardId card = 1;
+ Book book = 2 [(required) = true];
+}
diff --git a/server/src/test/proto/spine/test/library/rejections.proto b/server/src/test/proto/spine/test/library/rejections.proto
new file mode 100644
index 00000000000..f2d29809539
--- /dev/null
+++ b/server/src/test/proto/spine/test/library/rejections.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+
+package spine.test.server.log;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io";
+option java_package = "io.spine.server.log";
+option java_outer_classname = "LibraryRejections";
+
+import "spine/test/library/card.proto";
+
+message UnknownBook {
+ repeated Isbn book = 1 [(required) = true];
+}
diff --git a/testutil-server/src/main/java/io/spine/testing/server/blackbox/Actor.java b/testutil-server/src/main/java/io/spine/testing/server/blackbox/Actor.java
new file mode 100644
index 00000000000..532e1a419e0
--- /dev/null
+++ b/testutil-server/src/main/java/io/spine/testing/server/blackbox/Actor.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2020, 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.testing.server.blackbox;
+
+import com.google.common.annotations.VisibleForTesting;
+import io.spine.core.TenantId;
+import io.spine.core.UserId;
+import io.spine.testing.client.TestActorRequestFactory;
+import io.spine.time.ZoneId;
+import io.spine.time.ZoneOffset;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+import static io.spine.validate.Validate.checkValid;
+
+/**
+ * A factory of test actor request factories.
+ */
+@VisibleForTesting
+final class Actor {
+
+ private static final Actor defaultActor = from(BlackBoxBoundedContext.class.getName());
+
+ private final UserId id;
+ private final ZoneId zoneId;
+ private final ZoneOffset zoneOffset;
+
+ private Actor(UserId id, ZoneId zoneId, ZoneOffset zoneOffset) {
+ this.id = id;
+ this.zoneId = zoneId;
+ this.zoneOffset = zoneOffset;
+ }
+
+ /**
+ * Obtains the default actor.
+ */
+ static Actor defaultActor() {
+ return defaultActor;
+ }
+
+ private static Actor from(String userId) {
+ checkNotEmptyOrBlank(userId);
+ UserId id = UserId
+ .newBuilder()
+ .setValue(userId)
+ .build();
+ return from(id);
+ }
+
+ /**
+ * Creates a new actor with the given actor ID and the default time zone.
+ */
+ static Actor from(UserId userId) {
+ checkNotNull(userId);
+ checkValid(userId);
+ return new Actor(userId,
+ ZoneId.getDefaultInstance(),
+ ZoneOffset.getDefaultInstance());
+ }
+
+ /**
+ * Creates a new actor with the given time zone and the default actor ID.
+ */
+ static Actor from(ZoneId zoneId, ZoneOffset zoneOffset) {
+ checkNotNull(zoneId);
+ checkNotNull(zoneOffset);
+ return new Actor(defaultActor.id, zoneId, zoneOffset);
+ }
+
+ /**
+ * Creates a new actor with the given actor ID and time zone.
+ */
+ static Actor from(UserId userId, ZoneId zoneId, ZoneOffset zoneOffset) {
+ checkNotNull(userId);
+ checkNotNull(zoneId);
+ checkNotNull(zoneOffset);
+ return new Actor(userId, zoneId, zoneOffset);
+ }
+
+ /**
+ * Creates a new factory for requests of the single tenant.
+ */
+ TestActorRequestFactory requests() {
+ return new TestActorRequestFactory(null, id, zoneOffset, zoneId);
+ }
+
+ /**
+ * Creates a new factory for requests of the given tenant.
+ */
+ TestActorRequestFactory requestsFor(TenantId tenant) {
+ checkNotNull(tenant);
+ checkValid(tenant);
+ return new TestActorRequestFactory(tenant, id, zoneOffset, zoneId);
+ }
+}
diff --git a/testutil-server/src/main/java/io/spine/testing/server/blackbox/BlackBoxBoundedContext.java b/testutil-server/src/main/java/io/spine/testing/server/blackbox/BlackBoxBoundedContext.java
index 67839b215d2..31638a10aa8 100644
--- a/testutil-server/src/main/java/io/spine/testing/server/blackbox/BlackBoxBoundedContext.java
+++ b/testutil-server/src/main/java/io/spine/testing/server/blackbox/BlackBoxBoundedContext.java
@@ -40,6 +40,7 @@
import io.spine.core.BoundedContextName;
import io.spine.core.Command;
import io.spine.core.Event;
+import io.spine.core.UserId;
import io.spine.grpc.MemoizingObserver;
import io.spine.logging.Logging;
import io.spine.protobuf.AnyPacker;
@@ -71,6 +72,8 @@
import io.spine.testing.server.blackbox.verify.state.VerifyState;
import io.spine.testing.server.blackbox.verify.subscription.ToProtoSubjects;
import io.spine.testing.server.entity.EntitySubject;
+import io.spine.time.ZoneId;
+import io.spine.time.ZoneOffset;
import io.spine.type.TypeName;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -90,6 +93,7 @@
import static io.spine.core.BoundedContextNames.assumingTestsValue;
import static io.spine.grpc.StreamObservers.memoizingObserver;
import static io.spine.server.entity.model.EntityClass.stateClassOf;
+import static io.spine.testing.server.blackbox.Actor.defaultActor;
import static io.spine.util.Exceptions.illegalStateWithCauseOf;
import static java.util.Collections.singletonList;
import static java.util.Collections.synchronizedSet;
@@ -162,6 +166,8 @@ public abstract class BlackBoxBoundedContext
private final Map, Repository, ?>> repositories;
+ private Actor actor;
+
@SuppressWarnings("ThisEscapedInObjectConstruction") // to inject self as event dispatcher.
protected BlackBoxBoundedContext(boolean multitenant,
EventEnricher enricher,
@@ -185,6 +191,7 @@ protected BlackBoxBoundedContext(boolean multitenant,
this.repositories = newHashMap();
this.context.registerEventDispatcher(this);
this.context.registerEventDispatcher(DiagnosticLog.instance());
+ this.actor = defaultActor();
}
/**
@@ -323,6 +330,38 @@ public BoundedContextName name() {
return context.name();
}
+ /**
+ * Sets the given {@link UserId} as the actor ID for the requests produced by this context.
+ */
+ public final T withActor(UserId user) {
+ this.actor = Actor.from(user);
+ return thisRef();
+ }
+
+ /**
+ * Sets the given time zone parameters for the actor requests produced by this context.
+ */
+ public final T in(ZoneId zoneId, ZoneOffset zoneOffset) {
+ this.actor = Actor.from(zoneId, zoneOffset);
+ return thisRef();
+ }
+
+ /**
+ * Sets the given actor ID and time zone parameters for the actor requests produced by this
+ * context.
+ */
+ public final T withActorIn(UserId userId, ZoneId zoneId, ZoneOffset zoneOffset) {
+ this.actor = Actor.from(userId, zoneId, zoneOffset);
+ return thisRef();
+ }
+
+ /**
+ * Obtains the current {@link Actor}.
+ */
+ protected final Actor actor() {
+ return this.actor;
+ }
+
/**
* Obtains set of type names of entities known to this Bounded Context.
*/
diff --git a/testutil-server/src/main/java/io/spine/testing/server/blackbox/DiagnosticLog.java b/testutil-server/src/main/java/io/spine/testing/server/blackbox/DiagnosticLog.java
index 91241e27e61..1de167fb8d6 100644
--- a/testutil-server/src/main/java/io/spine/testing/server/blackbox/DiagnosticLog.java
+++ b/testutil-server/src/main/java/io/spine/testing/server/blackbox/DiagnosticLog.java
@@ -20,7 +20,8 @@
package io.spine.testing.server.blackbox;
-import com.google.common.flogger.FluentLogger;
+import com.google.common.flogger.FluentLogger.Api;
+import com.google.common.flogger.StackSize;
import com.google.errorprone.annotations.FormatMethod;
import com.google.errorprone.annotations.FormatString;
import io.spine.base.EventMessage;
@@ -128,7 +129,9 @@ void on(AggregateHistoryCorrupted event) {
@FormatMethod
private void log(EventMessage event, @FormatString String errorMessage, Object... formatArgs) {
String msg = format(errorMessage, formatArgs);
- FluentLogger.Api severeLogger = logger().atSevere();
+ Api severeLogger = logger()
+ .atSevere()
+ .withStackTrace(StackSize.NONE);
boolean loggingEnabled = severeLogger.isEnabled();
if (loggingEnabled) {
severeLogger.log(msg);
diff --git a/testutil-server/src/main/java/io/spine/testing/server/blackbox/MultitenantBlackBoxContext.java b/testutil-server/src/main/java/io/spine/testing/server/blackbox/MultitenantBlackBoxContext.java
index bd6b65fc854..85f76c10ab9 100644
--- a/testutil-server/src/main/java/io/spine/testing/server/blackbox/MultitenantBlackBoxContext.java
+++ b/testutil-server/src/main/java/io/spine/testing/server/blackbox/MultitenantBlackBoxContext.java
@@ -66,7 +66,7 @@ public MultitenantBlackBoxContext withTenant(TenantId tenant) {
@Override
protected TestActorRequestFactory requestFactory() {
- return new TestActorRequestFactory(MultitenantBlackBoxContext.class, tenantId());
+ return actor().requestsFor(tenantId());
}
@Override
diff --git a/testutil-server/src/main/java/io/spine/testing/server/blackbox/SingleTenantBlackBoxContext.java b/testutil-server/src/main/java/io/spine/testing/server/blackbox/SingleTenantBlackBoxContext.java
index f9eafa23736..fcd0ebf84c4 100644
--- a/testutil-server/src/main/java/io/spine/testing/server/blackbox/SingleTenantBlackBoxContext.java
+++ b/testutil-server/src/main/java/io/spine/testing/server/blackbox/SingleTenantBlackBoxContext.java
@@ -34,9 +34,6 @@
public final class SingleTenantBlackBoxContext
extends BlackBoxBoundedContext {
- private final TestActorRequestFactory requestFactory =
- new TestActorRequestFactory(SingleTenantBlackBoxContext.class);
-
SingleTenantBlackBoxContext(String name, EventEnricher enricher) {
super(false, enricher, name);
}
@@ -53,6 +50,6 @@ protected ImmutableList select(EventCollector collector) {
@Override
protected TestActorRequestFactory requestFactory() {
- return requestFactory;
+ return actor().requests();
}
}
diff --git a/testutil-server/src/test/java/io/spine/testing/server/blackbox/BlackBoxBoundedContextTest.java b/testutil-server/src/test/java/io/spine/testing/server/blackbox/BlackBoxBoundedContextTest.java
index 12fc4bf79c2..cd53a697356 100644
--- a/testutil-server/src/test/java/io/spine/testing/server/blackbox/BlackBoxBoundedContextTest.java
+++ b/testutil-server/src/test/java/io/spine/testing/server/blackbox/BlackBoxBoundedContextTest.java
@@ -29,6 +29,7 @@
import io.spine.client.QueryFactory;
import io.spine.client.Topic;
import io.spine.client.TopicFactory;
+import io.spine.core.ActorContext;
import io.spine.core.Event;
import io.spine.core.UserId;
import io.spine.server.BoundedContextBuilder;
@@ -41,10 +42,12 @@
import io.spine.server.event.EventEnricher;
import io.spine.server.projection.ProjectionRepository;
import io.spine.server.type.CommandClass;
+import io.spine.testing.core.given.GivenUserId;
import io.spine.testing.logging.MuteLogging;
import io.spine.testing.server.BlackBoxId;
import io.spine.testing.server.EventSubject;
import io.spine.testing.server.VerifyingCounter;
+import io.spine.testing.server.blackbox.command.BbAssignSelf;
import io.spine.testing.server.blackbox.command.BbCreateProject;
import io.spine.testing.server.blackbox.command.BbFinalizeProject;
import io.spine.testing.server.blackbox.command.BbRegisterCommandDispatcher;
@@ -67,6 +70,10 @@
import io.spine.testing.server.blackbox.given.RepositoryThrowingExceptionOnClose;
import io.spine.testing.server.blackbox.rejection.Rejections;
import io.spine.testing.server.entity.EntitySubject;
+import io.spine.time.ZoneId;
+import io.spine.time.ZoneIds;
+import io.spine.time.ZoneOffset;
+import io.spine.time.ZoneOffsets;
import io.spine.type.TypeName;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -79,10 +86,12 @@
import static com.google.common.collect.Iterables.getOnlyElement;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
import static io.spine.protobuf.AnyPacker.unpack;
import static io.spine.testing.core.given.GivenUserId.newUuid;
import static io.spine.testing.server.blackbox.given.Given.addProjectAssignee;
import static io.spine.testing.server.blackbox.given.Given.addTask;
+import static io.spine.testing.server.blackbox.given.Given.assignSelf;
import static io.spine.testing.server.blackbox.given.Given.createProject;
import static io.spine.testing.server.blackbox.given.Given.createReport;
import static io.spine.testing.server.blackbox.given.Given.createdProjectState;
@@ -703,4 +712,84 @@ void commandMessages() {
.size());
}
}
+
+ @Nested
+ @DisplayName("produce requests with the given actor")
+ class WithGivenActor {
+
+ @Test
+ @DisplayName("ID")
+ void id() {
+ UserId actor = GivenUserId.of("my-actor");
+ BbCreateProject createProject = createProject();
+ BbProjectId id = createProject.getProjectId();
+ BbAssignSelf assignSelf = assignSelf(id);
+ context.withActor(actor)
+ .receivesCommands(createProject, assignSelf)
+ .assertEntityWithState(BbProject.class, id)
+ .hasStateThat()
+ .comparingExpectedFieldsOnly()
+ .isEqualTo(BbProject
+ .newBuilder()
+ .setId(id)
+ .addAssignee(actor)
+ .buildPartial());
+ }
+
+ @Test
+ @DisplayName("time zone")
+ void timeZone() {
+ UserId actor = GivenUserId.of("my-other-actor");
+ BbCreateProject createProject = createProject();
+ BbProjectId id = createProject.getProjectId();
+ ZoneId zoneId = ZoneIds.of("UTC+1");
+ ZoneOffset zoneOffset = ZoneOffsets.ofHours(1);
+ context.withActorIn(actor, zoneId, zoneOffset)
+ .receivesCommand(createProject)
+ .assertEntityWithState(BbProject.class, id)
+ .exists();
+ EventSubject events = context.assertEvents()
+ .withType(BbProjectCreated.class);
+ events.hasSize(1);
+ ActorContext context = events.actual()
+ .get(0)
+ .context()
+ .actorContext();
+ assertThat(context)
+ .comparingExpectedFieldsOnly()
+ .isEqualTo(ActorContext
+ .newBuilder()
+ .setActor(actor)
+ .setZoneId(zoneId)
+ .setZoneOffset(zoneOffset)
+ .buildPartial());
+ }
+
+ @Test
+ @DisplayName("ID and time zone")
+ void idAndTimeZone() {
+ BbCreateProject createProject = createProject();
+ BbProjectId id = createProject.getProjectId();
+ ZoneId zoneId = ZoneIds.of("UTC-1");
+ ZoneOffset zoneOffset = ZoneOffsets.ofHours(-1);
+ context.in(zoneId, zoneOffset)
+ .receivesCommand(createProject)
+ .assertEntityWithState(BbProject.class, id)
+ .exists();
+ EventSubject events = context.assertEvents()
+ .withType(BbProjectCreated.class);
+ events.hasSize(1);
+ ActorContext context = events.actual()
+ .get(0)
+ .context()
+ .actorContext();
+ assertThat(context)
+ .comparingExpectedFieldsOnly()
+ .isEqualTo(ActorContext
+ .newBuilder()
+ .setZoneId(zoneId)
+ .setZoneOffset(zoneOffset)
+ .buildPartial());
+ }
+ }
}
diff --git a/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/BbProjectAggregate.java b/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/BbProjectAggregate.java
index fc8b868e80d..8e2b0cf8a45 100644
--- a/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/BbProjectAggregate.java
+++ b/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/BbProjectAggregate.java
@@ -20,6 +20,7 @@
package io.spine.testing.server.blackbox.given;
+import io.spine.core.CommandContext;
import io.spine.core.UserId;
import io.spine.server.aggregate.Aggregate;
import io.spine.server.aggregate.Apply;
@@ -30,6 +31,7 @@
import io.spine.testing.server.blackbox.BbTask;
import io.spine.testing.server.blackbox.command.BbAddTask;
import io.spine.testing.server.blackbox.command.BbAssignProject;
+import io.spine.testing.server.blackbox.command.BbAssignSelf;
import io.spine.testing.server.blackbox.command.BbCreateProject;
import io.spine.testing.server.blackbox.command.BbStartProject;
import io.spine.testing.server.blackbox.event.BbAssigneeAdded;
@@ -101,6 +103,16 @@ BbAssigneeAdded handle(BbAssignProject command) {
.build();
}
+ @Assign
+ BbAssigneeAdded handle(BbAssignSelf command, CommandContext context) {
+ UserId assignee = context.getActorContext().getActor();
+ return BbAssigneeAdded
+ .newBuilder()
+ .setId(id())
+ .setUserId(assignee)
+ .build();
+ }
+
@React(external = true)
Optional on(BbUserDeleted event) {
List assignees = state().getAssigneeList();
diff --git a/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/Given.java b/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/Given.java
index a7e57148e7e..a10b80bd624 100644
--- a/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/Given.java
+++ b/testutil-server/src/test/java/io/spine/testing/server/blackbox/given/Given.java
@@ -30,6 +30,7 @@
import io.spine.testing.server.blackbox.BbTask;
import io.spine.testing.server.blackbox.command.BbAddTask;
import io.spine.testing.server.blackbox.command.BbAssignProject;
+import io.spine.testing.server.blackbox.command.BbAssignSelf;
import io.spine.testing.server.blackbox.command.BbCreateProject;
import io.spine.testing.server.blackbox.command.BbCreateReport;
import io.spine.testing.server.blackbox.command.BbFinalizeProject;
@@ -129,6 +130,13 @@ public static BbCreateProject createProject(BbProjectId id) {
.build();
}
+ public static BbAssignSelf assignSelf(BbProjectId id) {
+ return BbAssignSelf
+ .newBuilder()
+ .setProjectId(id)
+ .build();
+ }
+
public static BbInitProject initProject(BbProjectId id, boolean scrum) {
BbInitProject.Builder builder = BbInitProject
.newBuilder()
diff --git a/testutil-server/src/test/proto/spine/testing/server/blackbox/commands.proto b/testutil-server/src/test/proto/spine/testing/server/blackbox/commands.proto
index 714b3a827b6..6a2df5dc589 100644
--- a/testutil-server/src/test/proto/spine/testing/server/blackbox/commands.proto
+++ b/testutil-server/src/test/proto/spine/testing/server/blackbox/commands.proto
@@ -51,6 +51,10 @@ message BbAssignTeam {
repeated spine.core.UserId member = 2 [(required) = true];
}
+message BbAssignSelf {
+ BbProjectId project_id = 1;
+}
+
message BbAssignScrumMaster {
BbProjectId project_id = 1;
spine.core.UserId scrum_master = 2 [(required) = true];
diff --git a/version.gradle b/version.gradle
index 517675af4a4..545519294b3 100644
--- a/version.gradle
+++ b/version.gradle
@@ -25,14 +25,14 @@
* as we want to manage the versions in a single source.
*/
-final def spineVersion = '1.4.9'
+final def spineVersion = '1.4.10'
ext {
// The version of the modules in this project.
versionToPublish = spineVersion
// Depend on `base` for the general definitions and a model compiler.
- spineBaseVersion = '1.4.8'
+ spineBaseVersion = '1.4.9'
// Depend on `time` for `ZoneId`, `ZoneOffset` and other date/time types and utilities.
spineTimeVersion = '1.4.7'