Skip to content

Commit b50a443

Browse files
metro-gn0meShreyansh Kulshreshtha
andauthored
Add support for query parameters, asynchronous response and non-Windows machines (#15)
Co-authored-by: Shreyansh Kulshreshtha <shreyansh.kulshreshtha@publicissapient.com>
1 parent c3bb549 commit b50a443

File tree

6 files changed

+159
-21
lines changed

6 files changed

+159
-21
lines changed

LogicAppUnit/Hosting/CallbackUrlDefinition.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,20 @@ public string QueryString
6464
/// <summary>
6565
/// Gets the value, with a relative path component.
6666
/// </summary>
67+
/// <param name="queryParams">The query parameters to be passed to the workflow.</param>
6768
/// <param name="relativePath">The relative path to be used in the trigger. The path must already be URL-encoded.</param>
68-
public Uri ValueWithRelativePath(string relativePath)
69+
public Uri ValueWithQueryAndRelativePath(Dictionary<string, string> queryParams, string relativePath)
6970
{
70-
// If there is no relative path, use the 'Value'
71-
if (string.IsNullOrEmpty(relativePath))
72-
return Value;
71+
// If there is a relative path, remove the preceding "/"
72+
// Relative path should not have a preceding "/";
73+
// See Remark under https://learn.microsoft.com/en-us/dotnet/api/system.uri.-ctor?view=net-7.0#system-uri-ctor(system-uri-system-string)
74+
if (!string.IsNullOrEmpty(relativePath))
75+
relativePath = relativePath.TrimStart('/');
76+
77+
// If there are query parameters, add them to the Queries property
78+
if (queryParams is not null)
79+
foreach (var pair in queryParams)
80+
Queries.Add(pair.Key, pair.Value);
7381

7482
// Make sure the base path has a trailing slash to preserve the relative path in 'Value'
7583
string basePathAsString = BasePath.ToString();

LogicAppUnit/Hosting/TestEnvironment.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,22 @@ internal class TestEnvironment
3939
/// <summary>
4040
/// The management host name.
4141
/// </summary>
42-
public static readonly string ManagementHostName = $"{Environment.MachineName}";
42+
public static readonly string MachineHostName = OperatingSystem.IsWindows() ? Environment.MachineName : "localhost";
43+
44+
/// <summary>
45+
/// The management host name.
46+
/// </summary>
47+
public static readonly string ManagementHostName = OperatingSystem.IsWindows() ? Environment.MachineName : "localhost";
4348

4449
/// <summary>
4550
/// The test host uri.
4651
/// </summary>
47-
public static readonly string FlowV2TestHostUri = (new UriBuilder(Uri.UriSchemeHttp, Environment.MachineName, 7071).Uri.ToString()).TrimEnd('/');
52+
public static readonly string FlowV2TestHostUri = (new UriBuilder(Uri.UriSchemeHttp, MachineHostName, 7071).Uri.ToString()).TrimEnd('/');
4853

4954
/// <summary>
5055
/// The mock test host uri.
5156
/// </summary>
52-
public static readonly string FlowV2MockTestHostUri = (new UriBuilder(Uri.UriSchemeHttp, Environment.MachineName, 7075).Uri.ToString()).TrimEnd('/');
57+
public static readonly string FlowV2MockTestHostUri = (new UriBuilder(Uri.UriSchemeHttp, MachineHostName, 7075).Uri.ToString()).TrimEnd('/');
5358

5459
/// <summary>
5560
/// The test host uri.

LogicAppUnit/Hosting/WorkflowTestHost.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -208,15 +208,23 @@ private static void KillFunctionHostProcesses()
208208
}
209209

210210
/// <summary>
211-
/// Retrieve the exact path of func.exe (Azure Function core tools).
211+
/// Retrieve the exact path of func executable (Azure Function core tools).
212212
/// </summary>
213-
/// <returns>The path to func.exe.</returns>
214-
/// <exception cref="Exception">Thrown when the location of func.exe could not be found.</exception>
213+
/// <returns>The path to the func executable.</returns>
214+
/// <exception cref="Exception">Thrown when the location of func executable could not be found.</exception>
215215
private static string GetEnvPathForFunctionTools()
216216
{
217-
var enviromentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine);
218-
219-
var exePath = enviromentPath.Split(';').Select(x => Path.Combine(x, "func.exe")).Where(x => File.Exists(x)).FirstOrDefault();
217+
string exePath;
218+
if(OperatingSystem.IsWindows())
219+
{
220+
var enviromentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine);
221+
exePath = enviromentPath.Split(';').Select(x => Path.Combine(x, "func.exe")).Where(x => File.Exists(x)).FirstOrDefault();
222+
}
223+
else
224+
{
225+
var enviromentPath = Environment.GetEnvironmentVariable("PATH");
226+
exePath = enviromentPath.Split(':').Select(x => Path.Combine(x, "func")).Where(x => File.Exists(x)).FirstOrDefault();
227+
}
220228

221229
if (!string.IsNullOrWhiteSpace(exePath))
222230
{
@@ -225,7 +233,7 @@ private static string GetEnvPathForFunctionTools()
225233
}
226234
else
227235
{
228-
throw new Exception("Enviroment variables do not have FUNC.EXE path added.");
236+
throw new Exception("Enviroment variables do not have func executable path added.");
229237
}
230238
}
231239

LogicAppUnit/ITestRunner.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,28 @@ public interface ITestRunner : IDisposable
151151
/// </remarks>
152152
HttpResponseMessage TriggerWorkflow(HttpMethod method, Dictionary<string, string> requestHeaders = null);
153153

154+
/// <summary>
155+
/// Trigger a workflow using query parameters, an empty request content and optional request headers.
156+
/// </summary>
157+
/// <param name="queryParams">The query parameters to be passed to the workflow.</param>
158+
/// <param name="method">The HTTP method, this needs to match the method defined in the HTTP trigger in the workflow.</param>
159+
/// <param name="requestHeaders">The request headers.</param>
160+
/// <returns>The response from the workflow.</returns>
161+
/// <remarks>
162+
/// An empty request body may be used for workflows that contain triggers that do not use a request body, for example a Recurrence trigger.
163+
/// </remarks>
164+
HttpResponseMessage TriggerWorkflow(Dictionary<string, string> queryParams, HttpMethod method, Dictionary<string, string> requestHeaders = null);
165+
166+
/// <summary>
167+
/// Trigger a workflow using query parameters, a relative path and optional request headers.
168+
/// </summary>
169+
/// <param name="queryParams">The query parameters to be passed to the workflow.</param>
170+
/// <param name="method">The HTTP method, this needs to match the method defined in the HTTP trigger in the workflow.</param>
171+
/// <param name="relativePath">The relative path to be used in the trigger. The path must already be URL-encoded.</param>
172+
/// <param name="requestHeaders">The request headers.</param>
173+
/// <returns>The response from the workflow.</returns>
174+
HttpResponseMessage TriggerWorkflow(Dictionary<string, string> queryParams, HttpMethod method, string relativePath, Dictionary<string, string> requestHeaders = null);
175+
154176
/// <summary>
155177
/// Trigger a workflow using a request body and optional request headers.
156178
/// </summary>
@@ -170,6 +192,17 @@ public interface ITestRunner : IDisposable
170192
/// <returns>The response from the workflow.</returns>
171193
HttpResponseMessage TriggerWorkflow(HttpContent content, HttpMethod method, string relativePath, Dictionary<string, string> requestHeaders = null);
172194

195+
/// <summary>
196+
/// Trigger a workflow using query parameters, a request body, a relative path and optional request headers.
197+
/// </summary>
198+
/// <param name="queryParams">The query parameters to be passed to the workflow.</param>
199+
/// <param name="content">The content (including any content headers) for running the workflow, or <c>null</c> if there is no content.</param>
200+
/// <param name="method">The HTTP method, this needs to match the method defined in the HTTP trigger in the workflow.</param>
201+
/// <param name="relativePath">The relative path to be used in the trigger. The path must already be URL-encoded.</param>
202+
/// <param name="requestHeaders">The request headers.</param>
203+
/// <returns>The response from the workflow.</returns>
204+
HttpResponseMessage TriggerWorkflow(Dictionary<string, string> queryParams, HttpContent content, HttpMethod method, string relativePath, Dictionary<string, string> requestHeaders = null);
205+
173206
#endregion // TriggerWorkflow
174207

175208
/// <summary>
@@ -178,5 +211,11 @@ public interface ITestRunner : IDisposable
178211
/// <param name="assertion">The test assertion to be run.</param>
179212
/// <exception cref="AssertFailedException">Thrown when the test assertion fails.</exception>
180213
void ExceptionWrapper(Action assertion);
214+
215+
/// <summary>
216+
/// Configure the runner to wait for and return the asynchronous response.
217+
/// </summary>
218+
/// <param name="maxTimeout">The maximum time to poll for the asynchronous response after which runner should time out.</param>
219+
void WaitForAsynchronousResponse(TimeSpan maxTimeout);
181220
}
182221
}

LogicAppUnit/InternalHelper/WorkflowApiHelper.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ public CallbackUrlDefinition GetWorkflowCallbackDefinition(string triggerName)
110110
// Documentation: https://learn.microsoft.com/en-us/rest/api/logic/workflow-triggers/list-callback-url
111111
try
112112
{
113+
// On a mac device, without some delay, the test fails
114+
// with a "System.Net.Http.HttpRequestException: Connection refused" exception
115+
if (!OperatingSystem.IsWindows())
116+
System.Threading.Thread.Sleep(500);
117+
113118
using (var workflowTriggerCallbackResponse = _client.PostAsync(
114119
TestEnvironment.GetTriggerCallbackRequestUri(flowName: _workflowName, triggerName: triggerName),
115120
ContentHelper.CreatePlainStringContent("")).Result)

LogicAppUnit/TestRunner.cs

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ public class TestRunner : ITestRunner, IDisposable
3131
private string _runId;
3232
private string _clientTrackingId;
3333

34+
// Whether to wait for the asynchronous response by polling the redirected URI
35+
private bool _waitForAsyncResponse;
36+
// Maximum time to wait for the asynchronous response before timing out
37+
private TimeSpan _asyncResponseTimeout;
38+
3439
// Requests sent to the mock test server that are generated by the workflow during its execution.
3540
// Use a ConcurrentBag to store the requests during the test execution to ensure thread safety of this collection
3641
private ConcurrentBag<MockRequest> _mockRequests;
@@ -99,6 +104,13 @@ public WorkflowRunStatus WorkflowRunStatus
99104

100105
#region Lifetime management
101106

107+
/// <inheritdoc cref="ITestRunner.WaitForAsynchronousResponse(TimeSpan)"/>
108+
public void WaitForAsynchronousResponse(TimeSpan maxTimeout)
109+
{
110+
_waitForAsyncResponse = true;
111+
_asyncResponseTimeout = maxTimeout;
112+
}
113+
102114
/// <summary>
103115
/// Initializes a new instance of the <see cref="TestRunner"/> class.
104116
/// </summary>
@@ -282,17 +294,35 @@ public Dictionary<string, string> GetWorkflowActionTrackedProperties(string acti
282294
/// <inheritdoc cref="ITestRunner.TriggerWorkflow(HttpMethod, Dictionary{string, string})" />
283295
public HttpResponseMessage TriggerWorkflow(HttpMethod method, Dictionary<string, string> requestHeaders = null)
284296
{
285-
return TriggerWorkflow(null, method, requestHeaders);
297+
return TriggerWorkflow(null, null, method, string.Empty, requestHeaders);
298+
}
299+
300+
/// <inheritdoc cref="ITestRunner.TriggerWorkflow(Dictionary{string, string}, HttpMethod, Dictionary{string, string})" />
301+
public HttpResponseMessage TriggerWorkflow(Dictionary<string, string> queryParams, HttpMethod method, Dictionary<string, string> requestHeaders = null)
302+
{
303+
return TriggerWorkflow(queryParams, null, method, string.Empty, requestHeaders);
304+
}
305+
306+
/// <inheritdoc cref="ITestRunner.TriggerWorkflow(Dictionary{string, string}, HttpMethod, string, Dictionary{string, string})" />
307+
public HttpResponseMessage TriggerWorkflow(Dictionary<string, string> queryParams, HttpMethod method, string relativePath, Dictionary<string, string> requestHeaders = null)
308+
{
309+
return TriggerWorkflow(queryParams, null, method, relativePath, requestHeaders);
286310
}
287311

288312
/// <inheritdoc cref="ITestRunner.TriggerWorkflow(HttpContent, HttpMethod, Dictionary{string, string})" />
289313
public HttpResponseMessage TriggerWorkflow(HttpContent content, HttpMethod method, Dictionary<string, string> requestHeaders = null)
290314
{
291-
return TriggerWorkflow(content, method, string.Empty, requestHeaders);
315+
return TriggerWorkflow(null, content, method, string.Empty, requestHeaders);
292316
}
293317

294318
/// <inheritdoc cref="ITestRunner.TriggerWorkflow(HttpContent, HttpMethod, string, Dictionary{string, string})" />
295319
public HttpResponseMessage TriggerWorkflow(HttpContent content, HttpMethod method, string relativePath, Dictionary<string, string> requestHeaders = null)
320+
{
321+
return TriggerWorkflow(null, content, method, relativePath, requestHeaders);
322+
}
323+
324+
/// <inheritdoc cref="ITestRunner.TriggerWorkflow(Dictionary{string, string}, HttpContent, HttpMethod, string, Dictionary{string, string})" />
325+
public HttpResponseMessage TriggerWorkflow(Dictionary<string, string> queryParams, HttpContent content, HttpMethod method, string relativePath, Dictionary<string, string> requestHeaders = null)
296326
{
297327
string triggerName = _workflowDefinition.HttpTriggerName;
298328
if (string.IsNullOrEmpty(triggerName))
@@ -306,7 +336,7 @@ public HttpResponseMessage TriggerWorkflow(HttpContent content, HttpMethod metho
306336
{
307337
Content = content,
308338
Method = method,
309-
RequestUri = callbackDef.ValueWithRelativePath(relativePath)
339+
RequestUri = callbackDef.ValueWithQueryAndRelativePath(queryParams, relativePath)
310340
};
311341

312342
if (requestHeaders != null)
@@ -432,30 +462,73 @@ private JToken GetWorkflowActionRepetitionMessage(string actionName, int repetit
432462
/// <returns>The response from the workflow.</returns>
433463
private HttpResponseMessage PollAndReturnFinalWorkflowResponse(HttpRequestMessage httpRequestMessage)
434464
{
465+
HttpResponseMessage asyncResponse = null;
466+
435467
// Call the endpoint for the HTTP trigger
436468
var initialWorkflowHttpResponse = _client.SendAsync(httpRequestMessage).Result;
437469

438470
// Store some of the run metadata for test assertions, this may not exist for stateless workflows
439471
_runId = GetHeader(initialWorkflowHttpResponse.Headers, "x-ms-workflow-run-id");
440472
_clientTrackingId = GetHeader(initialWorkflowHttpResponse.Headers, "x-ms-client-tracking-id");
441473

442-
if (initialWorkflowHttpResponse.StatusCode != HttpStatusCode.Accepted)
474+
// Check for and handle asynchronous response
475+
var callbackLocation = initialWorkflowHttpResponse.Headers?.Location;
476+
if (initialWorkflowHttpResponse.StatusCode == HttpStatusCode.Accepted && callbackLocation is not null)
443477
{
444-
return initialWorkflowHttpResponse;
478+
// If the _waitForAsyncResponse is not set (to true), return the initial response
479+
if (!_waitForAsyncResponse)
480+
{
481+
return initialWorkflowHttpResponse;
482+
}
483+
484+
var retryAfterSeconds = initialWorkflowHttpResponse.Headers?.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
485+
486+
var stopwatchAsyncRes = new Stopwatch();
487+
stopwatchAsyncRes.Start();
488+
489+
while (stopwatchAsyncRes.Elapsed < _asyncResponseTimeout)
490+
{
491+
using (var latestAsyncResponse = _client.GetAsync(callbackLocation).Result)
492+
{
493+
if (latestAsyncResponse.StatusCode != HttpStatusCode.Accepted)
494+
{
495+
stopwatchAsyncRes.Stop();
496+
asyncResponse = latestAsyncResponse;
497+
break;
498+
}
499+
Thread.Sleep(retryAfterSeconds);
500+
}
501+
}
502+
503+
if (stopwatchAsyncRes.Elapsed >= _asyncResponseTimeout)
504+
{
505+
throw new TestException($"Workflow is taking more than {_asyncResponseTimeout.TotalMinutes} minutes for returning the final async response.");
506+
}
445507
}
446508

509+
// Wait till the workflow ends, i.e., workflow status is not "Running".
510+
// This should be checked in case of HTTP trigger workflows as well to make sure that any
511+
// actions after the Response action are properly run before testing their outputs.
447512
var stopwatch = new Stopwatch();
448513
stopwatch.Start();
449-
450514
while (stopwatch.Elapsed < TimeSpan.FromMinutes(Constants.MAX_TIME_MINUTES_WHILE_POLLING_WORKFLOW_RESULT))
451515
{
452516
using (var latestWorkflowHttpResponse = _client.GetAsync(TestEnvironment.GetRunsRequestUriWithManagementHost(flowName: _workflowDefinition.WorkflowName)).Result)
453517
{
454518
var latestWorkflowHttpResponseContent = latestWorkflowHttpResponse.Content.ReadAsAsync<JToken>().Result;
455519
var runStatusOfWorkflow = latestWorkflowHttpResponseContent["value"][0]["properties"]["status"].ToString();
456-
// If we got status code other than Accepted then return the response
520+
// If we got status code other than Accepted then return the appropriate response.
457521
if (latestWorkflowHttpResponse.StatusCode != HttpStatusCode.Accepted && runStatusOfWorkflow != ActionStatus.Running.ToString())
458522
{
523+
// If there is an asynchronous response return it.
524+
if (asyncResponse is not null)
525+
return asyncResponse;
526+
527+
// If the initial response was from an HTTP trigger workflow, return it.
528+
if (initialWorkflowHttpResponse.StatusCode != HttpStatusCode.Accepted)
529+
return initialWorkflowHttpResponse;
530+
531+
// It must be a non-HTTP trigger workflow, return the output of the workflow run.
459532
return latestWorkflowHttpResponse;
460533
}
461534
Thread.Sleep(1000);

0 commit comments

Comments
 (0)