From 2bbb6598ec2e6c7087ca4bdd3b50d52943b1073b Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 14:14:30 +0200 Subject: [PATCH 01/11] test (Voice/E2E): Callouts E2E coverage --- .../test/java/com/sinch/sdk/e2e/Config.java | 13 ++ .../sdk/e2e/domains/voice/CalloutsSteps.java | 159 ++++++++++++++++++ .../sinch/sdk/e2e/domains/voice/VoiceIT.java | 10 ++ 3 files changed, 182 insertions(+) create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/voice/CalloutsSteps.java create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java diff --git a/client/src/test/java/com/sinch/sdk/e2e/Config.java b/client/src/test/java/com/sinch/sdk/e2e/Config.java index fc277d1e3..4c3d940f2 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/Config.java +++ b/client/src/test/java/com/sinch/sdk/e2e/Config.java @@ -4,6 +4,7 @@ import com.sinch.sdk.models.Configuration; import com.sinch.sdk.models.ConversationContext; import com.sinch.sdk.models.ConversationRegion; +import com.sinch.sdk.models.VoiceContext; public class Config { @@ -15,6 +16,11 @@ public class Config { public static final String CONVERSATION_TEMPLATE_HOST_NAME = "http://localhost:3015"; public static final ConversationRegion CONVERSATION_REGION = ConversationRegion.US; + public static final String APPLICATION_KEY = "appKey"; + public static final String APPLICATION_SECRET = "YXBwU2VjcmV0"; + public static final String VOICE_HOST_NAME = "http://localhost:3019"; + public static final String VOICE_MANAGEMENT_HOST_NAME = "http://localhost:3020"; + private final SinchClient client; private Config() { @@ -31,6 +37,13 @@ private Config() { .setRegion(Config.CONVERSATION_REGION) .setTemplateManagementUrl(CONVERSATION_TEMPLATE_HOST_NAME) .build()) + .setApplicationKey(APPLICATION_KEY) + .setApplicationSecret(APPLICATION_SECRET) + .setVoiceContext( + VoiceContext.builder() + .setVoiceApplicationMngmtUrl(VOICE_MANAGEMENT_HOST_NAME) + .setVoiceUrl(VOICE_HOST_NAME) + .build()) .build(); client = new SinchClient(configuration); diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/CalloutsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/CalloutsSteps.java new file mode 100644 index 000000000..0d67b1cd0 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/CalloutsSteps.java @@ -0,0 +1,159 @@ +package com.sinch.sdk.e2e.domains.voice; + +import com.sinch.sdk.domains.voice.CalloutsService; +import com.sinch.sdk.domains.voice.models.DestinationNumber; +import com.sinch.sdk.domains.voice.models.MusicOnHoldType; +import com.sinch.sdk.domains.voice.models.requests.CalloutRequestParametersConference; +import com.sinch.sdk.domains.voice.models.requests.CalloutRequestParametersCustom; +import com.sinch.sdk.domains.voice.models.requests.CalloutRequestParametersTTS; +import com.sinch.sdk.domains.voice.models.requests.ControlUrl; +import com.sinch.sdk.domains.voice.models.svaml.ActionConnectPstn; +import com.sinch.sdk.domains.voice.models.svaml.ActionRunMenu; +import com.sinch.sdk.domains.voice.models.svaml.InstructionSay; +import com.sinch.sdk.domains.voice.models.svaml.InstructionStartRecording; +import com.sinch.sdk.domains.voice.models.svaml.Menu; +import com.sinch.sdk.domains.voice.models.svaml.MenuOption; +import com.sinch.sdk.domains.voice.models.svaml.MenuOptionAction; +import com.sinch.sdk.domains.voice.models.svaml.MenuOptionActionType; +import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl; +import com.sinch.sdk.domains.voice.models.svaml.StartRecordingOptions; +import com.sinch.sdk.e2e.Config; +import com.sinch.sdk.models.DualToneMultiFrequency; +import com.sinch.sdk.models.E164PhoneNumber; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.Arrays; +import org.junit.jupiter.api.Assertions; + +public class CalloutsSteps { + + CalloutsService service; + String ttsResponse; + String conferenceResponse; + String customResponse; + + @Given("^the Voice service \"Callouts\" is available$") + public void serviceAvailable() { + service = Config.getSinchClient().voice().callouts(); + } + + @When("^I send a request to make a TTS call$") + public void createTTS() { + + CalloutRequestParametersTTS request = + CalloutRequestParametersTTS.builder() + .setLocale("en-US") + .setDestination(DestinationNumber.valueOf("+12017777777")) + .setCli(E164PhoneNumber.valueOf("+12015555555")) + .setText("Hello, this is a call from Sinch.") + .build(); + + ttsResponse = service.textToSpeech(request); + } + + @When("^I send a request to make a Conference call with the \"Callout\" service$") + public void createConference() { + CalloutRequestParametersConference request = + CalloutRequestParametersConference.builder() + .setLocale("en-US") + .setDestination(DestinationNumber.valueOf("+12017777777")) + .setCli(E164PhoneNumber.valueOf("+12015555555")) + .setConferenceId("myConferenceId-E2E") + .setGreeting("Welcome to this conference call.") + .setMusicOnHold(MusicOnHoldType.MUSIC1) + .build(); + + conferenceResponse = service.conference(request); + } + + @When("^I send a request to make a Custom call$") + public void createCustom() { + CalloutRequestParametersCustom request = + CalloutRequestParametersCustom.builder() + .setDestination(DestinationNumber.valueOf("+12017777777")) + .setCli(E164PhoneNumber.valueOf("+12015555555")) + .setCustom("Custom text") + .setIce( + SVAMLControl.builder() + .setAction( + ActionConnectPstn.builder() + .setNumber(E164PhoneNumber.valueOf("+12017777777")) + .setCli("+12015555555") + .build()) + .setInstructions( + Arrays.asList( + InstructionSay.builder() + .setText("Welcome to Sinch.") + .setLocale("en-US/male") + .build(), + InstructionStartRecording.builder() + .setOptions( + StartRecordingOptions.builder() + .setDestinationUrl("To specify") + .setCredentials("To specify") + .build()) + .build())) + .build()) + .setAce( + SVAMLControl.builder() + .setAction( + ActionRunMenu.builder() + .setLocale("Kimberly") + .setEnableVoice(true) + .setBarge(true) + .setMenus( + Arrays.asList( + Menu.builder() + .setId("main") + .setMainPrompt( + "#tts[Welcome to the main menu. Press 1 to confirm" + + " order or 2 to cancel]") + .setRepeatPrompt( + "#tts[We didn't get your input, please try again]") + .setTimeoutMills(5000) + .setOptions( + Arrays.asList( + MenuOption.builder() + .setDtfm(DualToneMultiFrequency.valueOf("1")) + .setAction( + MenuOptionAction.from( + MenuOptionActionType.MENU, "confirm")) + .build(), + MenuOption.builder() + .setDtfm(DualToneMultiFrequency.valueOf("2")) + .setAction( + MenuOptionAction.from( + MenuOptionActionType.RETURN, "cancel")) + .build())) + .build(), + Menu.builder() + .setId("confirm") + .setMainPrompt( + "#tts[Thank you for confirming your order. Enter your" + + " 4-digit PIN.]") + .setMaxDigits(4) + .build())) + .build()) + .build()) + .setPie(ControlUrl.from("https://callback-server.com/voice")) + .build(); + + customResponse = service.custom(request); + } + + @Then("the callout response contains the TTS call ID") + public void ttsResult() { + Assertions.assertEquals(ttsResponse, "1ce0ffee-ca11-ca11-ca11-abcdef000001"); + } + + @Then("the callout response contains the Conference call ID") + public void conferenceResult() { + Assertions.assertEquals(conferenceResponse, "1ce0ffee-ca11-ca11-ca11-abcdef000002"); + } + + @Then("the callout response contains the Custom call ID") + public void customResult() { + Assertions.assertEquals(customResponse, "1ce0ffee-ca11-ca11-ca11-abcdef000003"); + } +} diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java new file mode 100644 index 000000000..f34222138 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java @@ -0,0 +1,10 @@ +package com.sinch.sdk.e2e.domains.voice; + +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features/voice/callouts.feature") +public class VoiceIT {} From 733fae1e2576ac969a021cfb71d9dca667cb8e69 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 14:39:36 +0200 Subject: [PATCH 02/11] test (Voice/E2E): Calls E2E coverage --- .../sdk/e2e/domains/voice/CallsSteps.java | 134 ++++++++++++++++++ .../sinch/sdk/e2e/domains/voice/VoiceIT.java | 1 + 2 files changed, 135 insertions(+) create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/voice/CallsSteps.java diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/CallsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/CallsSteps.java new file mode 100644 index 000000000..e8d3ef0e0 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/CallsSteps.java @@ -0,0 +1,134 @@ +package com.sinch.sdk.e2e.domains.voice; + +import com.sinch.sdk.core.TestHelpers; +import com.sinch.sdk.core.exceptions.ApiException; +import com.sinch.sdk.domains.voice.CallsService; +import com.sinch.sdk.domains.voice.models.CallLegType; +import com.sinch.sdk.domains.voice.models.CallReasonType; +import com.sinch.sdk.domains.voice.models.CallResultType; +import com.sinch.sdk.domains.voice.models.DestinationNumber; +import com.sinch.sdk.domains.voice.models.DomainType; +import com.sinch.sdk.domains.voice.models.Price; +import com.sinch.sdk.domains.voice.models.response.CallInformation; +import com.sinch.sdk.domains.voice.models.response.CallStatusType; +import com.sinch.sdk.domains.voice.models.svaml.ActionContinue; +import com.sinch.sdk.domains.voice.models.svaml.ActionHangUp; +import com.sinch.sdk.domains.voice.models.svaml.InstructionPlayFiles; +import com.sinch.sdk.domains.voice.models.svaml.InstructionSay; +import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl; +import com.sinch.sdk.e2e.Config; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.time.Instant; +import java.util.Arrays; +import org.junit.jupiter.api.Assertions; + +public class CallsSteps { + + CallsService service; + CallInformation getResponse; + Boolean updatePassed; + ApiException updateNotFoundException; + Boolean manageWithCallLegPassed; + + @Given("^the Voice service \"Calls\" is available$") + public void serviceAvailable() { + service = Config.getSinchClient().voice().calls(); + } + + @When("^I send a request to get a call's information$") + public void getCall() { + + getResponse = service.get("1ce0ffee-ca11-ca11-ca11-abcdef000003"); + } + + @When("^I send a request to update a call$") + public void updateCall() { + + SVAMLControl request = + SVAMLControl.builder() + .setInstructions( + Arrays.asList( + InstructionSay.builder() + .setText("Sorry, the conference has been cancelled. The call will end now.") + .setLocale("en-US") + .build())) + .setAction(ActionHangUp.builder().build()) + .build(); + service.update("1ce0ffee-ca11-ca11-ca11-abcdef000022", request); + updatePassed = true; + } + + @When("^I send a request to update a call that doesn't exist$") + public void updateCallNotExits() { + + SVAMLControl request = + SVAMLControl.builder() + .setInstructions( + Arrays.asList( + InstructionSay.builder() + .setText("Sorry, the conference has been cancelled. The call will end now.") + .setLocale("en-US") + .build())) + .setAction(ActionHangUp.builder().build()) + .build(); + try { + service.update("not-existing-callId", request); + } catch (ApiException ae) { + updateNotFoundException = ae; + } + } + + @When("^I send a request to manage a call with callLeg$") + public void manageCallWithCallLeg() { + + SVAMLControl request = + SVAMLControl.builder() + .setInstructions( + Arrays.asList( + InstructionPlayFiles.builder() + .setIds( + Arrays.asList( + "https://samples-files.com/samples/Audio/mp3/sample-file-4.mp3")) + .build())) + .setAction(ActionContinue.builder().build()) + .build(); + service.manageWithCallLeg("1ce0ffee-ca11-ca11-ca11-abcdef000022", CallLegType.CALLEE, request); + manageWithCallLegPassed = true; + } + + @Then("the response contains the information about the call") + public void getCallResult() { + CallInformation information = + CallInformation.builder() + .setTo(DestinationNumber.valueOf("+12017777777")) + .setDomain(DomainType.PSTN) + .setCallId("1ce0ffee-ca11-ca11-ca11-abcdef000003") + .setDuration(14) + .setStatus(CallStatusType.FINAL) + .setResult(CallResultType.ANSWERED) + .setReason(CallReasonType.MANAGER_HANGUP) + .setTimeStamp(Instant.parse("2024-06-06T17:36:00Z")) + .setCustom("Custom text") + .setUserRate(Price.builder().setCurrencyId("EUR").setAmount(0.1758F).build()) + .setDebit(Price.builder().setCurrencyId("EUR").setAmount(0.1758F).build()) + .build(); + TestHelpers.recursiveEquals(getResponse, information); + } + + @Then("the update call response contains no data") + public void updateCallResult() { + Assertions.assertEquals(true, updatePassed); + } + + @Then("the update call response contains a \"not found\" error") + public void updateCallNotFoundResult() { + Assertions.assertTrue(updateNotFoundException.getMessage().contains("not found")); + } + + @Then("the manage a call with callLeg response contains no data") + public void manageCallWithCallLegResult() { + Assertions.assertEquals(true, manageWithCallLegPassed); + } +} diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java index f34222138..5e13c5559 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java @@ -7,4 +7,5 @@ @Suite @IncludeEngines("cucumber") @SelectClasspathResource("features/voice/callouts.feature") +@SelectClasspathResource("features/voice/calls.feature") public class VoiceIT {} From 819f345daab794de2dc2748846eaa4fbf7728525 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 15:37:30 +0200 Subject: [PATCH 03/11] fix (Voice): Remove 'required' for SVAML PlayFiles.locale field --- .../voice/models/dto/v1/SvamlInstructionPlayFilesDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/dto/v1/SvamlInstructionPlayFilesDto.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/dto/v1/SvamlInstructionPlayFilesDto.java index 156fa8522..78c143e28 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/dto/v1/SvamlInstructionPlayFilesDto.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/dto/v1/SvamlInstructionPlayFilesDto.java @@ -167,7 +167,7 @@ public SvamlInstructionPlayFilesDto locale(String locale) { * @return locale */ @JsonProperty(JSON_PROPERTY_LOCALE) - @JsonInclude(value = JsonInclude.Include.ALWAYS) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) public String getLocale() { return locale; } From 146f1ee7bcf10d2eb174053bef1bef7aab4c0301 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 16:04:21 +0200 Subject: [PATCH 04/11] test (Voice/E2E): Conferences E2E coverage --- .../e2e/domains/voice/ConferenceSteps.java | 126 ++++++++++++++++++ .../sinch/sdk/e2e/domains/voice/VoiceIT.java | 1 + 2 files changed, 127 insertions(+) create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/voice/ConferenceSteps.java diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/ConferenceSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/ConferenceSteps.java new file mode 100644 index 000000000..d77cf9677 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/ConferenceSteps.java @@ -0,0 +1,126 @@ +package com.sinch.sdk.e2e.domains.voice; + +import com.sinch.sdk.core.TestHelpers; +import com.sinch.sdk.domains.voice.ConferencesService; +import com.sinch.sdk.domains.voice.models.DestinationNumber; +import com.sinch.sdk.domains.voice.models.MusicOnHoldType; +import com.sinch.sdk.domains.voice.models.requests.CalloutRequestParametersConference; +import com.sinch.sdk.domains.voice.models.requests.ConferenceManageParticipantCommandType; +import com.sinch.sdk.domains.voice.models.requests.ConferenceManageParticipantRequestParameters; +import com.sinch.sdk.domains.voice.models.response.ConferenceParticipant; +import com.sinch.sdk.e2e.Config; +import com.sinch.sdk.models.E164PhoneNumber; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import org.junit.jupiter.api.Assertions; + +public class ConferenceSteps { + + ConferencesService service; + String callResponse; + Collection getResponse; + Boolean manageParticipantPassed; + Boolean kickParticipantPassed; + Boolean kickAllParticipantPassed; + + @Given("^the Voice service \"Conferences\" is available$") + public void serviceAvailable() { + service = Config.getSinchClient().voice().conferences(); + } + + @When("^I send a request to make a Conference call with the \"Conferences\" service$") + public void createCall() { + + CalloutRequestParametersConference request = + CalloutRequestParametersConference.builder() + .setLocale("en-US") + .setDestination(DestinationNumber.valueOf("+12017777777")) + .setCli(E164PhoneNumber.valueOf("+12015555555")) + .setConferenceId("myConferenceId-E2E") + .setGreeting("Welcome to this conference call.") + .setMusicOnHold(MusicOnHoldType.MUSIC1) + .build(); + + callResponse = service.call(request); + } + + @When("^I send a request to get the conference information$") + public void getCall() { + + getResponse = service.get("myConferenceId-E2E"); + } + + @When("^I send a request to put a participant on hold$") + public void manageParticipant() { + + ConferenceManageParticipantRequestParameters request = + ConferenceManageParticipantRequestParameters.builder() + .setCommand(ConferenceManageParticipantCommandType.ONHOLD) + .setMusicOnHold(MusicOnHoldType.MUSIC2) + .build(); + + service.manageParticipant( + "myConferenceId-E2E", "1ce0ffee-ca11-ca11-ca11-abcdef000012", request); + manageParticipantPassed = true; + } + + @When("^I send a request to kick a participant from a conference$") + public void kickParticipant() { + + service.kickParticipant("myConferenceId-E2E", "1ce0ffee-ca11-ca11-ca11-abcdef000012"); + kickParticipantPassed = true; + } + + @When("^I send a request to kick all the participants from a conference$") + public void kickAllParticipant() { + + service.kickAll("myConferenceId-E2E"); + kickAllParticipantPassed = true; + } + + @Then("the callout response from the \"Conferences\" service contains the Conference call ID") + public void createCallResult() { + Assertions.assertEquals(callResponse, "1ce0ffee-ca11-ca11-ca11-abcdef000002"); + } + + @Then("the response contains the information about the conference participants") + public void getCallResult() { + Collection participants = + new ArrayList<>( + Arrays.asList( + ConferenceParticipant.builder() + .setCli("+12015555555") + .setId("1ce0ffee-ca11-ca11-ca11-abcdef000012") + .setDuration(35) + .setMuted(true) + .setOnhold(true) + .build(), + ConferenceParticipant.builder() + .setCli("+12015555555") + .setId("1ce0ffee-ca11-ca11-ca11-abcdef000022") + .setDuration(6) + .setMuted(false) + .setOnhold(false) + .build())); + TestHelpers.recursiveEquals(getResponse, participants); + } + + @Then("the manage participant response contains no data") + public void manageParticipantResult() { + Assertions.assertTrue(manageParticipantPassed); + } + + @Then("the kick participant response contains no data") + public void kickParticipantPassedResult() { + Assertions.assertTrue(kickParticipantPassed); + } + + @Then("the kick all participants response contains no data") + public void kickAllParticipantPassedResult() { + Assertions.assertTrue(kickAllParticipantPassed); + } +} diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java index 5e13c5559..1c06c4d7d 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java @@ -8,4 +8,5 @@ @IncludeEngines("cucumber") @SelectClasspathResource("features/voice/callouts.feature") @SelectClasspathResource("features/voice/calls.feature") +@SelectClasspathResource("features/voice/conferences.feature") public class VoiceIT {} From 043eb880e1c210ce4cd60a8fa6c3f87c652abce0 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 17:46:27 +0200 Subject: [PATCH 05/11] test (E2E): Create WebhooksHelper class --- .../sinch/sdk/e2e/domains/WebhooksHelper.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java b/client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java new file mode 100644 index 000000000..0a3ea49f8 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java @@ -0,0 +1,58 @@ +package com.sinch.sdk.e2e.domains; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public class WebhooksHelper { + + public static Response callURL(URL url, Function parseEvent) + throws IOException { + + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + con.setRequestMethod("GET"); + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + byte[] buffer = new byte[1024]; + int bytesRead; + InputStream inputStream = con.getInputStream(); + while ((bytesRead = inputStream.read(buffer)) != -1) { + byteArrayOutputStream.write(buffer, 0, bytesRead); + } + Response response = new Response<>(); + response.headers = transformHeaders(con.getHeaderFields()); + response.rawPayload = byteArrayOutputStream.toString("UTF-8"); + response.event = parseEvent.apply(response.rawPayload); + return response; + } + + static Map transformHeaders(Map> headers) { + if (null == headers) { + return null; + } + HashMap newMap = new HashMap<>(); + headers.forEach((key, value) -> newMap.put(key, concatHeaderValues(value))); + return newMap; + } + + static String concatHeaderValues(List values) { + if (null == values) { + return null; + } + return values.stream() + .reduce(null, (previous, current) -> (null != previous ? previous + ";" : "") + current); + } + + public static class Response { + public Map headers; + public String rawPayload; + public T event; + } +} From 19e523e8e9a6338c4be4413cfb9c44d1720bbc76 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 17:49:40 +0200 Subject: [PATCH 06/11] test (Voice/E2E): Webhook events E2E coverage --- .../sinch/sdk/e2e/domains/voice/VoiceIT.java | 1 + .../domains/voice/WebhooksEventsSteps.java | 193 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java index 1c06c4d7d..b196117d3 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java @@ -9,4 +9,5 @@ @SelectClasspathResource("features/voice/callouts.feature") @SelectClasspathResource("features/voice/calls.feature") @SelectClasspathResource("features/voice/conferences.feature") +@SelectClasspathResource("features/voice/webhooks-events.feature") public class VoiceIT {} diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java new file mode 100644 index 000000000..32f6a7542 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java @@ -0,0 +1,193 @@ +package com.sinch.sdk.e2e.domains.voice; + +import com.sinch.sdk.core.TestHelpers; +import com.sinch.sdk.domains.voice.WebHooksService; +import com.sinch.sdk.domains.voice.models.CallReasonType; +import com.sinch.sdk.domains.voice.models.CallResultType; +import com.sinch.sdk.domains.voice.models.DestinationNumber; +import com.sinch.sdk.domains.voice.models.Price; +import com.sinch.sdk.domains.voice.models.webhooks.AnsweredCallEvent; +import com.sinch.sdk.domains.voice.models.webhooks.DisconnectCallEvent; +import com.sinch.sdk.domains.voice.models.webhooks.MenuInputType; +import com.sinch.sdk.domains.voice.models.webhooks.MenuResult; +import com.sinch.sdk.domains.voice.models.webhooks.MenuResultInputMethodType; +import com.sinch.sdk.domains.voice.models.webhooks.PromptInputEvent; +import com.sinch.sdk.domains.voice.models.webhooks.WebhooksEvent; +import com.sinch.sdk.e2e.Config; +import com.sinch.sdk.e2e.domains.WebhooksHelper; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.io.IOException; +import java.net.URL; +import java.time.Instant; +import org.junit.jupiter.api.Assertions; + +public class WebhooksEventsSteps { + + static final String WEBHOOKS_PATH_PREFIX = "/webhooks/voice"; + static final String WEBHOOKS_URL = Config.VOICE_HOST_NAME + WEBHOOKS_PATH_PREFIX + "/"; + + WebHooksService service; + + WebhooksHelper.Response pieReturn; + WebhooksHelper.Response pieSequence; + WebhooksHelper.Response diceEvent; + WebhooksHelper.Response aceEvent; + + PromptInputEvent expectedPieReturnEvent = + PromptInputEvent.builder() + .setApplicationKey("f00dcafe-abba-c0de-1dea-dabb1ed4caf3") + .setCallId("1ce0ffee-ca11-ca11-ca11-abcdef000013") + .setTimestamp(Instant.parse("2024-06-06T17:35:01Z")) + .setVersion(1) + .setCustom("Custom text") + .setMenuResult( + MenuResult.builder() + .setType(MenuInputType.RETURN) + .setValue("cancel") + .setMenuId("main") + .setInputMethod(MenuResultInputMethodType.DTMF) + .build()) + .build(); + + PromptInputEvent expectedPieSequenceEvent = + PromptInputEvent.builder() + .setApplicationKey("f00dcafe-abba-c0de-1dea-dabb1ed4caf3") + .setCallId("1ce0ffee-ca11-ca11-ca11-abcdef000023") + .setTimestamp(Instant.parse("2024-06-06T17:35:58Z")) + .setVersion(1) + .setCustom("Custom text") + .setMenuResult( + MenuResult.builder() + .setType(MenuInputType.SEQUENCE) + .setValue("1234") + .setMenuId("confirm") + .setInputMethod(MenuResultInputMethodType.DTMF) + .build()) + .build(); + + WebhooksEvent expectedDiceEvent = + DisconnectCallEvent.builder() + .setCallId("1ce0ffee-ca11-ca11-ca11-abcdef000033") + .setTimestamp(Instant.parse("2024-06-06T16:59:42Z")) + .setReason(CallReasonType.MANAGER_HANGUP) + .setResult(CallResultType.ANSWERED) + .setVersion(1) + .setCustom("Custom text") + .setDebit(Price.builder().setCurrencyId("EUR").setAmount(0.0095F).build()) + .setUserRate(Price.builder().setCurrencyId("EUR").setAmount(0.0095F).build()) + .setTo(DestinationNumber.valueOf("12017777777")) + .setApplicationKey("f00dcafe-abba-c0de-1dea-dabb1ed4caf3") + .setDuration(12) + .setFrom("12015555555") + .build(); + WebhooksEvent expectedAceEvent = + AnsweredCallEvent.builder() + .setCallId("1ce0ffee-ca11-ca11-ca11-abcdef000043") + .setTimestamp(Instant.parse("2024-06-06T17:10:34Z")) + .setVersion(1) + .setCustom("Custom text") + .build(); + + @Given("^the Voice Webhooks handler is available$") + public void serviceAvailable() { + service = Config.getSinchClient().voice().webhooks(); + } + + @When("^I send a request to trigger a \"PIE\" event with a \"return\" type$") + public void sendPieReturn() throws IOException { + pieReturn = + WebhooksHelper.callURL( + new URL(WEBHOOKS_URL + "pie-return"), service::unserializeWebhooksEvent); + } + + @When("^I send a request to trigger a \"PIE\" event with a \"sequence\" type$") + public void sendPieSequence() throws IOException { + pieSequence = + WebhooksHelper.callURL( + new URL(WEBHOOKS_URL + "pie-sequence"), service::unserializeWebhooksEvent); + } + + @When("^I send a request to trigger a \"DICE\" event$") + public void sendDICEEvent() throws IOException { + diceEvent = + WebhooksHelper.callURL(new URL(WEBHOOKS_URL + "dice"), service::unserializeWebhooksEvent); + } + + @When("^I send a request to trigger a \"ACE\" event$") + public void sendACEEvent() throws IOException { + aceEvent = + WebhooksHelper.callURL(new URL(WEBHOOKS_URL + "ace"), service::unserializeWebhooksEvent); + } + + @Then("the header of the {string} event with a {string} type contains a valid authorization") + public void validatePieHeader(String event, String type) { + + WebhooksHelper.Response receivedEvent = null; + if (event.equals("PIE") && type.equals("return")) { + receivedEvent = pieReturn; + } else if (event.equals("PIE") && type.equals("sequence")) { + receivedEvent = pieSequence; + } else { + Assertions.fail(); + } + boolean validated = + service.validateAuthenticatedRequest( + "POST", WEBHOOKS_PATH_PREFIX, receivedEvent.headers, receivedEvent.rawPayload); + Assertions.assertTrue(validated); + } + + @Then("the header of the {string} event contains a valid authorization") + public void validateHeader(String event) { + + WebhooksHelper.Response receivedEvent = null; + if (event.equals("DICE")) { + receivedEvent = diceEvent; + } else if (event.equals("ACE")) { + receivedEvent = aceEvent; + } else { + Assertions.fail(); + } + boolean validated = + service.validateAuthenticatedRequest( + "POST", WEBHOOKS_PATH_PREFIX, receivedEvent.headers, receivedEvent.rawPayload); + Assertions.assertTrue(validated); + } + + @Then("the Voice event describes a {string} event with a {string} type") + public void validatePieEvent(String event, String type) { + + WebhooksHelper.Response receivedEvent = null; + WebhooksEvent expectedEvent = null; + if (event.equals("PIE") && type.equals("return")) { + receivedEvent = pieReturn; + expectedEvent = expectedPieReturnEvent; + } else if (event.equals("PIE") && type.equals("sequence")) { + receivedEvent = pieSequence; + expectedEvent = expectedPieSequenceEvent; + } else { + Assertions.fail(); + } + + TestHelpers.recursiveEquals(expectedEvent, receivedEvent.event); + } + + @Then("the Voice event describes a {string} event") + public void validateEvent(String event) { + + WebhooksHelper.Response receivedEvent = null; + WebhooksEvent expectedEvent = null; + if (event.equals("DICE")) { + receivedEvent = diceEvent; + expectedEvent = expectedDiceEvent; + } else if (event.equals("ACE")) { + receivedEvent = aceEvent; + expectedEvent = expectedAceEvent; + } else { + Assertions.fail(); + } + + TestHelpers.recursiveEquals(expectedEvent, receivedEvent.event); + } +} From 14af8b158966fe909c1df823ac569788fe45c384 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 20:25:00 +0200 Subject: [PATCH 07/11] test (Voice/E2E): Applications E2E coverage --- .../e2e/domains/voice/ApplicationsSteps.java | 159 ++++++++++++++++++ .../sinch/sdk/e2e/domains/voice/VoiceIT.java | 5 +- 2 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/voice/ApplicationsSteps.java diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/ApplicationsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/ApplicationsSteps.java new file mode 100644 index 000000000..b7d3a2cd6 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/ApplicationsSteps.java @@ -0,0 +1,159 @@ +package com.sinch.sdk.e2e.domains.voice; + +import com.sinch.sdk.core.TestHelpers; +import com.sinch.sdk.domains.voice.ApplicationsService; +import com.sinch.sdk.domains.voice.models.ApplicationAssignedNumber; +import com.sinch.sdk.domains.voice.models.ApplicationURL; +import com.sinch.sdk.domains.voice.models.CallbackUrls; +import com.sinch.sdk.domains.voice.models.CapabilityType; +import com.sinch.sdk.domains.voice.models.NumberInformation; +import com.sinch.sdk.domains.voice.models.NumberType; +import com.sinch.sdk.domains.voice.models.Price; +import com.sinch.sdk.domains.voice.models.requests.ApplicationsAssignNumbersRequestParameters; +import com.sinch.sdk.domains.voice.models.response.AssignedNumbers; +import com.sinch.sdk.e2e.Config; +import com.sinch.sdk.models.E164PhoneNumber; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Assertions; + +public class ApplicationsSteps { + + ApplicationsService service; + + AssignedNumbers listNumbersResponse; + Boolean assignNumbersPassed; + Boolean unassignNumberPassed; + NumberInformation queryNumberResult; + CallbackUrls getCallbackUrlsResult; + Boolean updateCallbackUrlsPassed; + + @Given("^the Voice service \"Applications\" is available") + public void serviceAvailable() { + + service = Config.getSinchClient().voice().applications(); + } + + @When("^I send a request to get information about my owned numbers$") + public void listNumbers() { + + listNumbersResponse = service.listNumbers(); + } + + @When("^I send a request to assign some numbers to a Voice Application$") + public void assignNumbers() { + + ApplicationsAssignNumbersRequestParameters request = + ApplicationsAssignNumbersRequestParameters.builder() + .setNumbers(Collections.singletonList(E164PhoneNumber.valueOf("+12012222222"))) + .setApplicationKey("f00dcafe-abba-c0de-1dea-dabb1ed4caf3") + .setCapability(CapabilityType.VOICE) + .build(); + service.assignNumbers(request); + assignNumbersPassed = true; + } + + @When("^I send a request to unassign a number from a Voice Application$") + public void unassignNumber() { + + service.unassignNumber(E164PhoneNumber.valueOf("+12012222222"), ""); + unassignNumberPassed = true; + } + + @When("^I send a request to get information about a specific number$") + public void queryNumber() { + + queryNumberResult = service.queryNumber(E164PhoneNumber.valueOf("+12015555555")); + } + + @When("^I send a request to get the callback URLs associated to an application$") + public void getCallbackUrls() { + + getCallbackUrlsResult = service.getCallbackUrls("f00dcafe-abba-c0de-1dea-dabb1ed4caf3"); + } + + @When("^I send a request to update the callback URLs associated to an application$") + public void updateCallbackUrls() { + CallbackUrls request = + CallbackUrls.builder() + .setUrl( + ApplicationURL.builder() + .setPrimary("https://my-new.callback-server.com/voice") + .build()) + .build(); + service.updateCallbackUrls("f00dcafe-abba-c0de-1dea-dabb1ed4caf3", request); + updateCallbackUrlsPassed = true; + } + + @Then("the response contains details about the numbers that I own") + public void listNumbersResult() { + AssignedNumbers expected = + AssignedNumbers.builder() + .setNumbers( + Arrays.asList( + ApplicationAssignedNumber.builder() + .setNumber(E164PhoneNumber.valueOf("+12012222222")) + .setCapability(CapabilityType.VOICE) + .build(), + ApplicationAssignedNumber.builder() + .setNumber(E164PhoneNumber.valueOf("+12013333333")) + .setCapability(CapabilityType.VOICE) + .setApplicationKey("ba5eba11-1dea-1337-babe-5a1ad00d1eaf") + .build(), + ApplicationAssignedNumber.builder() + .setNumber(E164PhoneNumber.valueOf("+12014444444")) + .setCapability(CapabilityType.VOICE) + .build(), + ApplicationAssignedNumber.builder() + .setNumber(E164PhoneNumber.valueOf("+12015555555")) + .setCapability(CapabilityType.VOICE) + .setApplicationKey("f00dcafe-abba-c0de-1dea-dabb1ed4caf3") + .build())) + .build(); + TestHelpers.recursiveEquals(listNumbersResponse, expected); + } + + @Then("the assign numbers response contains no data") + public void assignNumbersResult() { + Assertions.assertTrue(assignNumbersPassed); + } + + @Then("the unassign number response contains no data") + public void unassignNumberResult() { + Assertions.assertTrue(unassignNumberPassed); + } + + @Then("the response contains details about the specific number") + public void queryNumberResult() { + NumberInformation expected = + NumberInformation.builder() + .setCountryId("US") + .setNumberType(NumberType.FIXED) + .setNormalizedNumber(E164PhoneNumber.valueOf("+12015555555")) + .setRestricted(true) + .setRate(Price.builder().setCurrencyId("USD").setAmount(0.01F).build()) + .build(); + TestHelpers.recursiveEquals(queryNumberResult, expected); + } + + @Then("the response contains callback URLs details") + public void getCallbackUrlsResult() { + CallbackUrls expected = + CallbackUrls.builder() + .setUrl( + ApplicationURL.builder() + .setPrimary("https://my.callback-server.com/voice") + .setFallback("https://my.fallback-server.com/voice") + .build()) + .build(); + TestHelpers.recursiveEquals(getCallbackUrlsResult, expected); + } + + @Then("the update callback URLs response contains no data") + public void updateCallbackUrlsResult() { + Assertions.assertTrue(updateCallbackUrlsPassed); + } +} diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java index b196117d3..176ca6816 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/VoiceIT.java @@ -6,8 +6,5 @@ @Suite @IncludeEngines("cucumber") -@SelectClasspathResource("features/voice/callouts.feature") -@SelectClasspathResource("features/voice/calls.feature") -@SelectClasspathResource("features/voice/conferences.feature") -@SelectClasspathResource("features/voice/webhooks-events.feature") +@SelectClasspathResource("features/voice") public class VoiceIT {} From dc3fee99ca8499c18824da2ee08bf48185d291fa Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 20:36:34 +0200 Subject: [PATCH 08/11] refactor (Conversation/E2E): Use shared WebhooksHelper for Conversation webhooks validation --- .../conversation/WebhooksEventsSteps.java | 95 ++++++------------- 1 file changed, 31 insertions(+), 64 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/conversation/WebhooksEventsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/conversation/WebhooksEventsSteps.java index 3818869c1..ca602f014 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/conversation/WebhooksEventsSteps.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/conversation/WebhooksEventsSteps.java @@ -31,17 +31,14 @@ import com.sinch.sdk.domains.conversation.models.v1.webhooks.events.smartconversations.MessageInboundSmartConversationRedactionEvent; import com.sinch.sdk.domains.conversation.models.v1.webhooks.events.smartconversations.SmartConversationsEvent; import com.sinch.sdk.e2e.Config; +import com.sinch.sdk.e2e.domains.WebhooksHelper; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.net.HttpURLConnection; import java.net.URL; import java.time.Instant; import java.util.AbstractMap; -import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @@ -116,14 +113,15 @@ public class WebhooksEventsSteps { WebHooksService service; - Map receivedEvents = new ConcurrentHashMap<>(); + Map> receivedEvents = + new ConcurrentHashMap<>(); - Response eventDeliveryReceiptFailedResponse; - Response messageDeliveryReceiptFailedResponse; - Response messageSubmitMediaResponse; - Response messageSubmitTextResponse; - Response smartConversationMediaResponse; - Response smartConversationTextResponse; + WebhooksHelper.Response eventDeliveryReceiptFailedResponse; + WebhooksHelper.Response messageDeliveryReceiptFailedResponse; + WebhooksHelper.Response messageSubmitMediaResponse; + WebhooksHelper.Response messageSubmitTextResponse; + WebhooksHelper.Response smartConversationMediaResponse; + WebhooksHelper.Response smartConversationTextResponse; @Given("^the Conversation Webhooks handler is available$") public void handlerAvailable() { @@ -173,7 +171,8 @@ public void triggerCONVERSATION_STOP() throws IOException { @When("^I send a request to trigger a \"EVENT_DELIVERY\" event with a \"FAILED\" status$") public void triggerEVENT_DELIVERY_FAILED() throws IOException { eventDeliveryReceiptFailedResponse = - callURL(new URL(WEBHOOKS_PATH + "event-delivery-report/failed")); + WebhooksHelper.callURL( + new URL(WEBHOOKS_PATH + "event-delivery-report/failed"), service::parseEvent); } @When("^I send a request to trigger a \"EVENT_DELIVERY\" event with a \"DELIVERED\" status$") @@ -189,7 +188,8 @@ public void triggerINBOUND() throws IOException { @When("^I send a request to trigger a \"MESSAGE_DELIVERY\" event with a \"FAILED\" status$") public void triggerMESSAGE_DELIVERY_FAILED() throws IOException { messageDeliveryReceiptFailedResponse = - callURL(new URL(WEBHOOKS_PATH + "message-delivery-report/failed")); + WebhooksHelper.callURL( + new URL(WEBHOOKS_PATH + "message-delivery-report/failed"), service::parseEvent); } @When( @@ -211,28 +211,36 @@ public void triggerMESSAGE_INBOUND_SMART_CONVERSATION_REDACTION() throws IOExcep @When("^I send a request to trigger a \"MESSAGE_SUBMIT\" event for a \"media\" message$") public void triggerMESSAGE_SUBMIT_MEDIA() throws IOException { - messageSubmitMediaResponse = callURL(new URL(WEBHOOKS_PATH + "message-submit/media")); + messageSubmitMediaResponse = + WebhooksHelper.callURL( + new URL(WEBHOOKS_PATH + "message-submit/media"), service::parseEvent); } @When("^I send a request to trigger a \"MESSAGE_SUBMIT\" event for a \"text\" message$") public void triggerMESSAGE_SUBMIT_TEXT() throws IOException { - messageSubmitTextResponse = callURL(new URL(WEBHOOKS_PATH + "message-submit/text")); + messageSubmitTextResponse = + WebhooksHelper.callURL(new URL(WEBHOOKS_PATH + "message-submit/text"), service::parseEvent); } @When("^I send a request to trigger a \"SMART_CONVERSATIONS\" event for a \"media\" message$") public void triggerSMART_CONVERSATIONS_MEDIA() throws IOException { - smartConversationMediaResponse = callURL(new URL(WEBHOOKS_PATH + "smart-conversations/media")); + smartConversationMediaResponse = + WebhooksHelper.callURL( + new URL(WEBHOOKS_PATH + "smart-conversations/media"), service::parseEvent); } @When("^I send a request to trigger a \"SMART_CONVERSATIONS\" event for a \"text\" message$") public void triggerSMART_CONVERSATIONS_TEXT() throws IOException { - smartConversationTextResponse = callURL(new URL(WEBHOOKS_PATH + "smart-conversations/text")); + smartConversationTextResponse = + WebhooksHelper.callURL( + new URL(WEBHOOKS_PATH + "smart-conversations/text"), service::parseEvent); } @Then("the header of the Conversation event {string} contains a valid signature") public void validateHeader(String e2eKeyword) { - Response receivedEvent = receivedEvents.get(WebhookTrigger.from(e2eKeyword)); + WebhooksHelper.Response receivedEvent = + receivedEvents.get(WebhookTrigger.from(e2eKeyword)); if (null != receivedEvent) { boolean validated = @@ -245,7 +253,7 @@ public void validateHeader(String e2eKeyword) { @Then("the Conversation event describes a {string} event type") public void triggerResult(String e2eKeyword) { WebhookTrigger trigger = WebhookTrigger.from(e2eKeyword); - Response receivedEvent = receivedEvents.get(trigger); + WebhooksHelper.Response receivedEvent = receivedEvents.get(trigger); if (null != receivedEvent) { Assertions.assertInstanceOf(expectedClasses.get(trigger), receivedEvent.event); } @@ -256,7 +264,7 @@ public void triggerResult(String e2eKeyword) { + " signature") public void validateEventDeliveryFailureHeader(String e2eKeyword, String status) { - Response receivedEvent = null; + WebhooksHelper.Response receivedEvent = null; if (e2eKeyword.equals(WebhookTrigger.EVENT_DELIVERY.value()) && status.equals(DeliveryStatus.FAILED.value())) { receivedEvent = eventDeliveryReceiptFailedResponse; @@ -359,7 +367,7 @@ public void messageDeliveryFailureResult() { "the header of the Conversation event {string} for a {string} message contains a valid" + " signature") public void validateMessageSubmitHeader(String e2eKeyword, String messageType) { - Response receivedEvent = null; + WebhooksHelper.Response receivedEvent = null; if (e2eKeyword.equals("MESSAGE_SUBMIT") && messageType.equals("media")) { receivedEvent = messageSubmitMediaResponse; } else if (e2eKeyword.equals("MESSAGE_SUBMIT") && messageType.equals("text")) { @@ -440,49 +448,8 @@ public void messageTypeEventResult(String e2eKeyword, String messageType) { public void handleRequest(WebhookTrigger trigger) throws IOException { - Response response = callURL(new URL(triggerToURL.get(trigger))); + WebhooksHelper.Response response = + WebhooksHelper.callURL(new URL(triggerToURL.get(trigger)), service::parseEvent); receivedEvents.put(trigger, response); } - - Response callURL(URL url) throws IOException { - - HttpURLConnection con = (HttpURLConnection) url.openConnection(); - con.setRequestMethod("GET"); - - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - - byte[] buffer = new byte[1024]; - int bytesRead; - while ((bytesRead = con.getInputStream().read(buffer)) != -1) { - byteArrayOutputStream.write(buffer, 0, bytesRead); - } - Response response = new Response(); - response.headers = transformHeaders(con.getHeaderFields()); - response.rawPayload = byteArrayOutputStream.toString("UTF-8"); - response.event = service.parseEvent(response.rawPayload); - return response; - } - - static Map transformHeaders(Map> headers) { - if (null == headers) { - return null; - } - HashMap newMap = new HashMap<>(); - headers.forEach((key, value) -> newMap.put(key, concatHeaderValues(value))); - return newMap; - } - - static String concatHeaderValues(List values) { - if (null == values) { - return null; - } - return values.stream() - .reduce(null, (previous, current) -> (null != previous ? previous + ";" : "") + current); - } - - static class Response { - Map headers; - String rawPayload; - ConversationWebhookEvent event; - } } From ac93425f38ee39d86557df5be53153e90dc0a2b7 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 1 Oct 2024 17:48:17 +0200 Subject: [PATCH 09/11] refactor (Voice & ApplicationAuthManager): Use MapUtils to create a case insensitive headers map --- .../com/sinch/sdk/auth/adapters/ApplicationAuthManager.java | 6 ++---- .../sinch/sdk/domains/voice/adapters/WebHooksService.java | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/client/src/main/com/sinch/sdk/auth/adapters/ApplicationAuthManager.java b/client/src/main/com/sinch/sdk/auth/adapters/ApplicationAuthManager.java index e24838e36..0e410eb6a 100644 --- a/client/src/main/com/sinch/sdk/auth/adapters/ApplicationAuthManager.java +++ b/client/src/main/com/sinch/sdk/auth/adapters/ApplicationAuthManager.java @@ -3,6 +3,7 @@ import com.sinch.sdk.core.exceptions.ApiAuthException; import com.sinch.sdk.core.exceptions.ApiException; import com.sinch.sdk.core.http.AuthManager; +import com.sinch.sdk.core.utils.MapUtils; import com.sinch.sdk.core.utils.Pair; import com.sinch.sdk.core.utils.StringUtil; import java.net.URI; @@ -15,7 +16,6 @@ import java.util.Base64; import java.util.Collection; import java.util.Map; -import java.util.TreeMap; import java.util.logging.Logger; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -107,9 +107,7 @@ public boolean validateAuthenticatedRequest( String method, String path, Map headers, String jsonPayload) { // convert header keys to use case-insensitive map keys - Map caseInsensitiveHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - caseInsensitiveHeaders.putAll(headers); - + Map caseInsensitiveHeaders = MapUtils.getCaseInsensitiveMap(headers); String authorizationHeader = caseInsensitiveHeaders.get("Authorization"); // missing authorization header diff --git a/client/src/main/com/sinch/sdk/domains/voice/adapters/WebHooksService.java b/client/src/main/com/sinch/sdk/domains/voice/adapters/WebHooksService.java index ac89e8322..bcdb9d5d5 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/adapters/WebHooksService.java +++ b/client/src/main/com/sinch/sdk/domains/voice/adapters/WebHooksService.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.sinch.sdk.core.exceptions.ApiMappingException; import com.sinch.sdk.core.http.AuthManager; +import com.sinch.sdk.core.utils.MapUtils; import com.sinch.sdk.core.utils.databind.Mapper; import com.sinch.sdk.domains.voice.adapters.converters.CallsDtoConverter; import com.sinch.sdk.domains.voice.adapters.converters.WebhooksEventDtoConverter; @@ -11,7 +12,6 @@ import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl; import com.sinch.sdk.domains.voice.models.webhooks.WebhooksEvent; import java.util.Map; -import java.util.TreeMap; import java.util.logging.Logger; public class WebHooksService implements com.sinch.sdk.domains.voice.WebHooksService { @@ -27,8 +27,7 @@ public boolean validateAuthenticatedRequest( String method, String path, Map headers, String jsonPayload) { // convert header keys to use case-insensitive map keys - Map caseInsensitiveHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - caseInsensitiveHeaders.putAll(headers); + Map caseInsensitiveHeaders = MapUtils.getCaseInsensitiveMap(headers); String authorizationHeader = caseInsensitiveHeaders.get("Authorization"); From 456e1a9fe41c07f7443a4f9b5e3abe4e9263d628 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Wed, 2 Oct 2024 09:56:27 +0200 Subject: [PATCH 10/11] refactor (E2E): Reduce source complexity --- .../sinch/sdk/e2e/domains/WebhooksHelper.java | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java b/client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java index 0a3ea49f8..45deaf9df 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/WebhooksHelper.java @@ -22,10 +22,12 @@ public static Response callURL(URL url, Function parseEvent) byte[] buffer = new byte[1024]; int bytesRead; - InputStream inputStream = con.getInputStream(); - while ((bytesRead = inputStream.read(buffer)) != -1) { - byteArrayOutputStream.write(buffer, 0, bytesRead); + try (InputStream inputStream = con.getInputStream()) { + while ((bytesRead = inputStream.read(buffer)) != -1) { + byteArrayOutputStream.write(buffer, 0, bytesRead); + } } + Response response = new Response<>(); response.headers = transformHeaders(con.getHeaderFields()); response.rawPayload = byteArrayOutputStream.toString("UTF-8"); @@ -38,18 +40,10 @@ static Map transformHeaders(Map> headers) { return null; } HashMap newMap = new HashMap<>(); - headers.forEach((key, value) -> newMap.put(key, concatHeaderValues(value))); + headers.forEach((key, value) -> newMap.put(key, String.join(";", value))); return newMap; } - static String concatHeaderValues(List values) { - if (null == values) { - return null; - } - return values.stream() - .reduce(null, (previous, current) -> (null != previous ? previous + ";" : "") + current); - } - public static class Response { public Map headers; public String rawPayload; From 80f42fbeece62d91fb49d6e9ebf74d670780eee9 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Wed, 2 Oct 2024 09:56:56 +0200 Subject: [PATCH 11/11] refactor (Voice/E2E): PR comments --- .../sdk/e2e/domains/voice/WebhooksEventsSteps.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java index 32f6a7542..60d7ae04a 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/voice/WebhooksEventsSteps.java @@ -26,7 +26,7 @@ public class WebhooksEventsSteps { static final String WEBHOOKS_PATH_PREFIX = "/webhooks/voice"; - static final String WEBHOOKS_URL = Config.VOICE_HOST_NAME + WEBHOOKS_PATH_PREFIX + "/"; + static final String WEBHOOKS_URL = Config.VOICE_HOST_NAME + WEBHOOKS_PATH_PREFIX; WebHooksService service; @@ -99,26 +99,26 @@ public void serviceAvailable() { public void sendPieReturn() throws IOException { pieReturn = WebhooksHelper.callURL( - new URL(WEBHOOKS_URL + "pie-return"), service::unserializeWebhooksEvent); + new URL(WEBHOOKS_URL + "/pie-return"), service::unserializeWebhooksEvent); } @When("^I send a request to trigger a \"PIE\" event with a \"sequence\" type$") public void sendPieSequence() throws IOException { pieSequence = WebhooksHelper.callURL( - new URL(WEBHOOKS_URL + "pie-sequence"), service::unserializeWebhooksEvent); + new URL(WEBHOOKS_URL + "/pie-sequence"), service::unserializeWebhooksEvent); } @When("^I send a request to trigger a \"DICE\" event$") public void sendDICEEvent() throws IOException { diceEvent = - WebhooksHelper.callURL(new URL(WEBHOOKS_URL + "dice"), service::unserializeWebhooksEvent); + WebhooksHelper.callURL(new URL(WEBHOOKS_URL + "/dice"), service::unserializeWebhooksEvent); } @When("^I send a request to trigger a \"ACE\" event$") public void sendACEEvent() throws IOException { aceEvent = - WebhooksHelper.callURL(new URL(WEBHOOKS_URL + "ace"), service::unserializeWebhooksEvent); + WebhooksHelper.callURL(new URL(WEBHOOKS_URL + "/ace"), service::unserializeWebhooksEvent); } @Then("the header of the {string} event with a {string} type contains a valid authorization")