Skip to content
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
[Oo]bj/
logs/
logs
/.tools
TestResults/
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Autofac.Extensions.DependencyInjection" Version="8.0.0" />
<PackageVersion Include="coverlet.collector" Version="3.2.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.28" />
<PackageVersion Include="Dapper.SqlBuilder" Version="2.0.78" />
<PackageVersion Include="FluentAssertions" Version="8.6.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,76 @@ await client.GetResponseAsync(new AIChatRequest
record.Succeeded.Should().BeTrue();
}

[Fact]
public void ProductionOptionValidators_WhenOptionsAreInvalid_ShouldReturnExpectedFailures()
{
var invalidLogSanitizer = new AILogSanitizerOptions
{
RedactionText = string.Empty,
};
invalidLogSanitizer.SensitiveFields.Clear();
invalidLogSanitizer.SensitiveFields.Add(string.Empty);

var rateResult = new AIRateLimitingOptionsValidator().Validate(null, new AIRateLimitingOptions
{
RequestsPerWindow = 0,
WindowSeconds = 0,
});
var circuitResult = new AICircuitBreakerOptionsValidator().Validate(null, new AICircuitBreakerOptions
{
FailureThreshold = 0,
BreakDurationSeconds = 0,
});
var logResult = new AILogSanitizerOptionsValidator().Validate(null, invalidLogSanitizer);
var secretResult = new AISecretProviderOptionsValidator().Validate(null, new AISecretProviderOptions
{
EnvironmentVariablePrefix = string.Empty,
});
var payloadResult = new AIPayloadLimitOptionsValidator().Validate(null, new AIPayloadLimitOptions
{
MaxChatCharacters = 0,
MaxStreamingChatCharacters = 0,
MaxTextCharacters = 0,
MaxAudioBytes = 0,
MaxImagePromptCharacters = 0,
MaxImageBytes = 0,
MaxImageMaskBytes = 0,
});

rateResult.Failures.Should().HaveCount(2);
circuitResult.Failures.Should().HaveCount(2);
logResult.Failures.Should().HaveCount(2);
secretResult.Failures.Should().ContainSingle("AI:SecretProvider:EnvironmentVariablePrefix is required.");
payloadResult.Failures.Should().HaveCount(7);
}

[Fact]
public void ProductionOptionValidators_WhenOptionsAreValid_ShouldSucceed()
{
var rateResult = new AIRateLimitingOptionsValidator().Validate(null, new AIRateLimitingOptions
{
RequestsPerWindow = 1,
WindowSeconds = 30,
});
var circuitResult = new AICircuitBreakerOptionsValidator().Validate(null, new AICircuitBreakerOptions
{
FailureThreshold = 1,
BreakDurationSeconds = 30,
});
var logResult = new AILogSanitizerOptionsValidator().Validate(null, new AILogSanitizerOptions());
var secretResult = new AISecretProviderOptionsValidator().Validate(null, new AISecretProviderOptions
{
EnvironmentVariablePrefix = "AI__PROVIDERS__",
});
var payloadResult = new AIPayloadLimitOptionsValidator().Validate(null, new AIPayloadLimitOptions());

rateResult.Succeeded.Should().BeTrue();
circuitResult.Succeeded.Should().BeTrue();
logResult.Succeeded.Should().BeTrue();
secretResult.Succeeded.Should().BeTrue();
payloadResult.Succeeded.Should().BeTrue();
}

private static IConfigurationRoot CreateConfiguration(IDictionary<string, string?>? overrides = null)
{
var values = new Dictionary<string, string?>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using System;
using FluentAssertions;
using MS.Microservice.AI.Abstractions;
using MS.Microservice.AI.Core;
using Xunit;

namespace MS.Microservice.AI.Core.Tests;

public sealed class AIRequestValidatorTests
{
[Fact]
public void ValidateChatRequest_ShouldThrow_WhenMessagesAreEmpty()
{
var request = new AIChatRequest { Messages = [] };

Action action = () => AIRequestValidator.ValidateChatRequest(request);

action.Should().Throw<AIConfigurationException>()
.WithMessage("*at least one message*");
}

[Fact]
public void ValidateChatRequest_ShouldThrow_WhenMessageRoleIsEmpty()
{
var request = new AIChatRequest { Messages = [new AIChatMessage("", "hello")] };

Action action = () => AIRequestValidator.ValidateChatRequest(request);

action.Should().Throw<AIConfigurationException>()
.WithMessage("*index 0*role*");
}

[Fact]
public void ValidateChatRequest_ShouldThrow_WhenMessageContentIsEmpty()
{
var request = new AIChatRequest { Messages = [new AIChatMessage("user", "")] };

Action action = () => AIRequestValidator.ValidateChatRequest(request);

action.Should().Throw<AIConfigurationException>()
.WithMessage("*index 0*content*");
}

[Fact]
public void ValidateChatRequest_ShouldThrow_WhenCharacterLimitIsExceeded()
{
var request = new AIChatRequest
{
Messages = [new AIChatMessage("user", "abcd")]
};
var limits = new AIPayloadLimitOptions { MaxChatCharacters = 3, MaxStreamingChatCharacters = 2 };

Action normal = () => AIRequestValidator.ValidateChatRequest(request, limits);
Action streaming = () => AIRequestValidator.ValidateChatRequest(request, limits, isStreaming: true);

normal.Should().Throw<AIConfigurationException>().WithMessage("*configured limit*");
streaming.Should().Throw<AIConfigurationException>().WithMessage("*configured limit*");
}

[Fact]
public void ValidateChatRequest_ShouldThrow_WhenNumericFieldsAreInvalid()
{
var request = new AIChatRequest
{
Messages = [new AIChatMessage("user", "hello")],
Temperature = -0.1
};

Action temperature = () => AIRequestValidator.ValidateChatRequest(request);
temperature.Should().Throw<AIConfigurationException>().WithMessage("*temperature*");

request = request with { Temperature = 0.5, TopP = 1.1 };
Action topP = () => AIRequestValidator.ValidateChatRequest(request);
topP.Should().Throw<AIConfigurationException>().WithMessage("*top_p*");

request = request with { TopP = 0.5, MaxOutputTokens = 0 };
Action maxOutput = () => AIRequestValidator.ValidateChatRequest(request);
maxOutput.Should().Throw<AIConfigurationException>().WithMessage("*max output tokens*");

request = request with { MaxOutputTokens = 16, Timeout = TimeSpan.Zero };
Action timeout = () => AIRequestValidator.ValidateChatRequest(request);
timeout.Should().Throw<AIConfigurationException>().WithMessage("*timeout*");
}

[Fact]
public void ValidateChatRequest_ShouldThrow_WhenOptionalStringsAreWhitespace()
{
var request = new AIChatRequest
{
Messages = [new AIChatMessage("user", "hello")],
Provider = " "
};

Action provider = () => AIRequestValidator.ValidateChatRequest(request);
provider.Should().Throw<AIConfigurationException>().WithMessage("*provider*");

request = request with { Provider = "openai", Model = " " };
Action model = () => AIRequestValidator.ValidateChatRequest(request);
model.Should().Throw<AIConfigurationException>().WithMessage("*model*");

request = request with { Model = "gpt-4o-mini", Scenario = " " };
Action scenario = () => AIRequestValidator.ValidateChatRequest(request);
scenario.Should().Throw<AIConfigurationException>().WithMessage("*scenario*");
}

[Fact]
public void ValidateTtsRequest_ShouldThrow_WhenFieldsAreInvalid()
{
var limits = new AIPayloadLimitOptions { MaxTextCharacters = 2 };
var request = new AITtsRequest { Input = "abc" };

Action inputLimit = () => AIRequestValidator.ValidateTtsRequest(request, limits);
inputLimit.Should().Throw<AIConfigurationException>().WithMessage("*configured limit*");

request = new AITtsRequest { Input = "ok", Voice = " " };
Action voice = () => AIRequestValidator.ValidateTtsRequest(request);
voice.Should().Throw<AIConfigurationException>().WithMessage("*voice*");

request = request with { Voice = "alloy", ResponseFormat = " " };
Action format = () => AIRequestValidator.ValidateTtsRequest(request);
format.Should().Throw<AIConfigurationException>().WithMessage("*response format*");

request = request with { ResponseFormat = "mp3", Speed = 0 };
Action speed = () => AIRequestValidator.ValidateTtsRequest(request);
speed.Should().Throw<AIConfigurationException>().WithMessage("*speed*");
}

[Fact]
public void ValidateAsrRequest_ShouldThrow_WhenAudioIsMissingOrTooLarge()
{
var emptyAudio = new AIBinaryContent { Content = [], ContentType = "audio/wav" };
var request = new AIAsrRequest { Audio = emptyAudio };

Action missing = () => AIRequestValidator.ValidateAsrRequest(request);
missing.Should().Throw<AIConfigurationException>().WithMessage("*binary content*");

var largeAudio = new AIBinaryContent { Content = new byte[5], ContentType = "audio/wav" };
request = new AIAsrRequest { Audio = largeAudio };
var limits = new AIPayloadLimitOptions { MaxAudioBytes = 4 };

Action tooLarge = () => AIRequestValidator.ValidateAsrRequest(request, limits);
tooLarge.Should().Throw<AIConfigurationException>().WithMessage("*configured limit*");
}

[Fact]
public void ValidateImageGenerationRequest_ShouldThrow_WhenFieldsAreInvalid()
{
var request = new AIImageGenerationRequest { Prompt = " ", Count = 1 };

Action prompt = () => AIRequestValidator.ValidateImageGenerationRequest(request);
prompt.Should().Throw<AIConfigurationException>().WithMessage("*include a prompt*");

request = new AIImageGenerationRequest { Prompt = "draw", Count = 0 };
Action count = () => AIRequestValidator.ValidateImageGenerationRequest(request);
count.Should().Throw<AIConfigurationException>().WithMessage("*count*");

request = request with { Count = 1, Size = " " };
Action size = () => AIRequestValidator.ValidateImageGenerationRequest(request);
size.Should().Throw<AIConfigurationException>().WithMessage("*size*");
}

[Fact]
public void ValidateImageEditRequest_ShouldThrow_WhenImageOrMaskAreInvalid()
{
var request = new AIImageEditRequest
{
Prompt = "edit",
Image = new AIBinaryContent { Content = [], ContentType = "image/png" },
Count = 1
};

Action image = () => AIRequestValidator.ValidateImageEditRequest(request);
image.Should().Throw<AIConfigurationException>().WithMessage("*image*binary content*");

request = request with
{
Image = new AIBinaryContent { Content = [1, 2, 3, 4, 5], ContentType = "image/png" },
Mask = new AIBinaryContent { Content = [], ContentType = "image/png" }
};
var limits = new AIPayloadLimitOptions { MaxImageBytes = 4, MaxImageMaskBytes = 1 };

Action imageLimit = () => AIRequestValidator.ValidateImageEditRequest(request, limits);
imageLimit.Should().Throw<AIConfigurationException>().WithMessage("*image exceeds*");

request = request with { Image = new AIBinaryContent { Content = [1], ContentType = "image/png" } };
Action mask = () => AIRequestValidator.ValidateImageEditRequest(request, limits);
mask.Should().Throw<AIConfigurationException>().WithMessage("*mask*binary content*");
}
}
Loading
Loading