From c9e81e03ad6afe47bdee4564414038759b1fc3e6 Mon Sep 17 00:00:00 2001 From: Hunter Jackson Date: Wed, 20 Dec 2023 13:31:02 -0500 Subject: [PATCH] finish migration to the multi service per path model --- src/main/java/com/meta/cp4m/Service.java | 17 - .../meta/cp4m/message/FBMessageHandler.java | 315 +++------ .../com/meta/cp4m/message/MessageHandler.java | 15 - .../meta/cp4m/message/WAMessageHandler.java | 82 +-- .../cp4m/message/FBMessageHandlerTest.java | 658 +++++++++--------- 5 files changed, 459 insertions(+), 628 deletions(-) diff --git a/src/main/java/com/meta/cp4m/Service.java b/src/main/java/com/meta/cp4m/Service.java index eadd21b..b085567 100644 --- a/src/main/java/com/meta/cp4m/Service.java +++ b/src/main/java/com/meta/cp4m/Service.java @@ -13,17 +13,13 @@ import com.meta.cp4m.message.MessageHandler; import com.meta.cp4m.message.RequestProcessor; import com.meta.cp4m.message.ThreadState; -import com.meta.cp4m.routing.Acceptor; -import com.meta.cp4m.routing.Handler; import com.meta.cp4m.routing.Route; import com.meta.cp4m.store.ChatStore; -import io.javalin.Javalin; import io.javalin.http.Context; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.concurrent.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,15 +42,6 @@ public Service( this.path = path; } - void handle(Context ctx) { - List messages = handler.processRequest(ctx); - // TODO: once we have a non-volatile store, on startup send stored but not replied to messages - for (T m : messages) { - ThreadState thread = store.add(m); - executorService.submit(() -> execute(thread)); - } - } - void handler(Context ctx, IN in, RequestProcessor processor) { List messages = null; try { @@ -74,10 +61,6 @@ void handler(Context ctx, IN in, RequestProcessor processor) { } } - public void register(Javalin app) { - handler.handlers().forEach(m -> app.addHandler(m, path, this::handle)); - } - public String path() { return path; } diff --git a/src/main/java/com/meta/cp4m/message/FBMessageHandler.java b/src/main/java/com/meta/cp4m/message/FBMessageHandler.java index d2e5d49..2d3704b 100644 --- a/src/main/java/com/meta/cp4m/message/FBMessageHandler.java +++ b/src/main/java/com/meta/cp4m/message/FBMessageHandler.java @@ -9,7 +9,6 @@ package com.meta.cp4m.message; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -17,14 +16,12 @@ import io.javalin.http.BadRequestResponse; import io.javalin.http.Context; import io.javalin.http.HandlerType; - import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.time.Instant; import java.util.*; import java.util.function.Function; - import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.client5.http.fluent.Response; import org.apache.hc.core5.http.ContentType; @@ -38,166 +35,66 @@ public class FBMessageHandler implements MessageHandler { - private static final String API_VERSION = "v17.0"; - private static final JsonMapper MAPPER = new JsonMapper(); - private static final Logger LOGGER = LoggerFactory.getLogger(FBMessageHandler.class); - private static final TextChunker CHUNKER = TextChunker.standard(2000); - - private final String verifyToken; - private final String appSecret; - - private final String accessToken; - private final @Nullable String connectedFacebookPageForInstagram; - - private final Deduplicator messageDeduplicator = new Deduplicator<>(10_000); - private Function baseURLFactory = - pageId -> { - try { - return new URIBuilder() - .setScheme("https") - .setHost("graph.facebook.com") - .appendPath(API_VERSION) - .appendPath(pageId.toString()) - .appendPath("messages") - .build(); - } catch (URISyntaxException e) { - // this should be impossible - throw new RuntimeException(e); - } - }; - - public FBMessageHandler(String verifyToken, String pageAccessToken, String appSecret, @Nullable String connectedFacebookPageForInstagram) { - this.verifyToken = verifyToken; - this.appSecret = appSecret; - this.accessToken = pageAccessToken; - this.connectedFacebookPageForInstagram = connectedFacebookPageForInstagram; - } + private static final String API_VERSION = "v17.0"; + private static final JsonMapper MAPPER = new JsonMapper(); + private static final Logger LOGGER = LoggerFactory.getLogger(FBMessageHandler.class); + private static final TextChunker CHUNKER = TextChunker.standard(2000); - public FBMessageHandler(String verifyToken, String pageAccessToken, String appSecret) { - this.verifyToken = verifyToken; - this.appSecret = appSecret; - this.accessToken = pageAccessToken; - this.connectedFacebookPageForInstagram = null; - } + private final String verifyToken; + private final String appSecret; - FBMessageHandler(FBMessengerConfig config) { - this.verifyToken = config.verifyToken(); - this.appSecret = config.appSecret(); - this.accessToken = config.pageAccessToken(); - this.connectedFacebookPageForInstagram = config.connectedFacebookPageForInstagram().isPresent() ? config.connectedFacebookPageForInstagram().get() : null; - } + private final String accessToken; + private final @Nullable String connectedFacebookPageForInstagram; - @Override - public List processRequest(Context ctx) { + private final Deduplicator messageDeduplicator = new Deduplicator<>(10_000); + private Function baseURLFactory = + pageId -> { try { - switch (ctx.handlerType()) { - case GET -> { - return getHandler(ctx); - } - case POST -> { - return postHandler(ctx); - } - } - } catch (JsonProcessingException | NullPointerException e) { - LOGGER - .atWarn() - .setMessage("Unable to parse message from Meta webhook") - .setCause(e) - .addKeyValue("body", ctx.body()) - .addKeyValue("headers", ctx.headerMap()) - .log(); - throw new BadRequestResponse("Invalid body"); - } catch (RuntimeException e) { - LOGGER.error(e.getMessage(), e); - throw e; - } catch (Exception e) { - LOGGER.error(e.getMessage(), e); - throw new RuntimeException(e); - } - throw new UnsupportedOperationException("Only accepting get and post methods"); - } - - private List getHandler(Context ctx) { - MetaHandlerUtils.subscriptionVerification(ctx, verifyToken); - LOGGER.debug("Meta verified callback url successfully"); - return Collections.emptyList(); - } - - @TestOnly - String hmac(String body) { - // TODO: refactor test so we don't need this - return MetaHandlerUtils.hmac(body, appSecret); - } - - private List postHandler(Context ctx) throws JsonProcessingException { - MetaHandlerUtils.postHeaderValidator(ctx, appSecret); - - String bodyString = ctx.body(); - JsonNode body = MAPPER.readTree(bodyString); - String object = body.get("object").textValue(); - if (!object.equals("page") && !object.equals("instagram")) { - LOGGER - .atWarn() - .setMessage("received body with value of " + object + " for 'object', expected 'page' or 'instagram'") - .addKeyValue("body", bodyString) - .log(); - return Collections.emptyList(); + return new URIBuilder() + .setScheme("https") + .setHost("graph.facebook.com") + .appendPath(API_VERSION) + .appendPath(pageId.toString()) + .appendPath("messages") + .build(); + } catch (URISyntaxException e) { + // this should be impossible + throw new RuntimeException(e); } - // TODO: need better validation - JsonNode entries = body.get("entry"); - ArrayList output = new ArrayList<>(); - for (JsonNode entry : entries) { - @Nullable JsonNode messaging = entry.get("messaging"); - if (messaging == null) { - continue; - } - for (JsonNode message : messaging) { - - Identifier senderId = Identifier.from(message.get("sender").get("id").asLong()); - Identifier recipientId = Identifier.from(message.get("recipient").get("id").asLong()); - Instant timestamp = Instant.ofEpochMilli(message.get("timestamp").asLong()); - @Nullable JsonNode messageObject = message.get("message"); - if (messageObject != null) { - if (messageObject.has("is_echo") && messageObject.get("is_echo").asText().equals("true")) { - return Collections.emptyList(); - } + }; + + public FBMessageHandler( + String verifyToken, + String pageAccessToken, + String appSecret, + @Nullable String connectedFacebookPageForInstagram) { + this.verifyToken = verifyToken; + this.appSecret = appSecret; + this.accessToken = pageAccessToken; + this.connectedFacebookPageForInstagram = connectedFacebookPageForInstagram; + } - // https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messages - Identifier messageId = Identifier.from(messageObject.get("mid").textValue()); - if (messageDeduplicator.addAndGetIsDuplicate(messageId)) { - continue; - } + public FBMessageHandler(String verifyToken, String pageAccessToken, String appSecret) { + this.verifyToken = verifyToken; + this.appSecret = appSecret; + this.accessToken = pageAccessToken; + this.connectedFacebookPageForInstagram = null; + } - @Nullable JsonNode textObject = messageObject.get("text"); - if (textObject != null && textObject.isTextual()) { - FBMessage m = - new FBMessage( - timestamp, - messageId, - senderId, - recipientId, - textObject.textValue(), - Message.Role.USER); - output.add(m); - } else { - LOGGER - .atWarn() - .setMessage("received message without text, unable to handle this") - .addKeyValue("body", bodyString) - .log(); - } - } else { - LOGGER - .atWarn() - .setMessage( - "received a message without a 'message' key, unable to handle this message type") - .addKeyValue("body", bodyString) - .log(); - } - } - } + FBMessageHandler(FBMessengerConfig config) { + this.verifyToken = config.verifyToken(); + this.appSecret = config.appSecret(); + this.accessToken = config.pageAccessToken(); + this.connectedFacebookPageForInstagram = + config.connectedFacebookPageForInstagram().isPresent() + ? config.connectedFacebookPageForInstagram().get() + : null; + } - return output; + @TestOnly + String hmac(String body) { + // TODO: refactor test so we don't need this + return MetaHandlerUtils.hmac(body, appSecret); } private List postHandler(Context ctx, JsonNode body) { @@ -215,6 +112,11 @@ private List postHandler(Context ctx, JsonNode body) { Instant timestamp = Instant.ofEpochMilli(message.get("timestamp").asLong()); @Nullable JsonNode messageObject = message.get("message"); if (messageObject != null) { + if (messageObject.has("is_echo") + && messageObject.get("is_echo").asText().equals("true")) { + return Collections.emptyList(); + } + // https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messages Identifier messageId = Identifier.from(messageObject.get("mid").textValue()); if (messageDeduplicator.addAndGetIsDuplicate(messageId)) { @@ -250,63 +152,62 @@ private List postHandler(Context ctx, JsonNode body) { } } - return output; - } + return output; + } - @TestOnly - public @This FBMessageHandler baseURLFactory(Function baseURLFactory) { - this.baseURLFactory = Objects.requireNonNull(baseURLFactory); - return this; - } + @TestOnly + public @This FBMessageHandler baseURLFactory(Function baseURLFactory) { + this.baseURLFactory = Objects.requireNonNull(baseURLFactory); + return this; + } - @Override - public void respond(FBMessage message) throws IOException { - List chunkedText = CHUNKER.chunks(message.message()).toList(); - for (String text : chunkedText) { - send(text, message.recipientId(), message.senderId()); - } + @Override + public void respond(FBMessage message) throws IOException { + List chunkedText = CHUNKER.chunks(message.message()).toList(); + for (String text : chunkedText) { + send(text, message.recipientId(), message.senderId()); } + } - private void send(String message, Identifier recipient, Identifier sender) throws IOException { - URI url; - ObjectNode body = MAPPER.createObjectNode(); - body.put("messaging_type", "RESPONSE").putObject("recipient").put("id", recipient.toString()); - body.putObject("message").put("text", message); - String bodyString; - try { - bodyString = MAPPER.writeValueAsString(body); - url = - new URIBuilder(baseURLFactory.apply(connectedFacebookPageForInstagram == null ? sender : Identifier.from(connectedFacebookPageForInstagram))) - .addParameter("access_token", accessToken) - .build(); - } catch (JsonProcessingException | URISyntaxException e) { - // should be impossible - throw new RuntimeException(e); - } - - Response response = - Request.post(url).bodyString(bodyString, ContentType.APPLICATION_JSON).execute(); - HttpResponse responseContent = response.returnResponse(); - if (responseContent.getCode() != 200) { - String errorMessage = - "received a " - + responseContent.getCode() - + " error code when attempting to reply. " - + responseContent.getReasonPhrase(); - - LOGGER.atError().addKeyValue("body", bodyString).setMessage(errorMessage).log(); - throw new IOException( - "received a " - + responseContent.getCode() - + " error code when attempting to reply. " - + responseContent.getReasonPhrase()); - } + private void send(String message, Identifier recipient, Identifier sender) throws IOException { + URI url; + ObjectNode body = MAPPER.createObjectNode(); + body.put("messaging_type", "RESPONSE").putObject("recipient").put("id", recipient.toString()); + body.putObject("message").put("text", message); + String bodyString; + try { + bodyString = MAPPER.writeValueAsString(body); + url = + new URIBuilder( + baseURLFactory.apply( + connectedFacebookPageForInstagram == null + ? sender + : Identifier.from(connectedFacebookPageForInstagram))) + .addParameter("access_token", accessToken) + .build(); + } catch (JsonProcessingException | URISyntaxException e) { + // should be impossible + throw new RuntimeException(e); } - @Override - public Collection handlers() { - return List.of(HandlerType.GET, HandlerType.POST); + Response response = + Request.post(url).bodyString(bodyString, ContentType.APPLICATION_JSON).execute(); + HttpResponse responseContent = response.returnResponse(); + if (responseContent.getCode() != 200) { + String errorMessage = + "received a " + + responseContent.getCode() + + " error code when attempting to reply. " + + responseContent.getReasonPhrase(); + + LOGGER.atError().addKeyValue("body", bodyString).setMessage(errorMessage).log(); + throw new IOException( + "received a " + + responseContent.getCode() + + " error code when attempting to reply. " + + responseContent.getReasonPhrase()); } + } @Override public List> routeDetails() { @@ -325,8 +226,10 @@ public List> routeDetails() { throw new BadRequestResponse("unable to parse body"); } // TODO: need better validation + String expectedObjectValue = + connectedFacebookPageForInstagram == null ? "page" : "instagram"; @Nullable JsonNode objectNode = body.get("object"); - if (objectNode != null && objectNode.textValue().equals("page")) { + if (objectNode != null && objectNode.textValue().equals(expectedObjectValue)) { return Optional.of(body); } } diff --git a/src/main/java/com/meta/cp4m/message/MessageHandler.java b/src/main/java/com/meta/cp4m/message/MessageHandler.java index 1ab16ee..ececbbf 100644 --- a/src/main/java/com/meta/cp4m/message/MessageHandler.java +++ b/src/main/java/com/meta/cp4m/message/MessageHandler.java @@ -9,24 +9,14 @@ package com.meta.cp4m.message; import com.meta.cp4m.routing.Acceptor; -import io.javalin.http.Context; import io.javalin.http.HandlerType; import java.io.IOException; -import java.util.Collection; import java.util.List; public interface MessageHandler { record RouteDetails( HandlerType handlerType, Acceptor acceptor, RequestProcessor requestProcessor) {} - /** - * Process incoming requests from the messaging service, including messages from the user. - * - * @param ctx the context corresponding to an incoming request - * @return return a {@link Message} object if appropriate - */ - List processRequest(Context ctx); - /** * The method needed to respond to a message from a user * @@ -34,10 +24,5 @@ record RouteDetails( */ void respond(T message) throws IOException; - /** - * @return The different {@link HandlerType}s that this handler expects to receive - */ - Collection handlers(); - List> routeDetails(); } diff --git a/src/main/java/com/meta/cp4m/message/WAMessageHandler.java b/src/main/java/com/meta/cp4m/message/WAMessageHandler.java index 17217de..00f5da5 100644 --- a/src/main/java/com/meta/cp4m/message/WAMessageHandler.java +++ b/src/main/java/com/meta/cp4m/message/WAMessageHandler.java @@ -76,30 +76,6 @@ public WAMessageHandler(WAMessengerConfig config) { this.appSecret = config.appSecret(); } - @Override - public List processRequest(Context ctx) { - - try { - switch (ctx.handlerType()) { - case GET -> { - MetaHandlerUtils.subscriptionVerification(ctx, verifyToken); - LOGGER.debug("Meta verified callback url successfully"); - return Collections.emptyList(); - } - case POST -> { - return postHandler(ctx); - } - } - } catch (RuntimeException e) { - LOGGER.error(e.getMessage(), e); - throw e; - } catch (Exception e) { - LOGGER.error(e.getMessage(), e); - throw new RuntimeException(e); - } - throw new UnsupportedOperationException("Only accepting get and post methods"); - } - private List post(Context ctx, WebhookPayload payload) { List waMessages = new ArrayList<>(); payload.entry().stream() @@ -133,55 +109,6 @@ private List post(Context ctx, WebhookPayload payload) { return waMessages; } - List postHandler(Context ctx) { - MetaHandlerUtils.postHeaderValidator(ctx, appSecret); - String bodyString = ctx.body(); - WebhookPayload payload; - try { - payload = MAPPER.readValue(bodyString, WebhookPayload.class); - } catch (Exception e) { - LOGGER - .atWarn() - .addKeyValue("body", bodyString) - .setMessage( - "unable to process message, server may be subscribed to a 'webhook field' it cannot process") - .log(); - throw new RuntimeException(e); - } - - List waMessages = new ArrayList<>(); - payload.entry().stream() - .flatMap(e -> e.changes().stream()) - .forEach( - change -> { - Identifier phoneNumberId = change.value().metadata().phoneNumberId(); - for (WebhookMessage message : change.value().messages()) { - if (messageDeduplicator.addAndGetIsDuplicate(message.id())) { - continue; // message is a duplicate - } - if (message.type() != WebhookMessage.WebhookMessageType.TEXT) { - LOGGER.warn( - "received message of type '" - + message.type() - + "', only able to handle text messages at this time"); - continue; - } - TextWebhookMessage textMessage = (TextWebhookMessage) message; - waMessages.add( - new WAMessage( - message.timestamp(), - message.id(), - message.from(), - phoneNumberId, - textMessage.text().body(), - Message.Role.USER)); - readExecutor.execute(() -> markRead(phoneNumberId, textMessage.id().toString())); - } - }); - - return waMessages; - } - @TestOnly @This WAMessageHandler baseUrlFactory(Function baseURLFactory) { @@ -213,11 +140,6 @@ private void send(Identifier recipient, Identifier sender, String text) throws I .execute(); } - @Override - public Collection handlers() { - return List.of(HandlerType.GET, HandlerType.POST); - } - @Override public List> routeDetails() { RouteDetails postDetails = @@ -225,8 +147,8 @@ public List> routeDetails() { HandlerType.POST, ctx -> { @Nullable String contentType = ctx.contentType(); - if (contentType != null && - ContentType.parse(contentType).isSameMimeType(ContentType.APPLICATION_JSON) + if (contentType != null + && ContentType.parse(contentType).isSameMimeType(ContentType.APPLICATION_JSON) && MetaHandlerUtils.postHeaderValid(ctx, appSecret)) { String bodyString = ctx.body(); WebhookPayload payload; diff --git a/src/test/java/com/meta/cp4m/message/FBMessageHandlerTest.java b/src/test/java/com/meta/cp4m/message/FBMessageHandlerTest.java index 9c8d2ce..7bcd33e 100644 --- a/src/test/java/com/meta/cp4m/message/FBMessageHandlerTest.java +++ b/src/test/java/com/meta/cp4m/message/FBMessageHandlerTest.java @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import com.meta.cp4m.Identifier; import com.meta.cp4m.Service; @@ -25,7 +26,6 @@ import com.meta.cp4m.store.MemoryStoreConfig; import io.javalin.Javalin; import io.javalin.http.HandlerType; - import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -39,7 +39,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; - import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.client5.http.fluent.Response; import org.apache.hc.core5.http.ContentType; @@ -53,14 +52,14 @@ import org.junit.jupiter.params.provider.MethodSource; public class FBMessageHandlerTest { - /** - * Example message collected directly from the messenger webhook - */ - public static final String SAMPLE_MESSAGE = - "{\"object\":\"page\",\"entry\":[{\"id\":\"106195825075770\",\"time\":1692813219204,\"messaging\":[{\"sender\":{\"id\":\"6357858494326947\"},\"recipient\":{\"id\":\"106195825075770\"},\"timestamp\":1692813218705,\"message\":{\"mid\":\"m_kT_mWOSYh_eK3kF8chtyCWfcD9-gomvu4mhaMFQl-gt4D3LjORi6k3BXD6_x9a-FOUt-D2LFuywJN6HfrpAnDg\",\"text\":\"asdfa\"}}]}]}"; - public static final String SAMPLE_IG_MESSAGE = - "{\"object\":\"instagram\",\"entry\":[{\"time\":1692813219204,\"id\":\"106195825075770\",\"messaging\":[{\"sender\":{\"id\":\"6357858494326947\"},\"recipient\":{\"id\":\"106195825075770\"},\"timestamp\":1692813218705,\"message\":{\"mid\":\"m_kT_mWOSYh_eK3kF8chtyCWfcD9-gomvu4mhaMFQl-gt4D3LjORi6k3BXD6_x9a-FOUt-D2LFuywJN6HfrpAnDg\",\"text\":\"asdfa\"}}]}]}"; - public static final String SAMPLE_IG_MESSAGE_ECHO = "{\"object\":\"instagram\",\"entry\":[{\"time\":1700539232059,\"id\":\"90010138419114\",\"messaging\":[{\"sender\":{\"id\":\"90010138419114\"},\"recipient\":{\"id\":\"7069898263033409\"},\"timestamp\":1700539231795,\"message\":{\"mid\":\"aWdfZAG1faXRlbToxOklHTWVzc2FnZAUlEOjkwMDEwMTM4NDE5MTE0OjM0MDI4MjM2Njg0MTcxMDMwMTI0NDI1OTQ1NTA3ODk2MDQ3NzQ1MDozMTM2OTQxMTk5NjIzMzQ1ODM2OTgxMjI5OTgwNTM2MDEyOAZDZD\",\"text\":\"This is a reply\",\"is_echo\":true}}]}]}"; + /** Example message collected directly from the messenger webhook */ + public static final String SAMPLE_MESSAGE = + "{\"object\":\"page\",\"entry\":[{\"id\":\"106195825075770\",\"time\":1692813219204,\"messaging\":[{\"sender\":{\"id\":\"6357858494326947\"},\"recipient\":{\"id\":\"106195825075770\"},\"timestamp\":1692813218705,\"message\":{\"mid\":\"m_kT_mWOSYh_eK3kF8chtyCWfcD9-gomvu4mhaMFQl-gt4D3LjORi6k3BXD6_x9a-FOUt-D2LFuywJN6HfrpAnDg\",\"text\":\"asdfa\"}}]}]}"; + + public static final String SAMPLE_IG_MESSAGE = + "{\"object\":\"instagram\",\"entry\":[{\"time\":1692813219204,\"id\":\"106195825075770\",\"messaging\":[{\"sender\":{\"id\":\"6357858494326947\"},\"recipient\":{\"id\":\"106195825075770\"},\"timestamp\":1692813218705,\"message\":{\"mid\":\"m_kT_mWOSYh_eK3kF8chtyCWfcD9-gomvu4mhaMFQl-gt4D3LjORi6k3BXD6_x9a-FOUt-D2LFuywJN6HfrpAnDg\",\"text\":\"asdfa\"}}]}]}"; + public static final String SAMPLE_IG_MESSAGE_ECHO = + "{\"object\":\"instagram\",\"entry\":[{\"time\":1700539232059,\"id\":\"90010138419114\",\"messaging\":[{\"sender\":{\"id\":\"90010138419114\"},\"recipient\":{\"id\":\"7069898263033409\"},\"timestamp\":1700539231795,\"message\":{\"mid\":\"aWdfZAG1faXRlbToxOklHTWVzc2FnZAUlEOjkwMDEwMTM4NDE5MTE0OjM0MDI4MjM2Njg0MTcxMDMwMTI0NDI1OTQ1NTA3ODk2MDQ3NzQ1MDozMTM2OTQxMTk5NjIzMzQ1ODM2OTgxMjI5OTgwNTM2MDEyOAZDZD\",\"text\":\"This is a reply\",\"is_echo\":true}}]}]}"; private static final ObjectMapper MAPPER = new ObjectMapper(); private static final String SAMPLE_MESSAGE_HMAC = @@ -78,7 +77,7 @@ public class FBMessageHandlerTest { private Javalin app; private BlockingQueue requests; - private static HttpResponse getRequest(String path, int port, Map params) + private static HttpResponse getRequest(String path, int port, Map params) throws IOException, URISyntaxException { URIBuilder uriBuilder = URIBuilder.loopbackAddress().setScheme("http").setPort(port).appendPath(path); @@ -86,327 +85,366 @@ private static HttpResponse getRequest(String path, int port, Map service = - (Service) runner.services().stream().findAny().orElseThrow(); - String path = service.path(); + private static Request createMessageRequest( + String body, ServicesRunner runner, boolean calculateHmac) + throws IOException, URISyntaxException { + @SuppressWarnings("unchecked") // for the scope of this test this is guaranteed + Service service = + (Service) runner.services().stream().findAny().orElseThrow(); + String path = service.path(); - // for the scope of this test this is guaranteed - FBMessageHandler messageHandler = (FBMessageHandler) service.messageHandler(); + // for the scope of this test this is guaranteed + FBMessageHandler messageHandler = (FBMessageHandler) service.messageHandler(); - URI uri = - URIBuilder.localhost().setScheme("http").setPort(runner.port()).appendPath(path).build(); + URI uri = + URIBuilder.localhost().setScheme("http").setPort(runner.port()).appendPath(path).build(); - Request request = + Request request = Request.post(uri) .bodyString(body, ContentType.APPLICATION_JSON) .setHeader("Content-Type", "application/json"); - if (calculateHmac) { - String hmac = messageHandler.hmac(body); - request.setHeader("X-Hub-Signature-256", "sha256=" + hmac); - } - - return request; + if (calculateHmac) { + String hmac = messageHandler.hmac(body); + request.setHeader("X-Hub-Signature-256", "sha256=" + hmac); } - public static Request createMessageRequest(String body, ServicesRunner runner) - throws IOException, URISyntaxException { - return createMessageRequest(body, runner, true); - } + return request; + } - static Stream requestFactory() throws JsonProcessingException { - JsonNode duplicateMessage = MAPPER.readTree(SAMPLE_MESSAGE); - ArrayNode messagingArray = (ArrayNode) duplicateMessage.get("entry").get(0).get("messaging"); - messagingArray.add(messagingArray.get(0)); - - JsonNode duplicateEntry = MAPPER.readTree(SAMPLE_MESSAGE); - ArrayNode entryArray = (ArrayNode) duplicateMessage.get("entry"); - entryArray.add(entryArray.get(0)); - Stream arguments = - Stream.of( - new TestArgument( - "valid sample", - 200, - r -> - createMessageRequest(SAMPLE_MESSAGE, r, false) - .addHeader("X-Hub-Signature-256", SAMPLE_MESSAGE_HMAC), - true), - new TestArgument( - // if this fails the test hmac calculator is flawed - "valid sample with hmac calculation", - 200, - r -> createMessageRequest(SAMPLE_MESSAGE, r), - true), - new TestArgument( - // if this fails the test hmac calculator is flawed - "test the send it twice scenario", - 200, - r -> createMessageRequest(SAMPLE_MESSAGE, r), - true), - new TestArgument( - "valid instagram message", 200, r -> createMessageRequest(SAMPLE_IG_MESSAGE, r), true), - new TestArgument( - "instagram message echo", 200, r -> createMessageRequest(SAMPLE_IG_MESSAGE_ECHO, r), false), - new TestArgument( - "a non page object type", - 400, - r -> createMessageRequest("{\"object\": \"not a page\"}", r), - false), - new TestArgument( - "an invalid page object", - 400, - r -> createMessageRequest("{\"object\": \"page\"}", r), - false), - new TestArgument( - "valid body with no entries/messages", - 200, - r -> createMessageRequest("{\"object\": \"page\", \"entry\": []}", r), - false), - new TestArgument( - "unknown entry objects", - 200, - r -> createMessageRequest("{\"object\": \"page\", \"entry\": [{}]}", r), - false), - new TestArgument( - "valid body without any messaging objects", - 200, - r -> - createMessageRequest( - "{\"object\": \"page\", \"entry\": [{\"messaging\": []}]}", r), - false), - new TestArgument( - "valid body with duplicate message", - 200, - r -> createMessageRequest(MAPPER.writeValueAsString(duplicateMessage), r), - true), - new TestArgument( - "valid body with duplicate entry", - 200, - r -> createMessageRequest(MAPPER.writeValueAsString(duplicateEntry), r), - true), - new TestArgument( - "malformed messaging object", - 400, - r -> - createMessageRequest( - "{\"object\": \"page\", \"entry\": [{\"messaging\": [{}]}]}", r), - false), - new TestArgument( - "messaging object with no messages", - 200, - r -> - createMessageRequest( - "{\"object\": \"page\", \"entry\": [{\"messaging\": [{\"recipient\": {\"id\": 123}, \"sender\": {\"id\": 123}, \"timestamp\": 0}]}]}", - r), - false), - new TestArgument( - "invalid message object", - 400, - r -> - createMessageRequest( - "{\"object\": \"page\", \"entry\": [{\"messaging\": [{\"recipient\": {\"id\": 123}, \"sender\": {\"id\": 123}, \"timestamp\": 0, \"message\": {}}]}]}", - r), - false), - new TestArgument( - "message object without text", - 200, - r -> - createMessageRequest( - "{\"object\": \"page\", \"entry\": [{\"messaging\": [{\"recipient\": {\"id\": 123}, \"sender\": {\"id\": 123}, \"timestamp\": 0, \"message\": {\"mid\": \"abc123789\"}}]}]}", - r), - false), - new TestArgument( - "missing hmac", 400, r -> createMessageRequest(SAMPLE_MESSAGE, r, false), false), - new TestArgument( - "invalid json", 400, r -> createMessageRequest("invalid_json.", r), false), - new TestArgument( - "valid json, invalid body", 400, r -> createMessageRequest("{}", r), false), - new TestArgument( - "invalid hmac", - 400, - r -> - createMessageRequest(SAMPLE_MESSAGE, r, false) - .addHeader("X-Hub-Signature-256", "abcdef0123456789"), - false)); - - return arguments.map( - a -> - Arguments.of( - Named.of(a.name, a.expectedReturnCode), - Named.of("_", a.requestFactory), - Named.of("_", a.messageExpected), - Named.of("_", a.timesToSendMessage()))); - } + public static Request createMessageRequest(String body, ServicesRunner runner) + throws IOException, URISyntaxException { + return createMessageRequest(body, runner, true); + } - @BeforeEach - void setUp() { - - app = Javalin.create(); - app.addHandler( - HandlerType.GET, "*", ctx -> fail("the pipeline should only be sending post requests")); - app.addHandler( - HandlerType.DELETE, "*", ctx -> fail("the pipeline should only be sending post requests")); - app.addHandler( - HandlerType.PUT, "*", ctx -> fail("the pipeline should only be sending post requests")); - requests = new LinkedBlockingQueue<>(); - app.addHandler( - HandlerType.POST, - "/", - ctx -> requests.add(new OutboundRequest(ctx.body(), ctx.headerMap(), ctx.queryParamMap()))); - } + static Stream requestFactory() throws JsonProcessingException { + JsonNode duplicateMessage = MAPPER.readTree(SAMPLE_MESSAGE); + ArrayNode messagingArray = (ArrayNode) duplicateMessage.get("entry").get(0).get("messaging"); + messagingArray.add(messagingArray.get(0)); + + JsonNode duplicateEntry = MAPPER.readTree(SAMPLE_MESSAGE); + ArrayNode entryArray = (ArrayNode) duplicateMessage.get("entry"); + entryArray.add(entryArray.get(0)); + Stream arguments = + Stream.of( + new TestArgument( + "valid sample", + 200, + r -> + createMessageRequest(SAMPLE_MESSAGE, r, false) + .addHeader("X-Hub-Signature-256", SAMPLE_MESSAGE_HMAC), + true), + new TestArgument( + // if this fails the test hmac calculator is flawed + "valid sample with hmac calculation", + 200, + r -> createMessageRequest(SAMPLE_MESSAGE, r), + true), + new TestArgument( + // if this fails the test hmac calculator is flawed + "test the send it twice scenario", + 200, + r -> createMessageRequest(SAMPLE_MESSAGE, r), + true, + 2), + new TestArgument( + "valid instagram message", + 200, + r -> createMessageRequest(SAMPLE_IG_MESSAGE, r), + true, + true), + new TestArgument( + "instagram message echo", + 200, + r -> createMessageRequest(SAMPLE_IG_MESSAGE_ECHO, r), + false, + true), + new TestArgument( + "a non page object type", + 400, + r -> createMessageRequest("{\"object\": \"not a page\"}", r), + false), + new TestArgument( + "an invalid page object", + 400, + r -> createMessageRequest("{\"object\": \"page\"}", r), + false), + new TestArgument( + "valid body with no entries/messages", + 200, + r -> createMessageRequest("{\"object\": \"page\", \"entry\": []}", r), + false), + new TestArgument( + "unknown entry objects", + 200, + r -> createMessageRequest("{\"object\": \"page\", \"entry\": [{}]}", r), + false), + new TestArgument( + "valid body without any messaging objects", + 200, + r -> + createMessageRequest( + "{\"object\": \"page\", \"entry\": [{\"messaging\": []}]}", r), + false), + new TestArgument( + "valid body with duplicate message", + 200, + r -> createMessageRequest(MAPPER.writeValueAsString(duplicateMessage), r), + true), + new TestArgument( + "valid body with duplicate entry", + 200, + r -> createMessageRequest(MAPPER.writeValueAsString(duplicateEntry), r), + true), + new TestArgument( + "malformed messaging object", + 400, + r -> + createMessageRequest( + "{\"object\": \"page\", \"entry\": [{\"messaging\": [{}]}]}", r), + false), + new TestArgument( + "messaging object with no messages", + 200, + r -> + createMessageRequest( + "{\"object\": \"page\", \"entry\": [{\"messaging\": [{\"recipient\": {\"id\": 123}, \"sender\": {\"id\": 123}, \"timestamp\": 0}]}]}", + r), + false), + new TestArgument( + "invalid message object", + 400, + r -> + createMessageRequest( + "{\"object\": \"page\", \"entry\": [{\"messaging\": [{\"recipient\": {\"id\": 123}, \"sender\": {\"id\": 123}, \"timestamp\": 0, \"message\": {}}]}]}", + r), + false), + new TestArgument( + "message object without text", + 200, + r -> + createMessageRequest( + "{\"object\": \"page\", \"entry\": [{\"messaging\": [{\"recipient\": {\"id\": 123}, \"sender\": {\"id\": 123}, \"timestamp\": 0, \"message\": {\"mid\": \"abc123789\"}}]}]}", + r), + false), + new TestArgument( + "missing hmac", 400, r -> createMessageRequest(SAMPLE_MESSAGE, r, false), false), + new TestArgument( + "invalid json", 400, r -> createMessageRequest("invalid_json.", r), false), + new TestArgument( + "valid json, invalid body", 400, r -> createMessageRequest("{}", r), false), + new TestArgument( + "invalid hmac", + 400, + r -> + createMessageRequest(SAMPLE_MESSAGE, r, false) + .addHeader("X-Hub-Signature-256", "abcdef0123456789"), + false)); + + return arguments.map( + a -> + Arguments.of( + Named.of(a.name, a.expectedReturnCode), + Named.of("_", a.requestFactory), + Named.of("_", a.messageExpected), + Named.of("_", a.timesToSendMessage()), + Named.of("_", a.isInstagram()))); + } - @AfterEach - void tearDown() { - app.close(); - } + @BeforeEach + void setUp() { + + app = Javalin.create(); + app.addHandler( + HandlerType.GET, "*", ctx -> fail("the pipeline should only be sending post requests")); + app.addHandler( + HandlerType.DELETE, "*", ctx -> fail("the pipeline should only be sending post requests")); + app.addHandler( + HandlerType.PUT, "*", ctx -> fail("the pipeline should only be sending post requests")); + requests = new LinkedBlockingQueue<>(); + app.addHandler( + HandlerType.POST, + "/", + ctx -> requests.add(new OutboundRequest(ctx.body(), ctx.headerMap(), ctx.queryParamMap()))); + } - @Test - void validation() throws IOException, URISyntaxException { - final String pageToken = "243af3c6-9994-4869-ae13-ad61a38323f5"; // this is fake - final int challenge = 1158201444; + @AfterEach + void tearDown() { + app.close(); + } + + @Test + void validation() throws IOException, URISyntaxException { + final String pageToken = "243af3c6-9994-4869-ae13-ad61a38323f5"; // this is fake + final int challenge = 1158201444; final String verifyToken = "oamnw9230rjadoia"; - Service service = - new Service<>( - MemoryStoreConfig.of(1, 1).toStore(), - new FBMessageHandler(verifyToken, pageToken, "dummy"), - new DummyLLMPlugin("this is a dummy message"), - "/testfbmessage"); - final ServicesRunner runner = ServicesRunner.newInstance().service(service).port(0); - HttpResponse response; - try (ServicesRunner ignored = runner.start()) { - ImmutableMap params = - ImmutableMap.builder() - .put("hub.mode", "subscribe") - .put("hub.challenge", Integer.toString(challenge)) - .put("hub.verify_token", verifyToken) - .build(); - response = getRequest("testfbmessage", runner.port(), params); - } - assertThat(response.getCode()).isEqualTo(200); - InputStream inputStream = ((BasicClassicHttpResponse) response).getEntity().getContent(); - byte[] input = new byte[inputStream.available()]; - inputStream.read(input); - String text = new String(input, StandardCharsets.UTF_8); - assertThat(text).isEqualTo(Integer.toString(challenge)); + Service service = + new Service<>( + MemoryStoreConfig.of(1, 1).toStore(), + new FBMessageHandler(verifyToken, pageToken, "dummy"), + new DummyLLMPlugin<>("this is a dummy message"), + "/testfbmessage"); + final ServicesRunner runner = ServicesRunner.newInstance().service(service).port(0); + HttpResponse response; + try (ServicesRunner ignored = runner.start()) { + ImmutableMap params = + ImmutableMap.builder() + .put("hub.mode", "subscribe") + .put("hub.challenge", Integer.toString(challenge)) + .put("hub.verify_token", verifyToken) + .build(); + response = getRequest("testfbmessage", runner.port(), params); } + assertThat(response.getCode()).isEqualTo(200); + InputStream inputStream = ((BasicClassicHttpResponse) response).getEntity().getContent(); + byte[] input = new byte[inputStream.available()]; + inputStream.read(input); + String text = new String(input, StandardCharsets.UTF_8); + assertThat(text).isEqualTo(Integer.toString(challenge)); + } - private Function testURLFactoryFactory(Identifier pageId) { - return p -> { - assertThat(p).isEqualTo(pageId); - try { - return URIBuilder.localhost().setScheme("http").setPort(app.port()).build(); - } catch (URISyntaxException | UnknownHostException e) { - fail("failed building url"); - throw new RuntimeException(e); - } - }; - } + private Function testURLFactoryFactory(Identifier pageId) { + return p -> { + Preconditions.checkArgument(p.equals(pageId)); + try { + return URIBuilder.localhost().setScheme("http").setPort(app.port()).build(); + } catch (URISyntaxException | UnknownHostException e) { + fail("failed building url"); + throw new RuntimeException(e); + } + }; + } - @ParameterizedTest - @MethodSource("requestFactory") - void invalidMessage( - int expectedReturnCode, - ThrowableFunction requestFactory, - boolean messageExpected, - int timesToSendMessage) - throws Exception { - String path = "/testfbmessage"; - Identifier pageId = Identifier.from(106195825075770L); - String token = "243af3c6-9994-4869-ae13-ad61a38323f5"; // this is fake don't worry - String secret = "f74a638462f975e9eadfcbb84e4aa06b"; // it's been rolled don't worry - FBMessageHandler messageHandler = new FBMessageHandler("0", token, secret); - DummyLLMPlugin llmHandler = new DummyLLMPlugin<>("this is a dummy message"); - MemoryStore memoryStore = MemoryStoreConfig.of(1, 1).toStore(); - Service service = new Service<>(memoryStore, messageHandler, llmHandler, path); - final ServicesRunner runner = ServicesRunner.newInstance().service(service).port(0); - - app.start(0); - runner.start(); - messageHandler.baseURLFactory(testURLFactoryFactory(pageId)); - - @Nullable Response response = null; - for (int ignored = 0; ignored < timesToSendMessage; ignored++) { - Request request = requestFactory.apply(runner); - response = request.execute(); - } - assert response != null; - assertThat(response.returnResponse().getCode()).isEqualTo(expectedReturnCode); - if (!messageExpected) { - assertThat(llmHandler.poll()) - .isNull(); // make sure the message wasn't processed and send to the llm handler - assertThat(memoryStore.size()) - .isEqualTo(0); // make sure the message wasn't processed and stored - assertThat(requests).hasSize(0); - } else { - ThreadState thread = llmHandler.take(500); - JsonNode messageObject = PARSED_SAMPLE_MESSAGE.get("entry").get(0).get("messaging").get(0); - String messageText = messageObject.get("message").get("text").textValue(); - String mid = messageObject.get("message").get("mid").textValue(); - Identifier recipientId = - Identifier.from(messageObject.get("recipient").get("id").textValue()); - Identifier senderId = Identifier.from(messageObject.get("sender").get("id").textValue()); - Instant timestamp = Instant.ofEpochMilli(messageObject.get("timestamp").longValue()); - assertThat(thread.messages()) - .hasSize(1) - .allSatisfy(m -> assertThat(m.message()).isEqualTo(messageText)) - .allSatisfy(m -> assertThat(m.instanceId().toString()).isEqualTo(mid)) - .allSatisfy(m -> assertThat(m.role()).isSameAs(Role.USER)) - .allSatisfy(m -> assertThat(m.timestamp()).isEqualTo(timestamp)) - .allSatisfy(m -> assertThat(m.recipientId()).isEqualTo(recipientId)) - .allSatisfy(m -> assertThat(m.senderId()).isEqualTo(senderId)); - - @Nullable OutboundRequest r = requests.poll(500, TimeUnit.MILLISECONDS); - assertThat(r).isNotNull(); - assertThat(r.queryParamMap().get("access_token")) - .hasSize(1) - .allSatisfy(t -> assertThat(t).isEqualTo(token)); - JsonNode body = MAPPER.readTree(r.body); - assertThat(body.get("messaging_type").textValue()).isEqualTo("RESPONSE"); - assertThat(body.get("recipient").get("id").textValue()).isEqualTo(senderId.toString()); - assertThat(body.get("message").get("text").textValue()).isEqualTo(llmHandler.dummyResponse()); - } + @ParameterizedTest + @MethodSource("requestFactory") + void invalidMessage( + int expectedReturnCode, + ThrowableFunction requestFactory, + boolean messageExpected, + int timesToSendMessage, + boolean isInstagram) + throws Exception { + String path = "/testfbmessage"; + Identifier pageId = Identifier.from(106195825075770L); + String token = "243af3c6-9994-4869-ae13-ad61a38323f5"; // this is fake don't worry + String secret = "f74a638462f975e9eadfcbb84e4aa06b"; // it's been rolled don't worry + + FBMessageHandler messageHandler; + if (isInstagram) { + messageHandler = new FBMessageHandler("0", token, secret, pageId.toString()); + } else { + messageHandler = new FBMessageHandler("0", token, secret); } - - @Test - void chunkingHappens() throws IOException { - app.start(0); - Identifier pageId = Identifier.from(106195825075770L); - String token = "243af3c6-9994-4869-ae13-ad61a38323f5"; // this is fake don't worry - String secret = "f74a638462f975e9eadfcbb84e4aa06b"; // it's been rolled don't worry - FBMessageHandler messageHandler = - new FBMessageHandler("0", token, secret).baseURLFactory(testURLFactoryFactory(pageId)); - - String bigText = - Stream.generate(() -> "0123456789.").limit(300).collect(Collectors.joining(" ")); - FBMessage bigMessage = - new FBMessage( - Instant.now(), Identifier.random(), pageId, Identifier.random(), bigText, Role.USER); - messageHandler.respond(bigMessage); - assertThat(requests.size()).isEqualTo(300); - assertThat(requests).allSatisfy(m -> assertThat(m.body()).contains("0123456789")); + DummyLLMPlugin llmHandler = new DummyLLMPlugin<>("this is a dummy message"); + MemoryStore memoryStore = MemoryStoreConfig.of(1, 1).toStore(); + Service service = new Service<>(memoryStore, messageHandler, llmHandler, path); + final ServicesRunner runner = ServicesRunner.newInstance().service(service).port(0); + + app.start(0); + runner.start(); + messageHandler.baseURLFactory(testURLFactoryFactory(pageId)); + + @Nullable Response response = null; + for (int ignored = 0; ignored < timesToSendMessage; ignored++) { + Request request = requestFactory.apply(runner); + response = request.execute(); } - - @FunctionalInterface - private interface ThrowableFunction { - R apply(T in) throws Exception; + assert response != null; + assertThat(response.returnResponse().getCode()).isEqualTo(expectedReturnCode); + if (!messageExpected) { + assertThat(llmHandler.poll()) + .isNull(); // make sure the message wasn't processed and send to the llm handler + assertThat(memoryStore.size()) + .isEqualTo(0); // make sure the message wasn't processed and stored + assertThat(requests).hasSize(0); + } else { + ThreadState thread = llmHandler.take(500); + JsonNode messageObject = PARSED_SAMPLE_MESSAGE.get("entry").get(0).get("messaging").get(0); + String messageText = messageObject.get("message").get("text").textValue(); + String mid = messageObject.get("message").get("mid").textValue(); + Identifier recipientId = + Identifier.from(messageObject.get("recipient").get("id").textValue()); + Identifier senderId = Identifier.from(messageObject.get("sender").get("id").textValue()); + Instant timestamp = Instant.ofEpochMilli(messageObject.get("timestamp").longValue()); + assertThat(thread.messages()) + .hasSize(1) + .allSatisfy(m -> assertThat(m.message()).isEqualTo(messageText)) + .allSatisfy(m -> assertThat(m.instanceId().toString()).isEqualTo(mid)) + .allSatisfy(m -> assertThat(m.role()).isSameAs(Role.USER)) + .allSatisfy(m -> assertThat(m.timestamp()).isEqualTo(timestamp)) + .allSatisfy(m -> assertThat(m.recipientId()).isEqualTo(recipientId)) + .allSatisfy(m -> assertThat(m.senderId()).isEqualTo(senderId)); + + @Nullable OutboundRequest r = requests.poll(500, TimeUnit.MILLISECONDS); + assertThat(r).isNotNull(); + assertThat(r.queryParamMap().get("access_token")) + .hasSize(1) + .allSatisfy(t -> assertThat(t).isEqualTo(token)); + JsonNode body = MAPPER.readTree(r.body); + assertThat(body.get("messaging_type").textValue()).isEqualTo("RESPONSE"); + assertThat(body.get("recipient").get("id").textValue()).isEqualTo(senderId.toString()); + assertThat(body.get("message").get("text").textValue()).isEqualTo(llmHandler.dummyResponse()); } + } + + @Test + void chunkingHappens() throws IOException { + app.start(0); + Identifier pageId = Identifier.from(106195825075770L); + String token = "243af3c6-9994-4869-ae13-ad61a38323f5"; // this is fake don't worry + String secret = "f74a638462f975e9eadfcbb84e4aa06b"; // it's been rolled don't worry + FBMessageHandler messageHandler = + new FBMessageHandler("0", token, secret).baseURLFactory(testURLFactoryFactory(pageId)); + + String bigText = + Stream.generate(() -> "0123456789.").limit(300).collect(Collectors.joining(" ")); + FBMessage bigMessage = + new FBMessage( + Instant.now(), Identifier.random(), pageId, Identifier.random(), bigText, Role.USER); + messageHandler.respond(bigMessage); + assertThat(requests.size()).isEqualTo(300); + assertThat(requests).allSatisfy(m -> assertThat(m.body()).contains("0123456789")); + } - private record OutboundRequest( - String body, Map headerMap, Map> queryParamMap) { + @FunctionalInterface + private interface ThrowableFunction { + R apply(T in) throws Exception; + } + + private record OutboundRequest( + String body, Map headerMap, Map> queryParamMap) {} + + private record TestArgument( + String name, + int expectedReturnCode, + ThrowableFunction requestFactory, + boolean messageExpected, + int timesToSendMessage, + boolean isInstagram) { + + private TestArgument( + String name, + int expectedReturnCode, + ThrowableFunction requestFactory, + boolean messageExpected, + boolean isInstagram) { + this(name, expectedReturnCode, requestFactory, messageExpected, 1, isInstagram); } - private record TestArgument( - String name, - int expectedReturnCode, - ThrowableFunction requestFactory, - boolean messageExpected) { + private TestArgument( + String name, + int expectedReturnCode, + ThrowableFunction requestFactory, + boolean messageExpected, + int timesToSendMessage) { + this(name, expectedReturnCode, requestFactory, messageExpected, timesToSendMessage, false); + } - // hacky way to try the send twice scenario - int timesToSendMessage() { - return name.contains("send it twice") ? 2 : 1; - } + private TestArgument( + String name, + int expectedReturnCode, + ThrowableFunction requestFactory, + boolean messageExpected) { + this(name, expectedReturnCode, requestFactory, messageExpected, 1); } + } }