diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000..32599ce --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1,10 @@ +--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED +--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED +--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED diff --git a/README.md b/README.md index 611bb8c..72a0980 100644 --- a/README.md +++ b/README.md @@ -36,22 +36,22 @@ curl -F "url=https://example.com/api/tg" \ Build is achieved through Maven. To build a `jar` run: ```shell -mvn package -DskipTests=true -Dmaven.test.skip=true -Dmaven.site.skip=true -Dmaven.javadoc.skip=true +./mvnw package -DskipTests=true -Dmaven.test.skip=true -Dmaven.site.skip=true -Dmaven.javadoc.skip=true ``` -In the `target/` folder wou will find an uber-jar and optionally the possibility to run it via a script and to setup +In the `target/` folder you will find an uber-jar and optionally the possibility to run it via a script and to setup auto-startup via systemd or openrc units. ## Developing ### Automatic testing -You can simply run tests with `mvn test`. This will run `UT` and `IT` tests together. +You can simply run tests with `./mvnw test`. This will run `UT` and `IT` tests together. ### Manual testing For a manual approach, just open a terminal and type `mvn jooby:run`. Assuming you have a database locally available -(check out [application.conf](conf/application.conf)) and a valid Telegram token set (maybe as enviroment variable) you +(check out [application.conf](conf/application.conf)) and a valid Telegram token set (maybe as environment variable) you can develop and see live changes of your Mezzotre on the fly. Finally, by using Postman, you can simulate incoming Telegram events. diff --git a/conf/application.conf b/conf/application.conf index dfa8f8e..3d8fa39 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -3,6 +3,10 @@ db.url = "jdbc:postgresql://localhost:5433/example" db.user = example db.password = example + +hikari.autoCommit = false +hikari.maximumPoolSize = 4 + telegram.key = akey application.lang = en en-US it it-IT diff --git a/pom.xml b/pom.xml index 529f51d..66b4eb0 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,8 @@ 1.1.1 2.13.3 5.9.1 + 5.1.0 + 2.18.0 @@ -86,6 +88,12 @@ jooby-guice + + com.google.inject.extensions + guice-assistedinject + ${google-guice.version} + + io.swagger.core.v3 swagger-annotations @@ -233,7 +241,11 @@ 31.1-jre - + + com.google.errorprone + error_prone_annotations + ${error-prone.version} + @@ -272,12 +284,19 @@ maven-compiler-plugin - 3.6.2 + 3.8.1 -parameters + -XDcompilePolicy=simple + -Xplugin:ErrorProne + + com.google.errorprone + error_prone_core + ${error-prone.version} + io.jooby jooby-apt diff --git a/src/main/java/com/github/polpetta/mezzotre/App.java b/src/main/java/com/github/polpetta/mezzotre/App.java index 9261d48..9ac428e 100644 --- a/src/main/java/com/github/polpetta/mezzotre/App.java +++ b/src/main/java/com/github/polpetta/mezzotre/App.java @@ -1,11 +1,12 @@ package com.github.polpetta.mezzotre; -import com.github.polpetta.mezzotre.orm.di.Db; +import com.github.polpetta.mezzotre.orm.OrmDI; +import com.github.polpetta.mezzotre.route.RouteDI; import com.github.polpetta.mezzotre.route.Telegram; -import com.github.polpetta.mezzotre.route.di.Route; -import com.github.polpetta.mezzotre.telegram.callbackquery.di.CallbackQuery; -import com.github.polpetta.mezzotre.telegram.command.di.Command; -import com.github.polpetta.mezzotre.util.di.ThreadPool; +import com.github.polpetta.mezzotre.telegram.callbackquery.CallbackQueryDI; +import com.github.polpetta.mezzotre.telegram.command.CommandDI; +import com.github.polpetta.mezzotre.util.ServiceModule; +import com.github.polpetta.mezzotre.util.UtilDI; import com.google.inject.*; import com.google.inject.Module; import com.google.inject.name.Names; @@ -23,11 +24,11 @@ public class App extends Jooby { public static final Function> DEFAULT_DI_MODULES = (jooby) -> { final HashSet modules = new HashSet<>(); - modules.add(new Db()); - modules.add(new ThreadPool()); - modules.add(new Route()); - modules.add(new Command()); - modules.add(new CallbackQuery()); + modules.add(new OrmDI()); + modules.add(new UtilDI()); + modules.add(new RouteDI()); + modules.add(new CommandDI()); + modules.add(new CallbackQueryDI()); return modules; }; @@ -42,7 +43,7 @@ public class App extends Jooby { if (modules == null || modules.size() == 0) { toInject = DEFAULT_DI_MODULES.apply(this); } - toInject.add(new InjectionModule(this)); + toInject.add(new AppDI(this)); toInject.add(new JoobyModule(this)); final Injector injector = Guice.createInjector(runningEnv, toInject); @@ -55,6 +56,7 @@ public class App extends Jooby { install( injector.getInstance(Key.get(Extension.class, Names.named("flyWayMigrationExtension")))); install(injector.getInstance(Key.get(Extension.class, Names.named("ebeanExtension")))); + install(injector.getInstance(Key.get(ServiceModule.class, Names.named("serviceModule")))); decorator(new AccessLogHandler()); decorator(new TransactionalRequest()); diff --git a/src/main/java/com/github/polpetta/mezzotre/AppDI.java b/src/main/java/com/github/polpetta/mezzotre/AppDI.java new file mode 100644 index 0000000..811ca05 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/AppDI.java @@ -0,0 +1,41 @@ +package com.github.polpetta.mezzotre; + +import com.github.polpetta.mezzotre.orm.BatchBeanCleanerService; +import com.google.common.util.concurrent.Service; +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import io.jooby.Jooby; +import java.util.List; +import javax.inject.Named; +import javax.inject.Singleton; +import org.slf4j.Logger; + +public class AppDI extends AbstractModule { + + // In the future we can get this name from mvn, by now it is good as it is + public static final String APPLICATION_NAME = "Mezzotre"; + private final Jooby jooby; + + public AppDI(Jooby jooby) { + this.jooby = jooby; + } + + @Provides + public Logger getLogger() { + return jooby.getLog(); + } + + @Named("applicationName") + @Singleton + @Provides + public String getAppName() { + return APPLICATION_NAME; + } + + @Provides + @Singleton + @Named("services") + public List getApplicationServices(BatchBeanCleanerService batchBeanCleanerService) { + return List.of(batchBeanCleanerService); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/InjectionModule.java b/src/main/java/com/github/polpetta/mezzotre/InjectionModule.java deleted file mode 100644 index 4d0d991..0000000 --- a/src/main/java/com/github/polpetta/mezzotre/InjectionModule.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.polpetta.mezzotre; - -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import io.jooby.Jooby; -import org.slf4j.Logger; - -public class InjectionModule extends AbstractModule { - private final Jooby jooby; - - public InjectionModule(Jooby jooby) { - this.jooby = jooby; - } - - @Provides - public Logger getLogger() { - return jooby.getLog(); - } -} diff --git a/src/main/java/com/github/polpetta/mezzotre/i18n/TemplateContentGenerator.java b/src/main/java/com/github/polpetta/mezzotre/i18n/TemplateContentGenerator.java new file mode 100644 index 0000000..ab8f959 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/i18n/TemplateContentGenerator.java @@ -0,0 +1,86 @@ +package com.github.polpetta.mezzotre.i18n; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.function.Consumer; +import javax.inject.Inject; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.tools.ToolManager; +import org.apache.velocity.util.StringBuilderWriter; + +/** + * This class aims to generate localized messages or strings either merging Velocity templates or + * taking directly the keys from {@link java.util.ResourceBundle} + * + * @author Davide Polonio + * @since 1.0 + */ +public class TemplateContentGenerator { + + private final LocalizedMessageFactory localizedMessageFactory; + + @Inject + public TemplateContentGenerator(LocalizedMessageFactory localizedMessageFactory) { + this.localizedMessageFactory = localizedMessageFactory; + } + + /** + * Merge a Velocity template located in the local Jar resource path with the given {@link Locale}, + * provided as a {@link String}. A {@link Consumer} is provided in order to let the user customize + * the {@link VelocityContext} with additional variables. Localization strings will be directly + * taken via the use of {@link LocalizedTool} class. + * + * @param velocityContextConsumer a lambda function that allows for the customization of {@link + * VelocityContext} instance + * @param localeAsString a {@link Locale} formatted as a {@link String}. Note that {@link + * Locale#forLanguageTag(String)} will be used for the conversion + * @param templateName the path to the local Jar resource + * @return a {@link String} containing the localized message + */ + public String mergeTemplate( + Consumer velocityContextConsumer, + String localeAsString, + String templateName) { + final Locale locale = Locale.forLanguageTag(localeAsString); + final ToolManager toolManager = localizedMessageFactory.createVelocityToolManager(locale); + final VelocityContext velocityContext = new VelocityContext(toolManager.createContext()); + + velocityContextConsumer.accept(velocityContext); + + final StringBuilder content = new StringBuilder(); + final StringBuilderWriter stringBuilderWriter = new StringBuilderWriter(content); + + toolManager + .getVelocityEngine() + .mergeTemplate( + templateName, StandardCharsets.UTF_8.name(), velocityContext, stringBuilderWriter); + + stringBuilderWriter.close(); + return content.toString(); + } + + /** + * Get a localized {@link String} + * + * @param locale a provided {@link Locale} + * @param key the key that will be retrieved from the localized messages + * @return a localized {@link String} + * @see #getString(String, String) + */ + public String getString(Locale locale, String key) { + return localizedMessageFactory.createResourceBundle(locale).getString(key); + } + + /** + * Get a localized {@link String} + * + * @param localeAsString a provided {@link Locale} as a {@link String}. Will be converted using + * {@link Locale#forLanguageTag(String)} + * @param key the key that will be retrieved from the localized messages + * @return a localized {@link String} + * @see #getString(Locale, String) + */ + public String getString(String localeAsString, String key) { + return getString(Locale.forLanguageTag(localeAsString), key); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/BatchBeanCleanerService.java b/src/main/java/com/github/polpetta/mezzotre/orm/BatchBeanCleanerService.java new file mode 100644 index 0000000..acbff5d --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/orm/BatchBeanCleanerService.java @@ -0,0 +1,137 @@ +package com.github.polpetta.mezzotre.orm; + +import com.google.common.util.concurrent.AbstractExecutionThreadService; +import io.ebean.typequery.PString; +import io.ebean.typequery.TQRootBean; +import io.vavr.Tuple; +import io.vavr.Tuple3; +import io.vavr.control.Try; +import java.util.LinkedList; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.inject.Inject; +import javax.inject.Named; +import org.apache.commons.lang3.tuple.Pair; +import org.slf4j.Logger; + +/** + * This class works as a "batch" persistence entries remover - basically, it removes all the entries + * that are in the database in an async way. This allows better performance on other pieces of + * codebase, where we don't really care about checking that the removal has been successful, and + * instead we just want to issue a deletion and instantly continue with our codebase flow. + * + *

This service provides the ability to listen to the operation in two ways: via old-fashioned + * listeners and via {@link CompletableFuture}. If the caller wants to be sure that the removal has + * effectively happened, it can sync with the given {@link CompletableFuture}. + * + * @author Davide Polonio + * @since 1.0 + */ +public class BatchBeanCleanerService extends AbstractExecutionThreadService { + + private final LinkedBlockingDeque< + Tuple3>, CompletableFuture>> + entriesToRemove; + private final Logger log; + private final Pair serviceRunningCheckTime; + + // Accessing adding listeners while we iterate on the list inside the service thread _can_ be + // considered a race condition - but given the frequency of adding and removing listeners, the + // computational cost of putting Locks in place and check for them everytime is way greater + private final LinkedList< + Consumer>, CompletableFuture>>> + deletionListener; + + @Inject + public BatchBeanCleanerService( + Logger logger, + @Named("serviceRunningCheckTime") Pair serviceRunningCheckTime) { + this.log = logger; + this.serviceRunningCheckTime = serviceRunningCheckTime; + this.entriesToRemove = new LinkedBlockingDeque<>(); + this.deletionListener = new LinkedList<>(); + } + + @Override + protected void run() throws Exception { + log.trace("BatchBeanCleanerService run() method invoked"); + while (isRunning()) { + // This statement is blocking for 1 sec if the queue is empty, and then it goes on - this way + // we check if the service is still supposed to be up or what + Optional.ofNullable( + entriesToRemove.poll( + serviceRunningCheckTime.getLeft(), serviceRunningCheckTime.getRight())) + .ifPresent( + entryToRemove -> { + Try.of( + () -> { + final int deleted = entryToRemove._2().eq(entryToRemove._1()).delete(); + if (deleted > 0) { + log.trace( + "Bean with id " + entryToRemove._1() + " removed successfully "); + } else { + log.warn( + "Bean(s) with id " + + entryToRemove._1() + + " for query " + + entryToRemove._2() + + " was not removed from the database because it was not" + + " found"); + } + entryToRemove._3().complete(deleted); + deletionListener.parallelStream().forEach(l -> l.accept(entryToRemove)); + return null; + }) + .onFailure(ex -> entryToRemove._3().completeExceptionally(ex)); + }); + } + } + + @Override + protected void shutDown() throws Exception { + super.shutDown(); + entriesToRemove.clear(); + } + + /** + * Add an entry to be removed + * + * @param id the id of the entry to remove + * @param column the column that will be used to perform the delete statement + * @return a {@link CompletableFuture} containing an {@link Integer} indicating the number of rows + * affected by the operation + */ + public CompletableFuture removeAsync( + String id, PString> column) { + final CompletableFuture jobExecution = new CompletableFuture<>(); + entriesToRemove.offer(Tuple.of(id, column, jobExecution)); + return jobExecution; + } + + /** + * Add a listener that will be invoked once the entry has been removed. This listener is invoked + * after the {@link CompletableFuture} completion. Note that all the listeners are called in a + * parallel fashion, so call order can change any time. + * + * @param listener the listener to add. + */ + public void addListener( + Consumer>, CompletableFuture>> + listener) { + deletionListener.add(listener); + } + + /** + * Remove a listener + * + * @param listener the listener to be removed + */ + public void removeListener( + Consumer>, CompletableFuture>> + listener) { + deletionListener.remove(listener); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/CallbackQueryContextCleaner.java b/src/main/java/com/github/polpetta/mezzotre/orm/CallbackQueryContextCleaner.java new file mode 100644 index 0000000..9603653 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/orm/CallbackQueryContextCleaner.java @@ -0,0 +1,67 @@ +package com.github.polpetta.mezzotre.orm; + +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.ebean.typequery.PString; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.slf4j.Logger; + +/** + * This class allows the system to lazily remove {@link CallbackQueryContextCleaner} entries that + * are no more needed, using the {@link BatchBeanCleanerService} service under the hood. + * + * @author Davide Polonio + * @see BatchBeanCleanerService + * @since 1.0 + */ +@Singleton +public class CallbackQueryContextCleaner { + + private static final Supplier> ENTRY_GROUP = + () -> new QCallbackQueryContext().entryGroup; + private static final Supplier> SINGLE_ENTRY = + () -> new QCallbackQueryContext().id; + + private final BatchBeanCleanerService batchBeanCleanerService; + private final Logger log; + + @Inject + public CallbackQueryContextCleaner(BatchBeanCleanerService batchBeanCleanerService, Logger log) { + this.batchBeanCleanerService = batchBeanCleanerService; + this.log = log; + } + + /** + * Add a group of {@link com.github.polpetta.mezzotre.orm.model.CallbackQueryContext} to be + * removed + * + * @param id the group id of the {@link + * com.github.polpetta.mezzotre.orm.model.CallbackQueryContext} + * @return a {@link CompletableFuture} with an {@link Integer} indicating how many entries have + * been deleted from the persistence layer + * @see CallbackQueryContext#getEntryGroup() + */ + @CanIgnoreReturnValue + public CompletableFuture removeGroupAsync(String id) { + log.trace("CallbackQueryContext entry group " + id + " queued for removal"); + return batchBeanCleanerService.removeAsync(id, ENTRY_GROUP.get()); + } + + /** + * Add a single {@link com.github.polpetta.mezzotre.orm.model.CallbackQueryContext} to be removed + * + * @param id the id of the {@link CallbackQueryContext} to remove + * @return a {@link CompletableFuture} with an {@link Integer} indicating how many entries have + * been deleted from the persistence layer. Can be 0 or 1. + * @see CallbackQueryContext#getId() + */ + @CanIgnoreReturnValue + public CompletableFuture removeIdAsync(String id) { + log.trace("CallbackQueryContext single entity " + id + " queued for removal"); + return batchBeanCleanerService.removeAsync(id, SINGLE_ENTRY.get()); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/di/Db.java b/src/main/java/com/github/polpetta/mezzotre/orm/OrmDI.java similarity index 88% rename from src/main/java/com/github/polpetta/mezzotre/orm/di/Db.java rename to src/main/java/com/github/polpetta/mezzotre/orm/OrmDI.java index 7119eac..62e2d70 100644 --- a/src/main/java/com/github/polpetta/mezzotre/orm/di/Db.java +++ b/src/main/java/com/github/polpetta/mezzotre/orm/OrmDI.java @@ -1,16 +1,16 @@ -package com.github.polpetta.mezzotre.orm.di; +package com.github.polpetta.mezzotre.orm; import com.google.inject.AbstractModule; import com.google.inject.Provides; -import com.google.inject.Singleton; import com.zaxxer.hikari.HikariConfig; import io.jooby.Extension; import io.jooby.ebean.EbeanModule; import io.jooby.flyway.FlywayModule; import io.jooby.hikari.HikariModule; import javax.inject.Named; +import javax.inject.Singleton; -public class Db extends AbstractModule { +public class OrmDI extends AbstractModule { /** * Returns null. This allows to fetch the configuration from file rather than fetch from other * environment diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtil.java b/src/main/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtil.java new file mode 100644 index 0000000..4fc26fd --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtil.java @@ -0,0 +1,83 @@ +package com.github.polpetta.mezzotre.orm.telegram; + +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.util.Clock; +import com.github.polpetta.types.json.ChatContext; +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.Update; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import javax.inject.Inject; + +/** + * ChatUtil provides utilities for interacting in an easier way when manipulating chat related data. + * In particular, it provides an easy and DRY way to modify a set of frequently-accessed fields, or + * simply to extract data performing all the necessary steps + * + * @author Davide Polonio + * @since 1.0 + */ +public class ChatUtil { + + private final Clock clock; + + @Inject + public ChatUtil(Clock clock) { + this.clock = clock; + } + + /** + * Update the {@link ChatContext} with the values passed to the method. The updated values are + * then saved in the database + * + * @param chat the chat that will be updated with the new {@link ChatContext} values + * @param stepName the step name to set + * @param stageNumber the stage number to set + * @param additionalFields if there are, additional custom fields that will be added to {@link + * ChatContext}. Note that these values will have to be manually retrieved since no method is + * available for custom entries. Use {@link Collections#emptyMap()} if you don't wish to add + * any additional field + */ + public void updateChatContext( + TgChat chat, String stepName, int stageNumber, Map additionalFields) { + final ChatContext chatContext = chat.getChatContext(); + chatContext.setStage(stepName); + chatContext.setStep(stageNumber); + chatContext.setPreviousMessageUnixTimestampInSeconds(clock.now()); + additionalFields.forEach(chatContext::setAdditionalProperty); + + chat.setChatContext(chatContext); + chat.save(); + } + + /** + * Retrieves a possible {@link TgChat} from the given {@link CallbackQueryContext} or the {@link + * Update}. Note that first the {@link CallbackQueryContext} is checked, then {@link Update} is + * used as fallback. Once the Telegram chat id is retrieved from one of these two, the persistence + * layer is queried and if there is any {@link TgChat} which the corresponding ID it is returned + * to the caller. + * + * @param callbackQueryContext the {@link CallbackQueryContext} coming from a Telegram callback + * query + * @param update the whole {@link Update} object that is sent from Telegram servers + * @return an {@link Optional} that may contain a {@link TgChat} if the ID is found in the + * persistence layer, otherwise {@link Optional#empty()} is given back either if the id is not + * present or if the chat is not present in the persistence layer. + */ + public Optional extractChat(CallbackQueryContext callbackQueryContext, Update update) { + return Optional.of(callbackQueryContext.getFields().getTelegramChatId()) + .map(Double::longValue) + // If we're desperate, search in the message for the chat id + .filter(chatId -> chatId != 0L && chatId != Long.MIN_VALUE) + .or( + () -> + Optional.ofNullable(update.callbackQuery().message()) + .map(Message::messageId) + .map(Long::valueOf)) + .filter(chatId -> chatId != 0L && chatId != Long.MIN_VALUE) + .flatMap(chatId -> new QTgChat().id.eq(chatId).findOneOrEmpty()); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/route/di/Route.java b/src/main/java/com/github/polpetta/mezzotre/route/RouteDI.java similarity index 92% rename from src/main/java/com/github/polpetta/mezzotre/route/di/Route.java rename to src/main/java/com/github/polpetta/mezzotre/route/RouteDI.java index dd57304..238be44 100644 --- a/src/main/java/com/github/polpetta/mezzotre/route/di/Route.java +++ b/src/main/java/com/github/polpetta/mezzotre/route/RouteDI.java @@ -1,4 +1,4 @@ -package com.github.polpetta.mezzotre.route.di; +package com.github.polpetta.mezzotre.route; import com.google.inject.AbstractModule; import com.google.inject.Provides; @@ -7,7 +7,7 @@ import io.jooby.Jooby; import java.util.Optional; import javax.inject.Singleton; -public class Route extends AbstractModule { +public class RouteDI extends AbstractModule { @Provides @Singleton public TelegramBot getTelegramBot(Jooby jooby) { diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/CallbackQueryDI.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/CallbackQueryDI.java new file mode 100644 index 0000000..34927ed --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/CallbackQueryDI.java @@ -0,0 +1,33 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.assistedinject.FactoryModuleBuilder; +import java.util.HashMap; +import java.util.Map; +import javax.inject.Named; +import javax.inject.Singleton; + +public class CallbackQueryDI extends AbstractModule { + + @Override + protected void configure() { + super.configure(); + + install( + new FactoryModuleBuilder() + .implement(Processor.class, NotFound.class) + .build(NotFoundFactory.class)); + } + + @Provides + @Singleton + @Named("eventProcessors") + public Map getEventProcessor( + SelectLanguageTutorial selectLanguageTutorial, ShowHelp showHelp) { + final HashMap commandMap = new HashMap<>(); + commandMap.put(selectLanguageTutorial.getEventName(), selectLanguageTutorial); + commandMap.put(showHelp.getEventName(), showHelp); + return commandMap; + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Dispatcher.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Dispatcher.java index 3d0c3fc..469ed0a 100644 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Dispatcher.java +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Dispatcher.java @@ -3,8 +3,8 @@ package com.github.polpetta.mezzotre.telegram.callbackquery; import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.request.BaseRequest; +import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import javax.inject.Inject; @@ -22,15 +22,18 @@ import javax.inject.Singleton; @Singleton public class Dispatcher { - private final Set tgEventProcessors; + private final Map tgEventProcessors; private final Executor threadPool; + private final NotFoundFactory notFoundFactory; @Inject public Dispatcher( - @Named("eventProcessors") Set tgEventProcessors, - @Named("eventThreadPool") Executor threadPool) { + @Named("eventProcessors") Map tgEventProcessors, + @Named("eventThreadPool") Executor threadPool, + NotFoundFactory notFoundFactory) { this.tgEventProcessors = tgEventProcessors; this.threadPool = threadPool; + this.notFoundFactory = notFoundFactory; } /** @@ -50,14 +53,11 @@ public class Dispatcher { .thenComposeAsync( ignored -> Optional.of(callbackQueryContext.getFields().getEvent()) - .flatMap( + .map( eventName -> - tgEventProcessors.stream() - // FIXME this is fucking stupid, why iterate over, just use a map! - // Make mapping at startup then we're gucci for the rest of the run - .filter(processor -> processor.getEventName().equals(eventName)) - .findAny()) - .map(processor -> processor.process(callbackQueryContext, update)) + tgEventProcessors + .getOrDefault(eventName, notFoundFactory.create(eventName)) + .process(callbackQueryContext, update)) .orElse(CompletableFuture.failedFuture(new EventProcessorNotFoundException())), threadPool); } diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Field.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Field.java new file mode 100644 index 0000000..14d6968 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Field.java @@ -0,0 +1,47 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +/** + * This interface is a placeholder to keep all fields in one place + * + * @author Davide Polonio + * @since 1.0 + */ +public interface Field { + /** + * Additional fields that are related to {@code changeLanguage} event + * + * @author Davide Polonio + * @since 1.0 + */ + enum SelectLanguageTutorial { + NewLanguage("newLanguage"); + + private final String name; + + SelectLanguageTutorial(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + /** + * Enumerator with custom fields for {@link + * com.github.polpetta.mezzotre.telegram.callbackquery.ShowHelp} callback query + */ + enum ShowHelp { + InvokedFromHelpMessage("invokedFromHelpMessage"); + + private final String name; + + ShowHelp(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFound.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFound.java new file mode 100644 index 0000000..901d6f9 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFound.java @@ -0,0 +1,51 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +import com.github.polpetta.mezzotre.orm.CallbackQueryContextCleaner; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.google.inject.assistedinject.Assisted; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import javax.inject.Inject; +import org.slf4j.Logger; + +/** + * Callback query event that is triggered only when a corresponding event is not found in the system + * + * @author Davide Polonio + * @since 1.0 + */ +public class NotFound implements Processor { + + private final Logger log; + private final CallbackQueryContextCleaner callbackQueryContextCleaner; + private final String eventName; + + @Inject + public NotFound( + Logger logger, + CallbackQueryContextCleaner callbackQueryContextCleaner, + @Assisted String eventName) { + this.log = logger; + this.callbackQueryContextCleaner = callbackQueryContextCleaner; + this.eventName = eventName; + } + + @Override + public String getEventName() { + return "eventNotFound"; + } + + @Override + public CompletableFuture>> process( + CallbackQueryContext callbackQueryContext, Update update) { + log.warn( + "A stray event was detected for callback " + + callbackQueryContext.getId() + + " event name " + + eventName); + callbackQueryContextCleaner.removeGroupAsync(callbackQueryContext.getEntryGroup()); + return CompletableFuture.completedFuture(Optional.empty()); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFoundFactory.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFoundFactory.java new file mode 100644 index 0000000..fac7c21 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFoundFactory.java @@ -0,0 +1,5 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +public interface NotFoundFactory { + NotFound create(String eventName); +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Processor.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Processor.java index 021a177..0707a50 100644 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Processor.java +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Processor.java @@ -17,12 +17,31 @@ import java.util.concurrent.CompletableFuture; public interface Processor { /** - * The even name this processor is able to process + * The event name this processor is able to process * - * @return a {@link String} containig the name of the event supported + * @return a {@link String} containing the name of the event supported */ String getEventName(); + /** + * Determines if the Callback Query can directly be invoked by the user, such as starting an + * interaction from this point or invoking it from an inline query button + * + * @return true if it is, false otherwise + */ + default boolean canBeDirectlyInvokedByTheUser() { + return false; + } + + /** + * Gives the ability to print a localized version of the event, in a pretty way + * + * @return the key for the corresponding localization string + */ + default Optional getPrettyPrintLocaleKeyName() { + return Optional.empty(); + } + /** * Process the current event * diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorial.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorial.java index c52ad0d..3b2abeb 100644 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorial.java +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorial.java @@ -1,12 +1,11 @@ package com.github.polpetta.mezzotre.telegram.callbackquery; -import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.CallbackQueryContextCleaner; import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; -import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; -import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; import com.github.polpetta.mezzotre.util.UUIDGenerator; import com.github.polpetta.types.json.CallbackQueryMetadata; -import com.pengrad.telegrambot.model.Message; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.model.request.InlineKeyboardButton; import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; @@ -14,9 +13,6 @@ import com.pengrad.telegrambot.model.request.ParseMode; import com.pengrad.telegrambot.request.BaseRequest; import com.pengrad.telegrambot.request.EditMessageText; import com.pengrad.telegrambot.request.SendMessage; -import io.vavr.control.Try; -import java.nio.charset.StandardCharsets; -import java.util.Locale; import java.util.NoSuchElementException; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -24,9 +20,6 @@ import java.util.concurrent.Executor; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; -import org.apache.velocity.VelocityContext; -import org.apache.velocity.tools.ToolManager; -import org.apache.velocity.util.StringBuilderWriter; import org.slf4j.Logger; /** @@ -41,61 +34,26 @@ public class SelectLanguageTutorial implements Processor { public static final String EVENT_NAME = "selectLanguageTutorial"; private final Executor threadPool; - private final LocalizedMessageFactory localizedMessageFactory; + private final TemplateContentGenerator templateContentGenerator; private final Logger log; private final UUIDGenerator uuidGenerator; - - /** - * Additional fields that are related to {@code changeLanguage} event - * - * @author Davide Polonio - * @since 1.0 - */ - public enum Field { - NewLanguage("newLanguage"); - - private final String name; - - Field(String name) { - this.name = name; - } - - public String getName() { - return name; - } - } - - /** - * Possible values for the additional {@link Field} of {@code changeLanguage} event - * - * @author Davide Polonio - * @since 1.0 - */ - public enum Language { - English("en-US"), - Italian("it-IT"); - - private final String locale; - - Language(String locale) { - this.locale = locale; - } - - public String getLocale() { - return locale; - } - } + private final ChatUtil chatUtil; + private final CallbackQueryContextCleaner callbackQueryContextCleaner; @Inject public SelectLanguageTutorial( @Named("eventThreadPool") Executor threadPool, - LocalizedMessageFactory localizedMessageFactory, + TemplateContentGenerator templateContentGenerator, Logger log, - UUIDGenerator uuidGenerator) { + UUIDGenerator uuidGenerator, + ChatUtil chatUtil, + CallbackQueryContextCleaner callbackQueryContextCleaner) { this.threadPool = threadPool; - this.localizedMessageFactory = localizedMessageFactory; + this.templateContentGenerator = templateContentGenerator; this.log = log; this.uuidGenerator = uuidGenerator; + this.chatUtil = chatUtil; + this.callbackQueryContextCleaner = callbackQueryContextCleaner; } @Override @@ -108,16 +66,8 @@ public class SelectLanguageTutorial implements Processor { CallbackQueryContext callbackQueryContext, Update update) { return CompletableFuture.supplyAsync( () -> - Optional.of(callbackQueryContext.getFields().getTelegramChatId()) - .map(Double::longValue) - // If we're desperate, search in the message for the chat id - .or( - () -> - Optional.ofNullable(update.callbackQuery().message()) - .map(Message::messageId) - .map(Long::valueOf)) - .filter(chatId -> chatId != 0L && chatId != Long.MIN_VALUE) - .flatMap(chatId -> new QTgChat().id.eq(chatId).findOneOrEmpty()) + chatUtil + .extractChat(callbackQueryContext, update) .map( tgChat -> { tgChat.setLocale( @@ -126,7 +76,8 @@ public class SelectLanguageTutorial implements Processor { .getFields() .getAdditionalProperties() .getOrDefault( - Field.NewLanguage.getName(), tgChat.getLocale())); + Field.SelectLanguageTutorial.NewLanguage.getName(), + tgChat.getLocale())); tgChat.save(); log.trace( "Locale for chat " @@ -147,55 +98,34 @@ public class SelectLanguageTutorial implements Processor { .thenApplyAsync( // If we are here then we're sure there is at least a chat associated with this callback tgChat -> { + if (tgChat.getHasHelpBeenShown()) { + log.trace( + "Help message has already been shown for this user - no help button will be" + + " present"); + } else { + log.trace("No help message shown yet - the help button will be added"); + } + final String message = - Try.of( - () -> { - final Locale locale = Locale.forLanguageTag(tgChat.getLocale()); - final ToolManager toolManager = - localizedMessageFactory.createVelocityToolManager(locale); - final VelocityContext velocityContext = - new VelocityContext(toolManager.createContext()); - - velocityContext.put("hasHelpBeenShown", tgChat.getHasHelpBeenShown()); - - final StringBuilder content = new StringBuilder(); - final StringBuilderWriter stringBuilderWriter = - new StringBuilderWriter(content); - - toolManager - .getVelocityEngine() - .mergeTemplate( - "/template/callbackQuery/selectLanguageTutorial.vm", - StandardCharsets.UTF_8.name(), - velocityContext, - stringBuilderWriter); - - stringBuilderWriter.close(); - return content.toString(); - }) - .get(); + templateContentGenerator.mergeTemplate( + velocityContext -> + velocityContext.put("hasHelpBeenShown", tgChat.getHasHelpBeenShown()), + tgChat.getLocale(), + "/template/telegram/selectLanguageTutorial.vm"); log.trace("SelectLanguageTutorial event - message to send back: " + message); - final String callBackGroupToDelete = callbackQueryContext.getEntryGroup(); - final int delete = - new QCallbackQueryContext().entryGroup.eq(callBackGroupToDelete).delete(); - log.trace( - "Deleted " - + delete - + " entries regarding callback group " - + callBackGroupToDelete); + callbackQueryContextCleaner.removeGroupAsync(callbackQueryContext.getEntryGroup()); - final Optional messageId = - Optional.ofNullable(update.callbackQuery().message()).map(Message::messageId); + final Optional messageId = Util.extractMessageId(update); BaseRequest baseRequest; Optional helpButton = Optional.empty(); if (!tgChat.getHasHelpBeenShown()) { // Add a button to show all the possible commands final String showMeTutorialString = - localizedMessageFactory - .createResourceBundle(Locale.forLanguageTag(tgChat.getLocale())) - .getString("button.showMeTutorial"); + templateContentGenerator.getString( + tgChat.getLocale(), + "selectLanguageTutorial.showMeTutorialInlineKeyboardButtonName"); final CallbackQueryMetadata callbackQueryMetadata = new CallbackQueryMetadata.CallbackQueryMetadataBuilder() diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelp.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelp.java index 6beb58e..81d9aa1 100644 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelp.java +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelp.java @@ -1,15 +1,66 @@ package com.github.polpetta.mezzotre.telegram.callbackquery; +import com.github.polpetta.mezzotre.orm.CallbackQueryContextCleaner; import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.github.polpetta.mezzotre.telegram.model.Help; import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; +import com.pengrad.telegrambot.model.request.ParseMode; import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.EditMessageText; import com.pengrad.telegrambot.request.SendMessage; +import java.util.Collections; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; -public class ShowHelp implements Processor { +/** + * ShowHelp callback query event edits a previous message (if available), otherwise it sends a new + * message with a list of available commands (the one implemented by {@link + * com.github.polpetta.mezzotre.telegram.command.Processor}), and a list of buttons that are + * callback queries (the ones implemented by {@link Processor}). The class is a Singleton since it + * is stateless and having multiple instances would only be a waste of memory. + * + * @author Davide Polonio + * @since 1.0 + * @see com.github.polpetta.mezzotre.telegram.command.Help same functionality but as {@link + * com.github.polpetta.mezzotre.telegram.command.Processor} command + */ +@Singleton +class ShowHelp implements Processor { public static String EVENT_NAME = "showHelp"; + private final Executor threadPool; + private final Help modelHelp; + private final Map + tgCommandProcessors; + private final Map eventProcessor; + private final ChatUtil chatUtil; + private final CallbackQueryContextCleaner callbackQueryContextCleaner; + + @Inject + public ShowHelp( + @Named("eventThreadPool") Executor threadPool, + com.github.polpetta.mezzotre.telegram.model.Help modelHelp, + @Named("commandProcessor") + Map tgCommandProcessors, + @Named("eventProcessors") + Map eventProcessor, + ChatUtil chatUtil, + CallbackQueryContextCleaner callbackQueryContextCleaner) { + this.threadPool = threadPool; + this.modelHelp = modelHelp; + this.tgCommandProcessors = tgCommandProcessors; + this.eventProcessor = eventProcessor; + this.chatUtil = chatUtil; + this.callbackQueryContextCleaner = callbackQueryContextCleaner; + } @Override public String getEventName() { @@ -19,8 +70,44 @@ public class ShowHelp implements Processor { @Override public CompletableFuture>> process( CallbackQueryContext callbackQueryContext, Update update) { - // TODO implement this method and put `hasHelpBeenShown` in tgChat to false - return CompletableFuture.completedFuture( - Optional.of(new SendMessage(callbackQueryContext.getFields().getTelegramChatId(), "TODO"))); + return CompletableFuture.supplyAsync( + () -> + chatUtil + .extractChat(callbackQueryContext, update) + .map( + chat -> { + final String message = modelHelp.getMessage(chat, tgCommandProcessors); + chatUtil.updateChatContext(chat, EVENT_NAME, 0, Collections.emptyMap()); + chat.setHasHelpBeenShown(true); + chat.save(); + final Optional messageId = Util.extractMessageId(update); + final InlineKeyboardButton[] buttons = + modelHelp.generateInlineKeyBoardButton(chat, eventProcessor); + BaseRequest request; + if (messageId.isPresent()) { + final EditMessageText editMessageText = + new EditMessageText(chat.getId(), messageId.get(), message) + .parseMode(ParseMode.Markdown); + if (buttons.length > 0) { + editMessageText.replyMarkup(new InlineKeyboardMarkup(buttons)); + } + request = editMessageText; + } else { + final SendMessage sendMessage = + new SendMessage(chat.getId(), message).parseMode(ParseMode.Markdown); + if (buttons.length > 0) { + sendMessage.replyMarkup(new InlineKeyboardMarkup(buttons)); + } + request = sendMessage; + } + + // We don't check if the element is removed or what - we just schedule its + // removal, then it is someone else problem + callbackQueryContextCleaner.removeGroupAsync( + callbackQueryContext.getEntryGroup()); + + return request; + }), + threadPool); } } diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Util.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Util.java new file mode 100644 index 0000000..3637292 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Util.java @@ -0,0 +1,25 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.Update; +import java.util.Optional; + +/** + * A class with misc utilities + * + * @author Davide Polonio + * @since 1.0 + */ +public class Util { + + /** + * Extract the message id of the given {@link Update} + * + * @param update the {@link Update} to check and search the message id for + * @return an {@link Optional} containing a {@link Integer} with the message id if it is present, + * otherwise a {@link Optional#empty()} if it is not found. + */ + public static Optional extractMessageId(Update update) { + return Optional.ofNullable(update.callbackQuery().message()).map(Message::messageId); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Value.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Value.java new file mode 100644 index 0000000..046ff8b --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/Value.java @@ -0,0 +1,25 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +public interface Value { + /** + * Possible values for the additional {@link Field.SelectLanguageTutorial} of {@code + * changeLanguage} event + * + * @author Davide Polonio + * @since 1.0 + */ + enum SelectLanguageTutorial { + English("en-US"), + Italian("it-IT"); + + private final String locale; + + SelectLanguageTutorial(String locale) { + this.locale = locale; + } + + public String getLocale() { + return locale; + } + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/di/CallbackQuery.java b/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/di/CallbackQuery.java deleted file mode 100644 index 0e252d4..0000000 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/callbackquery/di/CallbackQuery.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.polpetta.mezzotre.telegram.callbackquery.di; - -import com.github.polpetta.mezzotre.telegram.callbackquery.Processor; -import com.github.polpetta.mezzotre.telegram.callbackquery.SelectLanguageTutorial; -import com.github.polpetta.mezzotre.telegram.callbackquery.ShowHelp; -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import java.util.Set; -import javax.inject.Named; -import javax.inject.Singleton; - -public class CallbackQuery extends AbstractModule { - @Provides - @Singleton - @Named("eventProcessors") - public Set getEventProcessor( - SelectLanguageTutorial selectLanguageTutorial, ShowHelp showHelp) { - return Set.of(selectLanguageTutorial, showHelp); - } -} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/CommandDI.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/CommandDI.java new file mode 100644 index 0000000..767fdab --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/CommandDI.java @@ -0,0 +1,57 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.assistedinject.FactoryModuleBuilder; +import java.util.HashMap; +import java.util.Map; +import javax.inject.Named; +import javax.inject.Singleton; +import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.runtime.RuntimeConstants; +import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader; + +public class CommandDI extends AbstractModule { + + @Override + protected void configure() { + super.configure(); + + install( + new FactoryModuleBuilder() + .implement(Processor.class, NotFound.class) + .build(NotFoundFactory.class)); + } + + private static Map mapForProcessor(Processor processor) { + final HashMap commandMap = new HashMap<>(); + processor + .getTriggerKeywords() + .forEach( + keyword -> { + commandMap.put(keyword, processor); + }); + return commandMap; + } + + @Provides + @Singleton + @Named("commandProcessor") + public Map getCommandProcessor(Start start, Help help) { + final HashMap commandMap = new HashMap<>(); + commandMap.putAll(mapForProcessor(start)); + commandMap.putAll(mapForProcessor(help)); + + return commandMap; + } + + @Provides + public VelocityEngine getVelocityEngine() { + final VelocityEngine velocityEngine = new VelocityEngine(); + velocityEngine.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath"); + velocityEngine.setProperty( + "classpath.resource.loader.class", ClasspathResourceLoader.class.getName()); + velocityEngine.init(); + return velocityEngine; + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Help.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Help.java new file mode 100644 index 0000000..9b05c14 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Help.java @@ -0,0 +1,85 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; +import com.pengrad.telegrambot.model.request.ParseMode; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +/** + * The Help command class allows the user to be informed in any moment of commands that are + * available in the system. If possible, it also provides a {@code reply_markup} keyboard ({@link + * InlineKeyboardMarkup} for easier bot interactions + * + * @author Davide Polonio + * @since 1.0 + * @see com.github.polpetta.mezzotre.telegram.callbackquery.ShowHelp for event-based help + */ +@Singleton +class Help implements Processor { + + private static final String TRIGGERING_STAGING_NAME = "/help"; + + private final Executor threadPool; + private final Map tgCommandProcessors; + private final Map + eventProcessor; + private final com.github.polpetta.mezzotre.telegram.model.Help modelHelp; + private final ChatUtil chatUtil; + + @Inject + public Help( + @Named("eventThreadPool") Executor threadPool, + @Named("commandProcessor") Map tgCommandProcessors, + @Named("eventProcessors") + Map eventProcessor, + com.github.polpetta.mezzotre.telegram.model.Help modelHelp, + ChatUtil chatUtil) { + this.threadPool = threadPool; + this.tgCommandProcessors = tgCommandProcessors; + this.eventProcessor = eventProcessor; + this.modelHelp = modelHelp; + this.chatUtil = chatUtil; + } + + @Override + public Set getTriggerKeywords() { + return Set.of(TRIGGERING_STAGING_NAME); + } + + @Override + public CompletableFuture>> process(TgChat chat, Update update) { + return CompletableFuture.supplyAsync( + () -> { + final String message = modelHelp.getMessage(chat, tgCommandProcessors); + chatUtil.updateChatContext(chat, TRIGGERING_STAGING_NAME, 0, Collections.emptyMap()); + chat.setHasHelpBeenShown(true); + chat.save(); + + final SendMessage sendMessage = + new SendMessage(chat.getId(), message).parseMode(ParseMode.Markdown); + + final InlineKeyboardButton[] buttons = + modelHelp.generateInlineKeyBoardButton(chat, eventProcessor); + + if (buttons.length > 0) { + sendMessage.replyMarkup(new InlineKeyboardMarkup(buttons)); + } + + return Optional.of(sendMessage); + }, + threadPool); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/NotFound.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/NotFound.java new file mode 100644 index 0000000..c1e3155 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/NotFound.java @@ -0,0 +1,57 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.google.inject.assistedinject.Assisted; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.inject.Inject; +import javax.inject.Named; + +/** + * Generates a "Command not found" message + * + * @author Davide Polonio + * @since 1.0 + * @see com.github.polpetta.mezzotre.telegram.callbackquery.NotFound for the event-based version + */ +class NotFound implements Processor { + private static final String CMD_CTX_NAME = "cmdName"; + + private final String commandName; + private final TemplateContentGenerator templateContentGenerator; + private final Executor threadPool; + + @Inject + public NotFound( + @Assisted String commandName, + TemplateContentGenerator templateContentGenerator, + @Named("eventThreadPool") Executor threadPool) { + this.commandName = commandName; + this.templateContentGenerator = templateContentGenerator; + this.threadPool = threadPool; + } + + @Override + public Set getTriggerKeywords() { + return Collections.emptySet(); + } + + @Override + public CompletableFuture>> process(TgChat chat, Update update) { + return CompletableFuture.supplyAsync( + () -> + templateContentGenerator.mergeTemplate( + ctx -> ctx.put(CMD_CTX_NAME, commandName), + chat.getLocale(), + "template/telegram/notFound.vm"), + threadPool) + .thenApply(text -> Optional.of(new SendMessage(chat.getId(), text))); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/NotFoundFactory.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/NotFoundFactory.java new file mode 100644 index 0000000..381c449 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/NotFoundFactory.java @@ -0,0 +1,5 @@ +package com.github.polpetta.mezzotre.telegram.command; + +interface NotFoundFactory { + NotFound create(String commandName); +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Processor.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Processor.java index c83673b..c9a6216 100644 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Processor.java +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Processor.java @@ -4,6 +4,7 @@ import com.github.polpetta.mezzotre.orm.model.TgChat; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.request.BaseRequest; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; /** @@ -16,12 +17,12 @@ import java.util.concurrent.CompletableFuture; public interface Processor { /** - * Provides the keyword to trigger this executor. Note that it must start with "/" at the + * Provides the keywords to trigger this executor. Note that it must start with "/" at the * beginning, e.g. {@code /start}. * - * @return a {@link String} providing the keyword to trigger the current {@link Processor} + * @return a {@link Set} providing the keywords to trigger the current {@link Processor} */ - String getTriggerKeyword(); + Set getTriggerKeywords(); /** * Process the current update @@ -31,4 +32,17 @@ public interface Processor { * @return a {@link CompletableFuture} with the result of the computation */ CompletableFuture>> process(TgChat chat, Update update); + + /** + * Provide the key to retrieve the current processor descriptor. This is useful for help messages + * or to provide a user with a description of what this command does. Whilst a default + * implementation is provided, we suggest to rename it accordingly since the name generation is + * based on reflection and can be fragile and subject to code refactor changes. + * + * @return a {@link String} with the name of the localization key containing the command + * description + */ + default String getLocaleDescriptionKeyword() { + return this.getClass().getSimpleName().toLowerCase() + ".cmdDescription"; + } } diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Router.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Router.java index 5ff9eca..2954585 100644 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Router.java +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Router.java @@ -5,8 +5,8 @@ import com.google.inject.Inject; import com.google.inject.Singleton; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.request.BaseRequest; +import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import javax.inject.Named; @@ -21,15 +21,18 @@ import javax.inject.Named; @Singleton public class Router { - private final Set tgCommandProcessors; + private final Map tgCommandProcessors; private final Executor threadPool; + private final NotFoundFactory notFoundFactory; @Inject public Router( - @Named("commandProcessor") Set tgCommandProcessors, - @Named("eventThreadPool") Executor threadPool) { + @Named("commandProcessor") Map tgCommandProcessors, + @Named("eventThreadPool") Executor threadPool, + NotFoundFactory notFoundFactory) { this.tgCommandProcessors = tgCommandProcessors; this.threadPool = threadPool; + this.notFoundFactory = notFoundFactory; } /** @@ -58,15 +61,13 @@ public class Router { .map(list -> list[0]) .filter(wannabeCommand -> wannabeCommand.startsWith("/")) .or(() -> Optional.ofNullable(chat.getChatContext().getStage())) - .flatMap( + .map( command -> - tgCommandProcessors.stream() - // FIXME this is fucking stupid, why iterate over, just use a map! - // Make mapping at startup then we're gucci for the rest of the run - .filter(ex -> ex.getTriggerKeyword().equals(command)) - .findAny()) - .map(executor -> executor.process(chat, update)) - .orElse(CompletableFuture.failedFuture(new CommandNotFoundException())), + tgCommandProcessors + .getOrDefault(command, notFoundFactory.create(command)) + .process(chat, update)) + // This should never happen + .orElse(CompletableFuture.failedFuture(new IllegalStateException())), threadPool); } } diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Start.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Start.java index adeca62..390c6ea 100644 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Start.java +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Start.java @@ -1,13 +1,13 @@ package com.github.polpetta.mezzotre.telegram.command; -import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; import com.github.polpetta.mezzotre.orm.model.TgChat; -import com.github.polpetta.mezzotre.telegram.callbackquery.SelectLanguageTutorial; -import com.github.polpetta.mezzotre.util.Clock; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.github.polpetta.mezzotre.telegram.callbackquery.Field; +import com.github.polpetta.mezzotre.telegram.callbackquery.Value; import com.github.polpetta.mezzotre.util.UUIDGenerator; import com.github.polpetta.types.json.CallbackQueryMetadata; -import com.github.polpetta.types.json.ChatContext; import com.google.inject.Singleton; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.model.request.InlineKeyboardButton; @@ -15,17 +15,14 @@ import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; import com.pengrad.telegrambot.model.request.ParseMode; import com.pengrad.telegrambot.request.BaseRequest; import com.pengrad.telegrambot.request.SendMessage; -import io.vavr.control.Try; -import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.Locale; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import javax.inject.Inject; import javax.inject.Named; -import org.apache.velocity.VelocityContext; -import org.apache.velocity.tools.ToolManager; -import org.apache.velocity.util.StringBuilderWriter; import org.slf4j.Logger; /** @@ -35,31 +32,37 @@ import org.slf4j.Logger; * @since 1.0 */ @Singleton -public class Start implements Processor { +class Start implements Processor { + + private static final String TRIGGERING_STAGING_NAME = "/start"; private final Executor threadPool; private final Logger log; private final UUIDGenerator uuidGenerator; - private final Clock clock; - private final LocalizedMessageFactory localizedMessageFactory; + private final String applicationName; + private final ChatUtil chatUtil; + + private final TemplateContentGenerator templateContentGenerator; @Inject public Start( - LocalizedMessageFactory localizedMessageFactory, + TemplateContentGenerator templateContentGenerator, @Named("eventThreadPool") Executor threadPool, Logger log, UUIDGenerator uuidGenerator, - Clock clock) { - this.localizedMessageFactory = localizedMessageFactory; + @Named("applicationName") String applicationName, + ChatUtil chatUtil) { + this.templateContentGenerator = templateContentGenerator; this.threadPool = threadPool; this.log = log; this.uuidGenerator = uuidGenerator; - this.clock = clock; + this.applicationName = applicationName; + this.chatUtil = chatUtil; } @Override - public String getTriggerKeyword() { - return "/start"; + public Set getTriggerKeywords() { + return Set.of(TRIGGERING_STAGING_NAME); } @Override @@ -73,40 +76,18 @@ public class Start implements Processor { 3 - Reply to Telegram */ final String message = - Try.of( - () -> { - final Locale locale = Locale.forLanguageTag(chat.getLocale()); - final ToolManager toolManager = - localizedMessageFactory.createVelocityToolManager(locale); - final VelocityContext context = - new VelocityContext(toolManager.createContext()); - context.put("firstName", update.message().chat().firstName()); - context.put("programName", "_Mezzotre_"); - - final StringBuilder content = new StringBuilder(); - final StringBuilderWriter stringBuilderWriter = - new StringBuilderWriter(content); - - toolManager - .getVelocityEngine() - .mergeTemplate( - "template/command/start.0.vm", - StandardCharsets.UTF_8.name(), - context, - stringBuilderWriter); - - stringBuilderWriter.close(); - return content.toString(); - }) - .get(); + templateContentGenerator.mergeTemplate( + velocityContext -> { + velocityContext.put("firstName", update.message().chat().firstName()); + // FIXME add some very cool markdown formatter instead of concatenating stuff + // this way + velocityContext.put("programName", "_" + applicationName + "_"); + }, + chat.getLocale(), + "template/telegram/start.vm"); log.trace("Start command - message to send back: " + message); - final ChatContext chatContext = chat.getChatContext(); - chatContext.setStage(getTriggerKeyword()); - chatContext.setStep(0); - chatContext.setPreviousMessageUnixTimestampInSeconds(clock.now()); - chat.setChatContext(chatContext); - chat.save(); + chatUtil.updateChatContext(chat, TRIGGERING_STAGING_NAME, 0, Collections.emptyMap()); // To get the messageId we should send the message first, then save it in the database! final String groupId = uuidGenerator.generateAsString(); @@ -115,11 +96,13 @@ public class Start implements Processor { uuidGenerator.generateAsString(), groupId, new CallbackQueryMetadata.CallbackQueryMetadataBuilder() - .withEvent(SelectLanguageTutorial.EVENT_NAME) + .withEvent( + com.github.polpetta.mezzotre.telegram.callbackquery.SelectLanguageTutorial + .EVENT_NAME) .withTelegramChatId(update.message().chat().id()) .withAdditionalProperty( - SelectLanguageTutorial.Field.NewLanguage.getName(), - SelectLanguageTutorial.Language.English.getLocale()) + Field.SelectLanguageTutorial.NewLanguage.getName(), + Value.SelectLanguageTutorial.English.getLocale()) .build()); final CallbackQueryContext switchToItalian = @@ -127,21 +110,19 @@ public class Start implements Processor { uuidGenerator.generateAsString(), groupId, new CallbackQueryMetadata.CallbackQueryMetadataBuilder() - .withEvent(SelectLanguageTutorial.EVENT_NAME) + .withEvent( + com.github.polpetta.mezzotre.telegram.callbackquery.SelectLanguageTutorial + .EVENT_NAME) .withTelegramChatId(update.message().chat().id()) .withAdditionalProperty( - SelectLanguageTutorial.Field.NewLanguage.getName(), - SelectLanguageTutorial.Language.Italian.getLocale()) + Field.SelectLanguageTutorial.NewLanguage.getName(), + Value.SelectLanguageTutorial.Italian.getLocale()) .build()); final String englishButton = - localizedMessageFactory - .createResourceBundle(Locale.US) - .getString("changeLanguage.english"); + templateContentGenerator.getString(Locale.US, "changeLanguage.english"); final String italianButton = - localizedMessageFactory - .createResourceBundle(Locale.ITALY) - .getString("changeLanguage.italian"); + templateContentGenerator.getString(Locale.ITALY, "changeLanguage.italian"); final SendMessage messageToSend = new SendMessage(chat.getId(), message) diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/di/Command.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/di/Command.java deleted file mode 100644 index ef0ee6b..0000000 --- a/src/main/java/com/github/polpetta/mezzotre/telegram/command/di/Command.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.github.polpetta.mezzotre.telegram.command.di; - -import com.github.polpetta.mezzotre.telegram.command.Processor; -import com.github.polpetta.mezzotre.telegram.command.Start; -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import java.util.Set; -import javax.inject.Named; -import javax.inject.Singleton; -import org.apache.velocity.app.VelocityEngine; -import org.apache.velocity.runtime.RuntimeConstants; -import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader; - -public class Command extends AbstractModule { - @Provides - @Singleton - @Named("commandProcessor") - public Set getCommandProcessor(Start start) { - return Set.of(start); - } - - @Provides - public VelocityEngine getVelocityEngine() { - final VelocityEngine velocityEngine = new VelocityEngine(); - velocityEngine.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath"); - velocityEngine.setProperty( - "classpath.resource.loader.class", ClasspathResourceLoader.class.getName()); - velocityEngine.init(); - return velocityEngine; - } -} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/model/Help.java b/src/main/java/com/github/polpetta/mezzotre/telegram/model/Help.java new file mode 100644 index 0000000..2cc8e70 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/model/Help.java @@ -0,0 +1,104 @@ +package com.github.polpetta.mezzotre.telegram.model; + +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.telegram.callbackquery.Field; +import com.github.polpetta.mezzotre.telegram.command.Processor; +import com.github.polpetta.mezzotre.util.UUIDGenerator; +import com.github.polpetta.types.json.CallbackQueryMetadata; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import java.util.Map; +import java.util.stream.Collectors; +import javax.inject.Inject; +import org.apache.commons.lang3.tuple.Pair; + +/** + * This class provides the model for the Help message output. It is the business logic behind the + * message, and a way to keep the codebase DRY. + * + * @author Davide Polonio + * @since 1.0 + * @see com.github.polpetta.mezzotre.telegram.command.Help for the command help + * @see com.github.polpetta.mezzotre.telegram.callbackquery.ShowHelp for the event help + */ +public class Help { + + private final TemplateContentGenerator templateContentGenerator; + private final UUIDGenerator uuidGenerator; + + @Inject + public Help(TemplateContentGenerator templateContentGenerator, UUIDGenerator uuidGenerator) { + this.templateContentGenerator = templateContentGenerator; + this.uuidGenerator = uuidGenerator; + } + + /** + * Generates the message that will be sent back to the user. This takes the given {@link + * Processor} and formats them accordingly to the chat locale + * + * @param chat the {@link TgChat} conversation + * @param tgCommandProcessors a {@link Map} of all the {@link Processor} that will be printed in + * the help message + * @return a {@link String} localized ready to be sent to the user + */ + public String getMessage(TgChat chat, Map tgCommandProcessors) { + return templateContentGenerator.mergeTemplate( + velocityContext -> { + velocityContext.put( + "commands", + tgCommandProcessors.values().stream() + .distinct() + .map( + p -> + Pair.of( + p.getTriggerKeywords().stream().sorted().collect(Collectors.toList()), + p.getLocaleDescriptionKeyword())) + .collect(Collectors.toList())); + }, + chat.getLocale(), + "template/telegram/help.vm"); + } + + /** + * Generates {@link InlineKeyboardButton} to be returned to the user to give them the possibility + * to interact with them via events rather than commands. + * + * @param chat the current {@link TgChat} conversation + * @param eventProcessors a {@link Map} of {@link + * com.github.polpetta.mezzotre.telegram.callbackquery.Processor} that are currently used to + * process all events. Note that only the one the user can interact with will be added as + * buttons + * @return an array of {@link InlineKeyboardButton} + */ + public InlineKeyboardButton[] generateInlineKeyBoardButton( + TgChat chat, + Map eventProcessors) { + final String callBackGroupId = uuidGenerator.generateAsString(); + return eventProcessors.values().stream() + .filter( + com.github.polpetta.mezzotre.telegram.callbackquery.Processor + ::canBeDirectlyInvokedByTheUser) + .filter(e -> e.getPrettyPrintLocaleKeyName().isPresent()) + .map( + eventProcessor -> { + final CallbackQueryContext callbackQueryContext = + new CallbackQueryContext( + uuidGenerator.generateAsString(), + callBackGroupId, + new CallbackQueryMetadata.CallbackQueryMetadataBuilder() + .withEvent(eventProcessor.getEventName()) + .withTelegramChatId(chat.getId()) + .withAdditionalProperty( + Field.ShowHelp.InvokedFromHelpMessage.getName(), true) + .build()); + callbackQueryContext.save(); + + return new InlineKeyboardButton( + templateContentGenerator.getString( + chat.getLocale(), eventProcessor.getPrettyPrintLocaleKeyName().get())) + .callbackData(callbackQueryContext.getId()); + }) + .toArray(InlineKeyboardButton[]::new); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/util/ServiceModule.java b/src/main/java/com/github/polpetta/mezzotre/util/ServiceModule.java new file mode 100644 index 0000000..f509088 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/util/ServiceModule.java @@ -0,0 +1,86 @@ +package com.github.polpetta.mezzotre.util; + +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.Service; +import com.google.common.util.concurrent.ServiceManager; +import io.jooby.Extension; +import io.jooby.Jooby; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; +import javax.inject.Inject; +import javax.inject.Named; +import org.jetbrains.annotations.NotNull; + +/** + * This Jooby {@link Extension} allows to start and execute services that implement {@link Service} + * class. This allows implementation of this interface to be automatically started. Services need to + * me listed by the named key {@code "services"}, which is a {@link List} of services passed by the + * {@link com.google.inject.Injector}. + * + *

The module handles the services start and stop when the whole JVM is stopped, allowing a + * graceful service shutdown. + * + * @author Davide Polonio + * @since 1.0 + * @see Guava Service Wiki + * explained + * @see ServiceManager + */ +public class ServiceModule implements Extension { + + private final ServiceManager serviceManager; + + @Inject + public ServiceModule(@Named("services") List services) { + serviceManager = new ServiceManager(services); + } + + @Override + public boolean lateinit() { + return true; + } + + @Override + public void install(@NotNull Jooby application) throws Exception { + final CompletableFuture initialization = new CompletableFuture<>(); + serviceManager.addListener( + new ServiceManager.Listener() { + @Override + public void healthy() { + super.healthy(); + application.getLog().info("All internal application services are up and running"); + initialization.complete(null); + } + + @Override + public void failure(@NotNull Service service) { + super.failure(service); + initialization.completeExceptionally(service.failureCause()); + } + }, + MoreExecutors.directExecutor()); + + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + try { + serviceManager.stopAsync().awaitStopped(Duration.ofSeconds(15)); + } catch (TimeoutException ex) { + application + .getLog() + .warn( + "Unable to correctly stop all the services, got the following error" + + " while stopping: " + + ex.getMessage()); + throw new RuntimeException(ex); + } + })); + + // Blocking call to wait for all the services to be up and running + serviceManager.startAsync(); + initialization.get(); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/util/UtilDI.java b/src/main/java/com/github/polpetta/mezzotre/util/UtilDI.java new file mode 100644 index 0000000..cfdb144 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/util/UtilDI.java @@ -0,0 +1,43 @@ +package com.github.polpetta.mezzotre.util; + +import com.google.common.util.concurrent.Service; +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import javax.inject.Named; +import javax.inject.Singleton; +import org.apache.commons.lang3.tuple.Pair; + +public class UtilDI extends AbstractModule { + @Provides + @Singleton + @Named("eventThreadPool") + public Executor getEventExecutor() { + return ForkJoinPool.commonPool(); + } + + @Provides + @Singleton + @Named("longJobThreadPool") + public Executor getLongJobExecutor() { + return Executors.newCachedThreadPool(); + } + + @Provides + @Singleton + @Named("serviceModule") + public ServiceModule getServiceModule(@Named("services") List moduleList) { + return new ServiceModule(moduleList); + } + + @Provides + @Singleton + @Named("serviceRunningCheckTime") + public Pair getTime() { + return Pair.of(5, TimeUnit.SECONDS); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/util/di/ThreadPool.java b/src/main/java/com/github/polpetta/mezzotre/util/di/ThreadPool.java deleted file mode 100644 index e4ddf60..0000000 --- a/src/main/java/com/github/polpetta/mezzotre/util/di/ThreadPool.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.polpetta.mezzotre.util.di; - -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ForkJoinPool; -import javax.inject.Named; -import javax.inject.Singleton; - -public class ThreadPool extends AbstractModule { - @Provides - @Singleton - @Named("eventThreadPool") - public Executor getEventExecutor() { - return ForkJoinPool.commonPool(); - } - - @Provides - @Singleton - @Named("longJobThreadPool") - public Executor getLongJobExecutor() { - return Executors.newCachedThreadPool(); - } -} diff --git a/src/main/resources/i18n/message.properties b/src/main/resources/i18n/message.properties index cce17e2..f8458cd 100644 --- a/src/main/resources/i18n/message.properties +++ b/src/main/resources/i18n/message.properties @@ -1,11 +1,23 @@ start.helloFirstName=Hello {0}! \ud83d\udc4b start.description=This is {0}, a simple bot focused on DnD content management! Please start by choosing a language down below \ud83d\udc47 +start.cmdDescription=Trigger this very bot +start.inlineKeyboardButtonName=Let''s begin! +selectLanguageTutorial.inlineKeyboardButtonName=Select language selectLanguageTutorial.drinkAction=*Proceeds to drink a potion with a strange, multicolor liquid* selectLanguageTutorial.setLanguage=Thanks! Now that I drank this modified potion of {0} that I''ve found at the "Crystal Fermentary" magic potion shop yesterday I can speak with you in the language that you prefer! -selectLanguageTutorial.instructions=You can always change your language settings by typing /selectLanguageTutorial in the chat. +selectLanguageTutorial.instructions=You can always change your language settings by typing /changeLanguage in the chat. changeLanguage.english=English changeLanguage.italian=Italian +changeLanguage.cmdDescription=Select the new language I will use to speak to you +changeLanguage.inlineKeyboardButtonName=Change language +selectLanguageTutorial.english=English +selectLanguageTutorial.italian=Italian spell.speakWithAnimals=Speak with animals -button.showMeTutorial=Show me what you can do! +selectLanguageTutorial.showMeTutorialInlineKeyboardButtonName=Show me what you can do! help.notShownYet=It seems you haven''t checked out what I can do yet! To have a complete list of my abilities, type /help in chat at any time! help.buttonBelow=Alternatively, you can click the button down below. +help.description=Here is a list of what I can do +help.buttonsToo=You can do the same operations you''d do with the commands aforementioned by selecting the corresponding button below \ud83d\udc47 +help.cmdDescription=Print the help message +notfound.description=Mmm I''m not able to find command {0}, are you sure to have it typed correctly? +notfound.howToHelp=Let me show you what I can do by typing /help in the chat! diff --git a/src/main/resources/i18n/message_en_US.properties b/src/main/resources/i18n/message_en_US.properties index 64ca1f3..f8458cd 100644 --- a/src/main/resources/i18n/message_en_US.properties +++ b/src/main/resources/i18n/message_en_US.properties @@ -1,11 +1,23 @@ start.helloFirstName=Hello {0}! \ud83d\udc4b start.description=This is {0}, a simple bot focused on DnD content management! Please start by choosing a language down below \ud83d\udc47 +start.cmdDescription=Trigger this very bot +start.inlineKeyboardButtonName=Let''s begin! +selectLanguageTutorial.inlineKeyboardButtonName=Select language selectLanguageTutorial.drinkAction=*Proceeds to drink a potion with a strange, multicolor liquid* selectLanguageTutorial.setLanguage=Thanks! Now that I drank this modified potion of {0} that I''ve found at the "Crystal Fermentary" magic potion shop yesterday I can speak with you in the language that you prefer! -selectLanguageTutorial.instructions=You can always change your language settings by typing /selectLanguageTutorial in the chat. +selectLanguageTutorial.instructions=You can always change your language settings by typing /changeLanguage in the chat. +changeLanguage.english=English +changeLanguage.italian=Italian +changeLanguage.cmdDescription=Select the new language I will use to speak to you +changeLanguage.inlineKeyboardButtonName=Change language selectLanguageTutorial.english=English selectLanguageTutorial.italian=Italian spell.speakWithAnimals=Speak with animals -button.showMeTutorial=Show me what you can do! +selectLanguageTutorial.showMeTutorialInlineKeyboardButtonName=Show me what you can do! help.notShownYet=It seems you haven''t checked out what I can do yet! To have a complete list of my abilities, type /help in chat at any time! help.buttonBelow=Alternatively, you can click the button down below. +help.description=Here is a list of what I can do +help.buttonsToo=You can do the same operations you''d do with the commands aforementioned by selecting the corresponding button below \ud83d\udc47 +help.cmdDescription=Print the help message +notfound.description=Mmm I''m not able to find command {0}, are you sure to have it typed correctly? +notfound.howToHelp=Let me show you what I can do by typing /help in the chat! diff --git a/src/main/resources/i18n/message_it.properties b/src/main/resources/i18n/message_it.properties index 1c13674..6b49a81 100644 --- a/src/main/resources/i18n/message_it.properties +++ b/src/main/resources/i18n/message_it.properties @@ -1,11 +1,23 @@ start.helloFirstName=Ciao {0}! \ud83d\udc4b start.description=Questo è {0}, un semplice bot che ci concenta sulla gestione di contenuto per DnD! Per favore comincia selezionando la lingua qui sotto \ud83d\udc47 +start.cmdDescription=Comincia a chattare con questo bot +start.inlineKeyboardButtonName=Cominciamo! +selectLanguageTutorial.inlineKeyboardButtonName=Seleziona linguaggio selectLanguageTutorial.drinkAction=*Procede a bere una pozione al cui suo interno si trova uno strano liquido multicolore* selectLanguageTutorial.setLanguage=Grazie! Ora che ho bevuto questa posizione modificata di {0} che ho trovato ieri al negozio di pozioni magiche la "Cristalleria Fermentatrice" posso parlare con te nel linguaggio che preferisci! -selectLanguageTutorial.instructions=Puoi sempre cambiare le preferenze della tua lingua scrivendo /selectLanguageTutorial nella chat. +selectLanguageTutorial.instructions=Puoi sempre cambiare le preferenze della tua lingua scrivendo /changeLanguage nella chat. +changeLanguage.english=Inglese +changeLanguage.italian=Italiano +changeLanguage.cmdDescription=Seleziona il nuovo linguaggio che userò per parlare con te +changeLanguage.inlineKeyboardButtonName=Cambia lingua selectLanguageTutorial.english=Inglese selectLanguageTutorial.italian=Italiano spell.speakWithAnimals=Parlare con animali -button.showMeTutorial=Mostrami cosa puoi fare! +selectLanguageTutorial.showMeTutorialInlineKeyboardButtonName=Mostrami cosa puoi fare! help.notShownYet=Sembra tu non abbia ancora visto cosa posso fare! Per avere una lista completa delle mie abilità, scrivi /help nella chat in qualsiasi momento! help.buttonBelow=Alternativamente, puoi premere il bottone qui sotto. +help.description=Ecco una lista di quello che sono in grado di fare +help.buttonsToo=Puoi fare le stesse operazioni che faresti con i comandi elencati precedentemente cliccando il bottone corrispondente qui di sotto \ud83d\udc47 +help.cmdDescription=Stampa il messaggio d''aiuto +notfound.description=Mmm non sono in grado di trovare il comando {0}, sei sicuro di averlo scritto correttamente? +notfound.howToHelp=Lascia che ti mostri cosa posso fare, scrivi /help nella chat! diff --git a/src/main/resources/i18n/message_it_IT.properties b/src/main/resources/i18n/message_it_IT.properties index 1c13674..6b49a81 100644 --- a/src/main/resources/i18n/message_it_IT.properties +++ b/src/main/resources/i18n/message_it_IT.properties @@ -1,11 +1,23 @@ start.helloFirstName=Ciao {0}! \ud83d\udc4b start.description=Questo è {0}, un semplice bot che ci concenta sulla gestione di contenuto per DnD! Per favore comincia selezionando la lingua qui sotto \ud83d\udc47 +start.cmdDescription=Comincia a chattare con questo bot +start.inlineKeyboardButtonName=Cominciamo! +selectLanguageTutorial.inlineKeyboardButtonName=Seleziona linguaggio selectLanguageTutorial.drinkAction=*Procede a bere una pozione al cui suo interno si trova uno strano liquido multicolore* selectLanguageTutorial.setLanguage=Grazie! Ora che ho bevuto questa posizione modificata di {0} che ho trovato ieri al negozio di pozioni magiche la "Cristalleria Fermentatrice" posso parlare con te nel linguaggio che preferisci! -selectLanguageTutorial.instructions=Puoi sempre cambiare le preferenze della tua lingua scrivendo /selectLanguageTutorial nella chat. +selectLanguageTutorial.instructions=Puoi sempre cambiare le preferenze della tua lingua scrivendo /changeLanguage nella chat. +changeLanguage.english=Inglese +changeLanguage.italian=Italiano +changeLanguage.cmdDescription=Seleziona il nuovo linguaggio che userò per parlare con te +changeLanguage.inlineKeyboardButtonName=Cambia lingua selectLanguageTutorial.english=Inglese selectLanguageTutorial.italian=Italiano spell.speakWithAnimals=Parlare con animali -button.showMeTutorial=Mostrami cosa puoi fare! +selectLanguageTutorial.showMeTutorialInlineKeyboardButtonName=Mostrami cosa puoi fare! help.notShownYet=Sembra tu non abbia ancora visto cosa posso fare! Per avere una lista completa delle mie abilità, scrivi /help nella chat in qualsiasi momento! help.buttonBelow=Alternativamente, puoi premere il bottone qui sotto. +help.description=Ecco una lista di quello che sono in grado di fare +help.buttonsToo=Puoi fare le stesse operazioni che faresti con i comandi elencati precedentemente cliccando il bottone corrispondente qui di sotto \ud83d\udc47 +help.cmdDescription=Stampa il messaggio d''aiuto +notfound.description=Mmm non sono in grado di trovare il comando {0}, sei sicuro di averlo scritto correttamente? +notfound.howToHelp=Lascia che ti mostri cosa posso fare, scrivi /help nella chat! diff --git a/src/main/resources/template/command/start.0.vm b/src/main/resources/template/command/start.0.vm deleted file mode 100644 index 7889c0b..0000000 --- a/src/main/resources/template/command/start.0.vm +++ /dev/null @@ -1,3 +0,0 @@ -**${i18n.start.helloFirstName.insert(${firstName})}** - -${i18n.start.description.insert(${programName})} \ No newline at end of file diff --git a/src/main/resources/template/telegram/help.vm b/src/main/resources/template/telegram/help.vm new file mode 100644 index 0000000..0ff5078 --- /dev/null +++ b/src/main/resources/template/telegram/help.vm @@ -0,0 +1,7 @@ +${i18n.help.description}: + +#foreach(${command} in ${commands}) +-#foreach(${key} in ${command.left}) ${key}#end: ${i18n.get(${command.right})} +#end + +${i18n.help.buttonsToo} \ No newline at end of file diff --git a/src/main/resources/template/telegram/notFound.vm b/src/main/resources/template/telegram/notFound.vm new file mode 100644 index 0000000..18916a1 --- /dev/null +++ b/src/main/resources/template/telegram/notFound.vm @@ -0,0 +1,3 @@ +${i18n.notfound.description.insert(${cmdName})} + +${i18n.notfound.howToHelp} \ No newline at end of file diff --git a/src/main/resources/template/callbackQuery/selectLanguageTutorial.vm b/src/main/resources/template/telegram/selectLanguageTutorial.vm similarity index 100% rename from src/main/resources/template/callbackQuery/selectLanguageTutorial.vm rename to src/main/resources/template/telegram/selectLanguageTutorial.vm diff --git a/src/main/resources/template/telegram/start.vm b/src/main/resources/template/telegram/start.vm new file mode 100644 index 0000000..3d55cb5 --- /dev/null +++ b/src/main/resources/template/telegram/start.vm @@ -0,0 +1,3 @@ +*${i18n.start.helloFirstName.insert(${firstName})}* + +${i18n.start.description.insert(${programName})} \ No newline at end of file diff --git a/src/test/java/com/github/polpetta/mezzotre/UnitTest.java b/src/test/java/com/github/polpetta/mezzotre/UnitTest.java index 9c8a6bd..69cf976 100644 --- a/src/test/java/com/github/polpetta/mezzotre/UnitTest.java +++ b/src/test/java/com/github/polpetta/mezzotre/UnitTest.java @@ -3,15 +3,21 @@ package com.github.polpetta.mezzotre; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; -import com.google.inject.*; +import com.github.polpetta.mezzotre.util.ServiceModule; +import com.google.inject.AbstractModule; import com.google.inject.Module; +import com.google.inject.Provides; +import com.google.inject.Stage; import io.jooby.*; import io.jooby.ebean.EbeanModule; import io.jooby.flyway.FlywayModule; import io.jooby.hikari.HikariModule; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; import javax.inject.Named; +import javax.inject.Singleton; +import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.Test; public class UnitTest { @@ -37,6 +43,20 @@ public class UnitTest { public Extension getEbeanExtension() { return mock(EbeanModule.class); } + + @Provides + @Singleton + @Named("serviceModule") + public ServiceModule getServiceModule() { + return mock(ServiceModule.class); + } + + @Provides + @Singleton + @Named("serviceRunningCheckTime") + public Pair getTime() { + return Pair.of(0, TimeUnit.MILLISECONDS); + } } @Test diff --git a/src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java b/src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java index dc1ebb6..5d6d0c0 100644 --- a/src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java +++ b/src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java @@ -1,19 +1,23 @@ package com.github.polpetta.mezzotre.helper; import com.github.polpetta.mezzotre.App; -import com.github.polpetta.mezzotre.route.di.Route; +import com.github.polpetta.mezzotre.route.RouteDI; +import com.github.polpetta.mezzotre.util.ServiceModule; +import com.google.common.util.concurrent.Service; import com.google.inject.AbstractModule; import com.google.inject.Provides; -import com.google.inject.Singleton; import com.google.inject.Stage; import com.zaxxer.hikari.HikariConfig; import io.jooby.Extension; import io.jooby.ebean.EbeanModule; import io.jooby.flyway.FlywayModule; import io.jooby.hikari.HikariModule; +import java.util.List; import java.util.Properties; import java.util.Set; +import java.util.concurrent.TimeUnit; import javax.inject.Named; +import javax.inject.Singleton; import org.apache.commons.lang3.tuple.Pair; import org.testcontainers.containers.PostgreSQLContainer; @@ -50,13 +54,27 @@ public class IntegrationAppFactory { public Extension getEbeanExtension() { return new EbeanModule(); } + + @Singleton + @Provides + @Named("serviceModule") + public ServiceModule getServiceModule(@Named("services") List moduleList) { + return new ServiceModule(moduleList); + } + + @Provides + @Singleton + @Named("serviceRunningCheckTime") + public Pair getTime() { + return Pair.of(0, TimeUnit.MILLISECONDS); + } } public static App loadCustomDbApplication() { final PostgreSQLContainer container = new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); container.start(); - final Route routeModule = new Route(); + final RouteDI routeModule = new RouteDI(); final DatabaseDI databaseDI = new DatabaseDI(container); return new App(Stage.DEVELOPMENT, Set.of(databaseDI, routeModule)); } diff --git a/src/test/java/com/github/polpetta/mezzotre/helper/Loader.java b/src/test/java/com/github/polpetta/mezzotre/helper/Loader.java index 90bc7b9..6063993 100644 --- a/src/test/java/com/github/polpetta/mezzotre/helper/Loader.java +++ b/src/test/java/com/github/polpetta/mezzotre/helper/Loader.java @@ -30,6 +30,7 @@ public class Loader { hikariConnectionProperties.put("username", container.getUsername()); hikariConnectionProperties.put("password", container.getPassword()); hikariConnectionProperties.put("jdbcUrl", container.getJdbcUrl()); + hikariConnectionProperties.put("autoCommit", "false"); ebeanConnectionProperties.load(ebeanInputStream); ebeanConnectionProperties.put("datasource_db_username", container.getUsername()); diff --git a/src/test/java/com/github/polpetta/mezzotre/i18n/TemplateContentGeneratorTest.java b/src/test/java/com/github/polpetta/mezzotre/i18n/TemplateContentGeneratorTest.java new file mode 100644 index 0000000..2d8df23 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/i18n/TemplateContentGeneratorTest.java @@ -0,0 +1,103 @@ +package com.github.polpetta.mezzotre.i18n; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.Locale; +import java.util.ResourceBundle; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.tools.ToolContext; +import org.apache.velocity.tools.ToolManager; +import org.apache.velocity.util.StringBuilderWriter; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.mockito.ArgumentCaptor; + +@Execution(ExecutionMode.CONCURRENT) +class TemplateContentGeneratorTest { + + private LocalizedMessageFactory fakeLocalizedMessageFactory; + private TemplateContentGenerator templateContentGenerator; + + @BeforeEach + void setUp() { + fakeLocalizedMessageFactory = mock(LocalizedMessageFactory.class); + templateContentGenerator = new TemplateContentGenerator(fakeLocalizedMessageFactory); + } + + @Test + void shouldCallMergeTemplateWithRightArguments() { + final VelocityEngine fakeVelocityEngine = mock(VelocityEngine.class); + final ToolManager fakeToolManager = mock(ToolManager.class); + when(fakeToolManager.getVelocityEngine()).thenReturn(fakeVelocityEngine); + final ToolContext fakeToolContext = mock(ToolContext.class); + when(fakeToolManager.createContext()).thenReturn(fakeToolContext); + when(fakeLocalizedMessageFactory.createVelocityToolManager(Locale.forLanguageTag("en-US"))) + .thenReturn(fakeToolManager); + + templateContentGenerator.mergeTemplate( + ctx -> { + ctx.put("testtesttest", "value"); + }, + "en-US", + "a/fake/path.vm"); + + final ArgumentCaptor velocityContextArgumentCaptor = + ArgumentCaptor.forClass(VelocityContext.class); + verify(fakeVelocityEngine, times(1)) + .mergeTemplate( + eq("a/fake/path.vm"), + eq(StandardCharsets.UTF_8.name()), + velocityContextArgumentCaptor.capture(), + any(StringBuilderWriter.class)); + + final VelocityContext gotVelocityContext = velocityContextArgumentCaptor.getValue(); + assertEquals("value", gotVelocityContext.get("testtesttest")); + } + + @Test + void shouldCallLocaleFactoryWhenRetrievingKey() { + final Locale enUS = Locale.forLanguageTag("en-US"); + + class StubRB extends ResourceBundle { + + @Override + protected Object handleGetObject(@NotNull String s) { + return "testtest123"; + } + + @NotNull + @Override + public Enumeration getKeys() { + return new Enumeration() { + + private boolean next = true; + + @Override + public boolean hasMoreElements() { + final boolean toRet = next; + next = !next; + return toRet; + } + + @Override + public String nextElement() { + return "doens't matter"; + } + }; + } + } + + final StubRB stubRB = new StubRB(); + when(fakeLocalizedMessageFactory.createResourceBundle(any())).thenReturn(stubRB); + + final String got = templateContentGenerator.getString(enUS, "a.string"); + assertEquals("testtest123", got); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/orm/BatchBeanCleanerServiceIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/orm/BatchBeanCleanerServiceIntegrationTest.java new file mode 100644 index 0000000..40a5e78 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/orm/BatchBeanCleanerServiceIntegrationTest.java @@ -0,0 +1,93 @@ +package com.github.polpetta.mezzotre.orm; + +import static org.junit.jupiter.api.Assertions.*; + +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; +import com.github.polpetta.types.json.CallbackQueryMetadata; +import io.ebean.Database; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.*; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("slow") +@Tag("database") +@Tag("velocity") +@Testcontainers +class BatchBeanCleanerServiceIntegrationTest { + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + private BatchBeanCleanerService batchBeanCleanerService; + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + batchBeanCleanerService = + new BatchBeanCleanerService( + LoggerFactory.getLogger(BatchBeanCleanerService.class), + Pair.of(0, TimeUnit.MILLISECONDS)); + } + + @AfterEach + void tearDown() throws Exception { + if (batchBeanCleanerService != null) { + batchBeanCleanerService.stopAsync().awaitTerminated(Duration.ofSeconds(10)); + } + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void shouldRemoveEntryFromDatabase() throws Exception { + final CallbackQueryContext callbackQueryContext = + new CallbackQueryContext("1234", "4567", new CallbackQueryMetadata()); + callbackQueryContext.save(); + + batchBeanCleanerService.startAsync(); + batchBeanCleanerService.awaitRunning(); + + final CompletableFuture integerCompletableFuture = + batchBeanCleanerService.removeAsync("1234", new QCallbackQueryContext().id); + + final Integer gotDeletion = integerCompletableFuture.get(); + + assertEquals(1, gotDeletion); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void shouldRemoveMultipleEntriesFromDatabase() throws Exception { + final CallbackQueryContext callbackQueryContext = + new CallbackQueryContext("1234", "4567", new CallbackQueryMetadata()); + callbackQueryContext.save(); + final CallbackQueryContext callbackQueryContext2 = + new CallbackQueryContext("4321", "4567", new CallbackQueryMetadata()); + callbackQueryContext2.save(); + + batchBeanCleanerService.startAsync(); + batchBeanCleanerService.awaitRunning(); + + final CompletableFuture integerCompletableFuture = + batchBeanCleanerService.removeAsync("4567", new QCallbackQueryContext().entryGroup); + final CompletableFuture integerCompletableFuture2 = + batchBeanCleanerService.removeAsync("4567", new QCallbackQueryContext().entryGroup); + + final Integer gotDeletion1 = integerCompletableFuture.get(); + final Integer gotDeletion2 = integerCompletableFuture2.get(); + + assertEquals(2, gotDeletion1); + assertEquals(0, gotDeletion2); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/orm/CallbackQueryContextCleanerIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/orm/CallbackQueryContextCleanerIntegrationTest.java new file mode 100644 index 0000000..afe607a --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/orm/CallbackQueryContextCleanerIntegrationTest.java @@ -0,0 +1,78 @@ +package com.github.polpetta.mezzotre.orm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; +import com.github.polpetta.types.json.CallbackQueryMetadata; +import io.ebean.Database; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.*; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("slow") +@Tag("database") +@Tag("velocity") +@Testcontainers +class CallbackQueryContextCleanerIntegrationTest { + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + + private BatchBeanCleanerService batchBeanCleanerService; + private CallbackQueryContextCleaner callbackQueryContextCleaner; + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + + batchBeanCleanerService = + new BatchBeanCleanerService( + LoggerFactory.getLogger(BatchBeanCleanerService.class), + Pair.of(0, TimeUnit.MILLISECONDS)); + batchBeanCleanerService.startAsync().awaitRunning(Duration.ofSeconds(10)); + + callbackQueryContextCleaner = + new CallbackQueryContextCleaner( + batchBeanCleanerService, LoggerFactory.getLogger(CallbackQueryContextCleaner.class)); + } + + @AfterEach + void tearDown() throws Exception { + batchBeanCleanerService.stopAsync().awaitTerminated(Duration.ofSeconds(10)); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void shouldDeleteByGroupId() throws Exception { + new CallbackQueryContext("doesn't matter", "an id", new CallbackQueryMetadata()).save(); + + final Integer got = callbackQueryContextCleaner.removeGroupAsync("an id").get(); + + assertEquals(1, got); + assertEquals(0, new QCallbackQueryContext().findCount()); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void shouldDeleteById() throws Exception { + new CallbackQueryContext("an id", "doesn't matter", new CallbackQueryMetadata()).save(); + + final Integer got = callbackQueryContextCleaner.removeIdAsync("an id").get(); + + assertEquals(1, got); + assertEquals(0, new QCallbackQueryContext().findCount()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/orm/model/CallbackQueryContextIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/orm/model/CallbackQueryDIContextIntegrationTest.java similarity index 98% rename from src/test/java/com/github/polpetta/mezzotre/orm/model/CallbackQueryContextIntegrationTest.java rename to src/test/java/com/github/polpetta/mezzotre/orm/model/CallbackQueryDIContextIntegrationTest.java index 2c45563..148d19e 100644 --- a/src/test/java/com/github/polpetta/mezzotre/orm/model/CallbackQueryContextIntegrationTest.java +++ b/src/test/java/com/github/polpetta/mezzotre/orm/model/CallbackQueryDIContextIntegrationTest.java @@ -23,7 +23,7 @@ import org.testcontainers.junit.jupiter.Testcontainers; @Tag("slow") @Tag("database") @Testcontainers -class CallbackQueryContextIntegrationTest { +class CallbackQueryDIContextIntegrationTest { private static ObjectMapper objectMapper; private static UUIDGenerator uuidGenerator; diff --git a/src/test/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtilIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtilIntegrationTest.java new file mode 100644 index 0000000..0c90d6b --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtilIntegrationTest.java @@ -0,0 +1,176 @@ +package com.github.polpetta.mezzotre.orm.telegram; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.util.Clock; +import com.github.polpetta.types.json.CallbackQueryMetadata; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import io.ebean.Database; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("slow") +@Tag("database") +@Testcontainers +class ChatUtilIntegrationTest { + + private static Gson gson; + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + private Clock fakeClock; + private ChatUtil chatUtil; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + } + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + + fakeClock = mock(Clock.class); + chatUtil = new ChatUtil(fakeClock); + } + + @Test + void shouldExtractChatFromCallBackQueryContext() { + final Update update = + gson.fromJson( + "{\n" + + "\"update_id\":10000,\n" + + "\"message\":{\n" + + " \"date\":1441645532,\n" + + " \"chat\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"type\": \"private\",\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"message_id\":1365,\n" + + " \"from\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"text\":\"/help\"\n" + + "}\n" + + "}", + Update.class); + final CallbackQueryContext callbackQueryContext = + new CallbackQueryContext( + "123", + "456", + new CallbackQueryMetadata.CallbackQueryMetadataBuilder() + .withTelegramChatId(69420L) + .build()); + + final TgChat expectedChat = new TgChat(69420L, new ChatContext()); + expectedChat.save(); + + final Optional tgChatOptional = + chatUtil.extractChat( + callbackQueryContext, null); // null just for test purposes, not usually expected + + assertEquals(expectedChat, tgChatOptional.get()); + } + + @Test + void shouldExtractChatFromUpdateIfCallbackQueryContextIsEmpty() { + final Update update = + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " },\n" + + " \"message\": {\n" + + " \"message_id\": 69420,\n" + + " \"from\": {\n" + + " \"id\": 244745330,\n" + + " \"is_bot\": true,\n" + + " \"first_name\": \"Dev - DavideBot\",\n" + + " \"username\": \"devdavidebot\"\n" + + " },\n" + + " \"date\": 1681218838,\n" + + " \"chat\": {\n" + + " \"id\": 1111111,\n" + + " \"type\": \"private\",\n" + + " \"username\": \"Testusername\",\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\"\n" + + " },\n" + + " \"text\": \"Hello xxxxxx! \uD83D\uDC4B\\n" + + "\\n" + + "This is Mezzotre, a simple bot focused on DnD content management! Please start" + + " by choosing a language down below \uD83D\uDC47\",\n" + + " \"entities\": [\n" + + " {\n" + + " \"type\": \"bold\",\n" + + " \"offset\": 0,\n" + + " \"length\": 16\n" + + " },\n" + + " {\n" + + " \"type\": \"italic\",\n" + + " \"offset\": 26,\n" + + " \"length\": 8\n" + + " }\n" + + " ],\n" + + " \"reply_markup\": {\n" + + " \"inline_keyboard\": [\n" + + " [\n" + + " {\n" + + " \"text\": \"English\",\n" + + " \"callback_data\": \"9a64be11-d086-4bd9-859f-720c43dedcb5\"\n" + + " },\n" + + " {\n" + + " \"text\": \"Italian\",\n" + + " \"callback_data\": \"8768d660-f05f-4f4b-bda5-3451ab573d56\"\n" + + " }\n" + + " ]\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + Update.class); + final CallbackQueryContext callbackQueryContext = + new CallbackQueryContext("123", "456", new CallbackQueryMetadata()); + + final TgChat expectedChat = new TgChat(69420L, new ChatContext()); + expectedChat.save(); + + final Optional tgChatOptional = + chatUtil.extractChat( + callbackQueryContext, update); // null just for test purposes, not usually expected + + assertEquals(expectedChat, tgChatOptional.get()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtilTest.java b/src/test/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtilTest.java new file mode 100644 index 0000000..7de3fc5 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/orm/telegram/ChatUtilTest.java @@ -0,0 +1,70 @@ +package com.github.polpetta.mezzotre.orm.telegram; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.util.Clock; +import com.github.polpetta.types.json.ChatContext; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.mockito.ArgumentCaptor; + +@Execution(ExecutionMode.CONCURRENT) +class ChatUtilTest { + private Clock fakeClock; + private ChatUtil chatUtil; + + @BeforeEach + void setUp() { + fakeClock = mock(Clock.class); + chatUtil = new ChatUtil(fakeClock); + } + + @Test + void shouldUpdateChatContext() { + final TgChat fakeTgChat = mock(TgChat.class); + final ChatContext chatContext = new ChatContext(); + when(fakeTgChat.getChatContext()).thenReturn(chatContext); + when(fakeClock.now()).thenReturn(69420L); + chatUtil.updateChatContext(fakeTgChat, "/test1", 42, Collections.emptyMap()); + + verify(fakeTgChat, times(1)).getChatContext(); + final ArgumentCaptor chatContextArgumentCaptor = + ArgumentCaptor.forClass(ChatContext.class); + verify(fakeTgChat, times(1)).setChatContext(chatContextArgumentCaptor.capture()); + verify(fakeTgChat, times(0)).setHasHelpBeenShown(eq(true)); + final ChatContext capturedChatContextValue = chatContextArgumentCaptor.getValue(); + assertEquals("/test1", capturedChatContextValue.getStage()); + assertEquals(42, capturedChatContextValue.getStep()); + assertEquals(69420L, capturedChatContextValue.getPreviousMessageUnixTimestampInSeconds()); + } + + @Test + void shouldAddMapElementsToChatContextToo() { + final TgChat fakeTgChat = mock(TgChat.class); + final ChatContext chatContext = new ChatContext(); + when(fakeTgChat.getChatContext()).thenReturn(chatContext); + when(fakeClock.now()).thenReturn(69420L); + + final Object obj1 = new Object(); + final Object obj2 = new Object(); + + chatUtil.updateChatContext(fakeTgChat, "/test1", 42, Map.of("field1", obj1, "field2", obj2)); + + verify(fakeTgChat, times(1)).getChatContext(); + final ArgumentCaptor chatContextArgumentCaptor = + ArgumentCaptor.forClass(ChatContext.class); + verify(fakeTgChat, times(1)).setChatContext(chatContextArgumentCaptor.capture()); + final ChatContext capturedChatContextValue = chatContextArgumentCaptor.getValue(); + final Map gotAdditionalProperties = + capturedChatContextValue.getAdditionalProperties(); + assertEquals(2, gotAdditionalProperties.size()); + assertEquals(obj1, gotAdditionalProperties.get("field1")); + assertEquals(obj2, gotAdditionalProperties.get("field2")); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFoundTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFoundTest.java new file mode 100644 index 0000000..eca4652 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/NotFoundTest.java @@ -0,0 +1,46 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.github.polpetta.mezzotre.orm.CallbackQueryContextCleaner; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.slf4j.LoggerFactory; + +@Execution(ExecutionMode.CONCURRENT) +class NotFoundTest { + + private CallbackQueryContextCleaner fakeCallbackQueryCleaner; + private NotFound notFound; + + @BeforeEach + void setUp() { + + fakeCallbackQueryCleaner = mock(CallbackQueryContextCleaner.class); + + notFound = + new NotFound(LoggerFactory.getLogger(NotFound.class), fakeCallbackQueryCleaner, "anEvent"); + } + + @Test + void shouldCallCallbackQueryContextCleaner() throws Exception { + final CallbackQueryContext fakeCallbackQuery = mock(CallbackQueryContext.class); + when(fakeCallbackQuery.getId()).thenReturn("anId"); + when(fakeCallbackQuery.getEntryGroup()).thenReturn("123"); + final Update fakeUpdate = mock(Update.class); + + final CompletableFuture>> gotResult = + notFound.process(fakeCallbackQuery, fakeUpdate); + + verify(fakeCallbackQueryCleaner, times(1)).removeGroupAsync(eq("123")); + assertEquals(Optional.empty(), gotResult.get()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorialIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorialIntegrationTest.java index a4e53c1..3a78cb3 100644 --- a/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorialIntegrationTest.java +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/SelectLanguageTutorialIntegrationTest.java @@ -6,10 +6,15 @@ import static org.mockito.Mockito.*; import com.github.polpetta.mezzotre.helper.Loader; import com.github.polpetta.mezzotre.helper.TestConfig; import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.BatchBeanCleanerService; +import com.github.polpetta.mezzotre.orm.CallbackQueryContextCleaner; import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; import com.github.polpetta.mezzotre.orm.model.TgChat; import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.github.polpetta.mezzotre.util.Clock; import com.github.polpetta.mezzotre.util.UUIDGenerator; import com.github.polpetta.types.json.CallbackQueryMetadata; import com.github.polpetta.types.json.ChatContext; @@ -21,12 +26,17 @@ import com.pengrad.telegrambot.request.BaseRequest; import com.pengrad.telegrambot.request.EditMessageText; import com.pengrad.telegrambot.request.SendMessage; import io.ebean.Database; +import io.ebean.typequery.PString; +import io.ebean.typequery.TQRootBean; +import io.vavr.Tuple3; +import java.time.Duration; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -51,6 +61,8 @@ class SelectLanguageTutorialIntegrationTest { private Database database; private SelectLanguageTutorial selectLanguageTutorial; private UUIDGenerator fakeUUIDGenerator; + private ChatUtil chatUtil; + private BatchBeanCleanerService batchBeanCleanerService; @BeforeAll static void beforeAll() { @@ -63,19 +75,35 @@ class SelectLanguageTutorialIntegrationTest { Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); fakeUUIDGenerator = mock(UUIDGenerator.class); + chatUtil = new ChatUtil(new Clock()); + batchBeanCleanerService = + new BatchBeanCleanerService( + LoggerFactory.getLogger(BatchBeanCleanerService.class), + Pair.of(0, TimeUnit.MILLISECONDS)); + batchBeanCleanerService.startAsync().awaitRunning(Duration.ofSeconds(10)); selectLanguageTutorial = new SelectLanguageTutorial( Executors.newSingleThreadExecutor(), - new LocalizedMessageFactory(Loader.defaultVelocityEngine()), + new TemplateContentGenerator( + new LocalizedMessageFactory(Loader.defaultVelocityEngine())), LoggerFactory.getLogger(SelectLanguageTutorial.class), - fakeUUIDGenerator); + fakeUUIDGenerator, + chatUtil, + new CallbackQueryContextCleaner( + batchBeanCleanerService, + LoggerFactory.getLogger(CallbackQueryContextCleaner.class))); + } + + @AfterEach + void tearDown() throws Exception { + batchBeanCleanerService.stopAsync().awaitTerminated(Duration.ofSeconds(10)); } private static Stream getTestLocales() { return Stream.of( Arguments.of( - SelectLanguageTutorial.Language.Italian, + Value.SelectLanguageTutorial.Italian, "_*Procede a bere una pozione al cui suo interno si trova uno strano liquido" + " multicolore*_\n" + "\n" @@ -84,7 +112,7 @@ class SelectLanguageTutorialIntegrationTest { + " posso parlare con te nel linguaggio che preferisci!\n" + "\n" + "Puoi sempre cambiare le preferenze della tua lingua scrivendo" - + " /selectLanguageTutorial nella chat.\n" + + " /changeLanguage nella chat.\n" + "\n" + "Sembra tu non abbia ancora visto cosa posso fare! Per avere una lista completa" + " delle mie abilità, scrivi /help nella chat in qualsiasi momento!" @@ -93,14 +121,14 @@ class SelectLanguageTutorialIntegrationTest { "Mostrami cosa puoi fare!", false), Arguments.of( - SelectLanguageTutorial.Language.English, + Value.SelectLanguageTutorial.English, "_*Proceeds to drink a potion with a strange, multicolor liquid*_\n" + "\n" + "Thanks! Now that I drank this modified potion of Speak with animals that I've" + " found at the \"Crystal Fermentary\" magic potion shop yesterday I can speak" + " with you in the language that you prefer!\n" + "\n" - + "You can always change your language settings by typing /selectLanguageTutorial" + + "You can always change your language settings by typing /changeLanguage" + " in the chat.\n" + "\n" + "It seems you haven't checked out what I can do yet! To have a complete list of" @@ -110,7 +138,7 @@ class SelectLanguageTutorialIntegrationTest { "Show me what you can do!", false), Arguments.of( - SelectLanguageTutorial.Language.Italian, + Value.SelectLanguageTutorial.Italian, "_*Procede a bere una pozione al cui suo interno si trova uno strano liquido" + " multicolore*_\n" + "\n" @@ -119,19 +147,19 @@ class SelectLanguageTutorialIntegrationTest { + " posso parlare con te nel linguaggio che preferisci!\n" + "\n" + "Puoi sempre cambiare le preferenze della tua lingua scrivendo" - + " /selectLanguageTutorial nella chat.\n\n", + + " /changeLanguage nella chat.\n\n", "en-US", "Mostrami cosa puoi fare!", true), Arguments.of( - SelectLanguageTutorial.Language.English, + Value.SelectLanguageTutorial.English, "_*Proceeds to drink a potion with a strange, multicolor liquid*_\n" + "\n" + "Thanks! Now that I drank this modified potion of Speak with animals that I've" + " found at the \"Crystal Fermentary\" magic potion shop yesterday I can speak" + " with you in the language that you prefer!\n" + "\n" - + "You can always change your language settings by typing /selectLanguageTutorial" + + "You can always change your language settings by typing /changeLanguage" + " in the chat.\n\n", "it-IT", "Show me what you can do!", @@ -142,7 +170,7 @@ class SelectLanguageTutorialIntegrationTest { @Timeout(value = 1, unit = TimeUnit.MINUTES) @MethodSource("getTestLocales") void shouldProcessChangeLanguageToDesiredOneSendMessage( - SelectLanguageTutorial.Language language, + Value.SelectLanguageTutorial selectLanguageTutorial, String expectedResult, String startingLocale, String buttonLocale, @@ -182,7 +210,8 @@ class SelectLanguageTutorialIntegrationTest { .withEvent("selectLanguageTutorial") .withTelegramChatId(tgChatId) .withAdditionalProperty( - SelectLanguageTutorial.Field.NewLanguage.getName(), language.getLocale()) + Field.SelectLanguageTutorial.NewLanguage.getName(), + selectLanguageTutorial.getLocale()) .build(); final String entryGroupId = "2e67774a-e4e4-4369-a414-a7f8bfe74b80"; final CallbackQueryContext changeLanguageCallbackQueryContext = @@ -190,8 +219,13 @@ class SelectLanguageTutorialIntegrationTest { "c018108f-6612-4848-8fca-cf301460d4eb", entryGroupId, callbackQueryMetadata); changeLanguageCallbackQueryContext.save(); + final CompletableFuture< + Tuple3>, CompletableFuture>> + callBackFuture = new CompletableFuture<>(); + batchBeanCleanerService.addListener(callBackFuture::complete); + final CompletableFuture>> processFuture = - selectLanguageTutorial.process(changeLanguageCallbackQueryContext, update); + this.selectLanguageTutorial.process(changeLanguageCallbackQueryContext, update); final Optional> gotResponseOpt = processFuture.get(); final SendMessage gotMessage = (SendMessage) gotResponseOpt.get(); assertEquals(expectedResult, gotMessage.getParameters().get("text")); @@ -210,14 +244,20 @@ class SelectLanguageTutorialIntegrationTest { assertEquals( 1, new QCallbackQueryContext().id.eq("e86e6fa1-fdd4-4120-b85d-a5482db2e8b5").findCount()); } else { + callBackFuture.get(); // Await that callback are cleaned out first assertEquals(0, keyboardButtons.size()); assertEquals(0, new QCallbackQueryContext().findCount()); } final TgChat retrievedTgChat = new QTgChat().id.eq(tgChatId).findOne(); assertNotNull(retrievedTgChat); - assertEquals(language.getLocale(), retrievedTgChat.getLocale()); + assertEquals(selectLanguageTutorial.getLocale(), retrievedTgChat.getLocale()); + final Tuple3>, CompletableFuture> + deletionCallback = callBackFuture.get(); + + assertEquals(entryGroupId, deletionCallback._1()); + assertEquals(1, deletionCallback._3().get()); assertEquals(0, new QCallbackQueryContext().entryGroup.eq(entryGroupId).findCount()); } @@ -225,7 +265,7 @@ class SelectLanguageTutorialIntegrationTest { @Timeout(value = 1, unit = TimeUnit.MINUTES) @MethodSource("getTestLocales") void shouldProcessChangeLanguageToDesiredOneEditMessage( - SelectLanguageTutorial.Language language, + Value.SelectLanguageTutorial selectLanguageTutorial, String expectedResult, String startingLocale, String buttonLocale, @@ -297,16 +337,21 @@ class SelectLanguageTutorialIntegrationTest { .withEvent("selectLanguageTutorial") .withTelegramChatId(tgChatId) .withAdditionalProperty( - SelectLanguageTutorial.Field.NewLanguage.getName(), language.getLocale()) + Field.SelectLanguageTutorial.NewLanguage.getName(), + selectLanguageTutorial.getLocale()) .build(); final String entryGroupId = "2e67774a-e4e4-4369-a414-a7f8bfe74b80"; final CallbackQueryContext changeLanguageCallbackQueryContext = new CallbackQueryContext( "c018108f-6612-4848-8fca-cf301460d4eb", entryGroupId, callbackQueryMetadata); changeLanguageCallbackQueryContext.save(); + final CompletableFuture< + Tuple3>, CompletableFuture>> + callBackFuture = new CompletableFuture<>(); + batchBeanCleanerService.addListener(callBackFuture::complete); final CompletableFuture>> processFuture = - selectLanguageTutorial.process(changeLanguageCallbackQueryContext, update); + this.selectLanguageTutorial.process(changeLanguageCallbackQueryContext, update); final Optional> gotResponseOpt = processFuture.get(); final EditMessageText gotMessage = (EditMessageText) gotResponseOpt.get(); assertEquals(expectedResult, gotMessage.getParameters().get("text")); @@ -325,14 +370,20 @@ class SelectLanguageTutorialIntegrationTest { assertEquals( 1, new QCallbackQueryContext().id.eq("e86e6fa1-fdd4-4120-b85d-a5482db2e8b5").findCount()); } else { + callBackFuture.get(); // Await that callback are cleaned out first assertEquals(0, keyboardButtons.size()); assertEquals(0, new QCallbackQueryContext().findCount()); } final TgChat retrievedTgChat = new QTgChat().id.eq(tgChatId).findOne(); assertNotNull(retrievedTgChat); - assertEquals(language.getLocale(), retrievedTgChat.getLocale()); + assertEquals(selectLanguageTutorial.getLocale(), retrievedTgChat.getLocale()); + final Tuple3>, CompletableFuture> + deletionCallback = callBackFuture.get(); + + assertEquals(entryGroupId, deletionCallback._1()); + assertEquals(1, deletionCallback._3().get()); assertEquals(0, new QCallbackQueryContext().entryGroup.eq(entryGroupId).findCount()); } } diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelpIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelpIntegrationTest.java new file mode 100644 index 0000000..eba6430 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelpIntegrationTest.java @@ -0,0 +1,457 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.BatchBeanCleanerService; +import com.github.polpetta.mezzotre.orm.CallbackQueryContextCleaner; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.github.polpetta.mezzotre.telegram.model.Help; +import com.github.polpetta.mezzotre.util.Clock; +import com.github.polpetta.mezzotre.util.UUIDGenerator; +import com.github.polpetta.types.json.CallbackQueryMetadata; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.EditMessageText; +import com.pengrad.telegrambot.request.SendMessage; +import io.ebean.Database; +import io.ebean.typequery.PString; +import io.ebean.typequery.TQRootBean; +import io.vavr.Tuple3; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Stream; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.velocity.app.VelocityEngine; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("slow") +@Tag("database") +@Tag("velocity") +@Testcontainers +class ShowHelpIntegrationTest { + + private static Gson gson; + private static Logger testLog; + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + private VelocityEngine velocityEngine; + private UUIDGenerator fakeUUIDGenerator; + private Clock fakeClock; + private BatchBeanCleanerService batchBeanCleanerService; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + testLog = LoggerFactory.getLogger(ShowHelpIntegrationTest.class); + } + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + velocityEngine = Loader.defaultVelocityEngine(); + + final Logger log = LoggerFactory.getLogger(ShowHelp.class); + fakeUUIDGenerator = mock(UUIDGenerator.class); + fakeClock = mock(Clock.class); + } + + @AfterEach + void tearDown() throws Exception { + if (batchBeanCleanerService != null) { + batchBeanCleanerService.stopAsync().awaitTerminated(Duration.ofSeconds(10)); + } + } + + private ShowHelp generateShowHelpWithCustomCommands( + Map eventProcessors, + Map commandProcessors) + throws TimeoutException { + + batchBeanCleanerService = + new BatchBeanCleanerService( + LoggerFactory.getLogger(CallbackQueryContextCleaner.class), + Pair.of(0, TimeUnit.MILLISECONDS)); + batchBeanCleanerService.startAsync().awaitRunning(Duration.ofSeconds(10)); + + return new ShowHelp( + Executors.newSingleThreadExecutor(), + new Help( + new TemplateContentGenerator(new LocalizedMessageFactory(velocityEngine)), + fakeUUIDGenerator), + commandProcessors, + eventProcessors, + // We need to implement a service that is sync - no thread creation! + new ChatUtil(fakeClock), + new CallbackQueryContextCleaner( + batchBeanCleanerService, LoggerFactory.getLogger(CallbackQueryContextCleaner.class))); + } + + public static Stream getMessageIdOrNot() { + return Stream.of( + Arguments.of( + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " },\n" + + " \"message\": {\n" + + " \"message_id\": 2723,\n" + + " \"from\": {\n" + + " \"id\": 244745330,\n" + + " \"is_bot\": true,\n" + + " \"first_name\": \"Dev - DavideBot\",\n" + + " \"username\": \"devdavidebot\"\n" + + " },\n" + + " \"date\": 1681218838,\n" + + " \"chat\": {\n" + + " \"id\": 1111111,\n" + + " \"type\": \"private\",\n" + + " \"username\": \"Testusername\",\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\"\n" + + " },\n" + + " \"text\": \"a message\",\n" + + " \"reply_markup\": {\n" + + " \"inline_keyboard\": [\n" + + " [\n" + + " {\n" + + " \"text\": \"English\",\n" + + " \"callback_data\": \"9a64be11-d086-4bd9-859f-720c43dedcb5\"\n" + + " },\n" + + " {\n" + + " \"text\": \"Italian\",\n" + + " \"callback_data\": \"8768d660-f05f-4f4b-bda5-3451ab573d56\"\n" + + " }\n" + + " ]\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + Update.class), + EditMessageText.class), + Arguments.of( + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " }\n" + + " }\n" + + "}", + Update.class), + SendMessage.class)); + } + + @ParameterizedTest + @Timeout(value = 1, unit = TimeUnit.MINUTES) + @MethodSource("getMessageIdOrNot") + void shouldShowHelpMessageWithButtons(Update update, Class typeOfMessage) throws Exception { + // we have 2 events + group uuid + when(fakeUUIDGenerator.generateAsString()) + .thenReturn("e86e6fa1-fdd4-4120-b85d-a5482db2e8b5") + .thenReturn("16507fbd-9f28-48a8-9de1-3ea1c943af67") + .thenReturn("0b0ac18e-f621-484e-aa8d-9b176be5b930"); + when(fakeClock.now()).thenReturn(42L); + + final HashMap commands = + new HashMap<>(); + final com.github.polpetta.mezzotre.telegram.command.Processor dummy1 = + new com.github.polpetta.mezzotre.telegram.command.Processor() { + @Override + public Set getTriggerKeywords() { + return Set.of("/a", "/b"); + } + + @Override + public CompletableFuture>> process( + TgChat chat, Update update) { + return null; + } + + @Override + public String getLocaleDescriptionKeyword() { + return "start.cmdDescription"; + } + }; + final com.github.polpetta.mezzotre.telegram.command.Processor dummy2 = + new com.github.polpetta.mezzotre.telegram.command.Processor() { + @Override + public Set getTriggerKeywords() { + return Set.of("/different"); + } + + @Override + public CompletableFuture>> process( + TgChat chat, Update update) { + return null; + } + + @Override + public String getLocaleDescriptionKeyword() { + return "help.cmdDescription"; + } + }; + commands.put("/a", dummy1); + commands.put("/b", dummy1); + commands.put("/different", dummy2); + + final Map events = new HashMap<>(); + + final Processor dummyEvent1 = + new Processor() { + @Override + public String getEventName() { + return "exampleEvent"; + } + + @Override + public boolean canBeDirectlyInvokedByTheUser() { + return true; + } + + @Override + public Optional getPrettyPrintLocaleKeyName() { + return Optional.of("changeLanguage.inlineKeyboardButtonName"); + } + + @Override + public CompletableFuture>> process( + CallbackQueryContext callbackQueryContext, Update update) { + return null; + } + }; + + final Processor dummyEvent2 = + new com.github.polpetta.mezzotre.telegram.callbackquery.Processor() { + @Override + public String getEventName() { + return "secondExampleEvent"; + } + + @Override + public boolean canBeDirectlyInvokedByTheUser() { + return true; + } + + @Override + public Optional getPrettyPrintLocaleKeyName() { + return Optional.of("selectLanguageTutorial.inlineKeyboardButtonName"); + } + + @Override + public CompletableFuture>> process( + CallbackQueryContext callbackQueryContext, Update update) { + return null; + } + }; + + events.put(dummyEvent1.getEventName(), dummyEvent1); + events.put(dummyEvent2.getEventName(), dummyEvent2); + + final ShowHelp showHelp = generateShowHelpWithCustomCommands(events, commands); + final TgChat tgChat = new TgChat(1111111L, new ChatContext()); + tgChat.save(); + final CallbackQueryContext callbackQueryContext = + new CallbackQueryContext( + "1234", + "5678", + new CallbackQueryMetadata.CallbackQueryMetadataBuilder() + .withTelegramChatId(1111111L) + .build()); + callbackQueryContext.save(); + + final CompletableFuture>> gotResultFuture = + showHelp.process(callbackQueryContext, update); + + final Optional> baseRequestOptional = gotResultFuture.get(); + final BaseRequest gotResponse = baseRequestOptional.get(); + assertInstanceOf(typeOfMessage, gotResponse); + final String message = (String) gotResponse.getParameters().get("text"); + assertEquals( + "Here is a list of what I can do:\n" + + "\n" + + "- /a /b: Trigger this very bot\n" + + "- /different: Print the help message\n" + + "\n" + + "You can do the same operations you'd do with the commands aforementioned by" + + " selecting the corresponding button below \uD83D\uDC47", + message); + final InlineKeyboardButton[][] keyboard = + ((InlineKeyboardMarkup) + gotResponse + .getParameters() + .getOrDefault("reply_markup", new InlineKeyboardMarkup())) + .inlineKeyboard(); + + final List keyboardButtons = + Stream.of(keyboard).flatMap(Stream::of).toList(); + + assertEquals(2, keyboardButtons.size()); + + assertFalse(keyboardButtons.get(0).callbackData().isBlank()); + assertFalse(keyboardButtons.get(1).callbackData().isBlank()); + + assertTrue( + keyboardButtons.stream() + .map(InlineKeyboardButton::text) + .anyMatch("Select language"::equals)); + + assertTrue( + keyboardButtons.stream() + .map(InlineKeyboardButton::text) + .anyMatch("Change language"::equals)); + + final TgChat gotChat = new QTgChat().id.eq(1111111L).findOne(); + assertNotNull(gotChat); + assertTrue(gotChat.getHasHelpBeenShown()); + final ChatContext gotChatChatContext = gotChat.getChatContext(); + assertEquals(0, gotChatChatContext.getStep()); + assertEquals("showHelp", gotChatChatContext.getStage()); + assertEquals(42, gotChatChatContext.getPreviousMessageUnixTimestampInSeconds()); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void shouldCleanUpCallbackQueryContextAfterRun() throws Exception { + when(fakeClock.now()).thenReturn(42L); + + final ShowHelp showHelp = + generateShowHelpWithCustomCommands(Collections.emptyMap(), Collections.emptyMap()); + final TgChat tgChat = new TgChat(1111111L, new ChatContext()); + tgChat.save(); + final Update update = + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " },\n" + + " \"message\": {\n" + + " \"message_id\": 2723,\n" + + " \"from\": {\n" + + " \"id\": 244745330,\n" + + " \"is_bot\": true,\n" + + " \"first_name\": \"Dev - DavideBot\",\n" + + " \"username\": \"devdavidebot\"\n" + + " },\n" + + " \"date\": 1681218838,\n" + + " \"chat\": {\n" + + " \"id\": 1111111,\n" + + " \"type\": \"private\",\n" + + " \"username\": \"Testusername\",\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\"\n" + + " },\n" + + " \"text\": \"Hello xxxxxx! \uD83D\uDC4B\\n" + + "\\n" + + "This is Mezzotre, a simple bot focused on DnD content management! Please start" + + " by choosing a language down below \uD83D\uDC47\",\n" + + " \"entities\": [\n" + + " {\n" + + " \"type\": \"bold\",\n" + + " \"offset\": 0,\n" + + " \"length\": 16\n" + + " },\n" + + " {\n" + + " \"type\": \"italic\",\n" + + " \"offset\": 26,\n" + + " \"length\": 8\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + "}", + Update.class); + final CallbackQueryContext callbackQueryContext = + new CallbackQueryContext( + "1234", + "5678", + new CallbackQueryMetadata.CallbackQueryMetadataBuilder() + .withTelegramChatId(1111111L) + .build()); + callbackQueryContext.save(); + // Create another CallbackQuery context of the same group + final CallbackQueryContext callbackQueryContext2 = + new CallbackQueryContext( + "7891", + "5678", + new CallbackQueryMetadata.CallbackQueryMetadataBuilder() + .withTelegramChatId(1111111L) + .build()); + callbackQueryContext2.save(); + + // I know I know, a future containing a future...but it is for testing purposes, otherwise we'd + // have to do some while/sleep thread hack which I find horrible to do + final CompletableFuture< + Tuple3>, CompletableFuture>> + callBackFuture = new CompletableFuture<>(); + batchBeanCleanerService.addListener(callBackFuture::complete); + + final CompletableFuture>> gotResultFuture = + showHelp.process(callbackQueryContext, update); + + final Optional> baseRequestOptional = gotResultFuture.get(); + assertDoesNotThrow(baseRequestOptional::get); + + final Tuple3>, CompletableFuture> + deletionCallback = callBackFuture.get(); + + assertEquals("5678", deletionCallback._1()); + assertEquals(2, deletionCallback._3().get()); + assertEquals( + 0, + new QCallbackQueryContext().findCount(), + "The CallbackQuery context group has not been cleaned properly!"); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelpTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelpTest.java new file mode 100644 index 0000000..a722059 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/ShowHelpTest.java @@ -0,0 +1,314 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.*; + +import com.github.polpetta.mezzotre.orm.CallbackQueryContextCleaner; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.github.polpetta.mezzotre.telegram.model.Help; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; +import com.pengrad.telegrambot.request.BaseRequest; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.CONCURRENT) +class ShowHelpTest { + + private static Gson gson; + private ShowHelp showHelp; + private Help fakeModelHelp; + private ChatUtil fakeChatUtil; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + } + + @BeforeEach + void setUp() { + + fakeModelHelp = mock(Help.class); + fakeChatUtil = mock(ChatUtil.class); + final CallbackQueryContextCleaner fakeCallbackQueryContextCleaner = + mock(CallbackQueryContextCleaner.class); + + showHelp = + new ShowHelp( + Executors.newSingleThreadExecutor(), + fakeModelHelp, + Collections.emptyMap(), + Collections.emptyMap(), + fakeChatUtil, + fakeCallbackQueryContextCleaner); + } + + @Test + void shouldUpdateChatContext() throws Exception { + final CallbackQueryContext fakeCallbackQueryContext = mock(CallbackQueryContext.class); + final Update update = + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " },\n" + + " \"message\": {\n" + + " \"message_id\": 2723,\n" + + " \"from\": {\n" + + " \"id\": 244745330,\n" + + " \"is_bot\": true,\n" + + " \"first_name\": \"Dev - DavideBot\",\n" + + " \"username\": \"devdavidebot\"\n" + + " },\n" + + " \"date\": 1681218838,\n" + + " \"chat\": {\n" + + " \"id\": 1111111,\n" + + " \"type\": \"private\",\n" + + " \"username\": \"Testusername\",\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\"\n" + + " },\n" + + " \"text\": \"Hello xxxxxx! \uD83D\uDC4B\\n" + + "\\n" + + "This is Mezzotre, a simple bot focused on DnD content management! Please start" + + " by choosing a language down below \uD83D\uDC47\",\n" + + " \"entities\": [\n" + + " {\n" + + " \"type\": \"bold\",\n" + + " \"offset\": 0,\n" + + " \"length\": 16\n" + + " },\n" + + " {\n" + + " \"type\": \"italic\",\n" + + " \"offset\": 26,\n" + + " \"length\": 8\n" + + " }\n" + + " ],\n" + + " \"reply_markup\": {\n" + + " \"inline_keyboard\": [\n" + + " [\n" + + " {\n" + + " \"text\": \"English\",\n" + + " \"callback_data\": \"9a64be11-d086-4bd9-859f-720c43dedcb5\"\n" + + " },\n" + + " {\n" + + " \"text\": \"Italian\",\n" + + " \"callback_data\": \"8768d660-f05f-4f4b-bda5-3451ab573d56\"\n" + + " }\n" + + " ]\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + Update.class); + final TgChat fakeTgChat = mock(TgChat.class); + when(fakeTgChat.getId()).thenReturn(1111111L); + when(fakeTgChat.getChatContext()).thenReturn(new ChatContext()); + when(fakeModelHelp.getMessage(eq(fakeTgChat), any())).thenReturn("doesn't matter"); + when(fakeModelHelp.generateInlineKeyBoardButton(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn(new InlineKeyboardButton[] {}); + when(fakeChatUtil.extractChat(eq(fakeCallbackQueryContext), eq(update))) + .thenReturn(Optional.of(fakeTgChat)); + + final CompletableFuture>> gotResponseFuture = + showHelp.process(fakeCallbackQueryContext, update); + final Optional> gotResponseOpt = gotResponseFuture.get(); + + verify(fakeChatUtil, times(1)) + .updateChatContext(eq(fakeTgChat), eq("showHelp"), eq(0), eq(Collections.emptyMap())); + } + + @Test + void shouldAddInlineKeyboardIfButtonsAreReturned() throws Exception { + final CallbackQueryContext fakeCallbackQueryContext = mock(CallbackQueryContext.class); + final Update update = + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " },\n" + + " \"message\": {\n" + + " \"message_id\": 2723,\n" + + " \"from\": {\n" + + " \"id\": 244745330,\n" + + " \"is_bot\": true,\n" + + " \"first_name\": \"Dev - DavideBot\",\n" + + " \"username\": \"devdavidebot\"\n" + + " },\n" + + " \"date\": 1681218838,\n" + + " \"chat\": {\n" + + " \"id\": 1111111,\n" + + " \"type\": \"private\",\n" + + " \"username\": \"Testusername\",\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\"\n" + + " },\n" + + " \"text\": \"Hello xxxxxx! \uD83D\uDC4B\\n" + + "\\n" + + "This is Mezzotre, a simple bot focused on DnD content management! Please start" + + " by choosing a language down below \uD83D\uDC47\",\n" + + " \"entities\": [\n" + + " {\n" + + " \"type\": \"bold\",\n" + + " \"offset\": 0,\n" + + " \"length\": 16\n" + + " },\n" + + " {\n" + + " \"type\": \"italic\",\n" + + " \"offset\": 26,\n" + + " \"length\": 8\n" + + " }\n" + + " ],\n" + + " \"reply_markup\": {\n" + + " \"inline_keyboard\": [\n" + + " [\n" + + " {\n" + + " \"text\": \"English\",\n" + + " \"callback_data\": \"9a64be11-d086-4bd9-859f-720c43dedcb5\"\n" + + " },\n" + + " {\n" + + " \"text\": \"Italian\",\n" + + " \"callback_data\": \"8768d660-f05f-4f4b-bda5-3451ab573d56\"\n" + + " }\n" + + " ]\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + Update.class); + final TgChat fakeTgChat = mock(TgChat.class); + when(fakeTgChat.getId()).thenReturn(1111111L); + when(fakeTgChat.getChatContext()).thenReturn(new ChatContext()); + when(fakeModelHelp.getMessage(eq(fakeTgChat), any())).thenReturn("doesn't matter"); + when(fakeModelHelp.generateInlineKeyBoardButton(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn(new InlineKeyboardButton[] {new InlineKeyboardButton("example1")}); + when(fakeChatUtil.extractChat(eq(fakeCallbackQueryContext), eq(update))) + .thenReturn(Optional.of(fakeTgChat)); + + final CompletableFuture>> gotResponseFuture = + showHelp.process(fakeCallbackQueryContext, update); + final Optional> gotResponseOpt = gotResponseFuture.get(); + final BaseRequest gotResponse = gotResponseOpt.get(); + + final InlineKeyboardMarkup replyMarkup = + (InlineKeyboardMarkup) gotResponse.getParameters().get("reply_markup"); + assertEquals(1, replyMarkup.inlineKeyboard()[0].length); + assertEquals("example1", replyMarkup.inlineKeyboard()[0][0].text()); + } + + @Test + void shouldNotAddAnyKeyboardIfThereAreNoButtons() throws Exception { + final CallbackQueryContext fakeCallbackQueryContext = mock(CallbackQueryContext.class); + final Update update = + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " },\n" + + " \"message\": {\n" + + " \"message_id\": 2723,\n" + + " \"from\": {\n" + + " \"id\": 244745330,\n" + + " \"is_bot\": true,\n" + + " \"first_name\": \"Dev - DavideBot\",\n" + + " \"username\": \"devdavidebot\"\n" + + " },\n" + + " \"date\": 1681218838,\n" + + " \"chat\": {\n" + + " \"id\": 1111111,\n" + + " \"type\": \"private\",\n" + + " \"username\": \"Testusername\",\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\"\n" + + " },\n" + + " \"text\": \"Hello xxxxxx! \uD83D\uDC4B\\n" + + "\\n" + + "This is Mezzotre, a simple bot focused on DnD content management! Please start" + + " by choosing a language down below \uD83D\uDC47\",\n" + + " \"entities\": [\n" + + " {\n" + + " \"type\": \"bold\",\n" + + " \"offset\": 0,\n" + + " \"length\": 16\n" + + " },\n" + + " {\n" + + " \"type\": \"italic\",\n" + + " \"offset\": 26,\n" + + " \"length\": 8\n" + + " }\n" + + " ],\n" + + " \"reply_markup\": {\n" + + " \"inline_keyboard\": [\n" + + " [\n" + + " {\n" + + " \"text\": \"English\",\n" + + " \"callback_data\": \"9a64be11-d086-4bd9-859f-720c43dedcb5\"\n" + + " },\n" + + " {\n" + + " \"text\": \"Italian\",\n" + + " \"callback_data\": \"8768d660-f05f-4f4b-bda5-3451ab573d56\"\n" + + " }\n" + + " ]\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + Update.class); + final TgChat fakeTgChat = mock(TgChat.class); + when(fakeTgChat.getId()).thenReturn(1111111L); + when(fakeTgChat.getChatContext()).thenReturn(new ChatContext()); + when(fakeModelHelp.getMessage(eq(fakeTgChat), any())).thenReturn("doesn't matter"); + when(fakeModelHelp.generateInlineKeyBoardButton(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn(new InlineKeyboardButton[] {}); + when(fakeChatUtil.extractChat(eq(fakeCallbackQueryContext), eq(update))) + .thenReturn(Optional.of(fakeTgChat)); + + final CompletableFuture>> gotResponseFuture = + showHelp.process(fakeCallbackQueryContext, update); + final Optional> gotResponseOpt = gotResponseFuture.get(); + final BaseRequest gotResponse = gotResponseOpt.get(); + final InlineKeyboardMarkup replyMarkup = + (InlineKeyboardMarkup) gotResponse.getParameters().get("reply_markup"); + assertNull(replyMarkup); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/UtilTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/UtilTest.java new file mode 100644 index 0000000..ffc2d78 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/callbackquery/UtilTest.java @@ -0,0 +1,100 @@ +package com.github.polpetta.mezzotre.telegram.callbackquery; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.CONCURRENT) +class UtilTest { + + private static Gson gson; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + } + + @Test + void shouldProvideAMessageIdGivenTheUpdate() { + final Update update = + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " },\n" + + " \"message\": {\n" + + " \"message_id\": 2723,\n" + + " \"from\": {\n" + + " \"id\": 244745330,\n" + + " \"is_bot\": true,\n" + + " \"first_name\": \"Dev - DavideBot\",\n" + + " \"username\": \"devdavidebot\"\n" + + " },\n" + + " \"date\": 1681218838,\n" + + " \"chat\": {\n" + + " \"id\": 1111111,\n" + + " \"type\": \"private\",\n" + + " \"username\": \"Testusername\",\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\"\n" + + " },\n" + + " \"text\": \"a message\",\n" + + " \"reply_markup\": {\n" + + " \"inline_keyboard\": [\n" + + " [\n" + + " {\n" + + " \"text\": \"English\",\n" + + " \"callback_data\": \"9a64be11-d086-4bd9-859f-720c43dedcb5\"\n" + + " },\n" + + " {\n" + + " \"text\": \"Italian\",\n" + + " \"callback_data\": \"8768d660-f05f-4f4b-bda5-3451ab573d56\"\n" + + " }\n" + + " ]\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + Update.class); + + assertEquals( + 2723, Util.extractMessageId(update).get(), "A message id should be returned, 2723"); + } + + @Test + void shouldGiveAnEmptyOptionalWithoutMessageId() { + final Update update = + gson.fromJson( + "{\n" + + " \"update_id\": 158712614,\n" + + " \"callback_query\": {\n" + + " \"id\": \"20496049451114620\",\n" + + " \"from\": {\n" + + " \"id\": 1111111,\n" + + " \"is_bot\": false,\n" + + " \"first_name\": \"Test Firstname\",\n" + + " \"last_name\": \"Test Lastname\",\n" + + " \"username\": \"Testusername\",\n" + + " \"language_code\": \"en\"\n" + + " }\n" + + " }\n" + + "}", + Update.class); + + assertTrue(Util.extractMessageId(update).isEmpty(), "The shouldn't be any message id"); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/command/HelpIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/command/HelpIntegrationTest.java new file mode 100644 index 0000000..24453e2 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/HelpIntegrationTest.java @@ -0,0 +1,259 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.github.polpetta.mezzotre.util.Clock; +import com.github.polpetta.mezzotre.util.UUIDGenerator; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.SendMessage; +import io.ebean.Database; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.stream.Stream; +import org.apache.velocity.app.VelocityEngine; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("slow") +@Tag("database") +@Tag("velocity") +@Testcontainers +class HelpIntegrationTest { + private static Gson gson; + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + private VelocityEngine velocityEngine; + private Clock fakeClock; + private Help help; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + } + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + velocityEngine = Loader.defaultVelocityEngine(); + + final Logger log = LoggerFactory.getLogger(Start.class); + fakeClock = mock(Clock.class); + } + + @Test + void shouldProvideMessageWithButtons() throws Exception { + when(fakeClock.now()).thenReturn(42L); + + final TgChat tgChat = new TgChat(1111111L, new ChatContext(), "en-US", false); + tgChat.save(); + + final HashMap commands = new HashMap<>(); + final Processor dummy1 = + new Processor() { + @Override + public Set getTriggerKeywords() { + return Set.of("/a", "/b"); + } + + @Override + public CompletableFuture>> process( + TgChat chat, Update update) { + return null; + } + + @Override + public String getLocaleDescriptionKeyword() { + return "start.cmdDescription"; + } + }; + final Processor dummy2 = + new Processor() { + @Override + public Set getTriggerKeywords() { + return Set.of("/different"); + } + + @Override + public CompletableFuture>> process( + TgChat chat, Update update) { + return null; + } + + @Override + public String getLocaleDescriptionKeyword() { + return "help.cmdDescription"; + } + }; + commands.put("/a", dummy1); + commands.put("/b", dummy1); + commands.put("/different", dummy2); + + final Map events = + new HashMap<>(); + + final com.github.polpetta.mezzotre.telegram.callbackquery.Processor dummyEvent1 = + new com.github.polpetta.mezzotre.telegram.callbackquery.Processor() { + @Override + public String getEventName() { + return "exampleEvent"; + } + + @Override + public boolean canBeDirectlyInvokedByTheUser() { + return true; + } + + @Override + public Optional getPrettyPrintLocaleKeyName() { + return Optional.of("changeLanguage.inlineKeyboardButtonName"); + } + + @Override + public CompletableFuture>> process( + CallbackQueryContext callbackQueryContext, Update update) { + return null; + } + }; + + final com.github.polpetta.mezzotre.telegram.callbackquery.Processor dummyEvent2 = + new com.github.polpetta.mezzotre.telegram.callbackquery.Processor() { + @Override + public String getEventName() { + return "secondExampleEvent"; + } + + @Override + public boolean canBeDirectlyInvokedByTheUser() { + return true; + } + + @Override + public Optional getPrettyPrintLocaleKeyName() { + return Optional.of("selectLanguageTutorial.inlineKeyboardButtonName"); + } + + @Override + public CompletableFuture>> process( + CallbackQueryContext callbackQueryContext, Update update) { + return null; + } + }; + + events.put(dummyEvent1.getEventName(), dummyEvent1); + events.put(dummyEvent2.getEventName(), dummyEvent2); + + final com.github.polpetta.mezzotre.telegram.model.Help modelHelp = + new com.github.polpetta.mezzotre.telegram.model.Help( + new TemplateContentGenerator(new LocalizedMessageFactory(velocityEngine)), + new UUIDGenerator()); + + help = + new Help( + Executors.newSingleThreadExecutor(), + commands, + events, + modelHelp, + new ChatUtil(fakeClock)); + + final Update update = + gson.fromJson( + "{\n" + + "\"update_id\":10000,\n" + + "\"message\":{\n" + + " \"date\":1441645532,\n" + + " \"chat\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"type\": \"private\",\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"message_id\":1365,\n" + + " \"from\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"text\":\"/help\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotFuture = help.process(tgChat, update); + final Optional> gotResponseOpt = gotFuture.get(); + final BaseRequest gotResponse = gotResponseOpt.get(); + assertInstanceOf(SendMessage.class, gotResponse); + final String message = (String) gotResponse.getParameters().get("text"); + assertEquals( + "Here is a list of what I can do:\n" + + "\n" + + "- /a /b: Trigger this very bot\n" + + "- /different: Print the help message\n" + + "\n" + + "You can do the same operations you'd do with the commands aforementioned by" + + " selecting the corresponding button below \uD83D\uDC47", + message); + + final InlineKeyboardButton[][] keyboard = + ((InlineKeyboardMarkup) + gotResponse + .getParameters() + .getOrDefault("reply_markup", new InlineKeyboardMarkup())) + .inlineKeyboard(); + + final List keyboardButtons = + Stream.of(keyboard).flatMap(Stream::of).toList(); + + assertEquals(2, keyboardButtons.size()); + + assertFalse(keyboardButtons.get(0).callbackData().isBlank()); + assertFalse(keyboardButtons.get(1).callbackData().isBlank()); + + assertTrue( + keyboardButtons.stream() + .map(InlineKeyboardButton::text) + .anyMatch("Select language"::equals)); + + assertTrue( + keyboardButtons.stream() + .map(InlineKeyboardButton::text) + .anyMatch("Change language"::equals)); + + final TgChat gotChat = new QTgChat().id.eq(1111111L).findOne(); + assertNotNull(gotChat); + assertTrue(gotChat.getHasHelpBeenShown()); + final ChatContext gotChatChatContext = gotChat.getChatContext(); + assertEquals(0, gotChatChatContext.getStep()); + assertEquals("/help", gotChatChatContext.getStage()); + assertEquals(42, gotChatChatContext.getPreviousMessageUnixTimestampInSeconds()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/command/HelpTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/command/HelpTest.java new file mode 100644 index 0000000..7fd6de6 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/HelpTest.java @@ -0,0 +1,186 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup; +import com.pengrad.telegrambot.request.BaseRequest; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.CONCURRENT) +class HelpTest { + + private static Gson gson; + private com.github.polpetta.mezzotre.telegram.model.Help fakeModelHelp; + private ChatUtil fakeChatUtil; + private Help help; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + } + + @BeforeEach + void setUp() { + + final Map commands = Collections.emptyMap(); + final Map events = + Collections.emptyMap(); + + fakeModelHelp = mock(com.github.polpetta.mezzotre.telegram.model.Help.class); + fakeChatUtil = mock(ChatUtil.class); + + help = + new Help( + Executors.newSingleThreadExecutor(), commands, events, fakeModelHelp, fakeChatUtil); + } + + @Test + void shouldUpdateChatContext() throws Exception { + final TgChat fakeTgChat = mock(TgChat.class); + when(fakeTgChat.getId()).thenReturn(1111111L); + when(fakeTgChat.getLocale()).thenReturn("en-US"); + when(fakeTgChat.getChatContext()).thenReturn(new ChatContext()); + when(fakeModelHelp.getMessage(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn("doesn't matter"); + when(fakeModelHelp.generateInlineKeyBoardButton(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn(new InlineKeyboardButton[] {}); + + final Update update = + gson.fromJson( + "{\n" + + "\"update_id\":10000,\n" + + "\"message\":{\n" + + " \"date\":1441645532,\n" + + " \"chat\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"type\": \"private\",\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"message_id\":1365,\n" + + " \"from\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"text\":\"/help\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotResponseFuture = + help.process(fakeTgChat, update); + assertDoesNotThrow(() -> gotResponseFuture.get()); + verify(fakeChatUtil, times(1)) + .updateChatContext(eq(fakeTgChat), eq("/help"), eq(0), eq(Collections.emptyMap())); + } + + @Test + void shouldAppendKeyboardIfButtonsAreReturned() throws Exception { + final TgChat fakeTgChat = mock(TgChat.class); + when(fakeTgChat.getId()).thenReturn(1111111L); + when(fakeTgChat.getLocale()).thenReturn("en-US"); + when(fakeTgChat.getChatContext()).thenReturn(new ChatContext()); + when(fakeModelHelp.getMessage(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn("doesn't matter"); + when(fakeModelHelp.generateInlineKeyBoardButton(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn(new InlineKeyboardButton[] {new InlineKeyboardButton("example1")}); + + final Update update = + gson.fromJson( + "{\n" + + "\"update_id\":10000,\n" + + "\"message\":{\n" + + " \"date\":1441645532,\n" + + " \"chat\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"type\": \"private\",\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"message_id\":1365,\n" + + " \"from\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"text\":\"/help\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotResponseFuture = + help.process(fakeTgChat, update); + final Optional> gotResponseOpt = gotResponseFuture.get(); + final BaseRequest gotResponse = gotResponseOpt.get(); + final InlineKeyboardMarkup replyMarkup = + (InlineKeyboardMarkup) gotResponse.getParameters().get("reply_markup"); + assertEquals(1, replyMarkup.inlineKeyboard()[0].length); + assertEquals("example1", replyMarkup.inlineKeyboard()[0][0].text()); + } + + @Test + void shouldNotAppendKeyboardIfNoButtonIsPresent() throws Exception { + final TgChat fakeTgChat = mock(TgChat.class); + when(fakeTgChat.getId()).thenReturn(1111111L); + when(fakeTgChat.getLocale()).thenReturn("en-US"); + when(fakeTgChat.getChatContext()).thenReturn(new ChatContext()); + when(fakeModelHelp.getMessage(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn("doesn't matter"); + when(fakeModelHelp.generateInlineKeyBoardButton(eq(fakeTgChat), eq(Collections.emptyMap()))) + .thenReturn(new InlineKeyboardButton[] {}); + + final Update update = + gson.fromJson( + "{\n" + + "\"update_id\":10000,\n" + + "\"message\":{\n" + + " \"date\":1441645532,\n" + + " \"chat\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"type\": \"private\",\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"message_id\":1365,\n" + + " \"from\":{\n" + + " \"last_name\":\"Test Lastname\",\n" + + " \"id\":1111111,\n" + + " \"first_name\":\"Test Firstname\",\n" + + " \"username\":\"Testusername\"\n" + + " },\n" + + " \"text\":\"/help\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotResponseFuture = + help.process(fakeTgChat, update); + final Optional> gotResponseOpt = gotResponseFuture.get(); + final BaseRequest gotResponse = gotResponseOpt.get(); + final InlineKeyboardMarkup replyMarkup = + (InlineKeyboardMarkup) gotResponse.getParameters().get("reply_markup"); + assertNull(replyMarkup); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/command/ProcessorTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/command/ProcessorTest.java new file mode 100644 index 0000000..3e1402a --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/ProcessorTest.java @@ -0,0 +1,38 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import static org.junit.jupiter.api.Assertions.*; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.CONCURRENT) +class ProcessorTest { + + @Test + void shouldGetSimpleNameClassIfNotDefined() { + + class Test implements Processor { + + @Override + public Set getTriggerKeywords() { + return null; + } + + @Override + public CompletableFuture>> process(TgChat chat, Update update) { + return null; + } + } + + final Test test = new Test(); + + assertEquals("test.cmdDescription", test.getLocaleDescriptionKeyword()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/command/RouterTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/command/RouterTest.java index 1ca3bdf..f4bbcd7 100644 --- a/src/test/java/com/github/polpetta/mezzotre/telegram/command/RouterTest.java +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/RouterTest.java @@ -11,6 +11,7 @@ import com.google.gson.Gson; import com.pengrad.telegrambot.model.Update; import com.pengrad.telegrambot.request.BaseRequest; import com.pengrad.telegrambot.request.SendMessage; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -32,12 +33,12 @@ class RouterTest { gson = new Gson(); dummyEmptyExampleProcessor = mock(Processor.class); - when(dummyEmptyExampleProcessor.getTriggerKeyword()).thenReturn("/example"); + when(dummyEmptyExampleProcessor.getTriggerKeywords()).thenReturn(Set.of("/example")); when(dummyEmptyExampleProcessor.process(any(), any())) .thenReturn(CompletableFuture.completedFuture(Optional.empty())); anotherKeyWithResultProcessor = mock(Processor.class); - when(anotherKeyWithResultProcessor.getTriggerKeyword()).thenReturn("/anotherExample"); + when(anotherKeyWithResultProcessor.getTriggerKeywords()).thenReturn(Set.of("/anotherExample")); when(anotherKeyWithResultProcessor.process(any(), any())) .thenReturn( CompletableFuture.completedFuture(Optional.of(new SendMessage(1234L, "hello world")))); @@ -46,7 +47,10 @@ class RouterTest { @Test void shouldMessageExampleMessageAndGetEmptyOptional() throws Exception { final Router router = - new Router(Set.of(dummyEmptyExampleProcessor), Executors.newSingleThreadExecutor()); + new Router( + Map.of("/example", dummyEmptyExampleProcessor), + Executors.newSingleThreadExecutor(), + mock(NotFoundFactory.class)); final TgChat fakeChat = mock(TgChat.class); when(fakeChat.getChatContext()).thenReturn(new ChatContext()); final Update update = @@ -85,8 +89,13 @@ class RouterTest { void shouldSelectRightExecutorAndReturnResult() throws Exception { final Router router = new Router( - Set.of(dummyEmptyExampleProcessor, anotherKeyWithResultProcessor), - Executors.newSingleThreadExecutor()); + Map.of( + "/example", + dummyEmptyExampleProcessor, + "/anotherExample", + anotherKeyWithResultProcessor), + Executors.newSingleThreadExecutor(), + mock(NotFoundFactory.class)); final TgChat fakeChat = mock(TgChat.class); when(fakeChat.getChatContext()).thenReturn(new ChatContext()); final Update update = diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartIntegrationTest.java index cfc3e90..900db57 100644 --- a/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartIntegrationTest.java +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartIntegrationTest.java @@ -6,9 +6,11 @@ import static org.mockito.Mockito.*; import com.github.polpetta.mezzotre.helper.Loader; import com.github.polpetta.mezzotre.helper.TestConfig; import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; import com.github.polpetta.mezzotre.orm.model.TgChat; import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.orm.telegram.ChatUtil; import com.github.polpetta.mezzotre.util.Clock; import com.github.polpetta.mezzotre.util.UUIDGenerator; import com.github.polpetta.types.json.ChatContext; @@ -22,8 +24,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import org.apache.velocity.app.VelocityEngine; -import org.apache.velocity.runtime.RuntimeConstants; -import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -62,12 +62,7 @@ class StartIntegrationTest { void setUp() throws Exception { database = Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); - velocityEngine = new VelocityEngine(); - velocityEngine.setProperty(RuntimeConstants.RESOURCE_LOADERS, "classpath"); - velocityEngine.setProperty( - "resource.loader.classpath.class", ClasspathResourceLoader.class.getName()); - velocityEngine.init(); - localizedMessageFactory = new LocalizedMessageFactory(velocityEngine); + velocityEngine = Loader.defaultVelocityEngine(); final Logger log = LoggerFactory.getLogger(Start.class); @@ -76,11 +71,12 @@ class StartIntegrationTest { start = new Start( - localizedMessageFactory, + new TemplateContentGenerator(new LocalizedMessageFactory(velocityEngine)), Executors.newSingleThreadExecutor(), log, fakeUUIDGenerator, - fakeClock); + "Mezzotre", + new ChatUtil(fakeClock)); } @Test @@ -127,7 +123,7 @@ class StartIntegrationTest { assertInstanceOf(SendMessage.class, gotMessage); final String message = (String) gotMessage.getParameters().get("text"); assertEquals( - "**Hello Test Firstname! \uD83D\uDC4B**\n\n" + "*Hello Test Firstname! \uD83D\uDC4B*\n\n" + "This is _Mezzotre_, a simple bot focused on DnD content management! Please start by" + " choosing a language down below \uD83D\uDC47", message); @@ -185,7 +181,7 @@ class StartIntegrationTest { assertInstanceOf(SendMessage.class, gotMessage); final String message = (String) gotMessage.getParameters().get("text"); assertEquals( - "**Hello Test Firstname! \uD83D\uDC4B**\n\n" + "*Hello Test Firstname! \uD83D\uDC4B*\n\n" + "This is _Mezzotre_, a simple bot focused on DnD content management! Please start by" + " choosing a language down below \uD83D\uDC47", message); diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/model/HelpIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/model/HelpIntegrationTest.java new file mode 100644 index 0000000..fba44e3 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/model/HelpIntegrationTest.java @@ -0,0 +1,154 @@ +package com.github.polpetta.mezzotre.telegram.model; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.model.CallbackQueryContext; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.model.query.QCallbackQueryContext; +import com.github.polpetta.mezzotre.telegram.callbackquery.Processor; +import com.github.polpetta.mezzotre.util.UUIDGenerator; +import com.github.polpetta.types.json.ChatContext; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.InlineKeyboardButton; +import com.pengrad.telegrambot.request.BaseRequest; +import io.ebean.Database; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("slow") +@Tag("database") +@Tag("velocity") +@Testcontainers +class HelpIntegrationTest { + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private TemplateContentGenerator templateContentGenerator; + private UUIDGenerator fakeUUIDGenerator; + private Help help; + private Database database; + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + templateContentGenerator = + new TemplateContentGenerator(new LocalizedMessageFactory(Loader.defaultVelocityEngine())); + fakeUUIDGenerator = mock(UUIDGenerator.class); + + help = new Help(templateContentGenerator, fakeUUIDGenerator); + } + + @Test + void shouldGenerateAGoodHelpMessage() { + final TgChat tgChat = new TgChat(11111L, new ChatContext()); + tgChat.save(); + + final com.github.polpetta.mezzotre.telegram.command.Processor dummyCommand1 = + new com.github.polpetta.mezzotre.telegram.command.Processor() { + @Override + public Set getTriggerKeywords() { + return Set.of("/example", "/another"); + } + + @Override + public CompletableFuture>> process( + TgChat chat, Update update) { + return CompletableFuture.completedFuture(Optional.empty()); + } + + @Override + public String getLocaleDescriptionKeyword() { + return "help.cmdDescription"; + } + }; + + final String gotMessage = + help.getMessage(tgChat, Map.of("/example", dummyCommand1, "/another", dummyCommand1)); + assertEquals( + "Here is a list of what I can do:\n" + + "\n" + + "- /another /example: Print the help message\n" + + "\n" + + "You can do the same operations you'd do with the commands aforementioned by" + + " selecting the corresponding button below \uD83D\uDC47", + gotMessage); + } + + @Test + void shouldGenerateButtonsCorrectly() { + final TgChat tgChat = new TgChat(111111L, new ChatContext()); + tgChat.save(); + when(fakeUUIDGenerator.generateAsString()) + .thenReturn("53dc6dca-1042-4bc7-beb8-ce2a34df6a54") + .thenReturn("d5d3e016-7b60-4f1a-bd79-e1a6bff32f17"); + + final Processor visibleProcessor1 = + new Processor() { + @Override + public String getEventName() { + return "eventName"; + } + + @Override + public boolean canBeDirectlyInvokedByTheUser() { + return true; + } + + @Override + public Optional getPrettyPrintLocaleKeyName() { + return Optional.of("changeLanguage.inlineKeyboardButtonName"); + } + + @Override + public CompletableFuture>> process( + CallbackQueryContext callbackQueryContext, Update update) { + return CompletableFuture.completedFuture(Optional.empty()); + } + }; + + final Processor invisibleProcessor1 = + new Processor() { + @Override + public String getEventName() { + return "invisible"; + } + + @Override + public CompletableFuture>> process( + CallbackQueryContext callbackQueryContext, Update update) { + return CompletableFuture.completedFuture(Optional.empty()); + } + }; + + final InlineKeyboardButton[] buttons = + help.generateInlineKeyBoardButton( + tgChat, + Map.of( + visibleProcessor1.getEventName(), + visibleProcessor1, + invisibleProcessor1.getEventName(), + invisibleProcessor1)); + + assertEquals(1, buttons.length); + assertEquals("Change language", buttons[0].text()); + + assertEquals(1, new QCallbackQueryContext().findCount()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/model/HelpTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/model/HelpTest.java new file mode 100644 index 0000000..fbb5eb6 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/model/HelpTest.java @@ -0,0 +1,42 @@ +package com.github.polpetta.mezzotre.telegram.model; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.util.UUIDGenerator; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.CONCURRENT) +class HelpTest { + + private TemplateContentGenerator fakeTemplateContentGenerator; + private UUIDGenerator fakeUUIDGenerator; + private Help help; + + @BeforeEach + void setUp() { + + fakeTemplateContentGenerator = mock(TemplateContentGenerator.class); + fakeUUIDGenerator = mock(UUIDGenerator.class); + + help = new Help(fakeTemplateContentGenerator, fakeUUIDGenerator); + } + + @Test + void shouldCallTemplateContentGeneratorRight() { + + final TgChat fakeChat = mock(TgChat.class); + when(fakeChat.getLocale()).thenReturn("en-US"); + + final String message = help.getMessage(fakeChat, Collections.emptyMap()); + + verify(fakeTemplateContentGenerator, times(1)) + .mergeTemplate(any(), eq("en-US"), eq("template/telegram/help.vm")); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/util/ServiceModuleTest.java b/src/test/java/com/github/polpetta/mezzotre/util/ServiceModuleTest.java new file mode 100644 index 0000000..a698b78 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/util/ServiceModuleTest.java @@ -0,0 +1,72 @@ +package com.github.polpetta.mezzotre.util; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.common.util.concurrent.Service; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.CONCURRENT) +class ServiceModuleTest { + + /** + * This test seems stupid, but it is actually important that the behavior of the extension is + * always with a {@code lateInit} set to true, otherwise services won't be alive for the whole JVM + * execution + */ + @Test + void shouldBeLateInit() { + // Necessary otherwise ServiceManager will throw exception in the constructor 🤦 + final Service dumbService = + new Service() { + @Override + public Service startAsync() { + return null; + } + + @Override + public boolean isRunning() { + return false; + } + + @Override + public State state() { + return State.NEW; + } + + @Override + public Service stopAsync() { + return null; + } + + @Override + public void awaitRunning() {} + + @Override + public void awaitRunning(long timeout, TimeUnit unit) throws TimeoutException {} + + @Override + public void awaitTerminated() {} + + @Override + public void awaitTerminated(long timeout, TimeUnit unit) throws TimeoutException {} + + @Override + public Throwable failureCause() { + return null; + } + + @Override + public void addListener(Listener listener, Executor executor) {} + }; + + final ServiceModule serviceModule = new ServiceModule(List.of(dumbService)); + + assertTrue(serviceModule.lateinit()); + } +}