Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Marketing Cloud integration compatibility testing #21

Merged
merged 6 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
# This workflow will build a .NET project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net

name: .NET
name: CI Tests

on:
push:
branches: [ "main", "ci-dev" ]
branches: [ "main" ]
pull_request:
branches: [ "main", "ci-dev" ]
branches: [ "main" ]

jobs:
build:

runs-on: ubuntu-latest

steps:
Expand All @@ -28,4 +27,4 @@ jobs:
run: dotnet build --no-restore
- name: Test
working-directory: ./src
run: dotnet test --no-build --verbosity normal
run: dotnet test --no-build --verbosity normal --filter TestCategory!="Compatibility"
35 changes: 35 additions & 0 deletions .github/workflows/compatibility_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# This workflow will build a .NET project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net

name: Compatibility Tests

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:
environment: MC Integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Restore dependencies
working-directory: ./src
run: dotnet restore
- name: Build
working-directory: ./src
run: dotnet build --no-restore
- name: Test
working-directory: ./src
env:
MC_CLIENT_ID: ${{ secrets.MC_CLIENT_ID }}
MC_CLIENT_SECRET: ${{ secrets.MC_CLIENT_SECRET }}
MC_CLIENT_MID: ${{ secrets.MC_CLIENT_MID }}
MC_CLIENT_BASE_URI: ${{ secrets.MC_CLIENT_BASE_URI }}
run: dotnet test --no-build --verbosity normal --filter TestCategory="Compatibility"
11 changes: 11 additions & 0 deletions src/Sage.Engine.Tests/.runsettings
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<RunConfiguration>
<EnvironmentVariables>
<MC_CLIENT_ID></MC_CLIENT_ID>
<MC_CLIENT_SECRET></MC_CLIENT_SECRET>
<MC_CLIENT_MID></MC_CLIENT_MID>
<MC_CLIENT_BASE_URI></MC_CLIENT_BASE_URI>
</EnvironmentVariables>
</RunConfiguration>
</RunSettings>
22 changes: 22 additions & 0 deletions src/Sage.Engine.Tests/Compatibility/AuthTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2023, salesforce.com, inc.
// All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0

using Microsoft.Extensions.DependencyInjection;

namespace Sage.Engine.Tests.Compatibility
{
/// <summary>
/// Sanity tests for getting an auth token
/// </summary>
internal class AuthTests : SageTest
{
[Test]
[Category("Compatibility")]
public void GetAuthToken()
{
_serviceProvider.GetRequiredService<IMarketingCloudRestClient>().RefreshToken();
}
}
}
17 changes: 17 additions & 0 deletions src/Sage.Engine.Tests/Compatibility/BadScriptException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) 2022, salesforce.com, inc.
// All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0

namespace Sage.Engine.Tests.Compatibility
{
public class BadScriptException : Exception
{
private readonly string _code;
public BadScriptException(string code, string response, Exception? innerException = null)
: base(response, innerException)
{
this._code = code;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2023, salesforce.com, inc.
// All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0

using MarketingCloudIntegration.Render;
using Microsoft.Extensions.DependencyInjection;

namespace Sage.Engine.Tests.Compatibility
{
internal static class CompatibilityDependencyInjection
{
public static void AddMarketingCloudRenderingService(
this IServiceCollection services)
{
services.AddSingleton<IMarketingCloudRestClient, MarketingCloudRestClient>();
services.AddSingleton<IRenderService, RenderService>();
}
}
}
23 changes: 23 additions & 0 deletions src/Sage.Engine.Tests/Compatibility/IMarketingCloudRestClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) 2023, salesforce.com, inc.
// All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0

namespace Sage.Engine.Tests.Compatibility
{
/// <summary>
/// An interface to the Marketing Cloud REST API
/// </summary>
internal interface IMarketingCloudRestClient
{
/// <summary>
/// Sends a REST request using an exponential backoff
/// </summary>
HttpResponseMessage SendWithRetryBackoff(HttpRequestMessage message);

/// <summary>
/// Manually refreshes the auth token. Used for testing - should not be necessary for use of the interface
/// </summary>
Task RefreshToken();
}
}
19 changes: 19 additions & 0 deletions src/Sage.Engine.Tests/Compatibility/IRenderService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2023, salesforce.com, inc.
// All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0

namespace MarketingCloudIntegration.Render
{
/// <summary>
/// An interface to the REST API
/// </summary>
internal interface IRenderService
{
/// <summary>
/// Renders a message for the given channel
/// </summary>
/// <param name="channel">SMS or PUSH</param>
Task<RenderResponse> Render(string channel, RenderRequest request);
}
}
25 changes: 25 additions & 0 deletions src/Sage.Engine.Tests/Compatibility/JsonContent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2023, salesforce.com, inc.
// All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0

using System.Net.Http.Headers;
using System.Text.Json.Nodes;

namespace Sage.Engine.Tests.Compatibility
{
/// <summary>
/// Helper to add the media type for the requests
/// </summary>
public class JsonContent : StringContent
{
public JsonContent(JsonObject content) : base(content.ToString())
{
Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
public JsonContent(string content) : base(content)
{
Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
}
}
126 changes: 126 additions & 0 deletions src/Sage.Engine.Tests/Compatibility/MarketingCloudRestClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2023, salesforce.com, inc.
// All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0

using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using Polly;

namespace Sage.Engine.Tests.Compatibility
{
/// <summary>
/// A very simple REST client for testing compatibility of content
/// </summary>
internal class MarketingCloudRestClient : IMarketingCloudRestClient
{
private readonly string _clientId;
readonly string _clientSecret;
readonly int _mid;
readonly Uri _restUri;
readonly Uri _authUri;
private readonly HttpClient _authHttpClient;
private readonly HttpClient _restHttpClient;

DateTime _expiration = DateTime.MinValue;

public MarketingCloudRestClient()
{
this._clientId = Environment.GetEnvironmentVariable("MC_CLIENT_ID") ?? throw new InvalidDataException("MC_CLIENT_ID not set in the environment");
this._clientSecret = Environment.GetEnvironmentVariable("MC_CLIENT_SECRET") ?? throw new InvalidDataException("MC_CLIENT_SECRET not set in the environment");
this._mid = int.Parse(Environment.GetEnvironmentVariable("MC_CLIENT_MID") ?? throw new InvalidDataException("MC_CLIENT_MID not set in the environment"));
string baseUri = Environment.GetEnvironmentVariable("MC_CLIENT_BASE_URI") ?? throw new InvalidDataException("MC_CLIENT_BASE_URI not set in the environment");

this._authUri = new Uri($"https://{baseUri}.auth.marketingcloudapis.com");
this._restUri = new Uri($"https://{baseUri}.rest.marketingcloudapis.com");
_authHttpClient = new HttpClient()
{
BaseAddress = _authUri,
Timeout = TimeSpan.FromMinutes(5)
};
_authHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

_restHttpClient = new HttpClient()
{
BaseAddress = _restUri,
Timeout = TimeSpan.FromMinutes(5)
};
_restHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}

public async Task RefreshToken()
{
var content = new JsonObject
{
["grant_type"] = "client_credentials",
["client_id"] = this._clientId,
["client_secret"] = this._clientSecret,
["account_id"] = _mid
};

var exponentialBackoff = Policy
.Handle<HttpRequestException>()
.WaitAndRetry(5, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))
);

HttpResponseMessage response = exponentialBackoff.Execute(() => GetAuthResponse(this._authHttpClient, new JsonContent(content)).Result);
response.EnsureSuccessStatusCode();

string responseString = await response.Content.ReadAsStringAsync() ??
throw new InvalidDataException("Response successful but no payload");

JsonNode responseBody = JsonNode.Parse(responseString) ??
throw new InvalidDataException("Response successful but no payload");

string? accessToken = responseBody["access_token"]?.ToString();
if (string.IsNullOrWhiteSpace(accessToken))
{
throw new InvalidDataException("No access token in payload");
}

_restHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

if (!int.TryParse(responseBody["expires_in"]?.ToString(), out int expirationSeconds))
{
throw new InvalidDataException("No expiration in payload");
}

_expiration = DateTime.Now.AddSeconds(expirationSeconds);
}

protected virtual async Task<HttpResponseMessage> GetAuthResponse(HttpClient client, StringContent content)
{
return await client.PostAsync("/v2/token", content);
}

public HttpResponseMessage SendWithRetryBackoff(HttpRequestMessage message)
{
var exponentialBackoff = Policy
.Handle<HttpRequestException>()
.WaitAndRetry(5, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))
);

return exponentialBackoff.Execute(() =>
{
return GetRestResponse(this._restHttpClient, message).Result;
});
}

protected virtual async Task<HttpResponseMessage> GetRestResponse(HttpClient client, HttpRequestMessage request)
{
await RefreshTokenIfNecessary();

return await client.SendAsync(request);
}

protected virtual async Task RefreshTokenIfNecessary()
{
if (DateTime.Now > _expiration)
{
await RefreshToken();
}
}
}
}
43 changes: 43 additions & 0 deletions src/Sage.Engine.Tests/Compatibility/RenderObjects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;

namespace MarketingCloudIntegration.Render
{
[Serializable]
public class RenderRequest
{
public string? context { get; set; }
public string? content { get; set; }
public Dictionary<string, string>? attributes { get; set; }
public DataExtension? dataExtension { get; set; }
public Recipient? recipient { get; set; }
}

[Serializable]
public class DataExtension
{
public string? customerKey { get; set; }
public string? objectID { get; set; }
public string? row { get; set; }

}

[Serializable]
public class Recipient
{
public Dictionary<string, string>? attributes { get; set; }
public string? contactKey { get; set; }
public string? appID { get; set; }
public string? deviceID { get; set; }
public string? mobileNumber { get; set; }
}

[Serializable]
public class RenderResponse
{
public long? renderServiceLogID { get; set; }
public string? renderedContent { get; set; }
public List<string>? contentFieldsNotFound { get; set; }

}
}
Loading
Loading