Skip to content

Commit 7c3e016

Browse files
committed
feat(zendesk): enhance test coverage and retry logic
Added extensive unit tests across various components, including health checks, client instrumentation, and tool invocations. Achieved increased reliability by incorporating retry logic for rate-limited API responses.
1 parent 2d718f4 commit 7c3e016

17 files changed

Lines changed: 974 additions & 13 deletions

File tree

src/ES.FX.Ignite.Zendesk/ES.FX.Ignite.Zendesk.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
1212
</ItemGroup>
1313

14+
<ItemGroup>
15+
<InternalsVisibleTo Include="ES.FX.Ignite.Zendesk.Tests" />
16+
</ItemGroup>
17+
1418
<ItemGroup>
1519
<ProjectReference Include="..\ES.FX.Ignite.Spark\ES.FX.Ignite.Spark.csproj" />
1620
<ProjectReference Include="..\ES.FX.Zendesk\ES.FX.Zendesk.csproj" />

src/ES.FX.Zendesk.MCP.Host/Tools/ZendeskToolInvoker.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ namespace ES.FX.Zendesk.MCP.Host.Tools;
1313
/// status code and the Zendesk error body carried by <see cref="ZendeskApiException" />. Routing tool calls
1414
/// through here re-throws a <see cref="ZendeskApiException" /> as an <see cref="McpException" />, whose message
1515
/// the SDK surfaces verbatim, so the agent can distinguish (for example) <c>404 Not Found</c> from
16-
/// <c>403 Forbidden</c> from <c>422</c> and self-correct. Only the typed <see cref="ZendeskApiException" /> is
16+
/// <c>403 Forbidden</c> from <c>422</c> and self-correct. When the exception carries a
17+
/// <see cref="ZendeskApiException.RetryAfter" /> delay (typically a <c>429</c>), the message includes an
18+
/// explicit <c>"Retry after N seconds."</c> hint. Only the typed <see cref="ZendeskApiException" /> is
1719
/// translated; other exceptions keep their default (generic) SDK handling.
1820
/// </remarks>
1921
internal static class ZendeskToolInvoker
@@ -86,6 +88,8 @@ private static string Describe(ZendeskApiException exception)
8688
{
8789
var message =
8890
$"The Zendesk API request failed with status {(int)exception.StatusCode} ({exception.StatusCode}).";
91+
if (exception.RetryAfter is { } retryAfter)
92+
message = $"{message} Retry after {(long)Math.Ceiling(retryAfter.TotalSeconds)} seconds.";
8993
return string.IsNullOrWhiteSpace(exception.ResponseBody)
9094
? message
9195
: $"{message} Zendesk response: {exception.ResponseBody}";
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System.Net;
2+
using System.Text;
3+
using ES.FX.Ignite.Zendesk.HealthChecks;
4+
using ES.FX.Zendesk.Abstractions;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Diagnostics.HealthChecks;
7+
8+
namespace ES.FX.Ignite.Zendesk.Tests.HealthChecks;
9+
10+
public class ZendeskClientHealthCheckTests
11+
{
12+
private const string AccessToken = "stub-access-token";
13+
14+
private const string TokenJson =
15+
$$"""
16+
{ "access_token": "{{AccessToken}}", "token_type": "bearer", "expires_in": 3600 }
17+
""";
18+
19+
private const string CurrentUserJson =
20+
"""
21+
{
22+
"user": {
23+
"id": 12345,
24+
"name": "Support Bot",
25+
"email": "bot@unit-test.example",
26+
"role": "admin"
27+
}
28+
}
29+
""";
30+
31+
private static ServiceProvider BuildProvider(HttpMessageHandler handler)
32+
{
33+
var services = new ServiceCollection();
34+
// Every factory-created client — including the dedicated token client — gets the stub as its
35+
// primary handler, so no request in these tests can reach a real Zendesk host.
36+
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => handler));
37+
services.AddZendeskClient(options =>
38+
{
39+
options.Subdomain = "unit-test-offline";
40+
options.OAuth.ClientId = "cid";
41+
options.OAuth.ClientSecret = "unit-test-client-secret";
42+
});
43+
return services.BuildServiceProvider();
44+
}
45+
46+
private static HealthCheckContext CreateContext(IHealthCheck healthCheck, HealthStatus failureStatus) => new()
47+
{
48+
// A NON-default failure status, so a regression that hardcodes Unhealthy fails the assertion.
49+
Registration = new HealthCheckRegistration("ZendeskClient", healthCheck, failureStatus, null)
50+
};
51+
52+
[Fact]
53+
public async Task CheckHealthAsync_Calls_The_Authenticated_Users_Me_Endpoint_And_Reports_Healthy()
54+
{
55+
var stub = new RoutingStubHandler();
56+
await using var provider = BuildProvider(stub);
57+
var healthCheck = new ZendeskClientHealthCheck(provider.GetRequiredService<IZendeskClient>());
58+
59+
var result = await healthCheck.CheckHealthAsync(CreateContext(healthCheck, HealthStatus.Degraded),
60+
TestContext.Current.CancellationToken);
61+
62+
// Exactly two outbound requests, both intercepted by the stub: the token fetch, then the probe.
63+
Assert.Equal(2, stub.Requests.Count);
64+
Assert.Equal("/oauth/tokens", stub.TokenRequest?.RequestUri?.AbsolutePath);
65+
// The dedicated token client has no auth handler attached, so the token request is unauthenticated.
66+
Assert.Null(stub.TokenRequest?.Headers.Authorization);
67+
Assert.Equal("/api/v2/users/me.json", stub.UsersMeRequest?.RequestUri?.AbsolutePath);
68+
Assert.Equal("Bearer", stub.UsersMeRequest?.Headers.Authorization?.Scheme);
69+
Assert.Equal(AccessToken, stub.UsersMeRequest?.Headers.Authorization?.Parameter);
70+
71+
Assert.Equal(HealthStatus.Healthy, result.Status);
72+
Assert.Contains("12345", result.Description);
73+
Assert.Contains("admin", result.Description);
74+
// The description can surface on an unauthenticated /health endpoint — no PII, no secrets.
75+
Assert.DoesNotContain("bot@unit-test.example", result.Description);
76+
Assert.DoesNotContain("unit-test-client-secret", result.Description);
77+
Assert.DoesNotContain(AccessToken, result.Description);
78+
}
79+
80+
[Fact]
81+
public async Task CheckHealthAsync_On_Failure_Returns_The_Registration_FailureStatus_With_The_Exception()
82+
{
83+
var stub = new RoutingStubHandler { UsersMeStatusCode = HttpStatusCode.InternalServerError };
84+
await using var provider = BuildProvider(stub);
85+
var healthCheck = new ZendeskClientHealthCheck(provider.GetRequiredService<IZendeskClient>());
86+
87+
var result = await healthCheck.CheckHealthAsync(CreateContext(healthCheck, HealthStatus.Degraded),
88+
TestContext.Current.CancellationToken);
89+
90+
// Must honor context.Registration.FailureStatus, not hardcode Unhealthy.
91+
Assert.Equal(HealthStatus.Degraded, result.Status);
92+
Assert.NotNull(result.Exception);
93+
}
94+
95+
/// <summary>
96+
/// Serves the OAuth token endpoint and <c>users/me.json</c> with canned responses, records the requests
97+
/// for assertions, and fails the test on any other outbound request.
98+
/// </summary>
99+
private sealed class RoutingStubHandler : HttpMessageHandler
100+
{
101+
public List<HttpRequestMessage> Requests { get; } = [];
102+
103+
public HttpRequestMessage? TokenRequest { get; private set; }
104+
105+
public HttpRequestMessage? UsersMeRequest { get; private set; }
106+
107+
public HttpStatusCode UsersMeStatusCode { get; init; } = HttpStatusCode.OK;
108+
109+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
110+
CancellationToken cancellationToken)
111+
{
112+
Requests.Add(request);
113+
switch (request.RequestUri?.AbsolutePath)
114+
{
115+
case "/oauth/tokens":
116+
TokenRequest = request;
117+
return Task.FromResult(Json(HttpStatusCode.OK, TokenJson));
118+
case "/api/v2/users/me.json":
119+
UsersMeRequest = request;
120+
return Task.FromResult(Json(UsersMeStatusCode,
121+
UsersMeStatusCode == HttpStatusCode.OK
122+
? CurrentUserJson
123+
: """{ "error": "InternalServerError" }"""));
124+
default:
125+
throw new InvalidOperationException(
126+
$"Unexpected outbound request {request.Method} {request.RequestUri} — " +
127+
"health check tests must never leave the stub.");
128+
}
129+
}
130+
131+
private static HttpResponseMessage Json(HttpStatusCode statusCode, string body) => new(statusCode)
132+
{
133+
Content = new StringContent(body, Encoding.UTF8, "application/json")
134+
};
135+
}
136+
}

tests/ES.FX.Ignite.Zendesk.Tests/HostingTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Extensions.Diagnostics.HealthChecks;
1010
using Microsoft.Extensions.Hosting;
1111
using Microsoft.Extensions.Options;
12+
using OpenTelemetry.Trace;
1213

1314
namespace ES.FX.Ignite.Zendesk.Tests;
1415

@@ -80,6 +81,49 @@ public void HealthCheck_Can_Be_Disabled_Via_Settings()
8081
Assert.DoesNotContain(registrations, registration => registration.Name == ZendeskClientSpark.Name);
8182
}
8283

84+
[Fact]
85+
public void Binds_Settings_From_Configuration()
86+
{
87+
var builder = CreateBuilder(
88+
Setting($"{SparkConfig.Settings}:HealthChecks:Enabled", "false"),
89+
Setting($"{SparkConfig.Settings}:Tracing:Enabled", "false"));
90+
builder.IgniteZendeskClient();
91+
92+
var app = builder.Build();
93+
94+
var settings = app.Services.GetRequiredService<ZendeskClientSparkSettings>();
95+
Assert.False(settings.HealthChecks.Enabled);
96+
Assert.False(settings.Tracing.Enabled);
97+
}
98+
99+
[Fact]
100+
public void ConfigureSettings_Delegate_Overrides_Configuration()
101+
{
102+
var builder = CreateBuilder(Setting($"{SparkConfig.Settings}:HealthChecks:Enabled", "false"));
103+
builder.IgniteZendeskClient(configureSettings: settings => settings.HealthChecks.Enabled = true);
104+
105+
var app = builder.Build();
106+
107+
var settings = app.Services.GetRequiredService<ZendeskClientSparkSettings>();
108+
Assert.True(settings.HealthChecks.Enabled);
109+
110+
var registrations = app.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations;
111+
Assert.Contains(registrations, registration => registration.Name == ZendeskClientSpark.Name);
112+
}
113+
114+
[Fact]
115+
public void ConfigureOptions_Delegate_Overrides_Configuration()
116+
{
117+
var builder = CreateBuilder(ValidCredentials());
118+
builder.IgniteZendeskClient(configureOptions: options => options.Subdomain = "overridden");
119+
120+
var app = builder.Build();
121+
122+
var options = app.Services.GetRequiredService<IOptions<ZendeskClientOptions>>().Value;
123+
Assert.Equal("overridden", options.Subdomain);
124+
Assert.Equal("cid", options.OAuth.ClientId);
125+
}
126+
83127
[Fact]
84128
public void Does_Not_Allow_Reconfiguration_Of_Same_Instance()
85129
{
@@ -89,6 +133,15 @@ public void Does_Not_Allow_Reconfiguration_Of_Same_Instance()
89133
Assert.Throws<ReconfigurationNotSupportedException>(() => builder.IgniteZendeskClient());
90134
}
91135

136+
[Fact]
137+
public void Does_Not_Allow_Reconfiguration_Of_Same_ServiceKey()
138+
{
139+
var builder = CreateBuilder(ValidCredentials());
140+
builder.IgniteZendeskClient(serviceKey: "a");
141+
142+
Assert.Throws<ReconfigurationNotSupportedException>(() => builder.IgniteZendeskClient(serviceKey: "a"));
143+
}
144+
92145
[Fact]
93146
public void Supports_Multiple_Named_Keyed_Instances()
94147
{
@@ -106,6 +159,7 @@ public void Supports_Multiple_Named_Keyed_Instances()
106159

107160
Assert.NotNull(app.Services.GetRequiredService<IZendeskClient>());
108161
Assert.NotNull(app.Services.GetRequiredKeyedService<IZendeskClient>("sandbox"));
162+
Assert.NotNull(app.Services.GetRequiredKeyedService<ZendeskClientSparkSettings>("sandbox"));
109163

110164
var monitor = app.Services.GetRequiredService<IOptionsMonitor<ZendeskClientOptions>>();
111165
Assert.Equal("acme", monitor.Get(string.Empty).Subdomain);
@@ -128,4 +182,30 @@ public void Different_ServiceKeys_Do_Not_Conflict()
128182
Assert.NotNull(app.Services.GetRequiredKeyedService<IZendeskClient>("a"));
129183
Assert.NotNull(app.Services.GetRequiredKeyedService<IZendeskClient>("b"));
130184
}
185+
186+
[Fact]
187+
public void Tracing_Enabled_Registers_TracerProvider()
188+
{
189+
var builder = CreateBuilder(ValidCredentials());
190+
builder.IgniteZendeskClient();
191+
192+
var app = builder.Build();
193+
194+
// When tracing is enabled the Spark calls AddOpenTelemetry().WithTracing(...), which registers a
195+
// TracerProvider in the container. Resolving it confirms the tracing branch ran.
196+
Assert.NotNull(app.Services.GetService<TracerProvider>());
197+
}
198+
199+
[Fact]
200+
public void Tracing_Can_Be_Disabled_Via_Settings()
201+
{
202+
var builder = CreateBuilder(Setting($"{SparkConfig.Settings}:Tracing:Enabled", "false"));
203+
builder.IgniteZendeskClient();
204+
205+
var app = builder.Build();
206+
207+
// With tracing disabled the Spark never calls AddOpenTelemetry().WithTracing(...), so no
208+
// TracerProvider is registered.
209+
Assert.Null(app.Services.GetService<TracerProvider>());
210+
}
131211
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using System.Collections.Concurrent;
2+
using System.Diagnostics;
3+
using System.Net;
4+
using ES.FX.NousResearch.HermesAgent.Server;
5+
using ES.FX.NousResearch.HermesAgent.Tests.Testing;
6+
using Microsoft.Extensions.Logging.Abstractions;
7+
8+
namespace ES.FX.NousResearch.HermesAgent.Tests.Instrumentation;
9+
10+
/// <summary>
11+
/// Behavioral coverage of the client's <see cref="Activity" /> emission
12+
/// (<see cref="HermesAgentClientInstrumentation" />): one Client-kind span per operation named
13+
/// <c>HermesAgent.{Area}.{Operation}</c> with the <c>hermesagent.operation</c> tag, <c>Ok</c> on success
14+
/// and <c>Error</c> (with the exception attached) on failure.
15+
/// </summary>
16+
public class HermesAgentClientInstrumentationTests
17+
{
18+
private static HermesAgentServerApi CreateApi(HttpMessageHandler handler) =>
19+
new(new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8642/") },
20+
NullLogger<HermesAgentServerApi>.Instance);
21+
22+
/// <summary>
23+
/// Subscribes to the client's <see cref="ActivitySource" /> and collects stopped activities. The source
24+
/// is process-global and other tests may emit on it concurrently, so callers isolate their own spans by
25+
/// filtering on a parent activity they start themselves.
26+
/// </summary>
27+
private static ActivityListener Subscribe(ConcurrentQueue<Activity> stopped)
28+
{
29+
var listener = new ActivityListener
30+
{
31+
ShouldListenTo = source => source.Name == HermesAgentClientInstrumentation.ActivitySourceName,
32+
Sample = (ref ActivityCreationOptions<ActivityContext> _) =>
33+
ActivitySamplingResult.AllDataAndRecorded,
34+
ActivityStopped = stopped.Enqueue
35+
};
36+
ActivitySource.AddActivityListener(listener);
37+
return listener;
38+
}
39+
40+
[Fact]
41+
public void ActivitySourceName_Is_Pinned_To_The_Documented_Value()
42+
{
43+
// OpenTelemetry subscriptions (the Ignite Spark, user AddSource calls) reference this exact string —
44+
// a rename is a silent tracing outage, so pin the literal.
45+
Assert.Equal("ES.FX.NousResearch.HermesAgent", HermesAgentClientInstrumentation.ActivitySourceName);
46+
}
47+
48+
[Fact]
49+
public async Task Successful_Operation_Emits_One_Client_Activity_With_Operation_Tag_And_Ok_Status()
50+
{
51+
var stopped = new ConcurrentQueue<Activity>();
52+
using var listener = Subscribe(stopped);
53+
using var parent = new Activity("test-parent").Start();
54+
55+
var api = CreateApi(new StubHttpMessageHandler("""{ "status": "healthy" }"""));
56+
await api.GetHealthAsync(TestContext.Current.CancellationToken);
57+
58+
var activity = Assert.Single(stopped, a => a.ParentId == parent.Id);
59+
Assert.Equal("HermesAgent.Server.GetHealth", activity.OperationName); // HermesAgent.{Area}.{Operation}
60+
Assert.Equal(ActivityKind.Client, activity.Kind);
61+
Assert.Equal("HermesAgent.Server.GetHealth", activity.GetTagItem("hermesagent.operation"));
62+
Assert.Equal(ActivityStatusCode.Ok, activity.Status);
63+
}
64+
65+
[Fact]
66+
public async Task Failing_Operation_Sets_Error_Status_And_Attaches_The_Exception()
67+
{
68+
var stopped = new ConcurrentQueue<Activity>();
69+
using var listener = Subscribe(stopped);
70+
using var parent = new Activity("test-parent").Start();
71+
72+
var api = CreateApi(new StubHttpMessageHandler("""{ "error": "boom" }""",
73+
HttpStatusCode.InternalServerError));
74+
await Assert.ThrowsAsync<HermesAgentApiException>(async () =>
75+
await api.GetHealthAsync(TestContext.Current.CancellationToken));
76+
77+
var activity = Assert.Single(stopped, a => a.ParentId == parent.Id);
78+
Assert.Equal(ActivityStatusCode.Error, activity.Status);
79+
Assert.False(string.IsNullOrEmpty(activity.StatusDescription));
80+
Assert.Contains(activity.Events, e => e.Name == "exception"); // Activity.AddException event
81+
}
82+
}

0 commit comments

Comments
 (0)