From 0e6b1e00f9cc065a15b6d2f6e270add51fc052be Mon Sep 17 00:00:00 2001 From: Davide Polonio Date: Tue, 21 Mar 2023 18:09:06 +0100 Subject: [PATCH] chore: first commit --- .gitignore | 32 ++ .run/Execute all tests.run.xml | 17 + .run/Jooby run.run.xml | 32 ++ .run/docker-compose up (debug).run.xml | 22 ++ Dockerfile | 18 + conf/application.conf | 11 + conf/logback.dev.xml | 14 + conf/logback.prod.xml | 1 + conf/logback.xml | 14 + docker-compose.yml | 27 ++ pom.xml | 363 ++++++++++++++++++ schema/json/ChatContext.json | 30 ++ src/etc/stork/stork.yml | 41 ++ .../com/github/polpetta/mezzotre/App.java | 72 ++++ .../polpetta/mezzotre/InjectionModule.java | 17 + .../i18n/LocalizedMessageFactory.java | 43 +++ .../polpetta/mezzotre/i18n/LocalizedTool.java | 14 + .../github/polpetta/mezzotre/orm/di/Db.java | 38 ++ .../polpetta/mezzotre/orm/model/Base.java | 29 ++ .../polpetta/mezzotre/orm/model/TgChat.java | 104 +++++ .../polpetta/mezzotre/orm/model/User.java | 65 ++++ .../polpetta/mezzotre/route/Constants.java | 6 + .../polpetta/mezzotre/route/Telegram.java | 97 +++++ .../polpetta/mezzotre/route/di/Route.java | 27 ++ .../command/CommandNotFoundException.java | 22 ++ .../mezzotre/telegram/command/Executor.java | 36 ++ .../mezzotre/telegram/command/Router.java | 70 ++++ .../mezzotre/telegram/command/Start.java | 101 +++++ .../mezzotre/telegram/command/di/Command.java | 28 ++ .../github/polpetta/mezzotre/util/Clock.java | 59 +++ .../polpetta/mezzotre/util/UUIDGenerator.java | 33 ++ .../polpetta/mezzotre/util/di/ThreadPool.java | 25 ++ .../migration/V1_0_0__Create_initial_db.sql | 24 ++ src/main/resources/ebean.mf | 2 + src/main/resources/i18n/message.properties | 3 + .../resources/i18n/message_en_US.properties | 3 + src/main/resources/i18n/message_it.properties | 3 + .../resources/i18n/message_it_IT.properties | 3 + src/main/resources/logback.xml | 14 + src/main/resources/template/command/start.vm | 4 + .../polpetta/mezzotre/IntegrationTest.java | 50 +++ .../github/polpetta/mezzotre/UnitTest.java | 55 +++ .../helper/IntegrationAppFactory.java | 64 +++ .../polpetta/mezzotre/helper/Loader.java | 43 +++ .../polpetta/mezzotre/helper/TestConfig.java | 5 + .../orm/model/TgChatIntegrationTest.java | 106 +++++ .../orm/model/UserIntegrationTest.java | 73 ++++ .../route/TelegramIntegrationTest.java | 109 ++++++ .../mezzotre/telegram/command/RouterTest.java | 126 ++++++ .../command/StartIntegrationTest.java | 121 ++++++ .../mezzotre/telegram/command/StartTest.java | 134 +++++++ src/test/resources/database-test.properties | 11 + src/test/resources/hikari.properties | 0 53 files changed, 2461 insertions(+) create mode 100644 .gitignore create mode 100644 .run/Execute all tests.run.xml create mode 100644 .run/Jooby run.run.xml create mode 100644 .run/docker-compose up (debug).run.xml create mode 100644 Dockerfile create mode 100644 conf/application.conf create mode 100644 conf/logback.dev.xml create mode 120000 conf/logback.prod.xml create mode 100644 conf/logback.xml create mode 100644 docker-compose.yml create mode 100644 pom.xml create mode 100644 schema/json/ChatContext.json create mode 100644 src/etc/stork/stork.yml create mode 100644 src/main/java/com/github/polpetta/mezzotre/App.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/InjectionModule.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/i18n/LocalizedMessageFactory.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/i18n/LocalizedTool.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/orm/di/Db.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/orm/model/Base.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/orm/model/TgChat.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/orm/model/User.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/route/Constants.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/route/Telegram.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/route/di/Route.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/telegram/command/CommandNotFoundException.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/telegram/command/Executor.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/telegram/command/Router.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/telegram/command/Start.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/telegram/command/di/Command.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/util/Clock.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/util/UUIDGenerator.java create mode 100644 src/main/java/com/github/polpetta/mezzotre/util/di/ThreadPool.java create mode 100644 src/main/resources/db/migration/V1_0_0__Create_initial_db.sql create mode 100644 src/main/resources/ebean.mf create mode 100644 src/main/resources/i18n/message.properties create mode 100644 src/main/resources/i18n/message_en_US.properties create mode 100644 src/main/resources/i18n/message_it.properties create mode 100644 src/main/resources/i18n/message_it_IT.properties create mode 100644 src/main/resources/logback.xml create mode 100644 src/main/resources/template/command/start.vm create mode 100644 src/test/java/com/github/polpetta/mezzotre/IntegrationTest.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/UnitTest.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/helper/Loader.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/helper/TestConfig.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/orm/model/TgChatIntegrationTest.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/orm/model/UserIntegrationTest.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/route/TelegramIntegrationTest.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/telegram/command/RouterTest.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/telegram/command/StartIntegrationTest.java create mode 100644 src/test/java/com/github/polpetta/mezzotre/telegram/command/StartTest.java create mode 100644 src/test/resources/database-test.properties create mode 100644 src/test/resources/hikari.properties diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bfc819 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +.idea/ +env-bot +src/gen/ +env-bot +env-db +.env + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* +target/ diff --git a/.run/Execute all tests.run.xml b/.run/Execute all tests.run.xml new file mode 100644 index 0000000..28aa703 --- /dev/null +++ b/.run/Execute all tests.run.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.run/Jooby run.run.xml b/.run/Jooby run.run.xml new file mode 100644 index 0000000..56c6375 --- /dev/null +++ b/.run/Jooby run.run.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.run/docker-compose up (debug).run.xml b/.run/docker-compose up (debug).run.xml new file mode 100644 index 0000000..b0e3a7e --- /dev/null +++ b/.run/docker-compose up (debug).run.xml @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d93b8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +ARG DEBUG_BUILD + +FROM maven:3.8-openjdk-17 as builder + +WORKDIR /build + +COPY . /build + +RUN mvn package -B -DskipTests=true \ + -Dmaven.test.skip=true \ + -Dmaven.site.skip=true \ + -Dmaven.javadoc.skip=true | grep -Ev '(Downloading|Downloaded)' + +FROM gcr.io/distroless/java17:${DEBUG_BUILD}nonroot + +COPY --from=builder /build/target/mezzotre.jar /opt/mezzotre.jar + +ENTRYPOINT ["java", "-jar", "/opt/mezzotre.jar"] \ No newline at end of file diff --git a/conf/application.conf b/conf/application.conf new file mode 100644 index 0000000..d265273 --- /dev/null +++ b/conf/application.conf @@ -0,0 +1,11 @@ +# Application configuration file. See https://github.com/typesafehub/config/blob/master/HOCON.md for more details + +db.url = "jdbc:postgresql://localhost:5433/example" +db.user = example +db.password = example +telegram.key = akey + +application.lang = en en-US it it-IT + +server.port = 9191 +server.gzip = true \ No newline at end of file diff --git a/conf/logback.dev.xml b/conf/logback.dev.xml new file mode 100644 index 0000000..8ec9b58 --- /dev/null +++ b/conf/logback.dev.xml @@ -0,0 +1,14 @@ + + + + + [%d{ISO8601}]-[%thread] %-5level %logger - %msg%n + + + + + + + + + diff --git a/conf/logback.prod.xml b/conf/logback.prod.xml new file mode 120000 index 0000000..7b3c38c --- /dev/null +++ b/conf/logback.prod.xml @@ -0,0 +1 @@ +logback.xml \ No newline at end of file diff --git a/conf/logback.xml b/conf/logback.xml new file mode 100644 index 0000000..0e24d5e --- /dev/null +++ b/conf/logback.xml @@ -0,0 +1,14 @@ + + + + + [%d{ISO8601}]-[%thread] %-5level %logger - %msg%n + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..423da8e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + bot: + build: + context: . + args: + - DEBUG_BUILD=${DEBUG_OPTS} + restart: unless-stopped + command: + - db.url=jdbc:postgresql://db:5432/mezzotre + - db.user=mezzotre + - db.password=${DB_PASSWORD} + - telegram.key=${TELEGRAM_KEY} + - application.env=${APPLICATION_MODE} + ports: + - "9191:9191" + volumes: + - ./conf/application.conf:/home/nonroot/application.${APPLICATION_MODE}.conf:ro + - ./conf/logback.${APPLICATION_MODE}.xml:/home/nonroot/logback.${APPLICATION_MODE}.xml:ro + db: + image: postgres:13-alpine + restart: unless-stopped + environment: + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_USER=mezzotre + - POSTGRES_DB=mezzotre diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c4c4a4d --- /dev/null +++ b/pom.xml @@ -0,0 +1,363 @@ + + + + 4.0.0 + + mezzotre + com.github.polpetta + 1.0-SNAPSHOT + jar + + Mezzotre + A simple Telegram assistant for DnD content + 2023 + + + Davide Polonio + https://bitdispenser.dev + davide+mezzotre@poldebra.me + + + + + + com.github.polpetta.mezzotre.App + + 2.16.2 + + 17 + 17 + true + UTF-8 + 2.0.1.Final + + 12.16.0 + 4.3.1 + 5.11.2 + 1.16.3 + 1.16.3 + 1.1.1 + 2.13.3 + + + + + + io.jooby + jooby-netty + + + + + ch.qos.logback + logback-classic + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + io.jooby + jooby-test + + + + io.jooby + jooby-guice + + + + io.swagger.core.v3 + swagger-annotations + 2.2.0 + + + + + + io.jooby + jooby-hikari + + + + + io.jooby + jooby-ebean + + + + io.ebean + ebean-test + ${ebean.version} + test + + + + + org.postgresql + postgresql + 42.5.4 + + + + javax.validation + validation-api + ${javax.validation.version} + + + + + io.jooby + jooby-flyway + + + + + io.jooby + jooby-gson + + + + + io.jooby + jooby-banner + + + + + org.mockito + mockito-core + test + + + + + org.mockito + mockito-junit-jupiter + test + + + + org.testcontainers + testcontainers + ${org.testcontainers.junit-jupiter.version} + + + org.testcontainers + postgresql + ${testcontainers-postgresql.version} + + + org.testcontainers + junit-jupiter + ${org.testcontainers.junit-jupiter.version} + + + org.mock-server + mockserver-netty + ${mock-server.version} + + + org.mock-server + mockserver-junit-jupiter + ${mock-server.version} + + + + org.apache.velocity + velocity-engine-core + 2.3 + + + + org.apache.velocity.tools + velocity-tools-generic + 3.1 + + + + + com.github.pengrad + java-telegram-bot-api + 6.5.0 + + + + + io.vavr + vavr + 1.0.0-alpha-4 + + + + com.squareup.okhttp3 + okhttp + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson-databind.version} + + + + + com.google.guava + guava + 31.1-jre + + + + + + + mezzotre + + + org.jsonschema2pojo + jsonschema2pojo-maven-plugin + ${jsonschema2pojo.version} + + ${basedir}/schema/json + com.github.polpetta.types.json + ${basedir}/src/gen/java + jsonschema + true + true + true + true + true + true + true + jackson2 + true + true + true + true + true + + + + + generate + + + + + + maven-compiler-plugin + 3.6.2 + + + -parameters + + + + io.jooby + jooby-apt + 2.16.2 + + + io.ebean + querybean-generator + ${ebean.version} + + + + + + io.jooby + jooby-maven-plugin + 2.16.2 + + + + openapi + + + + + + maven-surefire-plugin + 2.22.2 + + + + maven-shade-plugin + 3.4.1 + + + uber-jar + package + + shade + + + false + false + + + + ${application.class} + + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.24 + true + + + io.jooby:jooby-stork:2.16.2 + io.ebean.tile:enhancement:${ebean.version} + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + true + true + + + + + + + + + + + + io.jooby + jooby-bom + ${jooby.version} + pom + import + + + + + diff --git a/schema/json/ChatContext.json b/schema/json/ChatContext.json new file mode 100644 index 0000000..b25e794 --- /dev/null +++ b/schema/json/ChatContext.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "http://example.com/example.json", + "type": "object", + "default": {}, + "required": [ + "stage", + "step", + "previousMessageUnixTimestampInSeconds" + ], + "additionalProperties": true, + "properties": { + "stage": { + "type": "string", + "default": "" + }, + "step": { + "type": "number", + "default": 0 + }, + "previousMessageUnixTimestampInSeconds": { + "type": "number", + "default": 0 + }, + "lastMessageSentId": { + "type": "number", + "default": 0 + } + } +} \ No newline at end of file diff --git a/src/etc/stork/stork.yml b/src/etc/stork/stork.yml new file mode 100644 index 0000000..3b63584 --- /dev/null +++ b/src/etc/stork/stork.yml @@ -0,0 +1,41 @@ +# Name of application (make sure it has no spaces) +name: "${project.artifactId}" + +# Display name of application (can have spaces) +display_name: "${project.name}" + +# Type of launcher (CONSOLE or DAEMON) +type: DAEMON + +# Java class to run +main_class: "${application.class}" + +domain: "${project.groupId}" + +short_description: "${project.artifactId}" + +# Platform launchers to generate (WINDOWS, LINUX, MAC_OSX) +# Linux launcher is suitable for Bourne shells (e.g. Linux/BSD) +platforms: [ LINUX ] + +# Working directory for app +# RETAIN will not change the working directory +# APP_HOME will change the working directory to the home of the app +# (where it was intalled) before running the main class +working_dir_mode: RETAIN + +# Minimum version of java required (system will be searched for acceptable jvm) +min_java_version: "17" + +# Min/max fixed memory (measured in MB) +min_java_memory: 512 +max_java_memory: 512 + +# Min/max memory by percentage of system +#min_java_memory_pct: 10 +#max_java_memory_pct: 20 + +# Try to create a symbolic link to java executable in /run with +# the name of "-java" so that commands like "ps" will make it +# easier to find your app +symlink_java: true \ No newline at end of file diff --git a/src/main/java/com/github/polpetta/mezzotre/App.java b/src/main/java/com/github/polpetta/mezzotre/App.java new file mode 100644 index 0000000..d0cf1ff --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/App.java @@ -0,0 +1,72 @@ +package com.github.polpetta.mezzotre; + +import com.github.polpetta.mezzotre.orm.di.Db; +import com.github.polpetta.mezzotre.route.Telegram; +import com.github.polpetta.mezzotre.route.di.Route; +import com.github.polpetta.mezzotre.telegram.command.di.Command; +import com.github.polpetta.mezzotre.util.di.ThreadPool; +import com.google.inject.*; +import com.google.inject.Module; +import com.google.inject.name.Names; +import io.jooby.*; +import io.jooby.banner.BannerModule; +import io.jooby.di.GuiceModule; +import io.jooby.di.JoobyModule; +import io.jooby.ebean.TransactionalRequest; +import io.jooby.json.GsonModule; +import java.util.*; +import java.util.function.Function; + +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()); + return modules; + }; + + public App() { + this(Stage.PRODUCTION, null); + } + + public App(Stage runningEnv, Collection modules) { + // FIXME change it in some configuration file! + setName("Mezzotre"); + setVersion("1.0-SNAPSHOT"); + // END FIXME + + Collection toInject = + new ArrayList<>(Optional.ofNullable(modules).orElse(Collections.emptySet())); + if (modules == null || modules.size() == 0) { + toInject = DEFAULT_DI_MODULES.apply(this); + } + toInject.add(new InjectionModule(this)); + toInject.add(new JoobyModule(this)); + + final Injector injector = Guice.createInjector(runningEnv, toInject); + install(new GuiceModule(injector)); // We need to do it here + + install(new BannerModule("Mezzotre")); + install(new OpenAPIModule()); + install(new GsonModule()); + install(injector.getInstance(Key.get(Extension.class, Names.named("hikariPoolExtension")))); + install( + injector.getInstance(Key.get(Extension.class, Names.named("flyWayMigrationExtension")))); + install(injector.getInstance(Key.get(Extension.class, Names.named("ebeanExtension")))); + + decorator(new AccessLogHandler()); + decorator(new TransactionalRequest()); + + // We need to put this there otherwise MockRouter for testing purposes won't work + get("/", ctx -> "Hello World!"); + mvc(Telegram.class); + } + + public static void main(final String[] args) { + runApp(args, ExecutionMode.EVENT_LOOP, App::new); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/InjectionModule.java b/src/main/java/com/github/polpetta/mezzotre/InjectionModule.java new file mode 100644 index 0000000..f0a8c6e --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/InjectionModule.java @@ -0,0 +1,17 @@ +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/LocalizedMessageFactory.java b/src/main/java/com/github/polpetta/mezzotre/i18n/LocalizedMessageFactory.java new file mode 100644 index 0000000..63660b4 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/i18n/LocalizedMessageFactory.java @@ -0,0 +1,43 @@ +package com.github.polpetta.mezzotre.i18n; + +import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.runtime.resource.loader.JarResourceLoader; +import org.apache.velocity.tools.Scope; +import org.apache.velocity.tools.ToolContext; +import org.apache.velocity.tools.ToolManager; +import org.apache.velocity.tools.config.FactoryConfiguration; +import org.apache.velocity.tools.config.ToolConfiguration; +import org.apache.velocity.tools.config.ToolboxConfiguration; +import javax.inject.Inject; +import java.util.Locale; + +public class LocalizedMessageFactory { + + private final VelocityEngine velocityEngine; + + @Inject + public LocalizedMessageFactory(VelocityEngine velocityEngine) { + this.velocityEngine = velocityEngine; + } + + public ToolManager create(Locale locale) { + +// properties.setProperty("file.resource.loader.class", FileResourceLoader.class.getName());. + + + final ToolManager toolManager = new ToolManager(); + toolManager.setVelocityEngine(velocityEngine); + final FactoryConfiguration factoryConfiguration = new FactoryConfiguration(); + final ToolboxConfiguration toolboxConfiguration = new ToolboxConfiguration(); + toolboxConfiguration.setScope(Scope.REQUEST); + final ToolConfiguration toolConfiguration = new ToolConfiguration(); + toolConfiguration.setClassname(LocalizedTool.class.getName()); + toolConfiguration.setProperty("file.resource.loader.class", JarResourceLoader.class.getName()); + toolConfiguration.setProperty(ToolContext.LOCALE_KEY, locale); + toolConfiguration.setProperty(LocalizedTool.BUNDLES_KEY, "i18n/message"); + toolboxConfiguration.addTool(toolConfiguration); + factoryConfiguration.addToolbox(toolboxConfiguration); + toolManager.configure(factoryConfiguration); + return toolManager; + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/i18n/LocalizedTool.java b/src/main/java/com/github/polpetta/mezzotre/i18n/LocalizedTool.java new file mode 100644 index 0000000..b009630 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/i18n/LocalizedTool.java @@ -0,0 +1,14 @@ +package com.github.polpetta.mezzotre.i18n; + +import org.apache.velocity.tools.config.DefaultKey; +import org.apache.velocity.tools.generic.ResourceTool; +import java.util.Locale; + +@DefaultKey("i18n") +public class LocalizedTool extends ResourceTool { + + @Override + public void setLocale(Locale locale) { + super.setLocale(locale); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/di/Db.java b/src/main/java/com/github/polpetta/mezzotre/orm/di/Db.java new file mode 100644 index 0000000..e441435 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/orm/di/Db.java @@ -0,0 +1,38 @@ +package com.github.polpetta.mezzotre.orm.di; + +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; + +public class Db extends AbstractModule { + /** + * Returns null. This allows to fetch the configuration from file rather than fetch from other + * environment + * + * @return a {@link HikariConfig} configuration if possible + */ + @Provides + @Singleton + public HikariConfig getHikariConfig() { + return null; + } + + @Provides + @Singleton + @Named("hikariPoolExtension") + public Extension getHikariExtension() { + return new HikariModule(); + } + + @Provides + @Singleton + @Named("flyWayMigrationExtension") + public Extension getFlyWayMigrationExtension() { + return new FlywayModule(); + } + + @Provides + @Singleton + @Named("ebeanExtension") + public Extension getEbeanExtension() { + return new EbeanModule(); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/model/Base.java b/src/main/java/com/github/polpetta/mezzotre/orm/model/Base.java new file mode 100644 index 0000000..f34e01a --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/orm/model/Base.java @@ -0,0 +1,29 @@ +package com.github.polpetta.mezzotre.orm.model; + +import io.ebean.Model; +import io.ebean.annotation.WhenCreated; +import io.ebean.annotation.WhenModified; +import javax.persistence.MappedSuperclass; +import java.time.Instant; + +/** + * Father class to all the database objects. It allows to add when an object has been created and modifies + * + * @author Davide Polonio + * @since 1.0-SNAPSHOT + */ +@MappedSuperclass +public class Base extends Model { + + @WhenCreated private Instant entryCreated; + + @WhenModified private Instant entryModified; + + public Instant getEntryModified() { + return entryModified; + } + + public Instant getEntryCreated() { + return entryCreated; + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/model/TgChat.java b/src/main/java/com/github/polpetta/mezzotre/orm/model/TgChat.java new file mode 100644 index 0000000..eeebce9 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/orm/model/TgChat.java @@ -0,0 +1,104 @@ +package com.github.polpetta.mezzotre.orm.model; + +import com.github.polpetta.types.json.ChatContext; +import io.ebean.annotation.DbJsonB; +import io.ebean.annotation.Length; +import javax.annotation.Nullable; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.validation.constraints.NotNull; + +/** + * TgChat represents a chat with a possible Telegram user. Here information about the chat settings, + * such as the locale to use and the {@link ChatContext} are stored + * + * @author Davide Polonio + * @since 1.0 + */ +@Entity +@Table(name = "telegram_chat") +public class TgChat extends Base { + + /** + * The ID of the conversation. For chat with users, the rules of thumb is that this id is + * positive, while if it is a group or supergroup it is negative + */ + @Id private final Long id; + + /** + * This element in the database indicates which step the user is with the bot interaction. It can + * be useful to track and store chat metadata and also have a context about the conversation + * itself. Since the metadata stored can vary there's not a solid structured. Please use {@link + * ChatContext#getAdditionalProperties()} to retrieve for properties that are not statically + * typed. + */ + @DbJsonB + @Column(columnDefinition = "jsonb not null default '{}'::jsonb") + @NotNull + private ChatContext chatContext; + + /** The locale to use when chatting. Defaults to {@code en-US} */ + @NotNull + @Length(5) + @Column(columnDefinition = "varchar(5) not null default 'en-US'") + private String locale; + + /** + * See {@link #TgChat( Long, ChatContext, String)}. The default locale set here is {@code en-US} + */ + public TgChat(Long id, ChatContext chatContext) { + this.id = id; + this.chatContext = chatContext; + this.locale = "en"; + } + + /** + * Build a new Telegram Chat + * + * @param id the id of the chat (can be negative) + * @param chatContext the context of the chat, where possible metadata can be stored. See {@link + * ChatContext} + * @param locale a specific locale for this chat + */ + public TgChat(Long id, @Nullable ChatContext chatContext, String locale) { + this.id = id; + this.chatContext = chatContext; + this.locale = locale; + } + + public Long getId() { + return id; + } + + @NotNull + public ChatContext getChatContext() { + return chatContext; + } + + public void setChatContext(@NotNull ChatContext chatContext) { + this.chatContext = chatContext; + } + + public String getLocale() { + return locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + @Override + public String toString() { + return "TgChat{" + + "id=" + + id + + ", chatContext=" + + chatContext + + ", locale='" + + locale + + '\'' + + '}'; + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/orm/model/User.java b/src/main/java/com/github/polpetta/mezzotre/orm/model/User.java new file mode 100644 index 0000000..105362b --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/orm/model/User.java @@ -0,0 +1,65 @@ +package com.github.polpetta.mezzotre.orm.model; + +import io.ebean.annotation.ConstraintMode; +import io.ebean.annotation.DbForeignKey; +import io.ebean.annotation.Length; +import io.ebean.annotation.NotNull; +import javax.annotation.Nullable; +import javax.persistence.*; + +@Entity +@Table(name = "registered_user") +public class User extends Base { + + @Id + @Length(64) + private final String id; + + @Column(columnDefinition = "boolean default false") + @NotNull + private Boolean isActive; + + @OneToOne(fetch = FetchType.LAZY, optional = true) + @DbForeignKey(onDelete = ConstraintMode.CASCADE) + @JoinColumn(name = "telegram_id", referencedColumnName = "id") + private TgChat telegramId; + + @Length(256) + @Nullable + private String emailAddress; + + public User(String id, String emailAddress, Boolean isActive, TgChat telegramId) { + this.id = id; + this.emailAddress = emailAddress; + this.isActive = isActive; + this.telegramId = telegramId; + } + + public String getId() { + return id; + } + + public Boolean isActive() { + return isActive; + } + + public void setActive(Boolean active) { + isActive = active; + } + + public TgChat getTelegramId() { + return telegramId; + } + + public void setTelegramId(TgChat telegramId) { + this.telegramId = telegramId; + } + + public String getEmailAddress() { + return emailAddress; + } + + public void setEmailAddress(String emailAddress) { + this.emailAddress = emailAddress; + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/route/Constants.java b/src/main/java/com/github/polpetta/mezzotre/route/Constants.java new file mode 100644 index 0000000..8e9d0f8 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/route/Constants.java @@ -0,0 +1,6 @@ +package com.github.polpetta.mezzotre.route; + +public final class Constants { + public final static String V1 = "v1"; + public final static String API = "api"; +} diff --git a/src/main/java/com/github/polpetta/mezzotre/route/Telegram.java b/src/main/java/com/github/polpetta/mezzotre/route/Telegram.java new file mode 100644 index 0000000..c2b62aa --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/route/Telegram.java @@ -0,0 +1,97 @@ +package com.github.polpetta.mezzotre.route; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.telegram.command.Router; +import com.github.polpetta.mezzotre.util.UUIDGenerator; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import io.jooby.Context; +import io.jooby.MediaType; +import io.jooby.annotations.POST; +import io.jooby.annotations.Path; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.inject.Inject; +import javax.inject.Named; +import org.slf4j.Logger; + +@Tag(name = "Telegram", description = "Telegram webhook endpoint") +@Path("/" + Constants.API + "/" + Telegram.ENDPOINT) +public class Telegram { + + static final String ENDPOINT = "tg"; + private final Logger log; + private final TelegramBot bot; + private final Gson gson; + private final Executor completableFutureThreadPool; + private final UUIDGenerator uuidGenerator; + private final Router router; + + @Inject + public Telegram( + Logger log, + TelegramBot bot, + Gson gson, + @Named("eventThreadPool") Executor completableFutureThreadPool, + UUIDGenerator uuidGenerator, + Router router) { + this.log = log; + this.bot = bot; + this.gson = gson; + this.completableFutureThreadPool = completableFutureThreadPool; + this.uuidGenerator = uuidGenerator; + this.router = router; + } + + @Operation( + summary = "Telegram webhook entrypoint", + description = + "Telegram servers will call this endpoint to send updates about incoming messages", + tags = "telegram", + requestBody = @RequestBody(required = true)) + @POST + public CompletableFuture incomingUpdate(Context context, Update update) { + return CompletableFuture.supplyAsync( + () -> { + context.setResponseType(MediaType.JSON); + log.trace(gson.toJson(update)); + + final Message message = update.message(); + return new QTgChat() + .id + .eq(message.chat().id()) + .findOneOrEmpty() + .map( + u -> { + log.debug( + "Telegram chat " + u.getId() + " already registered in the database"); + return u; + }) + .orElseGet( + () -> { + final TgChat newTgChat = new TgChat(message.chat().id(), new ChatContext()); + newTgChat.save(); + log.trace( + "New Telegram chat " + newTgChat.getId() + " added into the database"); + return newTgChat; + }); + }, + completableFutureThreadPool) + .thenComposeAsync(tgChat -> router.process(tgChat, update), completableFutureThreadPool) + // See https://core.telegram.org/bots/faq#how-can-i-make-requests-in-response-to-updates + .thenApply( + tgResponse -> { + final String response = tgResponse.map(BaseRequest::toWebhookResponse).orElse("{}"); + log.trace("About to send back " + response); + return response; + }); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/route/di/Route.java b/src/main/java/com/github/polpetta/mezzotre/route/di/Route.java new file mode 100644 index 0000000..3c6e420 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/route/di/Route.java @@ -0,0 +1,27 @@ +package com.github.polpetta.mezzotre.route.di; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.pengrad.telegrambot.TelegramBot; +import io.jooby.Jooby; +import java.util.Optional; +import javax.inject.Singleton; + +public class Route extends AbstractModule { + @Provides + @Singleton + public TelegramBot getTelegramBot(Jooby jooby) { + final String telegramKey = + Optional.ofNullable(jooby.getEnvironment().getConfig().getString("telegram.key")) + .or( + () -> + Optional.ofNullable( + jooby.getEnvironment().getConfig().getString("TELEGRAM_KEY"))) + .filter(s -> !s.isBlank()) + .orElseThrow( + () -> + new IllegalStateException( + "Telegram token is required to make the application work. Please set 'telegram.key = \"value\"' in your application.conf file or as the application argument. Alternatively, you can set 'TELEGRAM_KEY' as an environment variable.")); + return new TelegramBot(telegramKey); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/CommandNotFoundException.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/CommandNotFoundException.java new file mode 100644 index 0000000..016c421 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/CommandNotFoundException.java @@ -0,0 +1,22 @@ +package com.github.polpetta.mezzotre.telegram.command; + +public class CommandNotFoundException extends RuntimeException { + public CommandNotFoundException() {} + + public CommandNotFoundException(String message) { + super(message); + } + + public CommandNotFoundException(String message, Throwable cause) { + super(message, cause); + } + + public CommandNotFoundException(Throwable cause) { + super(cause); + } + + public CommandNotFoundException( + String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/telegram/command/Executor.java b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Executor.java new file mode 100644 index 0000000..4b5eacd --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Executor.java @@ -0,0 +1,36 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.SendMessage; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * This interfaces provides a way to digest incoming updates that are commands, e.g. {@code + * /this_command arg1} + * + * @author Davide Polonio + * @since 1.0 + */ +public interface Executor { + + /** + * Provides the keyword 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 Executor} + */ + String getTriggerKeyword(); + + /** + * Process the current update + * + * @param chat the chat the {@link Executor} is currently replying to + * @param update the update to process + * @return a {@link CompletableFuture} with the result of the computation + */ + // FIXME cannot be void - we don't want pesky side effects! + CompletableFuture>> process(TgChat chat, Update update); +} 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 new file mode 100644 index 0000000..45dde03 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Router.java @@ -0,0 +1,70 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +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.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import javax.inject.Named; + +/** + * This class has the goal of dispatching incoming {@link Update} events to the right {@link + * Executor}, that will provide an adequate response. + * + * @author Davide Polonio + * @since 1.0 + */ +@Singleton +public class Router { + + private final Set tgExecutors; + private final java.util.concurrent.Executor threadPool; + + @Inject + public Router( + @Named("commands") Set tgExecutors, + @Named("eventThreadPool") java.util.concurrent.Executor threadPool) { + this.tgExecutors = tgExecutors; + this.threadPool = threadPool; + } + + /** + * Process the incoming {@link Update}. If no suitable {@link Executor} is able to process the + * command, then {@link CommandNotFoundException} is used to signal this event. + * + * @param update the update coming from Telegram Servers + * @return a {@link CompletableFuture} that is marked as failure and containing a {@link + * CommandNotFoundException} exception if no suitable {@link Executor} is found + */ + public CompletableFuture>> process(TgChat chat, Update update) { + // This way exceptions are always under control + return CompletableFuture.completedStage(update) + .toCompletableFuture() + .thenComposeAsync( + up -> + /* + Brief explanation of this chain: + 1 - Check if the message has a command in it (e.g. "/start hey!") + 1.a - If it has, then search for the proper executor and go with it + 2 - If there is no command (e.g "example") then go to the chat context and retrieve the stage we + could be in (maybe we're continuing a chat from previous messages?) + 2.a - If there's a context with a valid stage, then continue with it + */ + Optional.of(up.message().text().split(" ")) + .filter(list -> list.length > 0) + .map(list -> list[0]) + .filter(wannabeCommand -> wannabeCommand.startsWith("/")) + .or(() -> Optional.ofNullable(chat.getChatContext().getStage())) + .flatMap( + command -> + tgExecutors.stream() + .filter(ex -> ex.getTriggerKeyword().equals(command)) + .findAny()) + .map(executor -> executor.process(chat, up)) + .orElse(CompletableFuture.failedFuture(new CommandNotFoundException())), + 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 new file mode 100644 index 0000000..1534b1a --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/Start.java @@ -0,0 +1,101 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.mezzotre.util.Clock;import com.github.polpetta.types.json.ChatContext; +import com.google.inject.Singleton; +import com.pengrad.telegrambot.model.Update; +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.Locale; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +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; + +/** + * This {@link Executor} has the goal to greet a user that typed {@code /start} to the bot. + * + * @author Davide Polonio + * @since 1.0 + */ +@Singleton +public class Start implements Executor { + + private final java.util.concurrent.Executor threadPool; + private final Logger log; + private final LocalizedMessageFactory localizedMessageFactory; + + @Inject + public Start( + LocalizedMessageFactory localizedMessageFactory, + @Named("eventThreadPool") java.util.concurrent.Executor threadPool, + Logger log) { + this.localizedMessageFactory = localizedMessageFactory; + this.threadPool = threadPool; + this.log = log; + } + + @Override + public String getTriggerKeyword() { + return "/start"; + } + + @Override + public CompletableFuture>> process(TgChat chat, Update update) { + return CompletableFuture.supplyAsync( + () -> { + /* + Steps: + 1 - Load the template and generate the message with the defined locale + 2 - Update the chat context + 3 - Reply to Telegram + */ + final String message = + Try.of( + () -> { + final Locale locale = Locale.forLanguageTag(chat.getLocale()); + final ToolManager toolContext = localizedMessageFactory.create(locale); + final VelocityContext context = + new VelocityContext(toolContext.createContext()); + context.put("firstName", update.message().chat().firstName()); + context.put("programName", "Mezzotre"); + + final StringBuilder content = new StringBuilder(); + final StringBuilderWriter stringBuilderWriter = + new StringBuilderWriter(content); + + toolContext + .getVelocityEngine() + .mergeTemplate( + "template/command/start.vm", + StandardCharsets.UTF_8.name(), + context, + stringBuilderWriter); + + stringBuilderWriter.close(); + return content.toString(); + }) + .get(); + log.trace("Start command - message to send back: " + message); + + final ChatContext chatContext = chat.getChatContext(); + chatContext.setLastMessageSentId(update.message().messageId()); + chatContext.setStage(getTriggerKeyword()); + chatContext.setStep(0); + chatContext.setPreviousMessageUnixTimestampInSeconds(update.message().date()); + chat.setChatContext(chatContext); + chat.save(); + + return Optional.of(new SendMessage(chat.getId(), message).parseMode(ParseMode.Markdown)); + }, + threadPool); + } +} 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 new file mode 100644 index 0000000..7a4adfc --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/telegram/command/di/Command.java @@ -0,0 +1,28 @@ +package com.github.polpetta.mezzotre.telegram.command.di; + +import com.github.polpetta.mezzotre.telegram.command.Executor;import com.github.polpetta.mezzotre.telegram.command.Start;import com.google.common.io.Resources;import com.google.inject.AbstractModule;import com.google.inject.Provides;import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.runtime.RuntimeConstants; +import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader; +import javax.inject.Named; +import javax.inject.Singleton;import java.util.Set; + +public class Command extends AbstractModule { + @Provides + @Singleton + @Named("commands") + public Set getCommandExecutors( + 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/util/Clock.java b/src/main/java/com/github/polpetta/mezzotre/util/Clock.java new file mode 100644 index 0000000..717734b --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/util/Clock.java @@ -0,0 +1,59 @@ +package com.github.polpetta.mezzotre.util; + +import java.sql.Timestamp; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.TimeZone; + +public class Clock { + + /** + * @return the current UNIX timestamp in milliseconds (so since 1970/01/01) + */ + public long now() { + return System.currentTimeMillis(); + } + + public Timestamp nowInTimestamp() { + return new Timestamp(now()); + } + + public Date nowInDate() { + return new Date(); + } + + public String getNowDateISO8601() { + final TimeZone tz = TimeZone.getTimeZone("UTC"); + final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'Z'"); + df.setTimeZone(tz); + return df.format(new Date()); + } + + public String getNowDateTimeISO8601() { + final DateTimeFormatter df = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneId.of("UTC")); + return df.format(Instant.now()); + } + + public static String getDateISO86001(long unixTimestamp) { + final DateTimeFormatter df = DateTimeFormatter.ISO_DATE.withZone(ZoneId.of("UTC")); + return df.format(Instant.ofEpochMilli(unixTimestamp)); + } + + public static String getDateTimeISO8601(long unixTimestamp) { + final DateTimeFormatter df = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneId.of("UTC")); + return df.format(Instant.ofEpochMilli(unixTimestamp)); + } + + public static Timestamp timestampFromString(String entry, String pattern) { + final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(pattern); + final LocalDate parse = LocalDate.parse(entry, dateTimeFormatter); + return new Timestamp(parse.toEpochSecond(LocalTime.MIDNIGHT, ZoneOffset.of("Z")) * 1000L); + } + + public static Timestamp getTimestampFromUnixEpoch(long unixTimestamp) { + return new Timestamp(unixTimestamp); + } +} diff --git a/src/main/java/com/github/polpetta/mezzotre/util/UUIDGenerator.java b/src/main/java/com/github/polpetta/mezzotre/util/UUIDGenerator.java new file mode 100644 index 0000000..3e34dc7 --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/util/UUIDGenerator.java @@ -0,0 +1,33 @@ +package com.github.polpetta.mezzotre.util; + +import java.util.UUID; +import javax.inject.Singleton; + +/** + * Simple UUID generator wrapper to make this code easily mockable in tests. It doesn't have any + * other purpose + * + * @author Davide Polonio + * @since 1.0-SNAPSHOT + */ +@Singleton +public class UUIDGenerator { + + /** + * Generate a standard Java {@link UUID} + * + * @return a {@link UUID} + */ + public UUID generate() { + return UUID.randomUUID(); + } + + /** + * Convenience method to get a {@link UUID} directly as {@link String} + * + * @return a {@link UUID} as {@link String} + */ + public String generateAsString() { + return UUID.randomUUID().toString(); + } +} 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 new file mode 100644 index 0000000..55e32eb --- /dev/null +++ b/src/main/java/com/github/polpetta/mezzotre/util/di/ThreadPool.java @@ -0,0 +1,25 @@ +package com.github.polpetta.mezzotre.util.di; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import javax.inject.Named; +import javax.inject.Singleton; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; + +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/db/migration/V1_0_0__Create_initial_db.sql b/src/main/resources/db/migration/V1_0_0__Create_initial_db.sql new file mode 100644 index 0000000..d541c90 --- /dev/null +++ b/src/main/resources/db/migration/V1_0_0__Create_initial_db.sql @@ -0,0 +1,24 @@ +-- apply changes +create table telegram_chat ( + id bigint generated by default as identity not null, + chat_context jsonb not null default '{}'::jsonb not null, + locale varchar(5) not null default 'en-US' not null, + entry_created timestamptz not null, + entry_modified timestamptz not null, + constraint pk_telegram_chat primary key (id) +); + +create table registered_user ( + id varchar(64) not null, + is_active boolean default false not null, + telegram_id bigint, + email_address varchar(256), + entry_created timestamptz not null, + entry_modified timestamptz not null, + constraint uq_registered_user_telegram_id unique (telegram_id), + constraint pk_registered_user primary key (id) +); + +-- foreign keys and indices +alter table registered_user add constraint fk_registered_user_telegram_id foreign key (telegram_id) references telegram_chat (id) on delete cascade on update restrict; + diff --git a/src/main/resources/ebean.mf b/src/main/resources/ebean.mf new file mode 100644 index 0000000..b47b0a8 --- /dev/null +++ b/src/main/resources/ebean.mf @@ -0,0 +1,2 @@ +entity-packages: com.github.polpetta.mezzotre.orm.model +transactional-packages: com.github.polpetta.mezzotre.orm.transaction diff --git a/src/main/resources/i18n/message.properties b/src/main/resources/i18n/message.properties new file mode 100644 index 0000000..159bd50 --- /dev/null +++ b/src/main/resources/i18n/message.properties @@ -0,0 +1,3 @@ +start.hello=Hello +start.thisIs=This is +start.description=a simple bot focused on DnD content management! Please start by choosing a language down below. \ No newline at end of file diff --git a/src/main/resources/i18n/message_en_US.properties b/src/main/resources/i18n/message_en_US.properties new file mode 100644 index 0000000..159bd50 --- /dev/null +++ b/src/main/resources/i18n/message_en_US.properties @@ -0,0 +1,3 @@ +start.hello=Hello +start.thisIs=This is +start.description=a simple bot focused on DnD content management! Please start by choosing a language down below. \ No newline at end of file diff --git a/src/main/resources/i18n/message_it.properties b/src/main/resources/i18n/message_it.properties new file mode 100644 index 0000000..e3458b1 --- /dev/null +++ b/src/main/resources/i18n/message_it.properties @@ -0,0 +1,3 @@ +start.hello=Ciao +start.thisIs=Questo è +start.description=un semplice bot che ci concenta sulla gestione di contenuto per DnD! Per favore comincia selezionando la lingua qui sotto \ No newline at end of file diff --git a/src/main/resources/i18n/message_it_IT.properties b/src/main/resources/i18n/message_it_IT.properties new file mode 100644 index 0000000..e3458b1 --- /dev/null +++ b/src/main/resources/i18n/message_it_IT.properties @@ -0,0 +1,3 @@ +start.hello=Ciao +start.thisIs=Questo è +start.description=un semplice bot che ci concenta sulla gestione di contenuto per DnD! Per favore comincia selezionando la lingua qui sotto \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..0e24d5e --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + [%d{ISO8601}]-[%thread] %-5level %logger - %msg%n + + + + + + + + + diff --git a/src/main/resources/template/command/start.vm b/src/main/resources/template/command/start.vm new file mode 100644 index 0000000..4e6a73d --- /dev/null +++ b/src/main/resources/template/command/start.vm @@ -0,0 +1,4 @@ +## https://velocity.apache.org/tools/2.0/apidocs/org/apache/velocity/tools/generic/ResourceTool.html +**$i18n.start.hello $firstName! 👋** + +$i18n.start.thisIs _${programName}_, $i18n.start.description 👇 \ No newline at end of file diff --git a/src/test/java/com/github/polpetta/mezzotre/IntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/IntegrationTest.java new file mode 100644 index 0000000..6a13ebc --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/IntegrationTest.java @@ -0,0 +1,50 @@ +package com.github.polpetta.mezzotre; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.github.polpetta.mezzotre.helper.IntegrationAppFactory; +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import io.ebean.Database; +import io.jooby.JoobyTest; +import io.jooby.StatusCode; +import java.io.IOException; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +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") +@JoobyTest( + value = App.class, + factoryClass = IntegrationAppFactory.class, + factoryMethod = "loadCustomDbApplication") +public class IntegrationTest { + + static OkHttpClient client = new OkHttpClient(); + + /** + * Integration test example using OKHttpClient. The serverPort(int) argument is generated by + * Jooby. Jooby automatically set it as long it is declared as int and name it: serverPort. + * + *

Please refer to: https://jooby.io/#testing-integration-testing for more information. + * + * @param serverPort Server port (must be compiled with --parameters enabled). + * @throws IOException If something goes wrong. + */ + @Test + public void shouldSayWelcome(int serverPort) throws IOException { + Request req = new Request.Builder().url("http://localhost:" + serverPort).build(); + + try (Response rsp = client.newCall(req).execute()) { + assertEquals("Hello World!", rsp.body().string()); + assertEquals(StatusCode.OK.value(), rsp.code()); + } + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/UnitTest.java b/src/test/java/com/github/polpetta/mezzotre/UnitTest.java new file mode 100644 index 0000000..9c8a6bd --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/UnitTest.java @@ -0,0 +1,55 @@ +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.google.inject.Module; +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 javax.inject.Named; +import org.junit.jupiter.api.Test; + +public class UnitTest { + + private static class TestDI extends AbstractModule { + @Provides + @Singleton + @Named("hikariPoolExtension") + public Extension getHikariExtension() { + return mock(HikariModule.class); + } + + @Provides + @Singleton + @Named("flyWayMigrationExtension") + public Extension getFlyWayMigrationExtension() { + return mock(FlywayModule.class); + } + + @Provides + @Singleton + @Named("ebeanExtension") + public Extension getEbeanExtension() { + return mock(EbeanModule.class); + } + } + + @Test + public void shouldSayWelcome() { + final TestDI testDI = new TestDI(); + final List abstractModules = new ArrayList<>(); + abstractModules.add(testDI); + MockRouter router = new MockRouter(new App(Stage.DEVELOPMENT, abstractModules)); + router.get( + "/", + rsp -> { + assertEquals("Hello World!", rsp.value()); + assertEquals(StatusCode.OK, rsp.getStatusCode()); + }); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java b/src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java new file mode 100644 index 0000000..261a26b --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/helper/IntegrationAppFactory.java @@ -0,0 +1,64 @@ +package com.github.polpetta.mezzotre.helper; + +import com.github.polpetta.mezzotre.App; +import com.github.polpetta.mezzotre.InjectionModule; +import com.github.polpetta.mezzotre.route.di.Route; +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.Properties; +import java.util.Set; +import javax.inject.Named; +import org.apache.commons.lang3.tuple.Pair; +import org.testcontainers.containers.PostgreSQLContainer; + +public class IntegrationAppFactory { + + private static class DatabaseDI extends AbstractModule { + + private final PostgreSQLContainer container; + + public DatabaseDI(PostgreSQLContainer container) { + this.container = container; + } + + @Provides + @Singleton + @Named("hikariPoolExtension") + public Extension getHikariExtension() throws Exception { + final Pair propertiesPropertiesPair = + Loader.loadDefaultEbeanConfigWithPostgresSettings(container); + + return new HikariModule(new HikariConfig(propertiesPropertiesPair.getRight())); + } + + @Provides + @Singleton + @Named("flyWayMigrationExtension") + public Extension getFlyWayMigrationExtension() { + return new FlywayModule(); + } + + @Provides + @Singleton + @Named("ebeanExtension") + public Extension getEbeanExtension() { + return new EbeanModule(); + } + } + + public static App loadCustomDbApplication() { + final PostgreSQLContainer container = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + container.start(); + final Route routeModule = new Route(); + 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 new file mode 100644 index 0000000..2e3ddc1 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/helper/Loader.java @@ -0,0 +1,43 @@ +package com.github.polpetta.mezzotre.helper; + +import com.github.polpetta.mezzotre.App;import com.google.common.io.Resources;import com.zaxxer.hikari.HikariConfig;import com.zaxxer.hikari.HikariDataSource;import io.ebean.Database;import io.ebean.DatabaseFactory;import io.ebean.config.DatabaseConfig;import org.apache.commons.lang3.tuple.Pair;import org.testcontainers.containers.PostgreSQLContainer;import java.io.IOException;import java.io.InputStream;import java.net.URL;import java.util.Properties;public class Loader { + + public static Pair loadDefaultEbeanConfigWithPostgresSettings( + PostgreSQLContainer container) throws IOException { + final URL ebeanRes = Resources.getResource("database-test.properties"); + final URL hikariRes = Resources.getResource("hikari.properties"); + final Properties ebeanConnectionProperties = new Properties(); + final Properties hikariConnectionProperties = new Properties(); + try (InputStream ebeanInputStream = ebeanRes.openStream(); + InputStream hikariInputStream = hikariRes.openStream()) { + hikariConnectionProperties.load(hikariInputStream); + hikariConnectionProperties.put("username", container.getUsername()); + hikariConnectionProperties.put("password", container.getPassword()); + hikariConnectionProperties.put("jdbcUrl", container.getJdbcUrl()); + + ebeanConnectionProperties.load(ebeanInputStream); + ebeanConnectionProperties.put("datasource_db_username", container.getUsername()); + ebeanConnectionProperties.put("datasource_db_password", container.getPassword()); + ebeanConnectionProperties.put("datasource_db_databaseUrl", container.getJdbcUrl()); + ebeanConnectionProperties.put("datasource_db_databaseDriver", "org.postgresql.Driver"); + } + return Pair.of(ebeanConnectionProperties, hikariConnectionProperties); + } + + public static Database connectToDatabase( + Properties ebean, + Properties hikari + ) { + final HikariDataSource hikariDataSource = + new HikariDataSource(new HikariConfig(hikari)); + final DatabaseConfig databaseConfig = new DatabaseConfig(); + databaseConfig.loadFromProperties(ebean); + databaseConfig.setDataSource(hikariDataSource); + + return DatabaseFactory.create(databaseConfig); + } + + public static Database connectToDatabase(Pair connectionProperties) { + return connectToDatabase(connectionProperties.getLeft(), connectionProperties.getRight()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/helper/TestConfig.java b/src/test/java/com/github/polpetta/mezzotre/helper/TestConfig.java new file mode 100644 index 0000000..e0e642f --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/helper/TestConfig.java @@ -0,0 +1,5 @@ +package com.github.polpetta.mezzotre.helper; + +public class TestConfig { + public static final String POSTGRES_DOCKER_IMAGE = "postgres:13-alpine"; +} diff --git a/src/test/java/com/github/polpetta/mezzotre/orm/model/TgChatIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/orm/model/TgChatIntegrationTest.java new file mode 100644 index 0000000..edf757a --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/orm/model/TgChatIntegrationTest.java @@ -0,0 +1,106 @@ +package com.github.polpetta.mezzotre.orm.model; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.polpetta.mezzotre.helper.Loader; +import com.github.polpetta.mezzotre.helper.TestConfig; +import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.mezzotre.util.Clock; +import com.github.polpetta.types.json.ChatContext; +import io.ebean.Database; +import io.ebean.SqlRow; +import java.sql.Timestamp; +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.postgresql.util.PGobject; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("slow") +@Tag("database") +@Testcontainers +class TgChatIntegrationTest { + + private static ObjectMapper objectMapper; + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + + @BeforeAll + static void beforeAll() { + objectMapper = new ObjectMapper(); + } + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + } + + @Test + void shouldInsertEntryIntoDatabase() throws Exception { + + final ChatContext chatContext = + new ChatContext.ChatContextBuilder() + .withStage("/example") + .withStep(2) + .withPreviousMessageUnixTimestampInSeconds(42) + .build(); + + final TgChat user = new TgChat(1234L, chatContext, "en"); + + user.save(); + final String query = "select * from telegram_chat where id = ?"; + final SqlRow savedChat = database.sqlQuery(query).setParameter(1234L).findOne(); + + assertNotNull(savedChat); + assertEquals(1234L, savedChat.getLong("id")); + assertEquals("en", savedChat.getString("locale")); + assertNotNull(savedChat.get("chat_context")); + assertEquals("jsonb", ((PGobject) savedChat.get("chat_context")).getType()); + assertEquals( + chatContext, + objectMapper.readValue( + ((PGobject) savedChat.get("chat_context")).getValue(), ChatContext.class)); + } + + @Test + void shouldRetrieveEntryInDatabase() throws Exception { + + final Timestamp timestampFromUnixEpoch = Clock.getTimestampFromUnixEpoch(1L); + final ChatContext chatContext = + new ChatContext.ChatContextBuilder() + .withStage("/start") + .withStep(1) + .withPreviousMessageUnixTimestampInSeconds(42) + .build(); + + final String insertQuery = "insert into telegram_chat values (?, ?::jsonb, ?, ?, ?)"; + final int affectedRows = + database + .sqlUpdate(insertQuery) + .setParameter(1234L) + .setParameter(objectMapper.writeValueAsString(chatContext)) + .setParameter("en-US") + .setParameter(timestampFromUnixEpoch) + .setParameter(timestampFromUnixEpoch) + .execute(); + + assertEquals(1, affectedRows); + + final TgChat got = new QTgChat().id.eq(1234L).findOne(); + assertNotNull(got); + final ChatContext gotChatContext = got.getChatContext(); + assertNotNull(gotChatContext); + assertEquals("/start", gotChatContext.getStage()); + assertEquals(1, gotChatContext.getStep()); + assertEquals(42, gotChatContext.getPreviousMessageUnixTimestampInSeconds()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/orm/model/UserIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/orm/model/UserIntegrationTest.java new file mode 100644 index 0000000..197b3d4 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/orm/model/UserIntegrationTest.java @@ -0,0 +1,73 @@ +package com.github.polpetta.mezzotre.orm.model; + +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.query.QUser; +import com.github.polpetta.mezzotre.util.Clock; +import io.ebean.Database; +import io.ebean.SqlRow; +import java.sql.Timestamp; +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 +public class UserIntegrationTest { + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + } + + @Test + void shouldInsertEntryIntoDatabase() { + + final User user = new User("1234", "example@example.com", true, null); + + user.save(); + final String query = "select * from registered_user where id = ?"; + final SqlRow savedUser = database.sqlQuery(query).setParameter("1234").findOne(); + + assertNotNull(savedUser); + assertEquals("1234", savedUser.getString("id")); + } + + @Test + void shouldRetrieveEntryInDatabase() { + + final Timestamp timestampFromUnixEpoch = Clock.getTimestampFromUnixEpoch(1L); + + final String insertQuery = + "insert into registered_user (id, is_active, telegram_id, email_address, entry_created, entry_modified) values (?, ?, null, ?, ?, ?)"; + final int affectedRows = + database + .sqlUpdate(insertQuery) + .setParameter("id123") + .setParameter(true) + .setParameter("example@example.com") + .setParameter(timestampFromUnixEpoch) + .setParameter(timestampFromUnixEpoch) + .execute(); + + assertEquals(1, affectedRows); + + final User id123 = new QUser().id.eq("id123").findOne(); + assertNotNull(id123); + assertTrue(id123.isActive()); + assertNull(id123.getTelegramId()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/route/TelegramIntegrationTest.java b/src/test/java/com/github/polpetta/mezzotre/route/TelegramIntegrationTest.java new file mode 100644 index 0000000..37e564b --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/route/TelegramIntegrationTest.java @@ -0,0 +1,109 @@ +package com.github.polpetta.mezzotre.route; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any;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.TgChat; +import com.github.polpetta.mezzotre.telegram.command.Router; +import com.github.polpetta.mezzotre.util.UUIDGenerator; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.SendMessage; +import com.pengrad.telegrambot.response.SendResponse; +import io.ebean.Database; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import io.jooby.Context;import io.jooby.MediaType;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") +@Testcontainers +class TelegramIntegrationTest { + + private static Gson gson; + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private Database database; + private Telegram telegram; + private TelegramBot fakeTelegramBot; + private Router fakeRouter; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + } + + @BeforeEach + void setUp() throws Exception { + database = + Loader.connectToDatabase(Loader.loadDefaultEbeanConfigWithPostgresSettings(postgresServer)); + + fakeTelegramBot = mock(TelegramBot.class); + fakeRouter = mock(Router.class); + + telegram = + new Telegram( + LoggerFactory.getLogger(getClass()), + fakeTelegramBot, + new GsonBuilder().setPrettyPrinting().create(), + Executors.newSingleThreadExecutor(), + new UUIDGenerator(), + fakeRouter); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void shouldReceiveAValidTelegramMessageUpdateAsResponse() throws Exception { + + final SendMessage expectedBaseRequest = new SendMessage(1111111, "Hello world"); + when(fakeRouter.process(any(TgChat.class), any(Update.class))) + .thenReturn(CompletableFuture.completedFuture(Optional.of(expectedBaseRequest))); + + final SendResponse fakeResponse = mock(SendResponse.class); + when(fakeResponse.isOk()).thenReturn(true); + when(fakeTelegramBot.execute(any(SendMessage.class))).thenReturn(fakeResponse); + final Context fakeContext = mock(Context.class); + 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\":\"/start\"\n" + + "}\n" + + "}", + Update.class); + final CompletableFuture integerCompletableFuture = telegram.incomingUpdate(fakeContext, update); + verify(fakeContext, times(1)).setResponseType(MediaType.JSON); + final String gotReply = integerCompletableFuture.get(); + assertDoesNotThrow(() -> gotReply); + assertEquals(expectedBaseRequest.toWebhookResponse(), gotReply); + } +} 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 new file mode 100644 index 0000000..4dd182f --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/RouterTest.java @@ -0,0 +1,126 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.SendMessage; +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; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Execution(ExecutionMode.CONCURRENT) +class RouterTest { + + private static Executor dummyEmptyExampleExecutor; + private static Executor anotherKeyWithResultExecutor; + private static Gson gson; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + + dummyEmptyExampleExecutor = mock(Executor.class); + when(dummyEmptyExampleExecutor.getTriggerKeyword()).thenReturn("/example"); + when(dummyEmptyExampleExecutor.process(any(), any())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + anotherKeyWithResultExecutor = mock(Executor.class); + when(anotherKeyWithResultExecutor.getTriggerKeyword()).thenReturn("/anotherExample"); + when(anotherKeyWithResultExecutor.process(any(), any())) + .thenReturn( + CompletableFuture.completedFuture(Optional.of(new SendMessage(1234L, "hello world")))); + } + + @Test + void shouldMessageExampleMessageAndGetEmptyOptional() throws Exception { + final Router router = + new Router(Set.of(dummyEmptyExampleExecutor), Executors.newSingleThreadExecutor()); + final TgChat fakeChat = mock(TgChat.class); + when(fakeChat.getChatContext()).thenReturn(new ChatContext()); + 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\":\"/example\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotExecution = + router.process(fakeChat, update); + assertDoesNotThrow(() -> gotExecution.get()); + final Optional> gotRequest = gotExecution.get(); + assertTrue(gotRequest.isEmpty()); + } + + @Test + void shouldSelectRightExecutorAndReturnResult() throws Exception { + final Router router = + new Router( + Set.of(dummyEmptyExampleExecutor, anotherKeyWithResultExecutor), + Executors.newSingleThreadExecutor()); + final TgChat fakeChat = mock(TgChat.class); + when(fakeChat.getChatContext()).thenReturn(new ChatContext()); + 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\":\"/anotherExample\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotExecution = + router.process(fakeChat, update); + assertDoesNotThrow(() -> gotExecution.get()); + final Optional> gotRequestOpt = gotExecution.get(); + assertDoesNotThrow(gotRequestOpt::get); + final BaseRequest gotMessage = gotRequestOpt.get(); + assertInstanceOf(SendMessage.class, gotMessage); + final String message = (String) gotMessage.getParameters().get("text"); + assertEquals("hello world", message); + assertEquals(1234L, (Long) gotMessage.getParameters().get("chat_id")); + } +} 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 new file mode 100644 index 0000000..4a3691a --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartIntegrationTest.java @@ -0,0 +1,121 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import static org.junit.jupiter.api.Assertions.*; +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.orm.model.TgChat; +import com.github.polpetta.mezzotre.orm.model.query.QTgChat; +import com.github.polpetta.types.json.ChatContext; +import com.google.gson.Gson; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.request.BaseRequest; +import com.pengrad.telegrambot.request.SendMessage; +import io.ebean.Database; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +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; +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 StartIntegrationTest { + + private static Gson gson; + + @Container + private final PostgreSQLContainer postgresServer = + new PostgreSQLContainer<>(TestConfig.POSTGRES_DOCKER_IMAGE); + + private VelocityEngine velocityEngine; + private LocalizedMessageFactory localizedMessageFactory; + private Start start; + private Database database; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + } + + @BeforeEach + 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); + + final Logger log = LoggerFactory.getLogger(Start.class); + + start = new Start(localizedMessageFactory, Executors.newSingleThreadExecutor(), log); + } + + @Test + void shouldUpdateContextInTheDatabase() throws Exception { + final TgChat tgChat = new TgChat(1111111L, new ChatContext()); + tgChat.save(); + + 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\":\"/start\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotFuture = start.process(tgChat, update); + assertDoesNotThrow(() -> gotFuture.get()); + final Optional> gotMessageOptional = gotFuture.get(); + assertDoesNotThrow(gotMessageOptional::get); + final BaseRequest gotMessage = gotMessageOptional.get(); + assertInstanceOf(SendMessage.class, gotMessage); + final String message = (String) gotMessage.getParameters().get("text"); + assertEquals( + "**Hello Test Firstname! \uD83D\uDC4B**\n\nThis is _Mezzotre_, a simple bot focused on DnD content management! Please start by choosing a language down below. \uD83D\uDC47", + message); + assertEquals(1111111L, (Long) gotMessage.getParameters().get("chat_id")); + + final TgChat retrievedTgChat = new QTgChat().id.eq(1111111L).findOne(); + assertNotNull(retrievedTgChat); + final ChatContext gotChatContext = retrievedTgChat.getChatContext(); + assertEquals(1441645532, gotChatContext.getPreviousMessageUnixTimestampInSeconds()); + assertEquals(1365, gotChatContext.getLastMessageSentId()); + assertEquals("/start", gotChatContext.getStage()); + assertEquals(0, gotChatContext.getStep()); + } +} diff --git a/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartTest.java b/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartTest.java new file mode 100644 index 0000000..6f80312 --- /dev/null +++ b/src/test/java/com/github/polpetta/mezzotre/telegram/command/StartTest.java @@ -0,0 +1,134 @@ +package com.github.polpetta.mezzotre.telegram.command; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.github.polpetta.mezzotre.i18n.LocalizedMessageFactory; +import com.github.polpetta.mezzotre.orm.model.TgChat; +import com.github.polpetta.types.json.ChatContext; +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.Optional; +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;import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.slf4j.Logger; + +@Tag("velocity") +@Execution(ExecutionMode.CONCURRENT) +class StartTest { + + private VelocityEngine velocityEngine; + private LocalizedMessageFactory localizedMessageFactory; + private Start start; + private static Gson gson; + + @BeforeAll + static void beforeAll() { + gson = new Gson(); + }@BeforeEach + void setUp() { + velocityEngine = new VelocityEngine(); + velocityEngine.setProperty(RuntimeConstants.RESOURCE_LOADERS, "classpath"); + velocityEngine.setProperty( + "resource.loader.classpath.class", ClasspathResourceLoader.class.getName()); + velocityEngine.init(); + localizedMessageFactory = new LocalizedMessageFactory(velocityEngine); + + final Logger fakeLog = mock(Logger.class); + + start = new Start(localizedMessageFactory, Executors.newSingleThreadExecutor(), fakeLog); + } + + @Test + void shouldReceiveHelloIntroduction() throws Exception { + final TgChat fakeChat = mock(TgChat.class); + when(fakeChat.getLocale()).thenReturn("en"); + when(fakeChat.getChatContext()).thenReturn(new ChatContext()); + when(fakeChat.getId()).thenReturn(1111111L); + + 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\":\"/start\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotFuture = + start.process(fakeChat, update); + assertDoesNotThrow(() -> gotFuture.get()); + final Optional> gotMessageOptional = gotFuture.get(); + assertDoesNotThrow(gotMessageOptional::get); + final BaseRequest gotMessage = gotMessageOptional.get(); + assertInstanceOf(SendMessage.class, gotMessage); + final String message = (String) gotMessage.getParameters().get("text"); + assertEquals( + "**Hello Test Firstname! \uD83D\uDC4B**\n\nThis is _Mezzotre_, a simple bot focused on DnD content management! Please start by choosing a language down below. \uD83D\uDC47", + message); + assertEquals(1111111L, (Long) gotMessage.getParameters().get("chat_id")); + verify(fakeChat, times(1)).save(); + } + + @Test + void shouldThrowErrorIfLocaleNonExists() { + final TgChat fakeChat = mock(TgChat.class); + // Do not set Locale on purpose + when(fakeChat.getId()).thenReturn(1111111L); + + 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\":\"/start\"\n" + + "}\n" + + "}", + Update.class); + + final CompletableFuture>> gotFuture = + start.process(fakeChat, update); + assertThrows(ExecutionException.class, gotFuture::get); + } +} diff --git a/src/test/resources/database-test.properties b/src/test/resources/database-test.properties new file mode 100644 index 0000000..463e40a --- /dev/null +++ b/src/test/resources/database-test.properties @@ -0,0 +1,11 @@ +# general ebean properties +ebean.db.ddl.generate=true +ebean.db.ddl.run=true + +ebean.db.generateMapping=true +ebean.db.dropCreate=true +ebean.db.create=true + +#uncomment the following line if you, want to setup 'V' as prefix in your migration version string +ebean.db.migration.applyPrefix=V +ebean.db.migration.run=true diff --git a/src/test/resources/hikari.properties b/src/test/resources/hikari.properties new file mode 100644 index 0000000..e69de29