diff --git a/ass2-aop/pom.xml b/ass2-aop/pom.xml new file mode 100644 index 0000000..47ba918 --- /dev/null +++ b/ass2-aop/pom.xml @@ -0,0 +1,45 @@ + + + + 4.0.0 + + + at.ac.tuwien.infosys.dst + dst + 2021.1 + .. + + + ass2-aop + + jar + + DST :: Assignment 2 :: Aspect-oriented Programming + + + + + commons-io + commons-io + + + org.aspectj + aspectjrt + + + org.aspectj + aspectjweaver + + + org.springframework + spring-aop + + + org.apache.commons + commons-lang3 + + + + diff --git a/ass2-aop/src/main/java/dst/ass2/aop/IPluginExecutable.java b/ass2-aop/src/main/java/dst/ass2/aop/IPluginExecutable.java new file mode 100644 index 0000000..ea07690 --- /dev/null +++ b/ass2-aop/src/main/java/dst/ass2/aop/IPluginExecutable.java @@ -0,0 +1,17 @@ +package dst.ass2.aop; + +/** + * Implementations of this interface are executable by the IPluginExecutor. + */ +public interface IPluginExecutable { + + /** + * Called when this plugin is executed. + */ + void execute(); + + /** + * Called when the execution of the plugin is interrupted + */ + void interrupted(); +} diff --git a/ass2-aop/src/main/java/dst/ass2/aop/IPluginExecutor.java b/ass2-aop/src/main/java/dst/ass2/aop/IPluginExecutor.java new file mode 100644 index 0000000..3bb74ee --- /dev/null +++ b/ass2-aop/src/main/java/dst/ass2/aop/IPluginExecutor.java @@ -0,0 +1,40 @@ +package dst.ass2.aop; + +import java.io.File; + +/** + * The plugin executor interface. + */ +public interface IPluginExecutor { + + /** + * Adds a directory to monitor. + * May be called before and also after start has been called. + * + * @param dir the directory to monitor. + */ + void monitor(File dir); + + /** + * Stops monitoring the specified directory. + * May be called before and also after start has been called. + * + * @param dir the directory which should not be monitored anymore. + */ + void stopMonitoring(File dir); + + /** + * Starts the plugin executor. + * All added directories will be monitored and any .jar file processed. + * If there are any {@link IPluginExecutable} implementations, + * they are executed within own threads. + */ + void start(); + + /** + * Stops the plugin executor. + * The monitoring of directories and the execution + * of the plugins should stop as soon as possible. + */ + void stop(); +} diff --git a/ass2-aop/src/main/java/dst/ass2/aop/PluginExecutorFactory.java b/ass2-aop/src/main/java/dst/ass2/aop/PluginExecutorFactory.java new file mode 100644 index 0000000..f0fcf1e --- /dev/null +++ b/ass2-aop/src/main/java/dst/ass2/aop/PluginExecutorFactory.java @@ -0,0 +1,10 @@ +package dst.ass2.aop; + +public class PluginExecutorFactory { + + public static IPluginExecutor createPluginExecutor() { + // TODO + return null; + } + +} diff --git a/ass2-aop/src/main/java/dst/ass2/aop/logging/Invisible.java b/ass2-aop/src/main/java/dst/ass2/aop/logging/Invisible.java new file mode 100644 index 0000000..12b0b9e --- /dev/null +++ b/ass2-aop/src/main/java/dst/ass2/aop/logging/Invisible.java @@ -0,0 +1,11 @@ +package dst.ass2.aop.logging; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Invisible { +} diff --git a/ass2-aop/src/main/java/dst/ass2/aop/logging/LoggingAspect.java b/ass2-aop/src/main/java/dst/ass2/aop/logging/LoggingAspect.java new file mode 100644 index 0000000..c0336c6 --- /dev/null +++ b/ass2-aop/src/main/java/dst/ass2/aop/logging/LoggingAspect.java @@ -0,0 +1,7 @@ +package dst.ass2.aop.logging; + +public class LoggingAspect { + + // TODO + +} diff --git a/ass2-aop/src/main/java/dst/ass2/aop/management/ManagementAspect.java b/ass2-aop/src/main/java/dst/ass2/aop/management/ManagementAspect.java new file mode 100644 index 0000000..aba3eab --- /dev/null +++ b/ass2-aop/src/main/java/dst/ass2/aop/management/ManagementAspect.java @@ -0,0 +1,7 @@ +package dst.ass2.aop.management; + +public class ManagementAspect { + + // TODO + +} diff --git a/ass2-aop/src/main/java/dst/ass2/aop/management/Timeout.java b/ass2-aop/src/main/java/dst/ass2/aop/management/Timeout.java new file mode 100644 index 0000000..aa190bb --- /dev/null +++ b/ass2-aop/src/main/java/dst/ass2/aop/management/Timeout.java @@ -0,0 +1,12 @@ +package dst.ass2.aop.management; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Timeout { + long value(); +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/event/Event.java b/ass2-aop/src/test/java/dst/ass2/aop/event/Event.java new file mode 100644 index 0000000..5cfaa4d --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/event/Event.java @@ -0,0 +1,74 @@ +package dst.ass2.aop.event; + +import org.springframework.util.Assert; + +import dst.ass2.aop.IPluginExecutable; + +/** + * Events triggered by {@link IPluginExecutable}s. + */ +public class Event { + private final long time = System.currentTimeMillis(); + private Class pluginClass; + private EventType type; + private String message; + + public Event(EventType type, Class pluginClass, String message) { + this.type = type; + this.pluginClass = pluginClass; + this.message = message; + + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + int pos = stackTrace[1].getMethodName().equals("") ? 1 : 2; + Assert.state(stackTrace[pos].getMethodName().equals(""), "Invalid Event Creation"); + Assert.state(stackTrace[pos + 1].getClassName().equals(EventBus.class.getName()), "Invalid Event Creation"); + Assert.state(stackTrace[pos + 1].getMethodName().equals("add"), "Invalid Event Creation"); + } + + /** + * Returns the time when the event occurred. + * + * @return the event creation time + */ + public long getTime() { + return time; + } + + /** + * Returns the type of the plugin that triggered the event. + * + * @return the plugin type + */ + public Class getPluginClass() { + return pluginClass; + } + + /** + * Returns the type of the event. + * + * @return the event type + */ + public EventType getType() { + return type; + } + + /** + * Returns the message of the event + * + * @return the event message + */ + public String getMessage() { + return message; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("Event"); + sb.append("{time=").append(time); + sb.append(", pluginClass=").append(pluginClass); + sb.append(", type=").append(type); + sb.append('}'); + return sb.toString(); + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/event/EventBus.java b/ass2-aop/src/test/java/dst/ass2/aop/event/EventBus.java new file mode 100644 index 0000000..741f8c1 --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/event/EventBus.java @@ -0,0 +1,135 @@ +package dst.ass2.aop.event; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.aop.support.AopUtils; + +import dst.ass2.aop.IPluginExecutable; + +/** + * Stateful event bus that stores events triggered by executable plugins. + *

+ * Note that this implementation is thread safe. + */ +public class EventBus { + private static final EventBus instance = new EventBus(); + private final List events = new ArrayList(); + + public static EventBus getInstance() { + return instance; + } + + private EventBus() { + } + + /** + * Returns all events of the certain type(s).
+ * If no types are specified, all events are returned instead. + * + * @param types the event types + * @return list of all events of the given types. + */ + public List getEvents(EventType... types) { + synchronized (events) { + if (types == null || types.length == 0) { + return new ArrayList(events); + } else { + List list = new ArrayList(); + for (Event event : events) { + for (EventType type : types) { + if (type == event.getType()) { + list.add(event); + } + } + } + return list; + } + } + } + + /** + * Resets the event bus by purging the event history. + */ + public synchronized void reset() { + synchronized (events) { + events.clear(); + } + } + + /** + * Adds a new event of a certain type triggered by the given plugin. + * + * @param type the event type + * @param pluginExecutable the plugin that triggered the event + * @param message the event message + */ + @SuppressWarnings("unchecked") + public void add(EventType type, IPluginExecutable pluginExecutable, String message) { + add(type, (Class) AopUtils.getTargetClass(pluginExecutable), message); + } + + /** + * Adds a new event of a certain type triggered by a plugin of the given type. + * + * @param type the event type + * @param pluginType the type of the plugin + * @param message the event message + */ + public void add(EventType type, Class pluginType, String message) { + Event event = new Event(type, pluginType, message); + synchronized (events) { + events.add(event); + } + } + + /** + * Returns the number of events of a certain type fired by this event bus. + * + * @param type the event type + * @return number of events + */ + public int count(EventType type) { + int counter = 0; + synchronized (events) { + for (Event event : events) { + if (event.getType() == type) { + counter++; + } + } + } + return counter; + } + + /** + * Returns the number of events fired so far. + * + * @return number of events + */ + public int size() { + return events.size(); + } + + /** + * Checks if there was at least one event of a certain type triggered by a plugin with the given full-qualified + * class name. + * + * If {@code pluginType} is {@code null}, the type of the plugin is not checked. The same is true for {@code type}. + * If all parameters are {@code null}, {@code true} is returned if there is at least one event. + * + * @param pluginType the class name of the plugin + * @param type the type of the event + * @return {@code true} if there is at least one event matching the criteria, {@code false} otherwise + */ + public boolean has(String pluginType, EventType type) { + synchronized (events) { + for (Event event : events) { + if ((pluginType == null || pluginType.equals(event.getPluginClass().getName())) + && (type == null || type == event.getType())) { + return true; + } + } + } + return false; + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/event/EventBusHandler.java b/ass2-aop/src/test/java/dst/ass2/aop/event/EventBusHandler.java new file mode 100644 index 0000000..aed173a --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/event/EventBusHandler.java @@ -0,0 +1,38 @@ +package dst.ass2.aop.event; + +import java.util.logging.Handler; +import java.util.logging.LogRecord; + +import dst.ass2.aop.IPluginExecutable; + +/** + * Logging handler that uses the {@link EventBus} for publishing events. + */ +public class EventBusHandler extends Handler { + @SuppressWarnings("unchecked") + @Override + public void publish(LogRecord record) { + if (record.getLoggerName().endsWith("PluginExecutable") && record.getMessage().contains("PluginExecutable")) { + try { + Class clazz = (Class) Class.forName(record.getLoggerName()); + EventBus.getInstance().add(EventType.INFO, clazz, record.getSourceClassName()); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Simply does nothing. + */ + @Override + public void flush() { + } + + /** + * Simply does nothing. + */ + @Override + public void close() { + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/event/EventType.java b/ass2-aop/src/test/java/dst/ass2/aop/event/EventType.java new file mode 100644 index 0000000..adee6b8 --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/event/EventType.java @@ -0,0 +1,5 @@ +package dst.ass2.aop.event; + +public enum EventType { + PLUGIN_START, PLUGIN_END, INFO +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/sample/AbstractPluginExecutable.java b/ass2-aop/src/test/java/dst/ass2/aop/sample/AbstractPluginExecutable.java new file mode 100644 index 0000000..be9b57e --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/sample/AbstractPluginExecutable.java @@ -0,0 +1,28 @@ +package dst.ass2.aop.sample; + +import org.springframework.aop.support.AopUtils; + +import dst.ass2.aop.IPluginExecutable; +import dst.ass2.aop.event.EventBus; +import dst.ass2.aop.event.EventType; + +public abstract class AbstractPluginExecutable implements IPluginExecutable { + @Override + public void execute() { + EventBus eventBus = EventBus.getInstance(); + eventBus.add(EventType.PLUGIN_START, this, AopUtils.getTargetClass(this).getSimpleName() + " is executed!"); + + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + // Should not happen but is not critical so the stack trace is printed to grab some attention ;-) + e.printStackTrace(); + } + + eventBus.add(EventType.PLUGIN_END, this, AopUtils.getTargetClass(this).getSimpleName() + " is finished!"); + } + + @Override + public void interrupted() { + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/sample/InterruptedPluginExecutable.java b/ass2-aop/src/test/java/dst/ass2/aop/sample/InterruptedPluginExecutable.java new file mode 100644 index 0000000..e935125 --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/sample/InterruptedPluginExecutable.java @@ -0,0 +1,37 @@ +package dst.ass2.aop.sample; + +import org.springframework.aop.support.AopUtils; + +import dst.ass2.aop.IPluginExecutable; +import dst.ass2.aop.event.EventBus; +import dst.ass2.aop.event.EventType; +import dst.ass2.aop.logging.Invisible; +import dst.ass2.aop.management.Timeout; + +public class InterruptedPluginExecutable implements IPluginExecutable { + private boolean interrupted = false; + + @Override + @Invisible + @Timeout(2000) + public void execute() { + EventBus eventBus = EventBus.getInstance(); + eventBus.add(EventType.PLUGIN_START, this, AopUtils.getTargetClass(this).getSimpleName() + " is executed!"); + + while (!interrupted) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + // Should not happen but is not critical so the stack trace is printed to grab some attention ;-) + e.printStackTrace(); + } + } + + eventBus.add(EventType.PLUGIN_END, this, AopUtils.getTargetClass(this).getSimpleName() + " is finished!"); + } + + @Override + public void interrupted() { + interrupted = true; + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/sample/InvisiblePluginExecutable.java b/ass2-aop/src/test/java/dst/ass2/aop/sample/InvisiblePluginExecutable.java new file mode 100644 index 0000000..023f1ee --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/sample/InvisiblePluginExecutable.java @@ -0,0 +1,11 @@ +package dst.ass2.aop.sample; + +import dst.ass2.aop.logging.Invisible; + +public class InvisiblePluginExecutable extends AbstractPluginExecutable { + @Override + @Invisible + public void execute() { + super.execute(); + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/sample/LoggingPluginExecutable.java b/ass2-aop/src/test/java/dst/ass2/aop/sample/LoggingPluginExecutable.java new file mode 100644 index 0000000..a3caac7 --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/sample/LoggingPluginExecutable.java @@ -0,0 +1,9 @@ +package dst.ass2.aop.sample; + +import java.util.logging.Logger; + +public class LoggingPluginExecutable extends AbstractPluginExecutable { + @SuppressWarnings("unused") + private static Logger log = Logger.getLogger(LoggingPluginExecutable.class + .getName()); +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/sample/SystemOutPluginExecutable.java b/ass2-aop/src/test/java/dst/ass2/aop/sample/SystemOutPluginExecutable.java new file mode 100644 index 0000000..341dda4 --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/sample/SystemOutPluginExecutable.java @@ -0,0 +1,4 @@ +package dst.ass2.aop.sample; + +public class SystemOutPluginExecutable extends AbstractPluginExecutable { +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_1Test.java b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_1Test.java new file mode 100644 index 0000000..e9e4232 --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_1Test.java @@ -0,0 +1,151 @@ +package dst.ass2.aop.tests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.List; + +import dst.ass2.aop.IPluginExecutor; +import dst.ass2.aop.PluginExecutorFactory; +import dst.ass2.aop.event.Event; +import dst.ass2.aop.event.EventBus; +import dst.ass2.aop.event.EventType; +import dst.ass2.aop.util.PluginUtils; +import org.apache.commons.io.FileUtils; +import org.junit.*; + +public class Ass2_4_1Test { + static final String SIMPLE_PLUGIN = "dst.ass2.aop.sample.SimplePluginExecutable"; + IPluginExecutor executor; + EventBus eventBus = EventBus.getInstance(); + + @BeforeClass + public static void beforeClass() { + Assert.assertEquals("Cannot create temporary plugin directory: " + PluginUtils.PLUGINS_DIR.getAbsolutePath(), + true, PluginUtils.PLUGINS_DIR.isDirectory() || PluginUtils.PLUGINS_DIR.mkdirs()); + } + + @AfterClass + public static void afterClass() throws IOException { + FileUtils.forceDeleteOnExit(PluginUtils.PLUGINS_DIR); + } + + @Before + public void before() { + PluginUtils.cleanPluginDirectory(); + executor = PluginExecutorFactory.createPluginExecutor(); + executor.monitor(PluginUtils.PLUGINS_DIR); + executor.start(); + eventBus.reset(); + } + + @After + public void after() { + executor.stop(); + eventBus.reset(); + PluginUtils.cleanPluginDirectory(); + } + + /** + * Executing plugin copied to plugin directory. + */ + @Test(timeout = PluginUtils.PLUGIN_TEST_TIMEOUT) + public void copiedPlugin_isExecutedCorrectly() throws Exception { + // Preparing new plugin + PluginUtils.preparePlugin(PluginUtils.SIMPLE_FILE); + + // Periodically check for the plugin to be executed + while (eventBus.size() != 2) { + Thread.sleep(100); + } + + // Verify that the plugin was started and stopped orderly + assertTrue(SIMPLE_PLUGIN + " was not started properly.", eventBus.has(SIMPLE_PLUGIN, EventType.PLUGIN_START)); + assertTrue(SIMPLE_PLUGIN + " did not finish properly.", eventBus.has(SIMPLE_PLUGIN, EventType.PLUGIN_END)); + } + + /** + * Checking that each plugin JAR uses its own ClassLoader. + */ + @Test(timeout = PluginUtils.PLUGIN_TEST_TIMEOUT) + public void samePlugins_useSeparateClassLoaders() throws Exception { + // Preparing two plugins + PluginUtils.preparePlugin(PluginUtils.SIMPLE_FILE); + PluginUtils.preparePlugin(PluginUtils.SIMPLE_FILE); + + // Periodically check for the plugins to be executed + while (eventBus.size() != 4) { + Thread.sleep(100); + } + + /* + * Verify that the plugins were loaded by different classloaders. + * This can be checked by comparing the ClassLoaders or comparing the classes themselves. + * In other words, if a class is loaded by two different ClassLoaders, it holds that + * a.getClass() != b.getClass() even if the byte code is identical. + */ + List events = eventBus.getEvents(EventType.PLUGIN_START); + String msg = "Both plugins where loaded by the same ClassLoader"; + assertNotSame(msg, events.get(0).getPluginClass().getClassLoader(), events.get(1).getPluginClass().getClassLoader()); + assertNotSame(msg, events.get(0).getPluginClass(), events.get(1).getPluginClass()); + } + + /** + * Checking whether two plugins in a single JAR are executed concurrently. + */ + @Test(timeout = PluginUtils.PLUGIN_TEST_TIMEOUT) + public void allPlugins_executeConcurrently() throws Exception { + // Start a plugin containing two IPluginExecutable classes + PluginUtils.preparePlugin(PluginUtils.ALL_FILE); + + // Periodically check for the plugins to be executed + while (eventBus.size() != 4) { + Thread.sleep(100); + } + + // Check that there is exactly one start and end event each + List starts = eventBus.getEvents(EventType.PLUGIN_START); + List ends = eventBus.getEvents(EventType.PLUGIN_END); + assertEquals("EventBus must contain exactly 2 start events.", 2, starts.size()); + assertEquals("EventBus must contain exactly 2 end events.", 2, ends.size()); + + // Verify that the plugins were started concurrently + String msg = "All plugins should have been started before the first ended - %d was after %d."; + for (Event end : ends) { + for (Event start : starts) { + assertTrue(String.format(msg, start.getTime(), end.getTime()), start.getTime() < end.getTime()); + } + } + } + + /** + * Checking whether two plugin JARs are executed concurrently. + */ + @Test(timeout = PluginUtils.PLUGIN_TEST_TIMEOUT) + public void multiplePlugins_executeConcurrently() throws Exception { + // Start two plugins at once + PluginUtils.preparePlugin(PluginUtils.SIMPLE_FILE); + PluginUtils.preparePlugin(PluginUtils.SIMPLE_FILE); + + // Periodically check for the plugins to be executed + while (eventBus.size() != 4) { + Thread.sleep(100); + } + + // Check that there is exactly one start and end event each + List starts = eventBus.getEvents(EventType.PLUGIN_START); + List ends = eventBus.getEvents(EventType.PLUGIN_END); + assertEquals("EventBus must contain exactly 2 start events.", 2, starts.size()); + assertEquals("EventBus must contain exactly 2 end events.", 2, ends.size()); + + // Verify that the plugins were started concurrently. + String msg = "All plugins should have been started before the first ended - %d was after %d."; + for (Event end : ends) { + for (Event start : starts) { + assertTrue(String.format(msg, start.getTime(), end.getTime()), start.getTime() < end.getTime()); + } + } + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_2Test.java b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_2Test.java new file mode 100644 index 0000000..026ae8f --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_2Test.java @@ -0,0 +1,180 @@ +package dst.ass2.aop.tests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import dst.ass2.aop.IPluginExecutable; +import dst.ass2.aop.event.Event; +import dst.ass2.aop.event.EventBus; +import dst.ass2.aop.event.EventType; +import dst.ass2.aop.sample.InvisiblePluginExecutable; +import dst.ass2.aop.sample.LoggingPluginExecutable; +import dst.ass2.aop.sample.SystemOutPluginExecutable; +import dst.ass2.aop.util.PluginUtils; +import org.aspectj.lang.annotation.After; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.weaver.internal.tools.PointcutExpressionImpl; +import org.aspectj.weaver.tools.ShadowMatch; +import org.junit.Test; +import org.springframework.aop.PointcutAdvisor; +import org.springframework.aop.framework.Advised; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ReflectionUtils; + +import dst.ass2.aop.logging.Invisible; +import dst.ass2.aop.logging.LoggingAspect; + +public class Ass2_4_2Test { + final EventBus eventBus = EventBus.getInstance(); + + @org.junit.Before + @org.junit.After + public void beforeAndAfter() { + eventBus.reset(); + } + + /** + * Verifies that the {@link LoggingAspect} is a valid AspectJ aspect i.e., {@link Aspect @Aspect} as well as + * {@link Around @Around} or {@link Before @Before} / {@link After @After}. + */ + @Test + public void loggingAspect_isValid() { + Aspect aspect = AnnotationUtils.findAnnotation(LoggingAspect.class, Aspect.class); + assertNotNull("LoggingAspect is not annotated with @Aspect", aspect); + + Map around = PluginUtils.findMethodAnnotation(LoggingAspect.class, Around.class); + Map before = PluginUtils.findMethodAnnotation(LoggingAspect.class, Before.class); + Map after = PluginUtils.findMethodAnnotation(LoggingAspect.class, After.class); + + boolean found = !around.isEmpty() || (!before.isEmpty() && !after.isEmpty()); + assertTrue("LoggingAspect does not contain methods annotated with @Around OR @Before / @After", found); + } + + /** + * Verifies that the pointcut expression of the {@link LoggingAspect} does not match any method except the + * {@link IPluginExecutable#execute()} method. + */ + @Test + public void pointcutExpression_matchesCorrectly() { + IPluginExecutable executable = PluginUtils.getExecutable(LoggingPluginExecutable.class, LoggingAspect.class); + assertTrue("Executable must implement the Advised interface", executable instanceof Advised); + Advised advised = (Advised) executable; + + PointcutAdvisor pointcutAdvisor = PluginUtils.getPointcutAdvisor(advised); + assertNotNull("PointcutAdvisor not found because there is no pointcut or the pointcut does not match", pointcutAdvisor); + + String expression = PluginUtils.getBestExpression(advised); + assertTrue("Pointcut expression must include '" + IPluginExecutable.class.getName() + "'", expression.contains(IPluginExecutable.class.getName())); + assertTrue("Pointcut expression must include '" + PluginUtils.EXECUTE_METHOD.getName() + "'", expression.contains(PluginUtils.EXECUTE_METHOD.getName())); + + PointcutExpressionImpl pointcutExpression = PluginUtils.getPointcutExpression(advised); + ShadowMatch shadowMatch = pointcutExpression.matchesMethodExecution(PluginUtils.EXECUTE_METHOD); + assertTrue("Pointcut does not match IPluginExecute.execute()", shadowMatch.alwaysMatches()); + + shadowMatch = pointcutExpression.matchesMethodExecution(PluginUtils.INTERRUPTED_METHOD); + assertTrue("Pointcut must not match IPluginExecute.interrupted()", shadowMatch.neverMatches()); + + shadowMatch = pointcutExpression.matchesMethodExecution(ReflectionUtils.findMethod(getClass(), PluginUtils.EXECUTE_METHOD.getName())); + assertTrue("Pointcut must not match LoggingPluginTest.execute()", shadowMatch.neverMatches()); + } + + /** + * Verifies that the pointcut expression of the LoggingAspect contains the {@link Invisible @Invisible} annotation. + */ + @Test + public void pointcutExpression_containsInvisibleAnnotation() { + IPluginExecutable executable = PluginUtils.getExecutable(LoggingPluginExecutable.class, LoggingAspect.class); + Advised advised = (Advised) executable; + + String expression = PluginUtils.getBestExpression(advised); + String annotationName = Invisible.class.getName(); + assertTrue("Pointcut expression does not contain " + annotationName, expression.contains(annotationName)); + } + + /** + * Verifies that the pointcut expression of the {@link LoggingAspect} does not match any method annotated with + * {@link Invisible @Invisible}. + */ + @Test + public void pointcutExpression_doesNotMatchInvisible() { + IPluginExecutable executable = PluginUtils.getExecutable(LoggingPluginExecutable.class, LoggingAspect.class); + Advised advised = (Advised) executable; + + PointcutExpressionImpl pointcutExpression = PluginUtils.getPointcutExpression(advised); + + Method loggingMethod = ReflectionUtils.findMethod(LoggingPluginExecutable.class, PluginUtils.EXECUTE_METHOD.getName()); + ShadowMatch shadowMatch = pointcutExpression.matchesMethodExecution(loggingMethod); + assertTrue("Pointcut does not match LoggingPluginExecutable.execute()", shadowMatch.alwaysMatches()); + + Method invisibleMethod = ReflectionUtils.findMethod(InvisiblePluginExecutable.class, PluginUtils.EXECUTE_METHOD.getName()); + shadowMatch = pointcutExpression.matchesMethodExecution(invisibleMethod); + assertTrue("Pointcut matches InvisiblePluginExecutable.execute()", shadowMatch.neverMatches()); + } + + /** + * Tests if the {@link LoggingAspect} uses the {@link java.util.logging.Logger Logger} defined in the plugin. + */ + @Test + public void loggingAspect_usesLogger() { + IPluginExecutable executable = PluginUtils.getExecutable(LoggingPluginExecutable.class, LoggingAspect.class); + Advised advised = (Advised) executable; + + // Add handler end check that there are no events + PluginUtils.addBusHandlerIfNecessary(advised); + assertEquals("EventBus must be empty", 0, eventBus.count(EventType.INFO)); + + // Execute plugin and check that there are 2 events + executable.execute(); + List events = eventBus.getEvents(EventType.INFO); + assertEquals("EventBus must exactly contain 2 INFO events", 2, events.size()); + + // Check if the logger contains the correct class name + events = eventBus.getEvents(EventType.INFO); + for (Event event : events) { + assertEquals("Event message must contain the name of the " + LoggingAspect.class.getSimpleName(), LoggingAspect.class.getName(), event.getMessage()); + assertSame("Event must be logged for " + LoggingPluginExecutable.class.getSimpleName(), LoggingPluginExecutable.class, event.getPluginClass()); + } + } + + /** + * Tests if the {@link LoggingAspect} uses {@code System.out} if the plugin does not contain a + * {@link java.util.logging.Logger Logger} field. + * + * @throws IllegalAccessException if {@code System.out} cannot be modified (must not happen) + */ + @Test + public void loggingAspect_usesSystemOut() throws IllegalAccessException { + // Redirect System.out + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + PrintStream out = PluginUtils.setStaticFinalField(System.class, "out", new PrintStream(byteArrayOutputStream)); + try { + // Execute plugin + IPluginExecutable executable = PluginUtils.getExecutable(SystemOutPluginExecutable.class, LoggingAspect.class); + assertEquals("EventBus must be empty", 0, eventBus.size()); + executable.execute(); + assertEquals("EventBus must exactly contain 2 events", 2, eventBus.size()); + + // Verify that the log output contains the class name of the executed plugin + String output = byteArrayOutputStream.toString(); + assertTrue(String.format("Log output must contain %s\n\tbut was%s", SystemOutPluginExecutable.class.getName(), output), + output.contains(SystemOutPluginExecutable.class.getName())); + } finally { + // Reset System.out + PluginUtils.setStaticFinalField(System.class, "out", out); + } + } + + public void execute() { + + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_3Test.java b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_3Test.java new file mode 100644 index 0000000..f13f8aa --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_3Test.java @@ -0,0 +1,90 @@ +package dst.ass2.aop.tests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import dst.ass2.aop.IPluginExecutable; +import dst.ass2.aop.event.Event; +import dst.ass2.aop.event.EventBus; +import dst.ass2.aop.sample.InterruptedPluginExecutable; +import dst.ass2.aop.util.PluginUtils; +import org.aspectj.lang.annotation.After; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.weaver.internal.tools.PointcutExpressionImpl; +import org.aspectj.weaver.tools.ShadowMatch; +import org.junit.Test; +import org.springframework.aop.PointcutAdvisor; +import org.springframework.aop.framework.Advised; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ReflectionUtils; + +import dst.ass2.aop.management.ManagementAspect; + +public class Ass2_4_3Test { + final EventBus eventBus = EventBus.getInstance(); + + @org.junit.Before + @org.junit.After + public void beforeAndAfter() { + eventBus.reset(); + } + + /** + * Verifies that the {@link ManagementAspect} is a valid AspectJ aspect i.e., {@link Aspect @Aspect} as well as + * {@link Around @Around} or {@link Before @Before} / {@link After @After}. + */ + @Test + public void managementAspect_isValid() { + Aspect aspect = AnnotationUtils.findAnnotation(ManagementAspect.class, Aspect.class); + assertNotNull("ManagementAspect is not annotated with @Aspect", aspect); + + Map around = PluginUtils.findMethodAnnotation(ManagementAspect.class, Around.class); + Map before = PluginUtils.findMethodAnnotation(ManagementAspect.class, Before.class); + Map after = PluginUtils.findMethodAnnotation(ManagementAspect.class, After.class); + + boolean found = !around.isEmpty() || (!before.isEmpty() && !after.isEmpty()); + assertEquals("ManagementAspect does not contain methods annotated with @Around OR @Before and @After", true, found); + } + + /** + * Verifies that the pointcut expression of the {@link ManagementAspect} + * does not match any method except the {@link IPluginExecutable#execute()} method. + */ + @Test + public void pointcutExpression_matchesCorrectly() { + IPluginExecutable executable = PluginUtils.getExecutable(InterruptedPluginExecutable.class, ManagementAspect.class); + assertEquals("Executable must implement the Advised interface", true, executable instanceof Advised); + Advised advised = (Advised) executable; + + PointcutAdvisor pointcutAdvisor = PluginUtils.getPointcutAdvisor(advised); + assertNotNull("PointcutAdvisor not found because there is no pointcut or the pointcut does not match", pointcutAdvisor); + + PointcutExpressionImpl pointcutExpression = PluginUtils.getPointcutExpression(advised); + Method interruptedMethod = ReflectionUtils.findMethod(InterruptedPluginExecutable.class, PluginUtils.EXECUTE_METHOD.getName()); + ShadowMatch shadowMatch = pointcutExpression.matchesMethodExecution(interruptedMethod); + assertEquals("Pointcut does not match InterruptedPluginExecutable.execute()", true, shadowMatch.alwaysMatches()); + } + + /** + * Tests if the {@link ManagementAspect} interrupts the plugin after the given timeout. + */ + @Test(timeout = PluginUtils.PLUGIN_TEST_TIMEOUT) + public void managementAspect_interruptsCorrectly() { + IPluginExecutable executable = PluginUtils.getExecutable(InterruptedPluginExecutable.class, ManagementAspect.class); + assertEquals("EventBus must be empty", 0, eventBus.size()); + executable.execute(); + + List events = eventBus.getEvents(); + assertEquals("EventBus must contain 2 events", 2, events.size()); + + long duration = events.get(1).getTime() - events.get(0).getTime(); + assertTrue("Plugin was not interrupted 2 seconds after starting it", duration < 3000); + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_Suite.java b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_Suite.java new file mode 100644 index 0000000..8702bff --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/tests/Ass2_4_Suite.java @@ -0,0 +1,13 @@ +package dst.ass2.aop.tests; + +import org.junit.runner.RunWith; +import org.junit.runners.Suite; + +@RunWith(Suite.class) +@Suite.SuiteClasses({ + Ass2_4_1Test.class, + Ass2_4_2Test.class, + Ass2_4_3Test.class +}) +public class Ass2_4_Suite { +} \ No newline at end of file diff --git a/ass2-aop/src/test/java/dst/ass2/aop/util/JarUtils.java b/ass2-aop/src/test/java/dst/ass2/aop/util/JarUtils.java new file mode 100644 index 0000000..801decb --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/util/JarUtils.java @@ -0,0 +1,60 @@ +package dst.ass2.aop.util; + +import static org.apache.commons.io.FileUtils.openOutputStream; +import static org.apache.commons.io.IOUtils.copy; +import static org.apache.commons.lang3.StringUtils.defaultIfBlank; +import static org.apache.commons.lang3.StringUtils.join; +import static org.springframework.util.ClassUtils.CLASS_FILE_SUFFIX; +import static org.springframework.util.ClassUtils.convertClassNameToResourcePath; + +import java.io.File; +import java.io.IOException; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipOutputStream; + +import org.apache.commons.io.input.AutoCloseInputStream; +import org.springframework.core.io.ClassPathResource; + +/** + * Builds plugin JARs on demand. + * + * This class is for internal purposes only. + * Note that the {@link #main(String...)} method can be adjusted to create other plugins. + */ +public final class JarUtils { + private JarUtils() { + } + + public static void main(String... args) throws IOException { + String path = join(args, " "); + File dir = new File(defaultIfBlank(path, "ass2-aop/src/test/resources")); + + createJar(new File(dir, "simple.zip"), + "dst.ass2.aop.sample.SimplePluginExecutable" + ); + + createJar(new File(dir, "all.zip"), + "dst.ass2.aop.sample.SimplePluginExecutable", + "dst.ass2.aop.sample.IgnoredPluginExecutable" + ); + } + + /** + * Creates a new JAR file containing the given classes. + * + * @param jarFile the destination JAR file + * @param classes the classes to add + * @throws IOException if an I/O error has occurred + */ + public static void createJar(File jarFile, String... classes) throws IOException { + try (JarOutputStream stream = new JarOutputStream(openOutputStream(jarFile))) { + stream.setLevel(ZipOutputStream.STORED); + for (String clazz : classes) { + String path = convertClassNameToResourcePath(clazz) + CLASS_FILE_SUFFIX; + stream.putNextEntry(new JarEntry(path)); + copy(new AutoCloseInputStream(new ClassPathResource(path).getInputStream()), stream); + } + } + } +} diff --git a/ass2-aop/src/test/java/dst/ass2/aop/util/PluginUtils.java b/ass2-aop/src/test/java/dst/ass2/aop/util/PluginUtils.java new file mode 100644 index 0000000..b18b474 --- /dev/null +++ b/ass2-aop/src/test/java/dst/ass2/aop/util/PluginUtils.java @@ -0,0 +1,277 @@ +package dst.ass2.aop.util; + +import static org.apache.commons.io.filefilter.FileFileFilter.FILE; +import static org.apache.commons.io.filefilter.FileFilterUtils.and; +import static org.apache.commons.io.filefilter.FileFilterUtils.or; +import static org.apache.commons.io.filefilter.FileFilterUtils.prefixFileFilter; +import static org.springframework.util.ReflectionUtils.findField; +import static org.springframework.util.ReflectionUtils.findMethod; +import static org.springframework.util.ReflectionUtils.getField; +import static org.springframework.util.ReflectionUtils.makeAccessible; + +import java.io.File; +import java.io.FileFilter; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.Logger; + +import dst.ass2.aop.event.EventBusHandler; +import org.apache.commons.io.FileUtils; +import org.aspectj.weaver.internal.tools.PointcutExpressionImpl; +import org.aspectj.weaver.patterns.Pointcut; +import org.springframework.aop.Advisor; +import org.springframework.aop.PointcutAdvisor; +import org.springframework.aop.aspectj.AbstractAspectJAdvice; +import org.springframework.aop.aspectj.AspectJExpressionPointcut; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; +import org.springframework.aop.framework.Advised; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.io.ClassPathResource; + +import dst.ass2.aop.IPluginExecutable; + +/** + * Contains some utility methods for plugins. + */ +public final class PluginUtils { + + public final static int PLUGIN_TEST_TIMEOUT = 30000; + + public static final File PLUGINS_DIR = new File( + FileUtils.getTempDirectoryPath(), "plugins_" + + System.currentTimeMillis()); + + public static File ALL_FILE; + public static File SIMPLE_FILE; + + public static final Method EXECUTE_METHOD = findMethod( + IPluginExecutable.class, "execute"); + public static final Method INTERRUPTED_METHOD = findMethod( + IPluginExecutable.class, "interrupted"); + + static { + try { + ALL_FILE = new ClassPathResource("all.zip").getFile(); + SIMPLE_FILE = new ClassPathResource("simple.zip").getFile(); + } catch (IOException e) { + throw new RuntimeException("Cannot locate plugin in classpath", e); + } + } + + private PluginUtils() { + } + + /** + * Modifies the value of a static (final) field and returns the previous + * value. + * + * @param clazz the class containing the static field + * @param name the name of the field + * @param value the value to set + * @return the previous value + * @throws IllegalAccessException if the field is inaccessible + * (should not be the case, since it is set to accessible manually) + */ + @SuppressWarnings("unchecked") + public static T setStaticFinalField(Class clazz, String name, T value) + throws IllegalAccessException { + // Retrieve the desired field + Field field = findField(clazz, name); + field.setAccessible(true); + + // Remove the final modifier (if necessary) + if (Modifier.isFinal(field.getModifiers())) { + Field modifiers = findField(field.getClass(), "modifiers"); + makeAccessible(modifiers); + modifiers.set(field, (Integer) modifiers.get(field) + & ~Modifier.FINAL); + } + + // Get the current value + T current = (T) field.get(null); + + // Set the new value + field.set(null, value); + + return current; + } + + /** + * Creates a new unique {@link File} object within the {@link #PLUGINS_DIR} + * directory. + * + * @return the file + */ + public static File uniqueFile() { + return new File(PLUGINS_DIR, "_" + System.nanoTime() + ".jar"); + } + + /** + * Copies the given file to a file in the plugin directory.
+ * + * @throws IOException if the destination file already exists or the file was not copied + * @see #uniqueFile() + */ + public static void preparePlugin(File file) throws IOException { + File destFile = uniqueFile(); + if (destFile.exists()) { + throw new IOException("Destination file must not exist."); + } + + File tempFile = new File(destFile.getParentFile(), "tmp_" + + UUID.randomUUID().toString()); + FileUtils.copyFile(file, tempFile); + if (!tempFile.renameTo(destFile) || !destFile.isFile()) { + throw new IOException(String.format( + "File '%s' was not copied to '%s'.", file, destFile)); + } + } + + /** + * Deletes all plugin JARs copied to the plugin directory. + */ + public static void cleanPluginDirectory() { + FileFilter filter = and(FILE, + or(prefixFileFilter("_"), prefixFileFilter("tmp_"))); + System.gc(); + + for (File file : PLUGINS_DIR.listFiles(filter)) { + file.delete(); + } + } + + /** + * Ads a new {@link EventBusHandler} to the logger declared within the given + * objects class if necessary.
+ * This method does nothing if the logger already has an + * {@link EventBusHandler} or there is no logger. + * + * @param obj the object + */ + public static void addBusHandlerIfNecessary(Object obj) { + Class targetClass = AopUtils.getTargetClass(obj); + Field field = findField(targetClass, null, Logger.class); + if (field != null) { + makeAccessible(field); + Logger logger = (Logger) getField(field, obj); + for (Handler handler : logger.getHandlers()) { + if (handler instanceof EventBusHandler) { + return; + } + } + logger.addHandler(new EventBusHandler()); + } + } + + /** + * Creates a new instance of the given {@link IPluginExecutable} class and + * returns a proxy with the AspectJ aspect applied to it.
+ * If {@code aspectClass} is {@code null}, no aspect is applied. + * + * @param clazz the plugin class + * @param aspectClass the class containing AspectJ definitions + * @return proxy of the plugin instance + */ + public static IPluginExecutable getExecutable( + Class clazz, Class aspectClass) { + IPluginExecutable target = BeanUtils.instantiateClass(clazz); + AspectJProxyFactory factory = new AspectJProxyFactory(target); + factory.setProxyTargetClass(true); + if (aspectClass != null) { + factory.addAspect(BeanUtils.instantiateClass(aspectClass)); + } + return factory.getProxy(); + } + + /** + * Returns the pointcut expression of the given advised proxy. + * + * @param advised the proxy with the applied aspect + * @return the pointcut expression or {@code null} if none was found + */ + public static PointcutExpressionImpl getPointcutExpression(Advised advised) { + PointcutAdvisor pointcutAdvisor = getPointcutAdvisor(advised); + if (pointcutAdvisor != null) { + AspectJExpressionPointcut pointcut = (AspectJExpressionPointcut) pointcutAdvisor + .getPointcut(); + if (pointcut.getPointcutExpression() instanceof PointcutExpressionImpl) { + return (PointcutExpressionImpl) pointcut + .getPointcutExpression(); + } + } + return null; + } + + /** + * Returns the pointcut advisor of the given proxy if its advice part is an + * {@link AbstractAspectJAdvice}. + * + * @param advised the proxy with the applied aspect + * @return the pointcut advisor or {@code null} if there is no AspectJ pointcut advisor applied + */ + public static PointcutAdvisor getPointcutAdvisor(Advised advised) { + for (Advisor advisor : advised.getAdvisors()) { + if (advisor instanceof PointcutAdvisor + && advisor.getAdvice() instanceof AbstractAspectJAdvice) { + return (PointcutAdvisor) advisor; + } + } + return null; + } + + /** + * Attempts to resolve all parts of the pointcut expression of the aspect + * applied to the given proxy. + * + * @param advised the proxy with the applied aspect + * @return a string representation of this pointcut expression + * @see #getPointcutExpression(org.springframework.aop.framework.Advised) + * @see #getPointcutAdvisor(org.springframework.aop.framework.Advised) + */ + public static String getBestExpression(Advised advised) { + PointcutExpressionImpl pointcutExpression = getPointcutExpression(advised); + if (pointcutExpression != null) { + Pointcut underlyingPointcut = pointcutExpression + .getUnderlyingPointcut(); + if (findMethod(underlyingPointcut.getClass(), "toString") + .getDeclaringClass() != Object.class) { + return underlyingPointcut.toString(); + } + return pointcutExpression.getPointcutExpression(); + } + PointcutAdvisor advisor = getPointcutAdvisor(advised); + AspectJExpressionPointcut pointcut = (AspectJExpressionPointcut) advisor + .getPointcut(); + return pointcut.getExpression(); + } + + /** + * Finds all public methods of the given class annotated with a certain + * annotation. + * + * @param clazz the class + * @param annotationType the annotation to search for + * @return methods annotated with the given annotation + */ + public static Map findMethodAnnotation( + Class clazz, Class annotationType) { + Map map = new HashMap(); + for (Method method : clazz.getDeclaredMethods()) { + A annotation = AnnotationUtils.findAnnotation(method, + annotationType); + if (annotation != null) { + map.put(method, annotation); + } + } + return map; + } +} diff --git a/ass2-ioc/pom.xml b/ass2-ioc/pom.xml new file mode 100644 index 0000000..be61216 --- /dev/null +++ b/ass2-ioc/pom.xml @@ -0,0 +1,32 @@ + + + + 4.0.0 + + + at.ac.tuwien.infosys.dst + dst + 2021.1 + .. + + + ass2-ioc + + jar + + DST :: Assignment 2 :: IoC + + + + org.javassist + javassist + + + org.springframework + spring-core + ${spring.version} + test + + + diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/IObjectContainer.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/IObjectContainer.java new file mode 100644 index 0000000..bcc219f --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/IObjectContainer.java @@ -0,0 +1,27 @@ +package dst.ass2.ioc.di; + +import dst.ass2.ioc.di.annotation.Property; + +import java.util.Properties; + +public interface IObjectContainer { + + /** + * Returns the (mutable) data structure holding property values that can be injected via the + * {@link Property} annotation. + * + * @return A mutable Properties object + */ + Properties getProperties(); + + /** + * Returns a container-managed object of the given type. + * + * @param type the type of object + * @param the class type + * @return the object + * @throws InjectionException throw the concrete InjectionException as specified in the assignment + */ + T getObject(Class type) throws InjectionException; + +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/IObjectContainerFactory.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/IObjectContainerFactory.java new file mode 100644 index 0000000..f422a05 --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/IObjectContainerFactory.java @@ -0,0 +1,17 @@ +package dst.ass2.ioc.di; + +import dst.ass2.ioc.di.annotation.Property; + +import java.util.Properties; + +public interface IObjectContainerFactory { + + /** + * Creates a new IObjectContainer instance that uses the given properties for injecting + * {@link Property} instances. + * + * @param properties a Properties object + * @return a new IObjectContainer instance + */ + IObjectContainer newObjectContainer(Properties properties); +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/InjectionException.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/InjectionException.java new file mode 100644 index 0000000..25c90d3 --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/InjectionException.java @@ -0,0 +1,18 @@ +package dst.ass2.ioc.di; + +public class InjectionException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public InjectionException(String message) { + super(message); + } + + public InjectionException(String message, Throwable cause) { + super(message, cause); + } + + public InjectionException(Throwable cause) { + super(cause); + } + +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/InvalidDeclarationException.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/InvalidDeclarationException.java new file mode 100644 index 0000000..bcd62d9 --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/InvalidDeclarationException.java @@ -0,0 +1,15 @@ +package dst.ass2.ioc.di; + +public class InvalidDeclarationException extends InjectionException { + public InvalidDeclarationException(String message) { + super(message); + } + + public InvalidDeclarationException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidDeclarationException(Throwable cause) { + super(cause); + } +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/ObjectCreationException.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/ObjectCreationException.java new file mode 100644 index 0000000..fa6dd5f --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/ObjectCreationException.java @@ -0,0 +1,15 @@ +package dst.ass2.ioc.di; + +public class ObjectCreationException extends InjectionException { + public ObjectCreationException(String message) { + super(message); + } + + public ObjectCreationException(String message, Throwable cause) { + super(message, cause); + } + + public ObjectCreationException(Throwable cause) { + super(cause); + } +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/TypeConversionException.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/TypeConversionException.java new file mode 100644 index 0000000..25ab77b --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/TypeConversionException.java @@ -0,0 +1,15 @@ +package dst.ass2.ioc.di; + +public class TypeConversionException extends InjectionException { + public TypeConversionException(String message) { + super(message); + } + + public TypeConversionException(String message, Throwable cause) { + super(message, cause); + } + + public TypeConversionException(Throwable cause) { + super(cause); + } +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Component.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Component.java new file mode 100644 index 0000000..85a1e2e --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Component.java @@ -0,0 +1,21 @@ +package dst.ass2.ioc.di.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a class to be container managed. + */ +// TODO: add correct retention policy and target type +public @interface Component { + + /** + * The component can either be marked to be a singleton or a prototype via the {@link Scope} enum. The default scope + * is singleton. + * + * @return the scope + */ + Scope scope() default Scope.SINGLETON; +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Initialize.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Initialize.java new file mode 100644 index 0000000..e7c07ef --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Initialize.java @@ -0,0 +1,15 @@ +package dst.ass2.ioc.di.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Initialize marks a method to be invoked by the container after it has been constructed and all dependencies and + * properties have been injected. + */ +// TODO: add correct retention policy and target type +public @interface Initialize { + +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Inject.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Inject.java new file mode 100644 index 0000000..1d666cc --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Inject.java @@ -0,0 +1,28 @@ +package dst.ass2.ioc.di.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Inject marks a field in a class to be autowired by the container. + */ +// TODO: add correct retention policy and target type +public @interface Inject { + + /** + * Marks whether the dependency is required or not. + * + * @return a boolean value + */ + boolean optional() default false; + + /** + * Explicitly states the class that should be injected by the container into this field. If the target type is Void, + * then we assume the targetType is not set, and the container uses the declared field type instead. + * + * @return a class + */ + Class targetType() default Void.class; +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Property.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Property.java new file mode 100644 index 0000000..b42a8b6 --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Property.java @@ -0,0 +1,20 @@ +package dst.ass2.ioc.di.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A property injects the value of a {@link java.util.Properties} object into a field based on a key. + */ +// TODO: add correct retention policy and target type +public @interface Property { + + /** + * The key to look up in the container's Properties object. + * + * @return a key + */ + String value(); +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Scope.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Scope.java new file mode 100644 index 0000000..d0b8f88 --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/annotation/Scope.java @@ -0,0 +1,9 @@ +package dst.ass2.ioc.di.annotation; + +/** + * The scope of a {@link Component}. + */ +public enum Scope { + SINGLETON, + PROTOTYPE +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/di/impl/ObjectContainerFactory.java b/ass2-ioc/src/main/java/dst/ass2/ioc/di/impl/ObjectContainerFactory.java new file mode 100644 index 0000000..f3c90ce --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/di/impl/ObjectContainerFactory.java @@ -0,0 +1,16 @@ +package dst.ass2.ioc.di.impl; + +import dst.ass2.ioc.di.IObjectContainer; +import dst.ass2.ioc.di.IObjectContainerFactory; + +import java.util.Properties; + +public final class ObjectContainerFactory implements IObjectContainerFactory { + + @Override + public IObjectContainer newObjectContainer(Properties properties) { + // TODO: implement + return null; + } + +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/lock/Lock.java b/ass2-ioc/src/main/java/dst/ass2/ioc/lock/Lock.java new file mode 100644 index 0000000..40b3141 --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/lock/Lock.java @@ -0,0 +1,21 @@ +package dst.ass2.ioc.lock; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method to be executed by acquiring a named lock. + */ +// TODO: add correct retention policy and target type +public @interface Lock { + + /** + * The name of the container managed lock. + * + * @return a name + */ + String value(); + +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/lock/LockingInjector.java b/ass2-ioc/src/main/java/dst/ass2/ioc/lock/LockingInjector.java new file mode 100644 index 0000000..a8d958f --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/lock/LockingInjector.java @@ -0,0 +1,19 @@ +package dst.ass2.ioc.lock; + +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.IllegalClassFormatException; +import java.security.ProtectionDomain; + +public class LockingInjector implements ClassFileTransformer { + + @Override + public byte[] transform(ClassLoader loader, String className, + Class classBeingRedefined, ProtectionDomain protectionDomain, + byte[] classfileBuffer) throws IllegalClassFormatException { + + // TODO transform all @Lock annotated methods of classes with a @Component annotation + + return classfileBuffer; + } + +} diff --git a/ass2-ioc/src/main/java/dst/ass2/ioc/lock/LockingInjectorAgent.java b/ass2-ioc/src/main/java/dst/ass2/ioc/lock/LockingInjectorAgent.java new file mode 100644 index 0000000..937dc07 --- /dev/null +++ b/ass2-ioc/src/main/java/dst/ass2/ioc/lock/LockingInjectorAgent.java @@ -0,0 +1,11 @@ +package dst.ass2.ioc.lock; + +import java.lang.instrument.Instrumentation; + +public class LockingInjectorAgent { + + public static void premain(String args, Instrumentation inst) { + // TODO + } + +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/Ass2_2_1_Suite.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/Ass2_2_1_Suite.java new file mode 100644 index 0000000..d4d86ca --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/Ass2_2_1_Suite.java @@ -0,0 +1,19 @@ +package dst.ass2.ioc.tests; + +import dst.ass2.ioc.tests.di.DependencyInjectionTest; +import dst.ass2.ioc.tests.di.HierarchyTest; +import dst.ass2.ioc.tests.di.InitializeTest; +import dst.ass2.ioc.tests.di.PropertyInjectionTest; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; + +@RunWith(Suite.class) +@Suite.SuiteClasses({ + DependencyInjectionTest.class, + PropertyInjectionTest.class, + HierarchyTest.class, + InitializeTest.class +}) +public class Ass2_2_1_Suite { + // suite +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/Ass2_2_2_Suite.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/Ass2_2_2_Suite.java new file mode 100644 index 0000000..738f25e --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/Ass2_2_2_Suite.java @@ -0,0 +1,13 @@ +package dst.ass2.ioc.tests; + +import dst.ass2.ioc.tests.lock.LockingTest; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; + +@RunWith(Suite.class) +@Suite.SuiteClasses({ + LockingTest.class +}) +public class Ass2_2_2_Suite { + // suite +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/CustomInitializeTest.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/CustomInitializeTest.java new file mode 100644 index 0000000..22e3294 --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/CustomInitializeTest.java @@ -0,0 +1,10 @@ +package dst.ass2.ioc.tests.di; + +public class CustomInitializeTest { + + /* + * TODO: In cases where the object hierarchy has multiple methods annotated with @Initialize or overwrites + * previously annotated methods, you should come up with your own behavior. Document the behavior with at least two + * unit tests. + */ +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/DependencyInjectionTest.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/DependencyInjectionTest.java new file mode 100644 index 0000000..5b27509 --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/DependencyInjectionTest.java @@ -0,0 +1,218 @@ +package dst.ass2.ioc.tests.di; + + +import dst.ass2.ioc.di.IObjectContainer; +import dst.ass2.ioc.di.IObjectContainerFactory; +import dst.ass2.ioc.di.InjectionException; +import dst.ass2.ioc.di.InvalidDeclarationException; +import dst.ass2.ioc.di.annotation.Component; +import dst.ass2.ioc.di.annotation.Inject; +import dst.ass2.ioc.di.annotation.Scope; +import dst.ass2.ioc.di.impl.ObjectContainerFactory; +import org.junit.Before; +import org.junit.Test; + +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.hamcrest.MatcherAssert.assertThat; + + +public class DependencyInjectionTest { + + private IObjectContainerFactory factory; + private IObjectContainer container; + + @Before + public void setUp() { + factory = new ObjectContainerFactory(); + container = factory.newObjectContainer(new Properties()); + + if (container == null) { + throw new NullPointerException("ObjectContainerFactory did not return an ObjectContainer instance"); + } + } + + @Component + public static class SimpleSingleton { + public String someValue; + + public SimpleSingleton() { + // just to verify that the constructor has been called correctly + this.someValue = "42"; + } + } + + public static class NotAComponentClass { + // a plain class shouldn't be autowired + + @Inject + SimpleSingleton singleton; + } + + @Test(expected = InvalidDeclarationException.class) + public void getObject_onNotAnnotatedClass_throwsException() { + container.getObject(NotAComponentClass.class); + } + + + @Component + public static abstract class AbstractComponentClass { + // abstract classes can't be instantiated + } + + @Test(expected = InjectionException.class) + public void getObject_onAbstractClass_throwsException() throws Exception { + container.getObject(AbstractComponentClass.class); + } + + @Test + public void getObject_onSimpleSingleton_createsObjectCorrectly() { + var object = container.getObject(SimpleSingleton.class); + assertNotNull(object); + assertThat(object, instanceOf(SimpleSingleton.class)); + assertEquals("constructor was not called correctly", "42", object.someValue); + } + + @Test + public void getObject_onSimpleSingleton_returnsSameObject() { + var object1 = container.getObject(SimpleSingleton.class); + var object2 = container.getObject(SimpleSingleton.class); + + assertNotNull(object1); + assertNotNull(object2); + assertSame(object1, object2); + } + + @Test + public void getObject_onDifferentContainer_returnsDifferentObject() { + var container1 = factory.newObjectContainer(new Properties()); + var object1 = container1.getObject(SimpleSingleton.class); + + var container2 = factory.newObjectContainer(new Properties()); + var object2 = container2.getObject(SimpleSingleton.class); + + assertNotNull(object1); + assertNotNull(object2); + assertNotSame(object1, object2); + } + + @Component(scope = Scope.PROTOTYPE) + public static class SimplePrototype { + + static final AtomicInteger COUNTER = new AtomicInteger(0); + + public int cnt; + + public SimplePrototype() { + // just to verify that the constructor has been called correctly + cnt = SimplePrototype.COUNTER.incrementAndGet(); + } + } + + @Test + public void getObject_onSimplePrototype_returnsDifferentObjects() { + SimplePrototype.COUNTER.set(0); + var object1 = container.getObject(SimplePrototype.class); + var object2 = container.getObject(SimplePrototype.class); + + assertNotNull(object1); + assertNotNull(object2); + assertNotSame(object1, object2); + + assertEquals(1, object1.cnt); + assertEquals(2, object2.cnt); + } + + @Component(scope = Scope.PROTOTYPE) + public static class CompositePrototype { + + @Inject + SimpleSingleton simpleSingleton; + } + + @Test + public void getObject_onCompositePrototype_createsObjectCorrectly() { + var object = container.getObject(CompositePrototype.class); + assertNotNull(object.simpleSingleton); + } + + @Test + public void getObject_onCompositePrototype_injectsPreviouslyCreatedSingleton() { + var singleton = container.getObject(SimpleSingleton.class); + + var object = container.getObject(CompositePrototype.class); + assertNotNull(object.simpleSingleton); + + assertSame("autowire should use already container managed singletons", singleton, object.simpleSingleton); + } + + @Component + public static class CompositeSingleton { + @Inject + SimpleSingleton simpleSingleton; + } + + @Test + public void getObject_onCompositeSingleton_createsObjectGraphCorrectly() { + var object = container.getObject(CompositeSingleton.class); + + assertNotNull(object); + assertThat(object, instanceOf(CompositeSingleton.class)); + + assertNotNull("dependency of CompositeSingleton not initialized", object.simpleSingleton); + assertNotNull("dependency of CompositeSingleton not initialized correctly", object.simpleSingleton.someValue); + } + + public interface IService { + int getSomeValue(); + } + + @Component + public static class ServiceImpl implements IService { + private int someValue; + + public ServiceImpl() { + someValue = 42; + } + + @Override + public int getSomeValue() { + return someValue; + } + } + + @Component + public static class ServiceUser { + @Inject(targetType = ServiceImpl.class) + IService service; + } + + @Test + public void getObject_usingTargetType_createsGraphCorrectly() throws Exception { + var object = container.getObject(ServiceUser.class); + + assertNotNull(object); + assertNotNull("dependency of ServiceUser not initialized", object.service); + assertThat("dependency of ServiceUser not initialized correctly", object.service, instanceOf(ServiceImpl.class)); + assertEquals("dependency of ServiceUser not initialized correctly", 42, object.service.getSomeValue()); + } + + @Component + public static class InvalidServiceUser { + // trying to inject an existing but invalid type (a SimplePrototype is not an IService) + @Inject(targetType = SimplePrototype.class) + IService service; + } + + @Test(expected = InvalidDeclarationException.class) + public void getObject_onInvalidTargetType_throwsException() { + container.getObject(InvalidServiceUser.class); + } + +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/HierarchyTest.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/HierarchyTest.java new file mode 100644 index 0000000..148d38b --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/HierarchyTest.java @@ -0,0 +1,125 @@ +package dst.ass2.ioc.tests.di; + +import dst.ass2.ioc.di.IObjectContainer; +import dst.ass2.ioc.di.IObjectContainerFactory; +import dst.ass2.ioc.di.annotation.Component; +import dst.ass2.ioc.di.annotation.Inject; +import dst.ass2.ioc.di.impl.ObjectContainerFactory; +import org.junit.Before; +import org.junit.Test; + +import java.util.Properties; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + + +public class HierarchyTest { + + private IObjectContainerFactory factory; + private IObjectContainer container; + + @Before + public void setUp() throws Exception { + factory = new ObjectContainerFactory(); + container = factory.newObjectContainer(new Properties()); + } + + @Component + public static class SimpleSingleton { + public String someValue; + + public SimpleSingleton() { + // just to verify that the constructor has been called correctly + this.someValue = "42"; + } + } + + public static abstract class AbstractComponent { + @Inject + protected SimpleSingleton superSingleton; + } + + @Component + public static class ConcreteComponent extends AbstractComponent { + @Inject + private SimpleSingleton privateSingleton; + + public SimpleSingleton getSuperSingleton() { + return superSingleton; + } + + public SimpleSingleton getPrivateSingleton() { + return privateSingleton; + } + } + + @Test + public void getObject_injectsDependencyOfSuperClassCorrectly() throws Exception { + ConcreteComponent component = container.getObject(ConcreteComponent.class); + + assertNotNull("getObject returned null", component); + assertNotNull("Dependency of superclass was not injected", component.getSuperSingleton()); + assertNotNull("Private dependency was not injected", component.getPrivateSingleton()); + assertSame("Multiple singleton instances", component.getSuperSingleton(), component.getPrivateSingleton()); + } + + @Component + public static class ClassA { + @Inject + SimpleSingleton singletonA; + } + + @Component + public static class ClassB extends ClassA { + + @Inject + SimpleSingleton singletonB; + + @Inject + ClassD classD; + } + + @Component + public static class ClassC { + + @Inject + SimpleSingleton singletonC; + } + + @Component + public static class ClassD extends ClassC { + + @Inject + SimpleSingleton singletonD; + } + + /** + * Tests the following hierarchy. Getting first D then B should should inject the previously created D into B. + * + * +-------+ +-------+ + * | A | | C | + * +-------+ +-------+ + * ^ is a ^ is a + * | | + * +-------+ uses +-------+ + * | B | ---> | D | + * +-------+ +-------+ + */ + @Test + public void getObject_initializesHierarchyCorrectly() throws Exception { + ClassD d = container.getObject(ClassD.class); + + assertNotNull("getObject returned null for ClassD", d); + assertNotNull("ClassD dependency was not injected", d.singletonD); + assertNotNull("ClassC dependency was not injected when instantiating ClassD", d.singletonC); + + ClassB b = container.getObject(ClassB.class); + assertNotNull("getObject returned null for ClassB", b); + assertNotNull("ClassB dependency was not injected", b.singletonB); + assertNotNull("ClassA dependency was not injected when instantiating ClassB", b.singletonA); + + assertNotNull("ClassD dependency was not injected into ClassB", b.classD); + assertSame("Container did not re-use already initialized ClassD instance", b.classD, d); + } +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/InitializeTest.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/InitializeTest.java new file mode 100644 index 0000000..b9c6d84 --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/InitializeTest.java @@ -0,0 +1,114 @@ +package dst.ass2.ioc.tests.di; + +import dst.ass2.ioc.di.IObjectContainer; +import dst.ass2.ioc.di.IObjectContainerFactory; +import dst.ass2.ioc.di.annotation.Component; +import dst.ass2.ioc.di.annotation.Initialize; +import dst.ass2.ioc.di.annotation.Inject; +import dst.ass2.ioc.di.impl.ObjectContainerFactory; +import org.junit.Before; +import org.junit.Test; + +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class InitializeTest { + + private IObjectContainerFactory factory; + private IObjectContainer container; + + @Before + public void setUp() { + factory = new ObjectContainerFactory(); + container = factory.newObjectContainer(new Properties()); + + if (container == null) { + throw new NullPointerException("ObjectContainerFactory did not return an ObjectContainer instance"); + } + } + + @Component + public static class ComponentClass { + + private AtomicInteger initializeCalls = new AtomicInteger(0); + + @Initialize + public void myInitMethod() { + initializeCalls.incrementAndGet(); + } + + public int getInitializeCalls() { + return initializeCalls.get(); + } + } + + @Test + public void getObject_runsInitializeMethodCorrectly() throws Exception { + ComponentClass component = container.getObject(ComponentClass.class); + assertEquals("expected exactly one call to myInitMethod", 1, component.getInitializeCalls()); + } + + @Test + public void getObject_runsInitializeMethodOnlyOnce() throws Exception { + ComponentClass component = container.getObject(ComponentClass.class); + assertEquals("expected exactly one call to myInitMethod", 1, component.getInitializeCalls()); + + component = container.getObject(ComponentClass.class); + assertEquals("expected exactly one call to myInitMethod", 1, component.getInitializeCalls()); + } + + @Component + public static class AnotherComponentClass { + + @Inject + private ComponentClass dependency; + + boolean wasNull; + + @Initialize + public void myOtherInitMethod() { + this.wasNull = dependency == null; + } + + public ComponentClass getDependency() { + return dependency; + } + } + + @Test + public void getObject_runsInitializeMethodAfterInjection() throws Exception { + AnotherComponentClass component = container.getObject(AnotherComponentClass.class); + assertFalse("Expected dependency to be injected before calls to @Initialize", component.wasNull); + } + + @Test + public void getObject_runsInitializeMethodOfDependencyCorrectly() throws Exception { + AnotherComponentClass component = container.getObject(AnotherComponentClass.class); + assertEquals("expected exactly one call to myInitMethod", 1, component.getDependency().getInitializeCalls()); + } + + @Component + public static class ComponentWithPrivateInitMethod { + + private AtomicInteger initializeCalls = new AtomicInteger(0); + + @Initialize + private void myPrivateInitMethod() { + initializeCalls.incrementAndGet(); + } + + public int getInitializeCalls() { + return initializeCalls.get(); + } + } + + @Test + public void getObject_runsPrivateInitializeMethodCorrectly() throws Exception { + ComponentWithPrivateInitMethod component = container.getObject(ComponentWithPrivateInitMethod.class); + assertEquals("expected exactly one call to myPrivateInitMethod", 1, component.getInitializeCalls()); + } + +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/PropertyInjectionTest.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/PropertyInjectionTest.java new file mode 100644 index 0000000..eb915a5 --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/di/PropertyInjectionTest.java @@ -0,0 +1,268 @@ +package dst.ass2.ioc.tests.di; + +import dst.ass2.ioc.di.IObjectContainerFactory; +import dst.ass2.ioc.di.ObjectCreationException; +import dst.ass2.ioc.di.TypeConversionException; +import dst.ass2.ioc.di.annotation.Component; +import dst.ass2.ioc.di.annotation.Property; +import dst.ass2.ioc.di.annotation.Scope; +import dst.ass2.ioc.di.impl.ObjectContainerFactory; +import org.junit.Before; +import org.junit.Test; + +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.hamcrest.MatcherAssert.assertThat; + +public class PropertyInjectionTest { + + private IObjectContainerFactory factory; + + @Before + public void setUp() { + factory = new ObjectContainerFactory(); + } + + @Component + static class StringPropertyClass { + @Property("my_string") + String value; + } + + @Test + public void simple_string_value_injection() { + var properties = new Properties(); + properties.setProperty("my_string", "my string value"); + var container = factory.newObjectContainer(properties); + + StringPropertyClass obj = container.getObject(StringPropertyClass.class); + assertNotNull("getObject(StringPropertyClass.class) returned null", obj); + assertEquals("my string value", obj.value); + } + + @Test(expected = ObjectCreationException.class) + public void wireMissingProperty_throwsException() { + var container = factory.newObjectContainer(new Properties()); + container.getObject(StringPropertyClass.class); + } + + @Component + static class IntegerPropertyClass { + @Property("my_integer") + Integer value; + } + + @Test + public void simple_integer_value_injection() { + var properties = new Properties(); + properties.setProperty("my_integer", "42"); + var container = factory.newObjectContainer(properties); + + IntegerPropertyClass obj = container.getObject(IntegerPropertyClass.class); + assertNotNull("getObject(IntegerPropertyClass.class) returned null", obj); + assertEquals(Integer.valueOf(42), obj.value); + } + + @Test(expected = TypeConversionException.class) + public void wireInvalidPropertyType_throwsException() { + var properties = new Properties(); + properties.setProperty("my_integer", "fourtytwo"); + var container = factory.newObjectContainer(properties); + + IntegerPropertyClass obj = container.getObject(IntegerPropertyClass.class); + assertNotNull("getObject(IntegerPropertyClass.class) returned null", obj); + assertEquals(Integer.valueOf(42), obj.value); + } + + @Component + static class IntegerPrimitivePropertyClass { + @Property("my_integer") + int value; + } + + @Test + public void simple_integer_primitive_value_injection() { + var properties = new Properties(); + properties.setProperty("my_integer", "42"); + var container = factory.newObjectContainer(properties); + + IntegerPrimitivePropertyClass obj = container.getObject(IntegerPrimitivePropertyClass.class); + assertNotNull("getObject(IntegerPrimitivePropertyClass.class) returned null", obj); + assertEquals(42, obj.value); + } + + @Component + static class MultiplePropertyClass { + @Property("my_string") + String stringValue; + + @Property("my_integer") + int intValue; + + @Property("my_integer") + Integer integerValue; + + @Property("my_float") + Float floatValue; + } + + @Test + public void simple_multiple_property_injection() { + var properties = new Properties(); + properties.setProperty("my_string", "my string value"); + properties.setProperty("my_integer", "42"); + properties.setProperty("my_float", "3.14"); + var container = factory.newObjectContainer(properties); + + MultiplePropertyClass obj = container.getObject(MultiplePropertyClass.class); + assertNotNull("getObject(MultiplePropertyClass.class) returned null", obj); + + assertEquals("my string value", obj.stringValue); + assertEquals(42, obj.intValue); + assertEquals(Integer.valueOf(42), obj.integerValue); + assertEquals(Float.valueOf(3.14f), obj.floatValue); + } + + @Component + static class InheritedMultiplePropertyClass extends MultiplePropertyClass { + @Property("my_float") + Float theSameFloatValue; + } + + @Test + public void multiple_property_injection_with_inheritance() { + var properties = new Properties(); + properties.setProperty("my_string", "my string value"); + properties.setProperty("my_integer", "42"); + properties.setProperty("my_float", "3.14"); + var container = factory.newObjectContainer(properties); + + InheritedMultiplePropertyClass obj = container.getObject(InheritedMultiplePropertyClass.class); + assertNotNull("getObject(InheritedMultiplePropertyClass.class) returned null", obj); + + assertEquals(Float.valueOf(3.14f), obj.theSameFloatValue); + + // inherited values + assertEquals("Property not set correctly through inheritance", "my string value", obj.stringValue); + assertEquals("Property not set correctly through inheritance", 42, obj.intValue); + assertEquals("Property not set correctly through inheritance", Integer.valueOf(42), obj.integerValue); + assertEquals("Property not set correctly through inheritance", Float.valueOf(3.14f), obj.floatValue); + } + + @Component(scope = Scope.PROTOTYPE) + static class PrototypePropertyClass { + + @Property("my_string") + String stringValue; + + } + + @Test + public void getPrototypeObject_usesLatestPropertyValue() { + Properties properties = new Properties(); + properties.setProperty("my_string", "initial value"); + var container = factory.newObjectContainer(properties); + + var object1 = container.getObject(PrototypePropertyClass.class); + assertEquals("initial value", object1.stringValue); + + // Properties returned by IObjectContainerFactory should be mutable + container.getProperties().setProperty("my_string", "changed value"); + + var object2 = container.getObject(PrototypePropertyClass.class); + assertEquals("properties should be mutable", "changed value", object2.stringValue); + assertEquals("initial value", object1.stringValue); + } + + @SuppressWarnings("unused") + @Component + static class ManyValuesClass { + @Property("my_string") + String stringValue01; + @Property("my_integer") + Integer value01; + @Property("my_integer") + Integer value02; + @Property("my_integer") + Integer value03; + @Property("my_integer") + Integer value04; + @Property("my_integer") + Integer value05; + @Property("my_string") + String stringValue02; + @Property("my_integer") + Integer value06; + @Property("my_integer") + Integer value07; + @Property("my_integer") + Integer value08; + @Property("my_integer") + Integer value09; + @Property("my_integer") + Integer value10; + @Property("my_string") + String stringValue03; + @Property("my_integer") + Integer value11; + @Property("my_integer") + Integer value12; + @Property("my_integer") + Integer value13; + @Property("my_integer") + Integer value14; + @Property("my_integer") + Integer value15; + } + + @SuppressWarnings("unchecked") + @Test + public void mutatePropertiesConcurrently_verifyThreadSafety() throws Exception { + var container = factory.newObjectContainer(new Properties()); + + Properties properties = container.getProperties(); + properties.setProperty("my_integer", "1"); + properties.setProperty("my_string", "some string value"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(10); + + executor.execute(() -> { + for (int i = 0; i < 100000; i++) { + latch.countDown(); + properties.setProperty("my_integer", Integer.toString(i)); + } + }); + Future getObject = executor.submit(() -> { + latch.await(); // wait until the property set loop has started and warmed up + return container.getObject(ManyValuesClass.class); + }); + + ManyValuesClass object = getObject.get(); + executor.shutdown(); + executor.awaitTermination(2, TimeUnit.SECONDS); + executor.shutdownNow(); + + assertNotEquals("updated property value was not used", Integer.valueOf(1), object.value01); + assertThat("property values are inconsistent indicating non-thread-safe property injection", + object.value01, allOf( + is(object.value01), is(object.value02), is(object.value03), is(object.value04), + is(object.value05), is(object.value06), is(object.value07), is(object.value08), + is(object.value09), is(object.value10), is(object.value11), is(object.value12), + is(object.value13), is(object.value14), is(object.value15) + )); + + } + + +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/lock/LockingTest.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/lock/LockingTest.java new file mode 100644 index 0000000..a213d2b --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/lock/LockingTest.java @@ -0,0 +1,434 @@ +package dst.ass2.ioc.tests.lock; + +import dst.ass2.ioc.di.annotation.Component; +import dst.ass2.ioc.lock.Lock; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ConcurrentModificationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; + + +public class LockingTest { + + private static final Logger LOG = LoggerFactory.getLogger(LockingTest.class); + + @Rule + public ThreadPoolResource executor = new ThreadPoolResource(2); + + /** + * This class simulates the use of a shared resource (the semaphore). If the resource is already acquired + * by some other thread, then a {@link ConcurrentModificationException} is thrown. We use this behavior to test + * various scenarios (whether mutex access is guaranteed when the @Lock value refers to the same lock). + */ + @Component + public static class SimpleLockManaged { + + public Semaphore semaphore; + + public SimpleLockManaged(Semaphore semaphore) { + this.semaphore = semaphore; + } + + @Lock("my_lock") + public void useResource() { + useResourceImpl(); + } + + @Lock("my_other_lock") + public void useResourceSomeMore() { + useResourceImpl(); + } + + private void useResourceImpl() { + LOG.info("{} trying to acquire semaphore", this); + boolean acquired = semaphore.tryAcquire(); + + if (!acquired) { + LOG.info("{} failed to acquire semaphore", this); + throw new ConcurrentModificationException("Semaphore was acquired concurrently"); + } + + // hold the permit for a few ms + try { + LOG.info("{} holding permit for 500ms", this); + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + LOG.info("{} releasing semaphore", this); + semaphore.release(); + } + } + } + + @Test + public void mutexTest_onSameClassAndObject_behavesCorrectly() throws Exception { + Semaphore semaphore = new Semaphore(1); + + SimpleLockManaged lockManaged = new SimpleLockManaged(semaphore); + + Future r1 = executor.submit(lockManaged::useResource); + Future r2 = executor.submit(lockManaged::useResource); + + try { + r1.get(); + r2.get(); + } catch (ExecutionException e) { + if (e.getCause() instanceof ConcurrentModificationException) { + throw new AssertionError("Access to shared resource was not mutually exclusive as required", e); + } else { + throw e; + } + } + } + + @Test + public void mutexTest_onSameClassButDifferentObject_behavesCorrectly() throws Exception { + Semaphore semaphore = new Semaphore(1); + + SimpleLockManaged lockManaged1 = new SimpleLockManaged(semaphore); + SimpleLockManaged lockManaged2 = new SimpleLockManaged(semaphore); + + Future r1 = executor.submit(lockManaged1::useResource); + Future r2 = executor.submit(lockManaged2::useResource); + + try { + r1.get(); + r2.get(); + } catch (ExecutionException e) { + if (e.getCause() instanceof ConcurrentModificationException) { + throw new AssertionError("Access to shared resource was not mutually exclusive as required", e); + } else { + throw e; + } + } + } + + @Test + public void nonMutexTest_onSameClassAndObject_behavesCorrectly() throws Exception { + Semaphore semaphore = new Semaphore(1); + + SimpleLockManaged lockManaged = new SimpleLockManaged(semaphore); + + Future r1 = executor.submit(lockManaged::useResource); + Future r2 = executor.submit(lockManaged::useResourceSomeMore); + + try { + r1.get(); + r2.get(); + throw new AssertionError("Different locks should allow concurrent access to resources"); + } catch (ExecutionException e) { + if (!(e.getCause() instanceof ConcurrentModificationException)) { + throw e; + } + // we expected a concurrent modification exception here + } + + } + + @Test + public void nonMutexTest_onSameClassButDifferentObject_behavesCorrectly() throws Exception { + Semaphore semaphore = new Semaphore(1); + + SimpleLockManaged lockManaged1 = new SimpleLockManaged(semaphore); + SimpleLockManaged lockManaged2 = new SimpleLockManaged(semaphore); + + Future r1 = executor.submit(lockManaged1::useResource); + Future r2 = executor.submit(lockManaged2::useResourceSomeMore); + + try { + r1.get(); + r2.get(); + throw new AssertionError("Different locks should allow concurrent access to resources"); + } catch (ExecutionException e) { + if (!(e.getCause() instanceof ConcurrentModificationException)) { + throw e; + } + // we expected a concurrent modification exception here + } + + } + + @Component + public static class AnotherSimpleLockManaged { + + private Semaphore semaphore; + + public AnotherSimpleLockManaged(Semaphore semaphore) { + this.semaphore = semaphore; + } + + @Lock("my_lock") + protected void useResourceAgain() { + LOG.info("{} trying to acquire semaphore", this); + boolean acquired = semaphore.tryAcquire(); + + if (!acquired) { + LOG.info("{} failed to acquire semaphore", this); + throw new ConcurrentModificationException("Semaphore was acquired concurrently"); + } + + // hold the permit for a few ms + try { + LOG.info("{} holding permit for 500ms", this); + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + LOG.info("{} releasing semaphore", this); + semaphore.release(); + } + } + } + + @Test + public void mutexTest_onDifferentClass_behavesCorrectly() throws Exception { + Semaphore semaphore = new Semaphore(1); + + SimpleLockManaged lockManaged1 = new SimpleLockManaged(semaphore); + AnotherSimpleLockManaged lockManaged2 = new AnotherSimpleLockManaged(semaphore); + + Future r1 = executor.submit(lockManaged1::useResource); + Future r2 = executor.submit(lockManaged2::useResourceAgain); + + try { + r1.get(); + r2.get(); + } catch (ExecutionException e) { + if (e.getCause() instanceof ConcurrentModificationException) { + throw new AssertionError("Access to shared resource was not mutually exclusive as required", e); + } else { + throw e; + } + } + } + + /** + * This class is annotated with @Lock, but does not have a @Component annotation, so it should not be instrumented. + */ + public static class NotLockManaged { + + public Semaphore semaphore; + + public NotLockManaged(Semaphore semaphore) { + this.semaphore = semaphore; + } + + @Lock("my_lock_0") + public void useResource() { + LOG.info("{} trying to acquire semaphore", this); + boolean acquired = semaphore.tryAcquire(); + + if (!acquired) { + LOG.info("{} failed to acquire semaphore", this); + throw new ConcurrentModificationException("Semaphore was acquired concurrently"); + } + + // hold the permit for a few ms + try { + LOG.info("{} holding permit for 500ms", this); + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + LOG.info("{} releasing semaphore", this); + semaphore.release(); + } + } + } + + @Test + public void testOnNonComponent_shouldNotBeInstrumented() throws Exception { + Semaphore semaphore = new Semaphore(1); + + NotLockManaged notManaged = new NotLockManaged(semaphore); + + Future r1 = executor.submit(notManaged::useResource); + Future r2 = executor.submit(notManaged::useResource); + + try { + r1.get(); + r2.get(); + throw new AssertionError("Only classes with the @Component annotation should be instrumented"); + } catch (ExecutionException e) { + if (!(e.getCause() instanceof ConcurrentModificationException)) { + throw e; + } + // we expected a concurrent modification exception here + } + } + + @Component + public static class LockManagedWithParameter { + + @Lock("my_lock_1") + protected void useResource(Semaphore semaphore) { + LOG.info("{} trying to acquire semaphore", this); + boolean acquired = semaphore.tryAcquire(); + + if (!acquired) { + LOG.info("{} failed to acquire semaphore", this); + throw new ConcurrentModificationException("Semaphore was acquired concurrently"); + } + + // hold the permit for a few ms + try { + LOG.info("{} holding permit for 500ms", this); + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + LOG.info("{} releasing semaphore", this); + semaphore.release(); + } + } + } + + @Test + public void mutexTest_withParameter_behavesCorrectly() throws Exception { + Semaphore semaphore = new Semaphore(1); + + LockManagedWithParameter lockManaged = new LockManagedWithParameter(); + + Future r1 = executor.submit(() -> lockManaged.useResource(semaphore)); + Future r2 = executor.submit(() -> lockManaged.useResource(semaphore)); + + try { + r1.get(); + r2.get(); + } catch (ExecutionException e) { + if (e.getCause() instanceof ConcurrentModificationException) { + throw new AssertionError("Access to shared resource was not mutually exclusive as required", e); + } else { + throw e; + } + } + } + + + @Component + public static class LockManagedWithReturnValue { + + public Semaphore semaphore; + + public LockManagedWithReturnValue(Semaphore semaphore) { + this.semaphore = semaphore; + } + + @Lock("my_lock") + public Integer useResource() { + useResourceImpl(); + return 42; + } + + private void useResourceImpl() { + LOG.info("{} trying to acquire semaphore", this); + boolean acquired = semaphore.tryAcquire(); + + if (!acquired) { + LOG.info("{} failed to acquire semaphore", this); + throw new ConcurrentModificationException("Semaphore was acquired concurrently"); + } + + // hold the permit for a few ms + try { + LOG.info("{} holding permit for 500ms", this); + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + LOG.info("{} releasing semaphore", this); + semaphore.release(); + } + } + } + + @Test + public void mutexTest_withReturnValue_behavesCorrectly() throws Exception { + Semaphore semaphore = new Semaphore(1); + + LockManagedWithReturnValue lockManaged = new LockManagedWithReturnValue(semaphore); + + Future r1 = executor.submit(lockManaged::useResource); + Future r2 = executor.submit(lockManaged::useResource); + + try { + Integer i1 = r1.get(); + Integer i2 = r2.get(); + + Assert.assertEquals(Integer.valueOf(42), i1); + Assert.assertEquals(Integer.valueOf(42), i2); + } catch (ExecutionException e) { + if (e.getCause() instanceof ConcurrentModificationException) { + throw new AssertionError("Access to shared resource was not mutually exclusive as required", e); + } else { + throw e; + } + } + } + + + @Component + public static class LockManagedWithParamAndReturnValue { + + private Integer i = 0; + + @Lock("my_lock_2") + public Integer checkAndIncrement(Integer test) throws RuntimeException { + System.out.println(Thread.currentThread().getName() + " getting value " + i); + int x = i; + + if (i < test) { + // this condition will always be true in the tests, just to add some logic + try { + Thread.sleep(500); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + x = x + 1; + i = x; + } + + return i; + } + + } + + /** + * This test verifies that the instrumentation also works when the function has input parameters and return values. + */ + @Test + public void mutexTest_withParamAndReturnValue_behavesCorrectly() throws Exception { + LockManagedWithParamAndReturnValue managed = new LockManagedWithParamAndReturnValue(); + + Future r1 = executor.submit(() -> managed.checkAndIncrement(10)); + Future r2 = executor.submit(() -> managed.checkAndIncrement(10)); + + try { + Integer i1 = r1.get(); + Integer i2 = r2.get(); + + Assert.assertNotEquals("Result of concurrent increment is equal, indicating a race condition", i1, i2); + if (i1 < i2) { + Assert.assertEquals(Integer.valueOf(1), i1); + Assert.assertEquals(Integer.valueOf(2), i2); + } else { + Assert.assertEquals(Integer.valueOf(2), i1); + Assert.assertEquals(Integer.valueOf(1), i2); + } + } catch (ExecutionException e) { + if (e.getCause() instanceof ConcurrentModificationException) { + throw new AssertionError("Access to shared resource was not mutually exclusive as required", e); + } else { + throw e; + } + } + } + +} diff --git a/ass2-ioc/src/test/java/dst/ass2/ioc/tests/lock/ThreadPoolResource.java b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/lock/ThreadPoolResource.java new file mode 100644 index 0000000..2d3390a --- /dev/null +++ b/ass2-ioc/src/test/java/dst/ass2/ioc/tests/lock/ThreadPoolResource.java @@ -0,0 +1,65 @@ +package dst.ass2.ioc.tests.lock; + +import org.junit.rules.ExternalResource; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * Simple JUnit Rule wrapper of a fixed thread pool {@link ExecutorService} that shuts down after each test execution. + */ +public class ThreadPoolResource extends ExternalResource { + + private ExecutorService executor; + private int nThreads; + + public ThreadPoolResource(int nThreads) { + this.nThreads = nThreads; + } + + @Override + protected void before() { + executor = Executors.newFixedThreadPool(nThreads); + } + + @Override + protected void after() { + shutdownAndAwaitTermination(executor); + } + + public Future submit(Runnable task) { + return executor.submit(task); + } + + public Future submit(Callable task) { + return executor.submit(task); + } + + public ExecutorService getExecutor() { + return executor; + } + + private void shutdownAndAwaitTermination(ExecutorService pool) { + System.out.println("shutting down pool"); + pool.shutdown(); // Disable new tasks from being submitted + try { + // Wait a while for existing tasks to terminate + System.out.println("awaiting pool termination"); + if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { + pool.shutdownNow(); // Cancel currently executing tasks + // Wait a while for tasks to respond to being cancelled + if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { + System.err.println("Pool did not terminate"); + } + } + } catch (InterruptedException ie) { + // (Re-)Cancel if current thread also interrupted + pool.shutdownNow(); + // Preserve interrupt status + Thread.currentThread().interrupt(); + } + } +} diff --git a/ass2-service/api/pom.xml b/ass2-service/api/pom.xml new file mode 100644 index 0000000..cea652a --- /dev/null +++ b/ass2-service/api/pom.xml @@ -0,0 +1,47 @@ + + + + 4.0.0 + + + at.ac.tuwien.infosys.dst + dst + 2021.1 + ../.. + + + ass2-service-api + + DST :: Assignment 2 :: Service :: API + + jar + + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + + + + + kr.motd.maven + os-maven-plugin + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + + + diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/auth/AuthenticationException.java b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/AuthenticationException.java new file mode 100644 index 0000000..e6c3746 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/AuthenticationException.java @@ -0,0 +1,24 @@ +package dst.ass2.service.api.auth; + +public class AuthenticationException extends Exception { + private static final long serialVersionUID = 1L; + + public AuthenticationException() { + } + + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } + + public AuthenticationException(Throwable cause) { + super(cause); + } + + public AuthenticationException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/auth/IAuthenticationService.java b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/IAuthenticationService.java new file mode 100644 index 0000000..352a44b --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/IAuthenticationService.java @@ -0,0 +1,52 @@ +package dst.ass2.service.api.auth; + +public interface IAuthenticationService { + + /** + * Attempts to authenticate the user with the given unique email address and the given password in plain text, by + * checking the data against the records in the database. If the credentials are successfully authenticated, the + * service generates a new authentication token which is stored (with the users email address) in-memory and then + * returned. + * + * @param email the user email + * @param password the password + * @return a new authentication token + * @throws NoSuchUserException if the given user was not found + * @throws AuthenticationException if the credentials could not be authenticated + */ + String authenticate(String email, String password) throws NoSuchUserException, AuthenticationException; + + /** + * Changes the password of the given user in the database. Also updates the in-memory cache in a thread-safe way. + * + * @param email the user email + * @param newPassword the new password in plain text. + * @throws NoSuchUserException if the given user was not found + */ + void changePassword(String email, String newPassword) throws NoSuchUserException; + + /** + * Returns the user that is associated with this token. Returns null if the token does not exist. + * + * @param token an authentication token previously created via {@link #authenticate(String, String)} + * @return the user's email address or null + */ + String getUser(String token); + + /** + * Checks whether the given token is valid (i.e., was issued by this service and has not been invalidated). + * + * @param token the token to validate + * @return true if the token is valid, false otherwise + */ + boolean isValid(String token); + + /** + * Invalidates the given token, i.e., removes it from the cache. Returns false if the token did not exist. + * + * @param token the token to invalidate + * @return true if the token existed and was successfully invalidated, false otherwise + */ + boolean invalidate(String token); + +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/auth/NoSuchUserException.java b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/NoSuchUserException.java new file mode 100644 index 0000000..e1dbe53 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/NoSuchUserException.java @@ -0,0 +1,25 @@ +package dst.ass2.service.api.auth; + +public class NoSuchUserException extends Exception { + + private static final long serialVersionUID = 1L; + + public NoSuchUserException() { + } + + public NoSuchUserException(String message) { + super(message); + } + + public NoSuchUserException(String message, Throwable cause) { + super(message, cause); + } + + public NoSuchUserException(Throwable cause) { + super(cause); + } + + public NoSuchUserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/auth/rest/IAuthenticationResource.java b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/rest/IAuthenticationResource.java new file mode 100644 index 0000000..56c971e --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/auth/rest/IAuthenticationResource.java @@ -0,0 +1,18 @@ +package dst.ass2.service.api.auth.rest; + +import javax.ws.rs.core.Response; + +import dst.ass2.service.api.auth.AuthenticationException; +import dst.ass2.service.api.auth.NoSuchUserException; + +/** + * The IAuthenticationResource exposes parts of the {@code IAuthenticationService} as a RESTful interface. + */ +public interface IAuthenticationResource { + + // TODO annotate the class and methods with the correct javax.ws.rs annotations + + Response authenticate(String email, String password) + throws NoSuchUserException, AuthenticationException; + +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/match/IMatchingService.java b/ass2-service/api/src/main/java/dst/ass2/service/api/match/IMatchingService.java new file mode 100644 index 0000000..7795088 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/match/IMatchingService.java @@ -0,0 +1,29 @@ +package dst.ass2.service.api.match; + +import dst.ass2.service.api.trip.InvalidTripException; +import dst.ass2.service.api.trip.MoneyDTO; +import dst.ass2.service.api.trip.TripDTO; + +/** + * An implementation of this interface is provided to you by the application. + */ +public interface IMatchingService { + + /** + * The passed trip needs at least the following properties set: riderId, pickup, destination and fare + * + * @param trip the trip the MatchingService will calculate a fare for + * @return the proposed fare for the given trip + * @throws InvalidTripException in case the route cannot be calculated + */ + MoneyDTO calculateFare(TripDTO trip) throws InvalidTripException; + + /** + * Puts the trip into the queue for driver matching. + * The trip with the specified ID needs at least the following properties set: + * riderId, pickup, destination and fare + * + * @param tripId + */ + void queueTripForMatching(Long tripId); +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/DriverNotAvailableException.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/DriverNotAvailableException.java new file mode 100644 index 0000000..190f986 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/DriverNotAvailableException.java @@ -0,0 +1,12 @@ +package dst.ass2.service.api.trip; + +public class DriverNotAvailableException extends Exception { + + public DriverNotAvailableException(String message) { + super(message); + } + + public DriverNotAvailableException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/EntityNotFoundException.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/EntityNotFoundException.java new file mode 100644 index 0000000..2cc69fb --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/EntityNotFoundException.java @@ -0,0 +1,17 @@ +package dst.ass2.service.api.trip; + +/** + * Exception indicating that a resource that was trying to be accessed does not exist. + */ +public class EntityNotFoundException extends Exception { + private static final long serialVersionUID = 1L; + + public EntityNotFoundException(String message) { + super(message); + } + + public EntityNotFoundException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/ITripService.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/ITripService.java new file mode 100644 index 0000000..5057f6a --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/ITripService.java @@ -0,0 +1,112 @@ +package dst.ass2.service.api.trip; + +public interface ITripService { + + /** + * Creates and persists a Trip, sets the state to CREATED and calculates an initial fare estimation for + * the route 'pickupId - destinationId' + * + * @param riderId the id of the rider, who is planning a trip + * @param pickupId the id of the pickupId location + * @param destinationId the id of the destinationId location + * @return a TripDTO corresponding to the persisted Trip and includes the fare (null if route is invalid) + * @throws EntityNotFoundException if the rider or one of the locations doesn't exist + */ + TripDTO create(Long riderId, Long pickupId, Long destinationId) throws EntityNotFoundException; + + + /** + * Confirms the given trip (i.e., sets the state of the corresponding trip to QUEUED and + * puts it into the queue for matching), if possible (i.e., the trip is still in CREATED state) + * + * @param tripId the trip to confirm + * @throws EntityNotFoundException if the trip cannot be found + * @throws IllegalStateException in case the trip is not in state CREATED or the rider is null + * @throws InvalidTripException in case the fare couldn't be estimated + */ + void confirm(Long tripId) throws EntityNotFoundException, IllegalStateException, InvalidTripException; + + + /** + * Creates a match for the given trip and sets the trip's state to MATCHED, if possible (i.e., if the trips is + * QUEUED). Rolls back transaction in case something goes wrong and re-queues the trip for a new match. + * You can assume that the locations (pickup, destination and stops) haven't been deleted and will not be deleted + * during the execution of this method. + * + * @param tripId the id of the trip the match will be created for + * @param match the match, containing the driver, the vehicle and the fare + * @throws EntityNotFoundException in case one of the following doesn't exist anymore: trip, driver or + * vehicle + * @throws DriverNotAvailableException in case the driver was assigned in the meantime to another customer + * @throws IllegalStateException in case the rider of the trip is null or the trip is not in QUEUED state + */ + void match(Long tripId, MatchDTO match) throws EntityNotFoundException, DriverNotAvailableException, IllegalStateException; + + + /** + * Completes the trip (i. e., persists a TripInfo object and set the trips state to COMPLETED) + * + * @param tripId the id of the trip to complete + * @param tripInfoDTO the tripInfo to persist + * @throws EntityNotFoundException in case the trip doesn't exist + */ + void complete(Long tripId, TripInfoDTO tripInfoDTO) throws EntityNotFoundException; + + + /** + * Cancels the given trip (i.e., sets the state to cancelled) + * + * @param tripId the trip to cancel + * @throws EntityNotFoundException in case the trip doesn't exist + */ + void cancel(Long tripId) throws EntityNotFoundException; + + + /** + * Adds the location as a stop if possible (i.e., if the referenced trip is still in the CREATED state and + * the given location is not already in the list of stops.) + * As a side effect, the list of the passed TripDTO is modified and the fare freshly estimated. + * In case the the estimation fails, sets the fare to null. + * + * You can assume that the passed TripDTO and the Trip entity have the same values + * @param trip the trip + * @param locationId the location + * @return true if the stop was added, otherwise false + * @throws EntityNotFoundException in case the location or trip doesn't exist + * @throws IllegalStateException in case the trip isn't longer in the CREATED state + */ + boolean addStop(TripDTO trip, Long locationId) throws EntityNotFoundException, IllegalStateException; + + + /** + * Removes the location from the stops only (i.e., if the referenced trip is still in the CREATED state). + * As a side effect, the list of the passed TripDTO is modified and the fare freshly estimated. + * In case the the estimation fails, sets the fare to null. + * + * You can assume that the passed TripDTO and the Trip entity have the same values + * @param trip the trip + * @param locationId the location to remove + * @return true if the trip was removed, otherwise false (for example when the location wasn't added to the stops) + * @throws EntityNotFoundException in case the location or trip doesn't exist + * @throws IllegalStateException in case the trip isn't longer in the CREATED state + */ + boolean removeStop(TripDTO trip, Long locationId) throws EntityNotFoundException, IllegalStateException; + + + /** + * Removes the trip with the given id. + * + * @param tripId the id of the trip to remove + * @throws EntityNotFoundException in case the trip doesn't exist + */ + void delete(Long tripId) throws EntityNotFoundException; + + + /** + * Finds the trip with the given id and returns it as DTO, including the latest fare estimation. + * + * @param tripId the id of the trip + * @return if found the DTO, otherwise null + */ + TripDTO find(Long tripId); +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/InvalidTripException.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/InvalidTripException.java new file mode 100644 index 0000000..c033e8e --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/InvalidTripException.java @@ -0,0 +1,10 @@ +package dst.ass2.service.api.trip; + +public class InvalidTripException extends Exception { + public InvalidTripException() { + } + + public InvalidTripException(String message) { + super(message); + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/MatchDTO.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/MatchDTO.java new file mode 100644 index 0000000..1b48431 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/MatchDTO.java @@ -0,0 +1,38 @@ +package dst.ass2.service.api.trip; + +import java.io.Serializable; + +public class MatchDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long driverId; + private Long vehicleId; + + private MoneyDTO fare; + + public Long getDriverId() { + return driverId; + } + + public void setDriverId(Long driverId) { + this.driverId = driverId; + } + + public Long getVehicleId() { + return vehicleId; + } + + public void setVehicleId(Long vehicleId) { + this.vehicleId = vehicleId; + } + + + public MoneyDTO getFare() { + return fare; + } + + public void setFare(MoneyDTO fare) { + this.fare = fare; + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/MoneyDTO.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/MoneyDTO.java new file mode 100644 index 0000000..f8abcd8 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/MoneyDTO.java @@ -0,0 +1,52 @@ +package dst.ass2.service.api.trip; + +import java.math.BigDecimal; +import java.util.Objects; + +public class MoneyDTO { + + private String currency; + private BigDecimal value; + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public BigDecimal getValue() { + return value; + } + + public void setValue(BigDecimal value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MoneyDTO moneyDTO = (MoneyDTO) o; + return Objects.equals(getCurrency(), moneyDTO.getCurrency()) && + Objects.equals(getValue(), moneyDTO.getValue()); + } + + @Override + public int hashCode() { + return Objects.hash(getCurrency(), getValue()); + } + + @Override + public String toString() { + return "MoneyDTO{" + + "currency='" + currency + '\'' + + ", value=" + value + + '}'; + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/TripDTO.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/TripDTO.java new file mode 100644 index 0000000..cc5ccf9 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/TripDTO.java @@ -0,0 +1,68 @@ +package dst.ass2.service.api.trip; + +import java.io.Serializable; +import java.util.LinkedList; +import java.util.List; + +public class TripDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private Long riderId; + private Long pickupId; + private Long destinationId; + private List stops; + private MoneyDTO fare; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getRiderId() { + return riderId; + } + + public void setRiderId(Long riderId) { + this.riderId = riderId; + } + + public Long getPickupId() { + return pickupId; + } + + public void setPickupId(Long pickupId) { + this.pickupId = pickupId; + } + + public Long getDestinationId() { + return destinationId; + } + + public void setDestinationId(Long destinationId) { + this.destinationId = destinationId; + } + + public List getStops() { + if (stops == null) { + stops = new LinkedList<>(); + } + return stops; + } + + public void setStops(List stops) { + this.stops = stops; + } + + public MoneyDTO getFare() { + return fare; + } + + public void setFare(MoneyDTO fare) { + this.fare = fare; + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/TripInfoDTO.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/TripInfoDTO.java new file mode 100644 index 0000000..1ec5440 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/TripInfoDTO.java @@ -0,0 +1,35 @@ +package dst.ass2.service.api.trip; + +import java.util.Date; + +public class TripInfoDTO { + + private Double distance; + private Date completed; + private MoneyDTO fare; + + public Double getDistance() { + return distance; + } + + public void setDistance(Double distance) { + this.distance = distance; + } + + public Date getCompleted() { + return completed; + } + + public void setCompleted(Date completed) { + this.completed = completed; + } + + + public MoneyDTO getFare() { + return fare; + } + + public void setFare(MoneyDTO fare) { + this.fare = fare; + } +} diff --git a/ass2-service/api/src/main/java/dst/ass2/service/api/trip/rest/ITripServiceResource.java b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/rest/ITripServiceResource.java new file mode 100644 index 0000000..2ee8a06 --- /dev/null +++ b/ass2-service/api/src/main/java/dst/ass2/service/api/trip/rest/ITripServiceResource.java @@ -0,0 +1,36 @@ +package dst.ass2.service.api.trip.rest; + +import dst.ass2.service.api.trip.*; + +import javax.ws.rs.core.Response; + + +/** + * This interface exposes the {@code ITripService} as a RESTful interface. + */ +public interface ITripServiceResource { + + // TODO annotate the class and methods with the correct javax.ws.rs annotations + + Response createTrip(Long riderId, Long pickupId, Long destinationId) + throws EntityNotFoundException, InvalidTripException; + + Response confirm(Long tripId) throws EntityNotFoundException, InvalidTripException; + + Response getTrip(Long tripId) throws EntityNotFoundException; + + Response deleteTrip(Long tripId) throws EntityNotFoundException; + + Response addStop(Long tripId, Long locationId) throws InvalidTripException, EntityNotFoundException; + + Response removeStop(Long tripId, Long locationId) throws InvalidTripException, EntityNotFoundException; + + Response match(Long tripId, MatchDTO matchDTO) throws EntityNotFoundException, DriverNotAvailableException; + + Response complete(Long tripId, TripInfoDTO tripInfoDTO) throws EntityNotFoundException; + + Response cancel(Long tripId) throws EntityNotFoundException; + + + +} diff --git a/ass2-service/api/src/main/proto/dst/ass2/service/api/auth/proto/auth.proto b/ass2-service/api/src/main/proto/dst/ass2/service/api/auth/proto/auth.proto new file mode 100644 index 0000000..2c92277 --- /dev/null +++ b/ass2-service/api/src/main/proto/dst/ass2/service/api/auth/proto/auth.proto @@ -0,0 +1,3 @@ +syntax = "proto3"; + +// TODO implement authentication service diff --git a/ass2-service/api/src/test/java/dst/ass2/proto/auth/ProtoSpecificationTest.java b/ass2-service/api/src/test/java/dst/ass2/proto/auth/ProtoSpecificationTest.java new file mode 100644 index 0000000..b2bfbee --- /dev/null +++ b/ass2-service/api/src/test/java/dst/ass2/proto/auth/ProtoSpecificationTest.java @@ -0,0 +1,66 @@ +package dst.ass2.proto.auth; + +import io.grpc.MethodDescriptor; +import io.grpc.ServiceDescriptor; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.hamcrest.CoreMatchers.hasItems; +import static org.hamcrest.MatcherAssert.assertThat; + +public class ProtoSpecificationTest { + + private ClassLoader cl; + public static final String SERVICE_NAME = "dst.ass2.service.api.auth.proto.AuthService"; + public static final String GRPC_CLASS_NAME = SERVICE_NAME + "Grpc"; + + @Before + public void setUp() throws Exception { + cl = ProtoSpecificationTest.class.getClassLoader(); + } + + @Test + public void generatedClass_exists() throws Exception { + try { + cl.loadClass(GRPC_CLASS_NAME); + } catch (ClassNotFoundException e) { + throw new AssertionError("Classpath did not contain expected gRPC service class", e); + } + } + + @Test + public void generatedClass_hasMethods() throws Exception { + assertThat(getMethodDescriptors().keySet(), hasItems( + SERVICE_NAME + "/authenticate", + SERVICE_NAME + "/validateToken" + )); + } + + private Map getMethodDescriptors() throws ClassNotFoundException { + return getMethodDescriptors(getServiceDescriptor(cl.loadClass(GRPC_CLASS_NAME))); + } + + private Map getMethodDescriptors(ServiceDescriptor sd) { + return sd.getMethods().stream() + .collect(Collectors.toMap( + MethodDescriptor::getFullMethodName, + Function.identity() + )); + } + + private ServiceDescriptor getServiceDescriptor(Class grpcClass) { + try { + Method getServiceDescriptor = grpcClass.getDeclaredMethod("getServiceDescriptor"); + getServiceDescriptor.setAccessible(true); + return (ServiceDescriptor) getServiceDescriptor.invoke(null); + } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + throw new RuntimeException("Error finding service descriptor in " + grpcClass.getName(), e); + } + } +} diff --git a/ass2-service/auth-client/pom.xml b/ass2-service/auth-client/pom.xml new file mode 100644 index 0000000..6b26ecc --- /dev/null +++ b/ass2-service/auth-client/pom.xml @@ -0,0 +1,72 @@ + + + + 4.0.0 + + + at.ac.tuwien.infosys.dst + dst + 2021.1 + ../.. + + + ass2-service-auth-client + + DST :: Assignment 2 :: Service :: Auth Client + + jar + + + + at.ac.tuwien.infosys.dst + ass2-service-api + ${project.version} + + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + io.grpc + grpc-netty + + + + at.ac.tuwien.infosys.dst + ass2-service-auth + ${project.version} + test + + + at.ac.tuwien.infosys.dst + ass1-jpa + ${project.version} + test-jar + test + + + at.ac.tuwien.infosys.dst + ass2-service-auth + ${project.version} + test-jar + test + + + org.springframework + spring-orm + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/AuthenticationClientProperties.java b/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/AuthenticationClientProperties.java new file mode 100644 index 0000000..ad91c83 --- /dev/null +++ b/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/AuthenticationClientProperties.java @@ -0,0 +1,37 @@ +package dst.ass2.service.auth.client; + +/** + * This class holds the host and port value used to connect to the gRPC server. The CDI context provides an instance + * that you can inject into your implementation of {@link IAuthenticationClient}. The config values are loaded from the + * application.properties file. + */ +public class AuthenticationClientProperties { + + private String host; + private int port; + + public AuthenticationClientProperties() { + + } + + public AuthenticationClientProperties(String host, int port) { + this.host = host; + this.port = port; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } +} diff --git a/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/IAuthenticationClient.java b/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/IAuthenticationClient.java new file mode 100644 index 0000000..dc38f0a --- /dev/null +++ b/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/IAuthenticationClient.java @@ -0,0 +1,17 @@ +package dst.ass2.service.auth.client; + +import dst.ass2.service.api.auth.AuthenticationException; +import dst.ass2.service.api.auth.NoSuchUserException; + +public interface IAuthenticationClient extends AutoCloseable { + + String authenticate(String email, String password) throws NoSuchUserException, AuthenticationException; + + boolean isTokenValid(String token); + + /** + * Shuts down any underlying resource required to maintain this client. + */ + @Override + void close(); +} diff --git a/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/impl/GrpcAuthenticationClient.java b/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/impl/GrpcAuthenticationClient.java new file mode 100644 index 0000000..3ae5082 --- /dev/null +++ b/ass2-service/auth-client/src/main/java/dst/ass2/service/auth/client/impl/GrpcAuthenticationClient.java @@ -0,0 +1,32 @@ +package dst.ass2.service.auth.client.impl; + +import dst.ass2.service.api.auth.AuthenticationException; +import dst.ass2.service.api.auth.NoSuchUserException; +import dst.ass2.service.auth.client.AuthenticationClientProperties; +import dst.ass2.service.auth.client.IAuthenticationClient; + +public class GrpcAuthenticationClient implements IAuthenticationClient { + + // TODO make use of the generated grpc sources to implement a blocking client + + public GrpcAuthenticationClient(AuthenticationClientProperties properties) { + // TODO + } + + @Override + public String authenticate(String email, String password) throws NoSuchUserException, AuthenticationException { + // TODO + return null; + } + + @Override + public boolean isTokenValid(String token) { + // TODO + return false; + } + + @Override + public void close() { + // TODO + } +} diff --git a/ass2-service/auth-client/src/test/java/dst/ass2/service/auth/client/AuthenticationClientTest.java b/ass2-service/auth-client/src/test/java/dst/ass2/service/auth/client/AuthenticationClientTest.java new file mode 100644 index 0000000..1f618f2 --- /dev/null +++ b/ass2-service/auth-client/src/test/java/dst/ass2/service/auth/client/AuthenticationClientTest.java @@ -0,0 +1,76 @@ +package dst.ass2.service.auth.client; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.UUID; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.annotation.Transactional; + +import dst.ass1.jpa.tests.TestData; +import dst.ass2.service.api.auth.AuthenticationException; +import dst.ass2.service.api.auth.NoSuchUserException; +import dst.ass2.service.auth.AuthenticationServiceApplication; +import dst.ass2.service.auth.client.impl.GrpcAuthenticationClient; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = AuthenticationServiceApplication.class) +@Transactional +@ActiveProfiles({"testdata", "grpc"}) +public class AuthenticationClientTest { + + @Value("${grpc.port}") + private int port; + + private IAuthenticationClient client; + + @Before + public void setUp() throws Exception { + AuthenticationClientProperties properties = new AuthenticationClientProperties("localhost", port); + client = new GrpcAuthenticationClient(properties); + } + + @After + public void tearDown() throws Exception { + client.close(); + } + + @Test(expected = NoSuchUserException.class) + public void authenticate_invalidUser_throwsException() throws Exception { + client.authenticate("nonexisting@example.com", "foo"); + } + + @Test(expected = AuthenticationException.class) + public void authenticate_invalidPassword_throwsException() throws Exception { + client.authenticate(TestData.RIDER_1_EMAIL, "foo"); + } + + @Test + public void authenticate_existingUser_returnsToken() throws Exception { + String token = client.authenticate(TestData.RIDER_1_EMAIL, TestData.RIDER_1_PW); + assertNotNull(token); + } + + @Test + public void isTokenValid_invalidToken_returnsFalse() throws Exception { + boolean valid = client.isTokenValid(UUID.randomUUID().toString()); // should be false in *almost* all cases ;-) + assertFalse(valid); + } + + @Test + public void isTokenValid_onCreatedToken_returnsTrue() throws Exception { + String token = client.authenticate(TestData.RIDER_2_EMAIL, TestData.RIDER_2_PW); + boolean valid = client.isTokenValid(token); + assertTrue(valid); + } + +} diff --git a/ass2-service/auth/pom.xml b/ass2-service/auth/pom.xml new file mode 100644 index 0000000..9e36d64 --- /dev/null +++ b/ass2-service/auth/pom.xml @@ -0,0 +1,80 @@ + + + + 4.0.0 + + + at.ac.tuwien.infosys.dst + dst + 2021.1 + ../.. + + + ass2-service-auth + + DST :: Assignment 2 :: Service :: Auth Server + + jar + + + + at.ac.tuwien.infosys.dst + ass1-jpa + ${project.version} + + + at.ac.tuwien.infosys.dst + ass2-service-api + ${project.version} + + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + io.grpc + grpc-netty + + + + at.ac.tuwien.infosys.dst + ass1-jpa + ${project.version} + test-jar + test + + + org.springframework + spring-orm + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + kr.motd.maven + os-maven-plugin + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + + + diff --git a/ass2-service/auth/src/main/java/dst/ass2/service/auth/ICachingAuthenticationService.java b/ass2-service/auth/src/main/java/dst/ass2/service/auth/ICachingAuthenticationService.java new file mode 100644 index 0000000..c2c4362 --- /dev/null +++ b/ass2-service/auth/src/main/java/dst/ass2/service/auth/ICachingAuthenticationService.java @@ -0,0 +1,30 @@ +package dst.ass2.service.auth; + +import dst.ass2.service.api.auth.AuthenticationException; +import dst.ass2.service.api.auth.IAuthenticationService; +import dst.ass2.service.api.auth.NoSuchUserException; + +public interface ICachingAuthenticationService extends IAuthenticationService { + + /** + * {@inheritDoc} + * + *

+ * Instead of checking database records directly, the method first checks the cache for existing users. If the user + * is not in the cache, then the service checks the database for the given email address, and updates the cache if + * necessary. + *

+ */ + @Override + String authenticate(String email, String password) throws NoSuchUserException, AuthenticationException; + + /** + * Loads user data from the database into memory. + */ + void loadData(); + + /** + * Clears the data cached from the database. + */ + void clearCache(); +} diff --git a/ass2-service/auth/src/main/java/dst/ass2/service/auth/grpc/GrpcServerProperties.java b/ass2-service/auth/src/main/java/dst/ass2/service/auth/grpc/GrpcServerProperties.java new file mode 100644 index 0000000..badbfa2 --- /dev/null +++ b/ass2-service/auth/src/main/java/dst/ass2/service/auth/grpc/GrpcServerProperties.java @@ -0,0 +1,26 @@ +package dst.ass2.service.auth.grpc; + +/** + * This class holds the port value used to bind the gRPC server. The CDI context provides an instance that you can + * inject into your implementation of {@link IGrpcServerRunner}. The config values are loaded from the + * grpc.properties. + */ +public class GrpcServerProperties { + + private int port; + + public GrpcServerProperties() { + } + + public GrpcServerProperties(int port) { + this.port = port; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } +} diff --git a/ass2-service/auth/src/main/java/dst/ass2/service/auth/grpc/IGrpcServerRunner.java b/ass2-service/auth/src/main/java/dst/ass2/service/auth/grpc/IGrpcServerRunner.java new file mode 100644 index 0000000..c847b9b --- /dev/null +++ b/ass2-service/auth/src/main/java/dst/ass2/service/auth/grpc/IGrpcServerRunner.java @@ -0,0 +1,16 @@ +package dst.ass2.service.auth.grpc; + +import java.io.IOException; + +/** + * An implementation of this interface is expected by the application to start the grpc server. Inject get + * {@link GrpcServerProperties} to access the configuration. + */ +public interface IGrpcServerRunner { + /** + * Starts the gRPC server. + * + * @throws IOException start error + */ + void run() throws IOException; +} diff --git a/ass2-service/auth/src/main/resources/logback.xml b/ass2-service/auth/src/main/resources/logback.xml new file mode 100644 index 0000000..e16fad1 --- /dev/null +++ b/ass2-service/auth/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} - %highlight(%5p) [%12.12thread] %cyan(%-40.40logger{39}): %m%n + + + + + + + + diff --git a/ass2-service/auth/src/test/java/dst/ass2/service/auth/AuthenticationServiceApplication.java b/ass2-service/auth/src/test/java/dst/ass2/service/auth/AuthenticationServiceApplication.java new file mode 100644 index 0000000..6c9772b --- /dev/null +++ b/ass2-service/auth/src/test/java/dst/ass2/service/auth/AuthenticationServiceApplication.java @@ -0,0 +1,15 @@ +package dst.ass2.service.auth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication +@EnableTransactionManagement +public class AuthenticationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthenticationServiceApplication.class, args); + } + +} diff --git a/ass2-service/auth/src/test/java/dst/ass2/service/auth/AuthenticationServiceApplicationConfig.java b/ass2-service/auth/src/test/java/dst/ass2/service/auth/AuthenticationServiceApplicationConfig.java new file mode 100644 index 0000000..e39264a --- /dev/null +++ b/ass2-service/auth/src/test/java/dst/ass2/service/auth/AuthenticationServiceApplicationConfig.java @@ -0,0 +1,117 @@ +package dst.ass2.service.auth; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.PropertySource; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalEntityManagerFactoryBean; +import org.springframework.transaction.PlatformTransactionManager; + +import dst.ass1.jpa.dao.IDAOFactory; +import dst.ass1.jpa.dao.impl.DAOFactory; +import dst.ass1.jpa.model.IModelFactory; +import dst.ass1.jpa.model.impl.ModelFactory; +import dst.ass1.jpa.tests.TestData; +import dst.ass1.jpa.util.Constants; +import dst.ass2.service.auth.grpc.GrpcServerProperties; + +@SpringBootConfiguration +@PropertySource("classpath:/dst/ass2/service/auth/grpc.properties") +public class AuthenticationServiceApplicationConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public IModelFactory modelFactory() { + return new ModelFactory(); + } + + @Bean + public IDAOFactory daoFactory() { + return new DAOFactory(em); + } + + @Bean + public GrpcServerProperties grpcServerProperties(@Value("${grpc.port}") int port) { + return new GrpcServerProperties(port); + } + + @Bean + @Profile("grpc") + public SpringGrpcServerRunner springGrpcServerRunner() { + return new SpringGrpcServerRunner(); + } + + @Bean + public LocalEntityManagerFactoryBean entityManagerFactoryBean() { + LocalEntityManagerFactoryBean bean = new LocalEntityManagerFactoryBean(); + bean.setPersistenceUnitName(Constants.JPA_PERSISTENCE_UNIT); + // fixes collection proxy problem when using jersey + bean.getJpaPropertyMap().put("hibernate.enable_lazy_load_no_trans", true); + return bean; + } + + @Bean + public PlatformTransactionManager transactionManager(LocalEntityManagerFactoryBean entityManagerFactoryBean) { + JpaTransactionManager transactionManager = new JpaTransactionManager(); + transactionManager.setPersistenceUnitName(Constants.JPA_PERSISTENCE_UNIT); + transactionManager.setEntityManagerFactory(entityManagerFactoryBean.getObject()); + return transactionManager; + } + + @Bean + @Profile("testdata") + public TestData testData() { + return new TestData(); + } + + @Bean + @Profile("testdata") + public TestDataInserter testDataInserter(TestData testData, IModelFactory modelFactory, PlatformTransactionManager transactionManager) { + return new TestDataInserter(testData, modelFactory, transactionManager); + } + + @Bean + @Profile("testdata") + public AuthServiceDataInjector dataInjector(TestDataInserter testDataInserter) { + return new AuthServiceDataInjector(em, testDataInserter); + } + + /** + * Makes sure data is in the database before the {@link ICachingAuthenticationService} is initialized. + */ + public static class AuthServiceDataInjector implements BeanPostProcessor { + private boolean dataInjected = false; + + private EntityManager em; + private TestDataInserter testDataInserter; + + public AuthServiceDataInjector(EntityManager em, TestDataInserter testDataInserter) { + this.em = em; + this.testDataInserter = testDataInserter; + } + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + if (!dataInjected && (bean instanceof ICachingAuthenticationService)) { + testDataInserter.insertTestData(em); + dataInjected = true; + } + return bean; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + return bean; + } + } + +} diff --git a/ass2-service/auth/src/test/java/dst/ass2/service/auth/SpringGrpcServerRunner.java b/ass2-service/auth/src/test/java/dst/ass2/service/auth/SpringGrpcServerRunner.java new file mode 100644 index 0000000..2b77fe0 --- /dev/null +++ b/ass2-service/auth/src/test/java/dst/ass2/service/auth/SpringGrpcServerRunner.java @@ -0,0 +1,32 @@ +package dst.ass2.service.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +import dst.ass2.service.auth.grpc.IGrpcServerRunner; + +/** + * This class loads the {@link IGrpcServerRunner} from the application context and runs it after the application starts. + */ +public class SpringGrpcServerRunner implements CommandLineRunner, ApplicationContextAware { + + private static final Logger LOG = LoggerFactory.getLogger(SpringGrpcServerRunner.class); + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public void run(String... args) throws Exception { + LOG.info("Getting instance of GrpcServerRunner"); + IGrpcServerRunner bean = applicationContext.getBean(IGrpcServerRunner.class); + LOG.info("Starting IGrpcServerRunner instance {}", bean); + bean.run(); + } +} diff --git a/ass2-service/auth/src/test/java/dst/ass2/service/auth/TestDataInserter.java b/ass2-service/auth/src/test/java/dst/ass2/service/auth/TestDataInserter.java new file mode 100644 index 0000000..a602f49 --- /dev/null +++ b/ass2-service/auth/src/test/java/dst/ass2/service/auth/TestDataInserter.java @@ -0,0 +1,30 @@ +package dst.ass2.service.auth; + +import dst.ass1.jpa.model.IModelFactory; +import dst.ass1.jpa.tests.TestData; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.persistence.EntityManager; + +public class TestDataInserter { + + private PlatformTransactionManager transactionManager; + private IModelFactory modelFactory; + private TestData testData; + + public TestDataInserter(TestData testData, IModelFactory modelFactory, PlatformTransactionManager transactionManager) { + this.testData = testData; + this.modelFactory = modelFactory; + this.transactionManager = transactionManager; + } + + public void insertTestData(EntityManager em) { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.execute(status -> { + testData.insert(modelFactory, em); + return null; + }); + } + +} diff --git a/ass2-service/auth/src/test/java/dst/ass2/service/auth/tests/AuthenticationServiceTest.java b/ass2-service/auth/src/test/java/dst/ass2/service/auth/tests/AuthenticationServiceTest.java new file mode 100644 index 0000000..0e6905d --- /dev/null +++ b/ass2-service/auth/src/test/java/dst/ass2/service/auth/tests/AuthenticationServiceTest.java @@ -0,0 +1,143 @@ +package dst.ass2.service.auth.tests; + +import dst.ass1.jpa.model.IModelFactory; +import dst.ass1.jpa.model.IPreferences; +import dst.ass1.jpa.model.IRider; +import dst.ass1.jpa.tests.TestData; +import dst.ass2.service.api.auth.AuthenticationException; +import dst.ass2.service.api.auth.NoSuchUserException; +import dst.ass2.service.auth.AuthenticationServiceApplication; +import dst.ass2.service.auth.ICachingAuthenticationService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.BeansException; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.transaction.Transactional; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = AuthenticationServiceApplication.class) +@Transactional +@ActiveProfiles("testdata") +public class AuthenticationServiceTest implements ApplicationContextAware { + + @PersistenceContext + private EntityManager em; + + private ApplicationContext applicationContext; + + private IModelFactory modelFactory; + private ICachingAuthenticationService authenticationService; + + @Override + public void setApplicationContext(ApplicationContext ctx) throws BeansException { + applicationContext = ctx; + } + + @Before + public void setUp() { + modelFactory = applicationContext.getBean(IModelFactory.class); + authenticationService = applicationContext.getBean(ICachingAuthenticationService.class); + + // reload the data before each test + authenticationService.loadData(); + } + + @Test + public void authenticate_existingUser_createsTokenCorrectly() throws Exception { + String token = authenticationService.authenticate(TestData.RIDER_1_EMAIL, TestData.RIDER_1_PW); + assertNotNull(token); + } + + @Test + public void authenticate_existingUserNotInCache_createsTokenCorrectly() throws Exception { + IRider p = modelFactory.createRider(); + + p.setEmail("non-cached@example.com"); + try { + p.setPassword(MessageDigest.getInstance("SHA1").digest("somepw".getBytes())); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + p.setName("non-cached"); + p.setAccountNo("accountno"); + p.setBankCode("bankcode"); + p.setTel("+43158801"); + IPreferences prefs = modelFactory.createPreferences(); + p.setPreferences(prefs); + + em.persist(prefs); + em.persist(p); + + String token = authenticationService.authenticate(p.getEmail(), "somepw"); + assertNotNull(token); + } + + @Test(expected = NoSuchUserException.class) + public void authenticate_invalidUser_throwsException() throws Exception { + authenticationService.authenticate("nonexisting@example.com", "foo"); + } + + @Test(expected = AuthenticationException.class) + public void authenticate_invalidPassword_throwsException() throws Exception { + authenticationService.authenticate(TestData.RIDER_1_EMAIL, "foo"); + } + + @Test + public void changePassword_existingUser_passwordChanged() throws Exception { + authenticationService.changePassword(TestData.RIDER_1_EMAIL, "newPwd"); + assertNotNull(authenticationService.authenticate(TestData.RIDER_1_EMAIL, "newPwd")); + } + + @Test(expected = NoSuchUserException.class) + public void changePassword_nonExistingUser_throwsException() throws Exception { + authenticationService.changePassword("nonexisting@example.com", "foo"); + } + + @Test + public void getUser_existingToken_returnsUser() throws Exception { + String token = authenticationService.authenticate(TestData.RIDER_1_EMAIL, TestData.RIDER_1_PW); + assertEquals(TestData.RIDER_1_EMAIL, authenticationService.getUser(token)); + } + + @Test + public void getUser_nonExistingToken_returnsNull() throws Exception { + assertNull(authenticationService.getUser("invalidToken")); + } + + @Test + public void isValid_existingToken_returnsTrue() throws Exception { + String token = authenticationService.authenticate(TestData.RIDER_1_EMAIL, TestData.RIDER_1_PW); + assertTrue(authenticationService.isValid(token)); + } + + @Test + public void isValid_nonExistingToken_returnsFalse() throws Exception { + assertFalse(authenticationService.isValid("invalidToken")); + } + + @Test + public void invalidate_validToken_tokenInvalidatedReturnsTrue() throws Exception { + String token = authenticationService.authenticate(TestData.RIDER_1_EMAIL, TestData.RIDER_1_PW); + assertTrue(authenticationService.invalidate(token)); + assertFalse(authenticationService.isValid(token)); + assertNull(authenticationService.getUser(token)); + } + + @Test + public void invalidate_invalidToken_returnsFalse() throws Exception { + assertFalse(authenticationService.invalidate("invalidToken")); + } + +} diff --git a/ass2-service/auth/src/test/java/dst/ass2/service/auth/tests/GrpcServerRunnerTest.java b/ass2-service/auth/src/test/java/dst/ass2/service/auth/tests/GrpcServerRunnerTest.java new file mode 100644 index 0000000..9ec8298 --- /dev/null +++ b/ass2-service/auth/src/test/java/dst/ass2/service/auth/tests/GrpcServerRunnerTest.java @@ -0,0 +1,44 @@ +package dst.ass2.service.auth.tests; + +import dst.ass2.service.auth.AuthenticationServiceApplication; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; + +import java.net.Socket; + +@ActiveProfiles({"testdata", "grpc"}) +@RunWith(SpringRunner.class) +@SpringBootTest(classes = AuthenticationServiceApplication.class) +public class GrpcServerRunnerTest { + + private static final Logger LOG = LoggerFactory.getLogger(GrpcServerRunnerTest.class); + + @Value("${grpc.port}") + private int port; + + @Test + public void canConnectToGrpcSocketAfterApplicationInitialization() throws Exception { + int n = 4; + + while (true) { + LOG.info("Tyring to connect to TCP socket on localhost:{}", port); + try (Socket socket = new Socket("localhost", port)) { + return; + } catch (Exception e) { + if (n == 0) { + throw new AssertionError("Expected gRPC server to run on port " + port, e); + } else { + Thread.sleep(250); + } + } + n--; + } + + } +} diff --git a/ass2-service/auth/src/test/resources/dst/ass2/service/auth/grpc.properties b/ass2-service/auth/src/test/resources/dst/ass2/service/auth/grpc.properties new file mode 100644 index 0000000..9d203f4 --- /dev/null +++ b/ass2-service/auth/src/test/resources/dst/ass2/service/auth/grpc.properties @@ -0,0 +1 @@ +grpc.port=50051 \ No newline at end of file diff --git a/ass2-service/facade/pom.xml b/ass2-service/facade/pom.xml new file mode 100644 index 0000000..a5c8eb9 --- /dev/null +++ b/ass2-service/facade/pom.xml @@ -0,0 +1,53 @@ + + + + 4.0.0 + + + at.ac.tuwien.infosys.dst + dst + 2021.1 + ../.. + + + ass2-service-facade + + DST :: Assignment 2 :: Service :: Facade + + jar + + + + at.ac.tuwien.infosys.dst + ass2-service-api + ${project.version} + + + at.ac.tuwien.infosys.dst + ass2-service-auth-client + ${project.version} + + + + org.glassfish.jersey.core + jersey-client + + + org.glassfish.jersey.ext + jersey-proxy-client + + + + org.springframework.boot + spring-boot-starter-jersey + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + \ No newline at end of file diff --git a/ass2-service/facade/src/main/java/dst/ass2/service/facade/impl/.gitkeep b/ass2-service/facade/src/main/java/dst/ass2/service/facade/impl/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ass2-service/facade/src/test/java/dst/ass2/service/facade/ServiceFacadeApplication.java b/ass2-service/facade/src/test/java/dst/ass2/service/facade/ServiceFacadeApplication.java new file mode 100644 index 0000000..998d5f2 --- /dev/null +++ b/ass2-service/facade/src/test/java/dst/ass2/service/facade/ServiceFacadeApplication.java @@ -0,0 +1,13 @@ +package dst.ass2.service.facade; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ServiceFacadeApplication { + + public static void main(String[] args) { + SpringApplication.run(ServiceFacadeApplication.class, args); + } + +} diff --git a/ass2-service/facade/src/test/java/dst/ass2/service/facade/ServiceFacadeApplicationConfig.java b/ass2-service/facade/src/test/java/dst/ass2/service/facade/ServiceFacadeApplicationConfig.java new file mode 100644 index 0000000..7e44d87 --- /dev/null +++ b/ass2-service/facade/src/test/java/dst/ass2/service/facade/ServiceFacadeApplicationConfig.java @@ -0,0 +1,81 @@ +package dst.ass2.service.facade; + +import dst.ass2.service.api.auth.AuthenticationException; +import dst.ass2.service.api.auth.NoSuchUserException; +import dst.ass2.service.auth.client.AuthenticationClientProperties; +import dst.ass2.service.auth.client.IAuthenticationClient; +import dst.ass2.service.auth.client.impl.GrpcAuthenticationClient; +import org.glassfish.jersey.server.ResourceConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; + +import java.net.URI; + +@SpringBootConfiguration +public class ServiceFacadeApplicationConfig { + + @Bean + public ResourceConfig jerseyConfig() { + return new ResourceConfig() + .packages("dst.ass2.service.facade"); + } + + @Bean + public URI tripServiceURI(@Value("${tripservice.uri}") URI target) { + return target; + } + + @Bean + public AuthenticationClientProperties authenticationClientProperties( + @Value("${auth.host}") String host, + @Value("${auth.port}") int port) { + return new AuthenticationClientProperties(host, port); + } + + @Bean + @Profile("!AuthenticationResourceTest") + // only use this when we're not running individual tests + public IAuthenticationClient grpcAuthenticationClient(AuthenticationClientProperties authenticationClientProperties) { + return new GrpcAuthenticationClient(authenticationClientProperties); + } + + @Bean + @Profile("AuthenticationResourceTest") + public IAuthenticationClient mockAuthenticationClient() { + return new MockAuthenticationClient(); + } + + public static class MockAuthenticationClient implements IAuthenticationClient { + + private static final Logger LOG = LoggerFactory.getLogger(MockAuthenticationClient.class); + + public static String TOKEN = "123e4567-e89b-12d3-a456-426655440000"; + + @Override + public String authenticate(String email, String password) throws NoSuchUserException, AuthenticationException { + LOG.info("Calling MockAuthenticationClient with {}, {}", email, password); + + if (email.equals("junit@example.com")) { + if (password.equals("junit")) { + return TOKEN; + } + throw new AuthenticationException(); + } + throw new NoSuchUserException(); + } + + @Override + public boolean isTokenValid(String t) { + return TOKEN.equals(t); + } + + @Override + public void close() { + // pass + } + } +} diff --git a/ass2-service/facade/src/test/java/dst/ass2/service/facade/test/AuthenticationResourceTest.java b/ass2-service/facade/src/test/java/dst/ass2/service/facade/test/AuthenticationResourceTest.java new file mode 100644 index 0000000..5b41d6a --- /dev/null +++ b/ass2-service/facade/src/test/java/dst/ass2/service/facade/test/AuthenticationResourceTest.java @@ -0,0 +1,97 @@ +package dst.ass2.service.facade.test; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertNotNull; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import dst.ass2.service.facade.ServiceFacadeApplication; +import dst.ass2.service.facade.ServiceFacadeApplicationConfig; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = ServiceFacadeApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("AuthenticationResourceTest") +public class AuthenticationResourceTest { + + @LocalServerPort + private int port; + + private RestTemplate restTemplate; + private HttpHeaders headers; + + @Before + public void setUp() { + headers = new HttpHeaders(); + restTemplate = new RestTemplate(); + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + BufferingClientHttpRequestFactory bufferingClientHttpRequestFactory = new BufferingClientHttpRequestFactory(requestFactory); + requestFactory.setOutputStreaming(false); + restTemplate.setRequestFactory(bufferingClientHttpRequestFactory); + } + + @Test + public void authenticate_withValidUser_returnsOkAndToken() throws Exception { + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String url = url("/auth/authenticate"); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("email", "junit@example.com"); + body.add("password", "junit"); + + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + assertThat(response.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + assertNotNull(response.getBody()); + assertThat(response.getBody(), is(ServiceFacadeApplicationConfig.MockAuthenticationClient.TOKEN)); + } + + @Test + public void authenticate_withInvalidUser_returnsAppropriateCode() throws Exception { + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String url = url("/auth/authenticate"); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("email", "nonexisting@example.com"); + body.add("password", "wrong"); + + HttpEntity request = new HttpEntity<>(body, headers); + HttpStatus status; + + try { + status = restTemplate.postForEntity(url, request, String.class).getStatusCode(); + } catch (HttpClientErrorException e) { + status = e.getStatusCode(); + } + + assertThat("Return an appropriate error code", status, allOf( + not(HttpStatus.OK), + not(HttpStatus.NOT_FOUND), + not(HttpStatus.INTERNAL_SERVER_ERROR) + )); + } + + private String url(String uri) { + return "http://localhost:" + port + uri; + } + +} diff --git a/ass2-service/facade/src/test/resources/application.properties b/ass2-service/facade/src/test/resources/application.properties new file mode 100644 index 0000000..e4ba079 --- /dev/null +++ b/ass2-service/facade/src/test/resources/application.properties @@ -0,0 +1,8 @@ +server.port=8090 + +# this is the gRPC host the client tries to connect to +auth.host=localhost +auth.port=50051 + +# this is the root of the remote resource that the facade accesses via its client +tripservice.uri=http://localhost:8091/ diff --git a/ass2-service/facade/src/test/resources/logback.xml b/ass2-service/facade/src/test/resources/logback.xml new file mode 100644 index 0000000..e16fad1 --- /dev/null +++ b/ass2-service/facade/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} - %highlight(%5p) [%12.12thread] %cyan(%-40.40logger{39}): %m%n + + + + + + + + diff --git a/ass2-service/trip/pom.xml b/ass2-service/trip/pom.xml new file mode 100644 index 0000000..c7821f9 --- /dev/null +++ b/ass2-service/trip/pom.xml @@ -0,0 +1,62 @@ + + + + 4.0.0 + + + at.ac.tuwien.infosys.dst + dst + 2021.1 + ../.. + + + ass2-service-trip + + DST :: Assignment 2 :: Service :: Trip + + jar + + + + at.ac.tuwien.infosys.dst + ass1-jpa + ${project.version} + + + at.ac.tuwien.infosys.dst + ass2-service-api + ${project.version} + + + + org.apache.httpcomponents + httpclient + + + + at.ac.tuwien.infosys.dst + ass1-jpa + ${project.version} + test-jar + test + + + org.springframework + spring-orm + test + + + org.springframework.boot + spring-boot-starter-jersey + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + diff --git a/ass2-service/trip/src/main/java/dst/ass2/service/trip/impl/.gitkeep b/ass2-service/trip/src/main/java/dst/ass2/service/trip/impl/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ass2-service/trip/src/test/java/dst/ass2/service/trip/MatchingService.java b/ass2-service/trip/src/test/java/dst/ass2/service/trip/MatchingService.java new file mode 100644 index 0000000..207323f --- /dev/null +++ b/ass2-service/trip/src/test/java/dst/ass2/service/trip/MatchingService.java @@ -0,0 +1,73 @@ +package dst.ass2.service.trip; + +import dst.ass1.jpa.dao.IDAOFactory; +import dst.ass1.jpa.dao.IDriverDAO; +import dst.ass1.jpa.model.IDriver; +import dst.ass2.service.api.match.IMatchingService; +import dst.ass2.service.api.trip.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.ManagedBean; +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.transaction.Transactional; +import java.math.BigDecimal; +import java.util.List; + +@ManagedBean +@Transactional +public class MatchingService implements IMatchingService { + + @Inject + private ITripService tripService; + + @Inject + private IDAOFactory daoFactory; + + private IDriverDAO driverDAO; + + private int driverIndex; + + private static final Logger LOG = LoggerFactory.getLogger(MatchingService.class); + private List all; + + @PostConstruct + public void setup() { + driverDAO = daoFactory.createDriverDAO(); + } + + @Override + public MoneyDTO calculateFare(TripDTO trip) throws InvalidTripException { + LOG.info("Calculate fare for trip: {}", trip.getId()); + BigDecimal value = Math.random() > 0.5 ? BigDecimal.ONE : BigDecimal.TEN; + MoneyDTO moneyDTO = new MoneyDTO(); + moneyDTO.setCurrency("EUR"); + moneyDTO.setValue(value); + return moneyDTO; + } + + @Override + public void queueTripForMatching(Long tripId) { + if (all == null) { + all = driverDAO.findAll(); + driverIndex = all.size() - 2; + } + TripDTO trip = tripService.find(tripId); + try { + LOG.info("Queue trip {} for matching", tripId); + IDriver driver = all.get(driverIndex++); + if (driverIndex >= all.size()) { + driverIndex = 0; + } + + MatchDTO matchDTO = new MatchDTO(); + matchDTO.setDriverId(driver.getId()); + matchDTO.setVehicleId(driver.getVehicle().getId()); + matchDTO.setFare(trip.getFare()); + tripService.match(tripId, matchDTO); + } catch (EntityNotFoundException | DriverNotAvailableException e) { + //LOG.error("Error during matching", e); + } + } +} diff --git a/ass2-service/trip/src/test/java/dst/ass2/service/trip/TestDataConfig.java b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TestDataConfig.java new file mode 100644 index 0000000..6fe9907 --- /dev/null +++ b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TestDataConfig.java @@ -0,0 +1,38 @@ +package dst.ass2.service.trip; + +import dst.ass1.jpa.model.IModelFactory; +import dst.ass1.jpa.tests.TestData; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +@SpringBootConfiguration +@Profile("testdata") +public class TestDataConfig implements ApplicationListener { + + @PersistenceContext + private EntityManager em; + + @Bean + public TestData testData() { + return new TestData(); + } + + @Bean + public TestDataInserter testDataInserter(TestData testData, IModelFactory modelFactory, PlatformTransactionManager transactionManager) { + return new TestDataInserter(testData, modelFactory, transactionManager); + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + event.getApplicationContext() + .getBean(TestDataInserter.class) + .insertTestData(em); + } +} diff --git a/ass2-service/trip/src/test/java/dst/ass2/service/trip/TestDataInserter.java b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TestDataInserter.java new file mode 100644 index 0000000..6227d5b --- /dev/null +++ b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TestDataInserter.java @@ -0,0 +1,34 @@ +package dst.ass2.service.trip; + +import dst.ass1.jpa.model.IModelFactory; +import dst.ass1.jpa.tests.TestData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.persistence.EntityManager; + +public class TestDataInserter { + private static final Logger LOG = LoggerFactory.getLogger(TestDataInserter.class); + + private PlatformTransactionManager transactionManager; + private IModelFactory modelFactory; + private TestData testData; + + public TestDataInserter(TestData testData, IModelFactory modelFactory, PlatformTransactionManager transactionManager) { + this.testData = testData; + this.modelFactory = modelFactory; + this.transactionManager = transactionManager; + } + + public void insertTestData(EntityManager em) { + LOG.info("Inserting test data..."); + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.execute(status -> { + testData.insert(modelFactory, em); + return null; + }); + } + +} diff --git a/ass2-service/trip/src/test/java/dst/ass2/service/trip/TripApplication.java b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TripApplication.java new file mode 100644 index 0000000..21eaac2 --- /dev/null +++ b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TripApplication.java @@ -0,0 +1,14 @@ +package dst.ass2.service.trip; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication +@EnableTransactionManagement +public class TripApplication { + + public static void main(String[] args) { + SpringApplication.run(TripApplication.class, args); + } +} diff --git a/ass2-service/trip/src/test/java/dst/ass2/service/trip/TripApplicationConfig.java b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TripApplicationConfig.java new file mode 100644 index 0000000..08bf63d --- /dev/null +++ b/ass2-service/trip/src/test/java/dst/ass2/service/trip/TripApplicationConfig.java @@ -0,0 +1,57 @@ +package dst.ass2.service.trip; + +import dst.ass1.jpa.dao.IDAOFactory; +import dst.ass1.jpa.dao.impl.DAOFactory; +import dst.ass1.jpa.model.IModelFactory; +import dst.ass1.jpa.model.impl.ModelFactory; +import dst.ass1.jpa.util.Constants; +import org.glassfish.jersey.server.ResourceConfig; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalEntityManagerFactoryBean; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +@SpringBootConfiguration +public class TripApplicationConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public ResourceConfig jerseyConfig() { + return new ResourceConfig() + .packages("dst.ass2.service.trip"); + } + + @Bean + public IModelFactory modelFactory() { + return new ModelFactory(); + } + + @Bean + public IDAOFactory daoFactory() { + return new DAOFactory(em); + } + + @Bean + public LocalEntityManagerFactoryBean entityManagerFactoryBean() { + LocalEntityManagerFactoryBean bean = new LocalEntityManagerFactoryBean(); + bean.setPersistenceUnitName(Constants.JPA_PERSISTENCE_UNIT); + // fixes collection proxy problem when using jersey + bean.getJpaPropertyMap().put("hibernate.enable_lazy_load_no_trans", true); + return bean; + } + + @Bean + public PlatformTransactionManager transactionManager(LocalEntityManagerFactoryBean entityManagerFactoryBean) { + JpaTransactionManager transactionManager = new JpaTransactionManager(); + transactionManager.setPersistenceUnitName(Constants.JPA_PERSISTENCE_UNIT); + transactionManager.setEntityManagerFactory(entityManagerFactoryBean.getObject()); + return transactionManager; + } + +} diff --git a/ass2-service/trip/src/test/java/dst/ass2/service/trip/tests/TripServiceResourceTest.java b/ass2-service/trip/src/test/java/dst/ass2/service/trip/tests/TripServiceResourceTest.java new file mode 100644 index 0000000..25e17dc --- /dev/null +++ b/ass2-service/trip/src/test/java/dst/ass2/service/trip/tests/TripServiceResourceTest.java @@ -0,0 +1,616 @@ +package dst.ass2.service.trip.tests; + +import dst.ass1.jpa.tests.TestData; +import dst.ass2.service.api.match.IMatchingService; +import dst.ass2.service.api.trip.*; +import dst.ass2.service.trip.TripApplication; +import org.hamcrest.CoreMatchers; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.http.*; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.HashMap; +import java.util.List; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.core.Is.is; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = TripApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("testdata") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class TripServiceResourceTest { + + @Autowired + private TestData testData; + + @LocalServerPort + private int port; + + @MockBean + private IMatchingService matchingService; + + private TestRestTemplate restTemplate; + private HttpHeaders headers; + + @Bean + public RestTemplate restTemplate() { + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); + return new RestTemplate(requestFactory); + } + + @Before + public void setUp() { + this.headers = new HttpHeaders(); + this.restTemplate = new TestRestTemplate(); + } + + private String url(String uri) { + return "http://localhost:" + port + uri; + } + + @Test + public void createTrip_withWrongHttpMethod_returnsError() { + String url = url("/trips"); + MultiValueMap map = getCreateMap(2134L, 2222L, 33L); + ResponseEntity response = restTemplate.getForEntity(url, String.class, map); + + assertThat("Response was: " + response, response.getStatusCode().series(), is(HttpStatus.Series.CLIENT_ERROR)); + assertThat(response.getStatusCode(), not(HttpStatus.NOT_FOUND)); + } + + @Test + public void createTrip_withUnknownRider_returnsNotFoundError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String url = url("/trips"); + MultiValueMap body = getCreateMap(3333L, testData.location1Id, testData.location2Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + assertThat("Response was: " + response, response.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + + @Test + public void createTrip_withValidKeys_returnsTripId() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String url = url("/trips"); + MultiValueMap body = getCreateMap(testData.rider1Id, testData.location1Id, testData.location2Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + assertThat(response.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + assertThat(response.getBody(), notNullValue()); + + try { + Long.parseLong(response.getBody()); + } catch (NumberFormatException e) { + throw new AssertionError("Response body of " + url + " should be a Long value (the trip ID)", e); + } + } + + @Test + public void createTrip_withInvalidTrip_andThenGetTrip_hasNoFare() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String url = url("/trips"); + MultiValueMap body = getCreateMap(testData.rider1Id, testData.location1Id, testData.location2Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + assertThat(response.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + assertThat(response.getBody(), notNullValue()); + + try { + long id = Long.parseLong(response.getBody()); + ResponseEntity trip = getTrip(id); + assertThat(trip.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + assertNull(trip.getBody().getFare()); + } catch (NumberFormatException e) { + throw new AssertionError("Response body of " + url + " should be a Long value (the trip ID)", e); + } + } + + + @Test + public void findTrip_withUnknownKey_returnsNotFoundError() { + String url = url("/trips/" + (Long) 1338L); + ResponseEntity trip = restTemplate.getForEntity(url, String.class); + assertThat(trip.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + @Test + public void findTrip_onCreatedTrip_returnsJsonEntity() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + Long pickup = testData.location5Id; + Long rider = testData.rider4Id; + Long destination = testData.location1Id; + Long trip = testData.trip6Id; + String url = url("/trips/" + trip); + + ResponseEntity response = restTemplate.getForEntity(url, TripDTO.class); + TripDTO tripDTO = response.getBody(); + + assertThat(response.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + assertNotNull(tripDTO); + assertEquals(trip, tripDTO.getId()); + assertEquals(destination, tripDTO.getDestinationId()); + assertEquals(pickup, tripDTO.getPickupId()); + assertEquals(rider, tripDTO.getRiderId()); + assertEquals(3, tripDTO.getStops().size()); + assertEquals(getTen(), tripDTO.getFare()); + } + + @Test + public void addStop_onCreatedTrip_returnsOkAndCalculatedFare() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String url = url("/trips/" + testData.trip6Id + "/stops"); + MultiValueMap body = body("locationId", testData.location5Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, MoneyDTO.class); + MoneyDTO moneyDTO = response.getBody(); + + assertThat(response.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + assertNotNull(moneyDTO); + assertEquals(getTen(), moneyDTO); + } + + @Test + public void addStop_andThenGetTrip_containsStopInList() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + String url = url("/trips/" + testData.trip6Id + "/stops"); + MultiValueMap body = body("locationId", testData.location5Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, MoneyDTO.class); + assertThat(response.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + + ResponseEntity tripResponse = getTrip(testData.trip6Id); + assertThat(tripResponse.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + assertThat(tripResponse.getBody().getStops(), hasItem(testData.location5Id)); + } + + @Test + public void addStop_onQueuedTrip_returnsAppropriateError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + String url = url("/trips/" + testData.trip10Id + "/stops"); + MultiValueMap body = body("locationId", testData.location5Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + assertThat("Make use of an appropriate HTTP status code", response.getStatusCode(), allOf( + CoreMatchers.is(not(HttpStatus.OK)), + CoreMatchers.is(not(HttpStatus.NOT_FOUND)), + CoreMatchers.is(not(HttpStatus.INTERNAL_SERVER_ERROR)) + )); + } + + @Test + public void addStop_withNonExistingLocation_returnsNotFoundError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + Long trip = testData.trip6Id; + + String url = url("/trips/" + trip + "/stops"); + MultiValueMap body = body("locationId", 1337L); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + assertThat(response.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + @Test + public void addStop_withLocationAlreadyInStopList_returnsAppropriateError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + Long trip = testData.trip6Id; + + String url = url("/trips/" + trip + "/stops"); + MultiValueMap body = body("locationId", testData.location2Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + assertThat("Make use of an appropriate HTTP status code", response.getStatusCode(), allOf( + CoreMatchers.is(not(HttpStatus.OK)), + CoreMatchers.is(not(HttpStatus.NOT_FOUND)), + CoreMatchers.is(not(HttpStatus.INTERNAL_SERVER_ERROR)) + )); + } + + @Test + public void addStop_withInvalidTrip_returnsOk() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + Long trip = testData.trip6Id; + + String url = url("/trips/" + trip + "/stops"); + MultiValueMap body = body("locationId", testData.location5Id); + HttpEntity request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + assertThat(response.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void removeStop_onCreatedTrip_returnOk() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + Long trip = testData.trip6Id; + Long location = testData.location2Id; + String url = url("/trips/" + trip + "/stops/" + location); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.DELETE, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void removeStop_andThenGetTrip_doesntContainStopInList() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + Long trip = testData.trip6Id; + Long location = testData.location2Id; + String url = url("/trips/" + trip + "/stops/" + location); + + restTemplate.delete(url, new HashMap<>()); + ResponseEntity response = getTrip(trip); + assertNotNull(response); + List ids = response.getBody().getStops(); + assertFalse(ids.contains(location)); + assertTrue(ids.contains(testData.location3Id)); + assertTrue(ids.contains(testData.location4Id)); + } + + + @Test + public void removeStop_onQueuedTrip_returnsAppropriateError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + Long trip = testData.trip10Id; + Long location = testData.location4Id; + String url = url("/trips/" + trip + "/stops/" + location); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.DELETE, + null, + (Class) null, + new HashMap() + ); + + assertThat("Make use of an appropriate HTTP status code", response.getStatusCode(), allOf( + CoreMatchers.is(not(HttpStatus.OK)), + CoreMatchers.is(not(HttpStatus.NOT_FOUND)), + CoreMatchers.is(not(HttpStatus.INTERNAL_SERVER_ERROR)) + )); + } + + @Test + public void removeStop_withNonExistingLocation_returnsNotFoundError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + Long trip = testData.trip6Id; + Long location = 1234L; + String url = url("/trips/" + trip + "/stops/" + location); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.DELETE, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + + @Test + public void removeStop_WithLocationNotInStopsList_returnsAppropriateError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + Long trip = testData.trip6Id; + Long location = testData.location5Id; + String url = url("/trips/" + trip + "/stops/" + location); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.DELETE, + null, + (Class) null, + new HashMap() + ); + + assertThat("Make use of an appropriate HTTP status code", response.getStatusCode(), allOf( + CoreMatchers.is(not(HttpStatus.OK)), + CoreMatchers.is(not(HttpStatus.NOT_FOUND)), + CoreMatchers.is(not(HttpStatus.INTERNAL_SERVER_ERROR)) + )); + } + + @Test + public void removeStop_withInvalidTrip_returnsOk() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + Long trip = testData.trip6Id; + Long location = testData.location2Id; + String url = url("/trips/" + trip + "/stops/" + location); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.DELETE, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void confirmTrip_withKnownTrip_shouldReturnSuccessful() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + Long trip = testData.trip6Id; + String url = url("/trips/" + trip + "/confirm"); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.PATCH, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void confirmTrip_withUnknownTrip_shouldReturnNotFoundError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + Long trip = 1244L; + String url = url("/trips/" + trip + "/confirm"); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.PATCH, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + @Test + public void confirmTrip_withInvalidTrip_shouldReturnAppropriateError() throws InvalidTripException { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + + Long trip = testData.trip6Id; + String url = url("/trips/" + trip + "/confirm"); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.PATCH, + null, + (Class) null, + new HashMap() + ); + + assertThat("Make use of an appropriate HTTP status code", response.getStatusCode(), allOf( + CoreMatchers.is(not(HttpStatus.OK)), + CoreMatchers.is(not(HttpStatus.NOT_FOUND)), + CoreMatchers.is(not(HttpStatus.INTERNAL_SERVER_ERROR)) + )); + } + + @Test + public void deleteTrip_withKnownTrip_shouldReturnSuccessful() { + Long trip = testData.trip6Id; + String url = url("/trips/" + trip); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.DELETE, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void deleteTrip_withUnknownTrip_shouldReturnNotFoundError() { + Long trip = 1245L; + String url = url("/trips/" + trip); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.DELETE, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + @Test + public void cancelTrip_withKnownKey_shouldReturnSuccessful() { + Long trip = testData.trip6Id; + String url = url("/trips/" + trip + "/cancel"); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.PATCH, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void cancelTrip_withUnknownKey_shouldReturnNotFoundError() { + Long trip = 12545L; + String url = url("/trips/" + trip + "/cancel"); + + ResponseEntity exchange = restTemplate.exchange( + url, + HttpMethod.PATCH, + null, + (Class) null, + new HashMap() + ); + + assertThat(exchange.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + @Test + public void completeTrip_withKnownTrip_shouldReturnSuccessful() { + Long trip = testData.trip9Id; + TripInfoDTO tripInfoDTO = new TripInfoDTO(); + tripInfoDTO.setCompleted(new Date()); + tripInfoDTO.setDistance(100.0); + tripInfoDTO.setFare(getTen()); + String url = url("/trips/" + trip + "/complete"); + + ResponseEntity exchange = restTemplate.postForEntity( + url, + tripInfoDTO, + String.class + ); + + assertThat(exchange.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void completeTrip_withUnknownTrip_shouldReturnNotFoundError() { + Long trip = 12545L; + TripInfoDTO tripInfoDTO = new TripInfoDTO(); + tripInfoDTO.setCompleted(new Date()); + tripInfoDTO.setDistance(100.0); + tripInfoDTO.setFare(getTen()); + String url = url("/trips/" + trip + "/complete"); + + ResponseEntity exchange = restTemplate.postForEntity( + url, + tripInfoDTO, + String.class + ); + + assertThat(exchange.getStatusCode(), is(HttpStatus.NOT_FOUND)); + } + + @Test + public void matchTrip_withKnownTrip_shouldReturnSuccessful() { + Long trip = testData.trip10Id; + Long driver = testData.driver4Id + 1L; + Long vehicle = testData.vehicle1Id; + String url = url("/trips/" + trip + "/match"); + + MatchDTO matchDTO = new MatchDTO(); + matchDTO.setVehicleId(vehicle); + matchDTO.setDriverId(driver); + matchDTO.setFare(getTen()); + + ResponseEntity exchange = restTemplate.postForEntity( + url, + matchDTO, + String.class + ); + + assertThat(exchange.getStatusCode().series(), is(HttpStatus.Series.SUCCESSFUL)); + } + + @Test + public void matchTrip_withUnavailableDriver_shouldReturnAppropriateError() { + Long trip = testData.trip10Id; + Long driver = testData.driver4Id; + Long vehicle = testData.vehicle1Id; + String url = url("/trips/" + trip + "/match"); + + MatchDTO matchDTO = new MatchDTO(); + matchDTO.setVehicleId(vehicle); + matchDTO.setDriverId(driver); + matchDTO.setFare(getTen()); + + ResponseEntity response = restTemplate.postForEntity( + url, + matchDTO, + String.class + ); + + assertThat("Make use of an appropriate HTTP status code", response.getStatusCode(), allOf( + CoreMatchers.is(not(HttpStatus.OK)), + CoreMatchers.is(not(HttpStatus.NOT_FOUND)), + CoreMatchers.is(not(HttpStatus.INTERNAL_SERVER_ERROR)) + )); + } + + private MultiValueMap getCreateMap(Long riderId, Long pickupId, Long destinationId) { + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("riderId", riderId + ""); + map.add("pickupId", pickupId + ""); + map.add("destinationId", destinationId + ""); + return map; + } + + + private MoneyDTO getTen() { + MoneyDTO moneyDTO = new MoneyDTO(); + moneyDTO.setCurrency("EUR"); + moneyDTO.setValue(BigDecimal.TEN); + return moneyDTO; + } + + private MultiValueMap body(String key, Object value) { + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add(key, String.valueOf(value)); + return map; + } + + private ResponseEntity getTrip(Long id) { + String url = url("/trips/" + id); + return restTemplate.getForEntity(url, TripDTO.class); + } + +} diff --git a/ass2-service/trip/src/test/java/dst/ass2/service/trip/tests/TripServiceTest.java b/ass2-service/trip/src/test/java/dst/ass2/service/trip/tests/TripServiceTest.java new file mode 100644 index 0000000..b2a5260 --- /dev/null +++ b/ass2-service/trip/src/test/java/dst/ass2/service/trip/tests/TripServiceTest.java @@ -0,0 +1,464 @@ +package dst.ass2.service.trip.tests; + +import dst.ass1.jpa.dao.IDAOFactory; +import dst.ass1.jpa.dao.ITripDAO; +import dst.ass1.jpa.model.*; +import dst.ass1.jpa.tests.TestData; +import dst.ass2.service.api.match.IMatchingService; +import dst.ass2.service.api.trip.*; +import dst.ass2.service.trip.TripApplication; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = TripApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("testdata") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class TripServiceTest implements ApplicationContextAware { + + private static final Logger LOG = LoggerFactory.getLogger(TripServiceTest.class); + + private ApplicationContext ctx; + + @MockBean + private IMatchingService matchingService; + + private ITripService tripService; + private IDAOFactory daoFactory; + private TestData testData; + private ITripDAO tripDAO; + + @Override + public void setApplicationContext(ApplicationContext ctx) throws BeansException { + this.ctx = ctx; + } + + @Before + public void setUp() { + LOG.info("Test resolving beans from application context"); + daoFactory = ctx.getBean(IDAOFactory.class); + tripService = ctx.getBean(ITripService.class); + testData = ctx.getBean(TestData.class); + tripDAO = daoFactory.createTripDAO(); + } + + @Test + public void testCreateWithValidArguments_persistsTrip_and_returnsTripDTO() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getOne()); + + TripDTO tripDTO = tripService.create(testData.rider1Id, testData.location1Id, testData.location2Id); + + verify(matchingService, times(1)).calculateFare(any()); + + assertNotNull(tripDTO); + assertNotNull(tripDTO.getId()); + assertNotNull(tripDTO.getFare()); + + assertEquals(testData.rider1Id, tripDTO.getRiderId()); + assertEquals(testData.location1Id, tripDTO.getPickupId()); + assertEquals(testData.location2Id, tripDTO.getDestinationId()); + assertEquals(getOne(), tripDTO.getFare()); + + ITrip trip = tripDAO.findById(tripDTO.getId()); + assertNotNull(trip); + + assertEquals(testData.location1Id, trip.getPickup().getId()); + assertEquals(testData.location2Id, trip.getDestination().getId()); + assertEquals(testData.rider1Id, trip.getRider().getId()); + assertEquals(TripState.CREATED, trip.getState()); + } + + + @Test(expected = EntityNotFoundException.class) + public void testCreateWithInvalidRider_throwsException() throws Exception { + tripService.create(1337L, testData.location1Id, testData.location2Id); + } + + @Test(expected = EntityNotFoundException.class) + public void testCreateWithInvalidPickup_throwsException() throws Exception { + tripService.create(testData.rider1Id, 1444L, testData.location2Id); + } + + @Test(expected = EntityNotFoundException.class) + public void testCreateWithInvalidDestination_throwsException() throws Exception { + tripService.create(testData.rider1Id, testData.location1Id, 1337L); + } + + @Test + public void testCreateWithInvalidTrip_setsFareToNull() throws Exception { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + + TripDTO tripDTO = tripService.create(testData.rider1Id, testData.location1Id, testData.location2Id); + assertNotNull(tripDTO); + assertNotNull(tripDTO.getId()); + + ITrip trip = tripDAO.findById(tripDTO.getId()); + assertNotNull(trip); + assertNull(tripDTO.getFare()); + } + + @Test + public void testConfirmWithValidTrip_isQueued() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + tripService.confirm(testData.trip6Id); + ITrip confirmed = tripDAO.findById(testData.trip6Id); + assertEquals(TripState.QUEUED, confirmed.getState()); + verify(matchingService, times(1)).queueTripForMatching(any()); + } + + @Test(expected = EntityNotFoundException.class) + public void testConfirmWithUnknownTrip_throwsException() throws Exception { + tripService.confirm(1337L); + } + + @Test(expected = IllegalStateException.class) + public void testConfirmQueuedTrip_throwsException() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getOne()); + tripService.confirm(testData.trip10Id); + } + + @Test(expected = InvalidTripException.class) + public void testConfirmWithInvalidTrip_throwsException() throws Exception { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + tripService.confirm(testData.trip6Id); + } + + @Test + public void testMatch_matchCreated_stateMatched() throws Exception { + Long tripId = testData.trip10Id; + + MatchDTO matchDTO = new MatchDTO(); + matchDTO.setFare(getOne()); + matchDTO.setDriverId(testData.driver4Id + 1); + matchDTO.setVehicleId(testData.vehicle1Id); + + tripService.match(tripId, matchDTO); + + ITrip updated = tripDAO.findById(tripId); + assertNotNull(updated); + assertEquals(TripState.MATCHED, updated.getState()); + + IMatch match = updated.getMatch(); + assertNotNull(match); + assertEquals(matchDTO.getDriverId(), match.getDriver().getId()); + assertEquals(matchDTO.getVehicleId(), match.getVehicle().getId()); + + IMoney fare = match.getFare(); + assertEquals(getOne().getCurrency(), fare.getCurrency()); + assertEquals(getOne().getValue().compareTo(fare.getValue()), 0); + } + + @Test + public void testMatchWithUnknownTrip_throwsException_and_requeueTrip() throws Exception { + long tripId = 1337L; + + MatchDTO matchDTO = new MatchDTO(); + matchDTO.setFare(getOne()); + matchDTO.setDriverId(testData.driver4Id + 1); + matchDTO.setVehicleId(testData.vehicle1Id); + + try { + tripService.match(tripId, matchDTO); + } catch (EntityNotFoundException ex) { + verify(matchingService, times(1)).queueTripForMatching(any()); + return; + } + + fail(); + } + + @Test + public void testMatch_driverAlreadyAssigned_throwsException_and_requeueTrip() throws Exception { + Long tripId = testData.trip10Id; + + MatchDTO matchDTO = new MatchDTO(); + matchDTO.setFare(getTen()); + matchDTO.setDriverId(testData.driver4Id); + matchDTO.setVehicleId(testData.vehicle1Id); + + try { + tripService.match(tripId, matchDTO); + } catch (DriverNotAvailableException ex) { + verify(matchingService, times(1)).queueTripForMatching(any()); + return; + } + + fail(); + } + + @Test + public void testCompleteWithValidTripInfo_shouldPersistTripInfo_and_setTripCompleted() throws Exception { + Long tripId = testData.trip9Id; + TripInfoDTO tripInfoDTO = new TripInfoDTO(); + tripInfoDTO.setCompleted(new Date()); + tripInfoDTO.setFare(getTen()); + tripInfoDTO.setDistance(2.0); + + tripService.complete(tripId, tripInfoDTO); + + ITrip updated = tripDAO.findById(tripId); + assertNotNull(updated); + assertEquals(TripState.COMPLETED, updated.getState()); + + ITripInfo tripInfo = updated.getTripInfo(); + assertNotNull(tripInfo); + + assertEquals(tripInfoDTO.getCompleted(), tripInfo.getCompleted()); + assertEquals(2.0, tripInfo.getDistance(), 0); + assertEquals(getTen().getCurrency(), tripInfo.getTotal().getCurrency()); + assertEquals(0, getTen().getValue().compareTo(tripInfo.getTotal().getValue())); + } + + @Test(expected = EntityNotFoundException.class) + public void testCompleteWithUnknownTrip_throwsException() throws Exception { + long tripId = 1337L; + TripInfoDTO tripInfoDTO = new TripInfoDTO(); + tripInfoDTO.setCompleted(new Date()); + tripInfoDTO.setFare(getTen()); + tripInfoDTO.setDistance(2.0); + + tripService.complete(tripId, tripInfoDTO); + } + + @Test + public void testCancelWithValidTrip_shouldCancelTrip() throws Exception { + tripService.cancel(testData.trip6Id); + + ITrip updated = tripDAO.findById(testData.trip6Id); + assertNotNull(updated); + assertEquals(TripState.CANCELLED, updated.getState()); + } + + @Test(expected = EntityNotFoundException.class) + public void testCancelWithUnkownTrip_throwsException() throws Exception { + tripService.cancel(1337L); + } + + @Test + public void testAddStop_shouldReturnTrue() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getTen()); + + boolean added = tripService.addStop(tripDTO, testData.location1Id); + assertTrue(added); + assertEquals(getTen(), tripDTO.getFare()); + verify(matchingService, times(1)).calculateFare(any()); + } + + @Test(expected = EntityNotFoundException.class) + public void testAddStopUnknownLocation_throwsException() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getTen()); + + tripService.addStop(tripDTO, 1344L); + } + + @Test(expected = IllegalStateException.class) + public void testAddStopInvalidTripState_throwsException() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + ITrip trip = tripDAO.findById(testData.trip1Id); + TripDTO tripDTO = new TripDTO(); + tripDTO.setId(trip.getId()); + tripDTO.setPickupId(trip.getPickup().getId()); + tripDTO.setDestinationId(trip.getDestination().getId()); + tripDTO.setRiderId(trip.getRider().getId()); + tripDTO.setStops(trip.getStops().stream().map(ILocation::getId).collect(Collectors.toList())); + tripDTO.setFare(getTen()); + + tripService.addStop(tripDTO, testData.location2Id); + } + + @Test + public void testAddStopInvalidTrip_setsFareToNull() throws Exception { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getOne()); + + boolean added = tripService.addStop(tripDTO, testData.location1Id); + assertTrue(added); + assertNull(tripDTO.getFare()); + assertEquals(4, tripDTO.getStops().size()); + } + + + @Test + public void testAddStop_shouldReturnFalse() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getOne()); + + boolean added = tripService.addStop(tripDTO, testData.location2Id); + assertFalse(added); + + //fare should not be updated if the location hasn't been added + assertEquals(getOne(), tripDTO.getFare()); + } + + @Test + public void testRemoveStop_shouldReturnTrue() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getOne()); + + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getTen()); + + boolean removed = tripService.removeStop(tripDTO, testData.location2Id); + assertTrue(removed); + assertEquals(getOne(), tripDTO.getFare()); + assertEquals(2, tripDTO.getStops().size()); + ITrip updated = tripDAO.findById(testData.trip6Id); + assertEquals(2, updated.getStops().size()); + verify(matchingService, times(1)).calculateFare(any()); + } + + private MoneyDTO getZero() { + MoneyDTO moneyDTO = new MoneyDTO(); + moneyDTO.setCurrency("EUR"); + moneyDTO.setValue(BigDecimal.ZERO); + return moneyDTO; + } + + @Test + public void testRemoveStop_shouldReturnFalse() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getZero()); + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getOne()); + + boolean removed = tripService.removeStop(tripDTO, testData.location1Id); + assertFalse(removed); + + //fare should not be updated if the location hasn't been removed + assertEquals(getOne(), tripDTO.getFare()); + + assertEquals(3, tripDTO.getStops().size()); + ITrip updated = tripDAO.findById(testData.trip6Id); + assertEquals(3, updated.getStops().size()); + } + + @Test(expected = EntityNotFoundException.class) + public void testRemoveStopUnknownLocation_throwsException() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getOne()); + + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getTen()); + + tripService.removeStop(tripDTO, 1344L); + } + + @Test(expected = IllegalStateException.class) + public void testRemoveStopInvalidTripState_throwsException() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getOne()); + + ITrip trip = tripDAO.findById(testData.trip10Id); + TripDTO tripDTO = new TripDTO(); + tripDTO.setId(trip.getId()); + tripDTO.setPickupId(trip.getPickup().getId()); + tripDTO.setDestinationId(trip.getDestination().getId()); + tripDTO.setRiderId(trip.getRider().getId()); + List stopIds = trip.getStops().stream().map(ILocation::getId).collect(Collectors.toList()); + tripDTO.setStops(stopIds); + tripDTO.setFare(getTen()); + + tripService.removeStop(tripDTO, testData.location4Id); + } + + @Test + public void testRemoveStopInvalidTrip_setsFareToNull() throws Exception { + when(matchingService.calculateFare(any())).thenThrow(new InvalidTripException()); + + TripDTO tripDTO = getTrip6DTO(); + tripDTO.setFare(getOne()); + + boolean removed = tripService.removeStop(tripDTO, testData.location4Id); + assertTrue(removed); + assertNull(tripDTO.getFare()); + } + + @Test + public void testDeleteValidTrip_shouldSucceed() throws Exception { + tripService.delete(testData.trip6Id); + ITrip deleted = tripDAO.findById(testData.trip6Id); + assertNull(deleted); + } + + @Test(expected = EntityNotFoundException.class) + public void testDeleteUnknownTrip_throwsException() throws Exception { + tripService.delete(1111L); + } + + @Test + public void testFindTrip_shouldSucceed() throws Exception { + when(matchingService.calculateFare(any())).thenReturn(getTen()); + + TripDTO tripDTO = tripService.find(testData.trip9Id); + assertNotNull(tripDTO); + assertEquals(testData.trip9Id, tripDTO.getId()); + assertEquals(testData.rider1Id, tripDTO.getRiderId()); + assertEquals(testData.location2Id, tripDTO.getPickupId()); + assertEquals(testData.location5Id, tripDTO.getDestinationId()); + assertEquals(getTen(), tripDTO.getFare()); + } + + @Test + public void testFindTrip_shouldReturnNull() { + TripDTO tripDTO = tripService.find(1337L); + assertNull(tripDTO); + } + + private MoneyDTO getOne() { + MoneyDTO moneyDTO = new MoneyDTO(); + moneyDTO.setCurrency("EUR"); + moneyDTO.setValue(BigDecimal.ONE); + return moneyDTO; + } + + private MoneyDTO getTen() { + MoneyDTO moneyDTO = new MoneyDTO(); + moneyDTO.setValue(BigDecimal.TEN); + moneyDTO.setCurrency("EUR"); + return moneyDTO; + } + + + private TripDTO getTrip6DTO() { + TripDTO tripDTO = new TripDTO(); + tripDTO.setId(testData.trip6Id); + tripDTO.setPickupId(testData.location5Id); + tripDTO.setDestinationId(testData.location1Id); + tripDTO.setRiderId(testData.rider4Id); + LinkedList longs = new LinkedList<>(); + longs.add(testData.location2Id); + longs.add(testData.location3Id); + longs.add(testData.location4Id); + tripDTO.setStops(longs); + return tripDTO; + } +} diff --git a/ass2-service/trip/src/test/resources/application.properties b/ass2-service/trip/src/test/resources/application.properties new file mode 100644 index 0000000..c68b46d --- /dev/null +++ b/ass2-service/trip/src/test/resources/application.properties @@ -0,0 +1 @@ +server.port=8091 diff --git a/pom.xml b/pom.xml index f742244..137e20a 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,13 @@ org.apache.maven.plugins maven-surefire-plugin ${maven-surefire-plugin.version} + + + org.apache.maven.surefire + surefire-junit4 + ${maven-surefire-plugin.version} + + false alphabetical @@ -255,6 +262,131 @@ javassist ${javassist.version} + + + commons-io + commons-io + ${commons-io.version} + + + org.aspectj + aspectjrt + ${aspectj.version} + + + org.aspectj + aspectjweaver + ${aspectj.version} + + + org.springframework + spring-aop + ${spring.version} + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + io.grpc + grpc-all + ${grpc.version} + + + io.grpc + grpc-netty + ${grpc.version} + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + io.grpc + grpc-testing + ${grpc.version} + + + org.springframework + spring-orm + ${spring.version} + + + org.springframework + spring-context + ${spring.version} + + + org.springframework.boot + spring-boot-starter-jersey + ${spring-boot.version} + + + + org.springframework + spring-context + + + + + org.glassfish.jersey.core + jersey-client + ${jersey.version} + + + + org.glassfish.jersey.core + jersey-server + ${jersey.version} + + + org.glassfish.jersey.ext + jersey-proxy-client + ${jersey.version} + + + org.springframework.boot + spring-boot-starter + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.apache.httpcomponents + httpclient + ${httpclient.version} + + @@ -266,6 +398,13 @@ ass1-jpa ass1-doc ass1-kv + ass2-service/api + ass2-service/auth-client + ass2-service/auth + ass2-service/trip + ass2-service/facade + ass2-aop + ass2-ioc @@ -290,6 +429,65 @@ + + ass2-service + + ass1-jpa + ass2-service/api + ass2-service/auth-client + ass2-service/auth + ass2-service/trip + ass2-service/facade + + + + + ass2-aop + + ass2-aop + + + + + ass2-ioc + + + + maven-jar-plugin + 2.6 + + + + dst.ass2.ioc.lock.LockingInjectorAgent + + + dst-ioc-agent + + + + + jar + + test-compile + + + + + maven-surefire-plugin + + -javaagent:"${project.build.directory}/dst-ioc-agent.jar" + + dst/ass2/ioc/**/*.java + + + + + + + ass2-ioc + + + @@ -321,6 +519,16 @@ 1.4.200 3.12.8 3.0.0 + + 1.9.6 + 5.3.4 + 2.4.3 + 2.32 + 2.8.0 + 1.35.0 + 0.6.1 + 1.7.0 + 4.5.13