diff --git a/.github/workflows/azure-dev-build-only.yml b/.github/workflows/azure-dev-build-only.yml index c42de024..ad48cba8 100644 --- a/.github/workflows/azure-dev-build-only.yml +++ b/.github/workflows/azure-dev-build-only.yml @@ -83,8 +83,7 @@ jobs: - name: Create openapi.json shell: pwsh run: | - $fileContent = Get-Content './src/AzureOpenAIProxy.ApiApp/Constants.cs' - $API_VERSION = [regex]::Match($fileContent, 'public const string Version = "([^"]+)"').Groups[1].Value + $API_VERSION = $(Get-Content ./src/AzureOpenAIProxy.ApiApp/appsettings.json | ConvertFrom-Json).OpenApi.DocVersion Invoke-WebRequest -Uri "https://localhost:7001/swagger/$API_VERSION/swagger.json" -OutFile "openapi.json" diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index 7500eda5..7f7b121b 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -97,8 +97,7 @@ jobs: - name: Create openapi.json shell: pwsh run: | - $fileContent = Get-Content './src/AzureOpenAIProxy.ApiApp/Constants.cs' - $API_VERSION = [regex]::Match($fileContent, 'public const string Version = "([^"]+)"').Groups[1].Value + $API_VERSION = $(Get-Content ./src/AzureOpenAIProxy.ApiApp/appsettings.json | ConvertFrom-Json).OpenApi.DocVersion Invoke-WebRequest -Uri "https://localhost:7001/swagger/$API_VERSION/swagger.json" -OutFile "openapi.json" diff --git a/src/AzureOpenAIProxy.ApiApp/Configurations/OpenApiSettings.cs b/src/AzureOpenAIProxy.ApiApp/Configurations/OpenApiSettings.cs new file mode 100644 index 00000000..36ae9ecd --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Configurations/OpenApiSettings.cs @@ -0,0 +1,17 @@ +namespace AzureOpenAIProxy.ApiApp.Configurations; + +/// +/// This represents the settings entity for Open API. +/// +public class OpenApiSettings +{ + /// + /// Gets the name of the configuration settings. + /// + public const string Name = "OpenApi"; + + /// + /// Gets or sets the Open API Doc version. + /// + public string? DocVersion { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Constants.cs b/src/AzureOpenAIProxy.ApiApp/Constants.cs index a133b166..ab6f5722 100644 --- a/src/AzureOpenAIProxy.ApiApp/Constants.cs +++ b/src/AzureOpenAIProxy.ApiApp/Constants.cs @@ -5,11 +5,6 @@ /// public static class Constants { - /// - /// Declares the current version of the API. - /// - public const string Version = "v1.0.0"; - /// /// Declares the title of the OpenAPI doc. /// diff --git a/src/AzureOpenAIProxy.ApiApp/Converters/EnumMemberConverter.cs b/src/AzureOpenAIProxy.ApiApp/Converters/EnumMemberConverter.cs new file mode 100644 index 00000000..beded902 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Converters/EnumMemberConverter.cs @@ -0,0 +1,72 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AzureOpenAIProxy.ApiApp.Converters; + +/// +/// This represents the converter entity for . +/// +/// The type of the enum to be converted. +public class EnumMemberConverter : JsonConverter where T : Enum +{ + /// + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var enumText = reader.GetString(); + + if (enumText == null) + { + throw new JsonException($"Unable to convert null to Enum \"{typeToConvert}\"."); + } + + foreach (var field in typeToConvert.GetFields()) + { + var attribute = Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)) as EnumMemberAttribute; + + if (attribute != null && attribute.Value == enumText) + { + var value = field.GetValue(null); + if (value != null) + { + return (T)value; + } + } + else if (field.Name == enumText) + { + var value = field.GetValue(null); + if (value != null) + { + return (T)value; + } + } + } + + throw new JsonException($"Unable to convert \"{enumText}\" to Enum \"{typeToConvert}\"."); + } + + /// + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var field = value.GetType().GetField(value.ToString()); + + if (field != null) + { + var attribute = Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)) as EnumMemberAttribute; + + if (attribute != null) + { + writer.WriteStringValue(attribute.Value); + } + else + { + writer.WriteStringValue(value.ToString()); + } + } + else + { + writer.WriteStringValue(value.ToString()); + } + } + +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs index 82f89d60..0fdba526 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs @@ -1,18 +1,25 @@ namespace AzureOpenAIProxy.ApiApp.Endpoints; +/// +/// This represents the collection of the admin endpoint URLs. +/// public static class AdminEndpointUrls { /// /// Declares the admin event details endpoint. /// + /// + /// - GET method for an event details + /// - PUT method for update an event details + /// public const string AdminEventDetails = "/admin/events/{eventId}"; /// /// Declares the admin event list endpoint. /// /// - /// - Get method for listing all events - /// - Post method for new event creation + /// - GET method for listing all events + /// - POST method for new event creation /// public const string AdminEvents = "/admin/events"; } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs index c319e491..beb5371f 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs @@ -1,39 +1,65 @@ using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Services; using Microsoft.AspNetCore.Mvc; namespace AzureOpenAIProxy.ApiApp.Endpoints; /// -/// This represents the endpoint entity for get event details by admin +/// This represents the endpoint entity for event details by admin /// public static class AdminEventEndpoints { /// - /// Adds the get event details by admin endpoint + /// Adds the admin event endpoint /// /// instance. /// Returns instance. - public static RouteHandlerBuilder AddAdminEvents(this WebApplication app) + public static RouteHandlerBuilder AddNewAdminEvent(this WebApplication app) { - // Todo: Issue #19 https://github.com/aliencube/azure-openai-sdk-proxy/issues/19 - // Need authorization by admin - var builder = app.MapGet(AdminEndpointUrls.AdminEventDetails, ( - [FromRoute] string eventId) => + var builder = app.MapPost(AdminEndpointUrls.AdminEvents, async ( + [FromBody] AdminEventDetails payload, + IAdminEventService service, + ILoggerFactory loggerFactory) => { - // Todo: Issue #208 https://github.com/aliencube/azure-openai-sdk-proxy/issues/208 - return Results.Ok(); - // Todo: Issue #208 + var logger = loggerFactory.CreateLogger(nameof(AdminEventEndpoints)); + logger.LogInformation("Received a new event request"); + + if (payload is null) + { + logger.LogError("No payload found"); + + return Results.BadRequest("Payload is null"); + } + + //try + //{ + // var result = await service.CreateEvent(payload); + + // logger.LogInformation("Created a new event"); + + // return Results.Ok(result); + //} + //catch (Exception ex) + //{ + // logger.LogError(ex, "Failed to create a new event"); + + // return Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); + //} + + return await Task.FromResult(Results.Ok()); }) + .Accepts(contentType: "application/json") .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") + .Produces(statusCode: StatusCodes.Status400BadRequest) .Produces(statusCode: StatusCodes.Status401Unauthorized) .Produces(statusCode: StatusCodes.Status500InternalServerError, contentType: "text/plain") .WithTags("admin") - .WithName("GetAdminEventDetails") + .WithName("CreateAdminEvent") .WithOpenApi(operation => { - operation.Summary = "Gets event details from the given event ID"; - operation.Description = "This endpoint gets the event details from the given event ID."; + operation.Summary = "Create admin event"; + operation.Description = "Create admin event"; return operation; }); @@ -46,7 +72,7 @@ public static RouteHandlerBuilder AddAdminEvents(this WebApplication app) /// /// instance. /// Returns instance. - public static RouteHandlerBuilder AddAdminEventList(this WebApplication app) + public static RouteHandlerBuilder AddListAdminEvents(this WebApplication app) { // Todo: Issue #19 https://github.com/aliencube/azure-openai-sdk-proxy/issues/19 // Need authorization by admin @@ -73,65 +99,64 @@ public static RouteHandlerBuilder AddAdminEventList(this WebApplication app) } /// - /// Adds the update event details by admin endpoint + /// Adds the get event details by admin endpoint /// /// instance. /// Returns instance. - public static RouteHandlerBuilder AddUpdateAdminEvents(this WebApplication app) + public static RouteHandlerBuilder AddGetAdminEvent(this WebApplication app) { // Todo: Issue #19 https://github.com/aliencube/azure-openai-sdk-proxy/issues/19 // Need authorization by admin - var builder = app.MapPut(AdminEndpointUrls.AdminEventDetails, ( - [FromRoute] string eventId, - [FromBody] AdminEventDetails payload) => + var builder = app.MapGet(AdminEndpointUrls.AdminEventDetails, ( + [FromRoute] string eventId) => { - // Todo: Issue #203 https://github.com/aliencube/azure-openai-sdk-proxy/issues/203 + // Todo: Issue #208 https://github.com/aliencube/azure-openai-sdk-proxy/issues/208 return Results.Ok(); + // Todo: Issue #208 }) - .Accepts(contentType: "application/json") .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") .Produces(statusCode: StatusCodes.Status401Unauthorized) - .Produces(statusCode: StatusCodes.Status404NotFound) .Produces(statusCode: StatusCodes.Status500InternalServerError, contentType: "text/plain") .WithTags("admin") - .WithName("UpdateAdminEventDetails") + .WithName("GetAdminEvent") .WithOpenApi(operation => { - operation.Summary = "Updates event details from the given event ID"; - operation.Description = "This endpoint updates the event details from the given event ID."; + operation.Summary = "Gets event details from the given event ID"; + operation.Description = "This endpoint gets the event details from the given event ID."; return operation; }); return builder; } - + /// - /// Adds the admin event endpoint + /// Adds the update event details by admin endpoint /// /// instance. /// Returns instance. - public static RouteHandlerBuilder CreateAdminEvent(this WebApplication app) + public static RouteHandlerBuilder AddUpdateAdminEvent(this WebApplication app) { - var builder = app.MapPost(AdminEndpointUrls.AdminEvents, async ( - [FromBody] AdminEventDetails payload, - HttpRequest request) => + // Todo: Issue #19 https://github.com/aliencube/azure-openai-sdk-proxy/issues/19 + // Need authorization by admin + var builder = app.MapPut(AdminEndpointUrls.AdminEventDetails, ( + [FromRoute] string eventId, + [FromBody] AdminEventDetails payload) => { - return await Task.FromResult(Results.Ok()); + // Todo: Issue #203 https://github.com/aliencube/azure-openai-sdk-proxy/issues/203 + return Results.Ok(); }) - // TODO: Check both request/response payloads .Accepts(contentType: "application/json") .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") - // TODO: Check both request/response payloads - .Produces(statusCode: StatusCodes.Status400BadRequest) .Produces(statusCode: StatusCodes.Status401Unauthorized) + .Produces(statusCode: StatusCodes.Status404NotFound) .Produces(statusCode: StatusCodes.Status500InternalServerError, contentType: "text/plain") .WithTags("admin") - .WithName("CreateAdminEvent") + .WithName("UpdateAdminEvent") .WithOpenApi(operation => { - operation.Summary = "Create admin event"; - operation.Description = "Create admin event"; + operation.Summary = "Updates event details from the given event ID"; + operation.Description = "This endpoint updates the event details from the given event ID."; return operation; }); diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpointUrls.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpointUrls.cs new file mode 100644 index 00000000..b8ccbd2b --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpointUrls.cs @@ -0,0 +1,15 @@ +namespace AzureOpenAIProxy.ApiApp.Endpoints; + +/// +/// This represents the collection of the playground endpoint URLs. +/// +public static class PlaygroundEndpointUrls +{ + /// + /// Declares the event endpoint. + /// + /// + /// - GET method for listing all events + /// + public const string Events = "/events"; +} diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/EventEndpoint.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs similarity index 83% rename from src/AzureOpenAIProxy.ApiApp/Endpoints/EventEndpoint.cs rename to src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs index a82efb31..c21d6029 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/EventEndpoint.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs @@ -1,22 +1,18 @@ -using System.Text.Json; - -using AzureOpenAIProxy.ApiApp.Models; - namespace AzureOpenAIProxy.ApiApp.Endpoints; /// /// This represents the endpoint entity for events that the logged user joined. /// -public static class EventEndpoint +public static class PlaygroundEndpoints { /// /// Adds the event endpoint. /// /// instance. /// Returns instance. - public static RouteHandlerBuilder AddEventList(this WebApplication app) + public static RouteHandlerBuilder AddListEvents(this WebApplication app) { - var builder = app.MapGet(EndpointUrls.Events, () => + var builder = app.MapGet(PlaygroundEndpointUrls.Events, () => { // TODO: Issue #179 https://github.com/aliencube/azure-openai-sdk-proxy/issues/179 return Results.Ok(); diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/ChatCompletionsEndpoint.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyChatCompletionsEndpoint.cs similarity index 86% rename from src/AzureOpenAIProxy.ApiApp/Endpoints/ChatCompletionsEndpoint.cs rename to src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyChatCompletionsEndpoint.cs index 72d7572e..84d3e9e2 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/ChatCompletionsEndpoint.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyChatCompletionsEndpoint.cs @@ -1,6 +1,7 @@ using System.Text.Json; using AzureOpenAIProxy.ApiApp.Attributes; +using AzureOpenAIProxy.ApiApp.Models; using AzureOpenAIProxy.ApiApp.Services; using Microsoft.AspNetCore.Mvc; @@ -12,7 +13,7 @@ namespace AzureOpenAIProxy.ApiApp.Endpoints; /// /// This represents the endpoint entity for chat completions. /// -public static class ChatCompletionsEndpoint +public static class ProxyChatCompletionsEndpoint { /// /// Adds the chat completion endpoint. @@ -21,7 +22,7 @@ public static class ChatCompletionsEndpoint /// Returns instance. public static RouteHandlerBuilder AddChatCompletions(this WebApplication app) { - var builder = app.MapPost(EndpointUrls.ChatCompletions, async ( + var builder = app.MapPost(ProxyEndpointUrls.ChatCompletions, async ( [OpenApiParameterIgnore][FromHeader(Name = "api-key")] string apiKey, [FromRoute] string deploymentName, [FromQuery(Name = "api-version")] string apiVersion, @@ -30,7 +31,7 @@ public static RouteHandlerBuilder AddChatCompletions(this WebApplication app) IOpenAIService openai, ILoggerFactory loggerFactory) => { - var logger = loggerFactory.CreateLogger(nameof(ChatCompletionsEndpoint)); + var logger = loggerFactory.CreateLogger(nameof(ProxyChatCompletionsEndpoint)); logger.LogInformation("Received a chat completion request"); request.Body.Position = 0; @@ -54,7 +55,7 @@ public static RouteHandlerBuilder AddChatCompletions(this WebApplication app) }) // TODO: Check both request/response payloads .Accepts(contentType: "application/json") - .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") + .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") // TODO: Check both request/response payloads .Produces(statusCode: StatusCodes.Status401Unauthorized) .Produces(statusCode: StatusCodes.Status500InternalServerError, contentType: "text/plain") diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/EndpointUrls.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyEndpointUrls.cs similarity index 66% rename from src/AzureOpenAIProxy.ApiApp/Endpoints/EndpointUrls.cs rename to src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyEndpointUrls.cs index b69068eb..0808fc42 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/EndpointUrls.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyEndpointUrls.cs @@ -1,9 +1,9 @@ namespace AzureOpenAIProxy.ApiApp.Endpoints; /// -/// This represents the collection of the endpoint URLs. +/// This represents the collection of the proxy endpoint URLs. /// -public static class EndpointUrls +public static class ProxyEndpointUrls { /// /// Declares the weather forecast endpoint. @@ -14,9 +14,4 @@ public static class EndpointUrls /// Declares the chat completions endpoint. /// public const string ChatCompletions = "/openai/deployments/{deploymentName}/chat/completions"; - - /// - /// Declares the event endpoint. - /// - public const string Events = "/events"; } diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/WeatherForecastEndpoint.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/WeatherForecastEndpoint.cs index 8656c921..7e87fed0 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/WeatherForecastEndpoint.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/WeatherForecastEndpoint.cs @@ -19,7 +19,7 @@ public static class WeatherForecastEndpoint /// Returns instance. public static RouteHandlerBuilder AddWeatherForecast(this WebApplication app) { - var builder = app.MapGet(EndpointUrls.WeatherForecast, () => + var builder = app.MapGet(ProxyEndpointUrls.WeatherForecast, () => { var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast diff --git a/src/AzureOpenAIProxy.ApiApp/Extensions/ApplicationBuilderExtensions.cs b/src/AzureOpenAIProxy.ApiApp/Extensions/ApplicationBuilderExtensions.cs index 390767c1..b24e4943 100644 --- a/src/AzureOpenAIProxy.ApiApp/Extensions/ApplicationBuilderExtensions.cs +++ b/src/AzureOpenAIProxy.ApiApp/Extensions/ApplicationBuilderExtensions.cs @@ -18,6 +18,8 @@ public static IApplicationBuilder UseSwaggerUI(this WebApplication app, string b return app; } + var settings = app.Services.GetOpenApiSettings(); + app.UseSwagger(options => { //options.RouteTemplate = $"swagger/{Constants.Version}/swagger.json"; @@ -32,7 +34,7 @@ public static IApplicationBuilder UseSwaggerUI(this WebApplication app, string b app.UseSwaggerUI(options => { - options.SwaggerEndpoint($"{Constants.Version}/swagger.json", Constants.Title); + options.SwaggerEndpoint($"{settings.DocVersion}/swagger.json", Constants.Title); }); return app; diff --git a/src/AzureOpenAIProxy.ApiApp/Extensions/HttpRequestExtensions.cs b/src/AzureOpenAIProxy.ApiApp/Extensions/HttpRequestExtensions.cs index d3cfa52b..951f6841 100644 --- a/src/AzureOpenAIProxy.ApiApp/Extensions/HttpRequestExtensions.cs +++ b/src/AzureOpenAIProxy.ApiApp/Extensions/HttpRequestExtensions.cs @@ -10,7 +10,7 @@ public static class HttpRequestExtensions /// Gets the base URL. /// /// instance. - /// + /// Returns the base URL from . public static string? BaseUrl(this HttpRequest req) { if (req == null) return null; diff --git a/src/AzureOpenAIProxy.ApiApp/Extensions/OpenApiSettingsExtensions.cs b/src/AzureOpenAIProxy.ApiApp/Extensions/OpenApiSettingsExtensions.cs new file mode 100644 index 00000000..ef8a7877 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Extensions/OpenApiSettingsExtensions.cs @@ -0,0 +1,37 @@ +using AzureOpenAIProxy.ApiApp.Configurations; + +namespace AzureOpenAIProxy.ApiApp.Extensions; + +/// +/// This represents the extension entity for the class. +/// +public static class OpenApiSettingsExtensions +{ + /// + /// Gets the OpenApi configuration settings by reading appsettings.json. + /// + /// instance. + /// Returns instance. + public static OpenApiSettings GetOpenApiSettings(this IServiceProvider serviceProvider) + { + var configuration = serviceProvider.GetService() + ?? throw new InvalidOperationException($"{nameof(IConfiguration)} service is not registered."); + + var settings = configuration.GetSection(OpenApiSettings.Name).Get() + ?? throw new InvalidOperationException($"{nameof(OpenApiSettings)} could not be retrieved from the configuration."); + + return settings; + } + + /// + /// Gets the OpenApi configuration settings by reading appsettings.json. + /// + /// instance. + /// Returns instance. + public static OpenApiSettings GetOpenApiSettings(this IServiceCollection services) + { + var serviceProvider = services.BuildServiceProvider(); + + return serviceProvider.GetOpenApiSettings(); + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs b/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs index c5ad44fe..ae2971bb 100644 --- a/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs +++ b/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Azure.Identity; +using Azure.Identity; using Azure.Security.KeyVault.Secrets; using AzureOpenAIProxy.ApiApp.Builders; @@ -88,13 +88,15 @@ public static IServiceCollection AddOpenAIService(this IServiceCollection servic /// Returns instance. public static IServiceCollection AddOpenApiService(this IServiceCollection services) { + var settings = services.GetOpenApiSettings(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle services.AddEndpointsApiExplorer(); services.AddSwaggerGen(options => { var info = new OpenApiInfo() { - Version = Constants.Version, + Version = settings.DocVersion, Title = Constants.Title, Description = "Providing a proxy service to Azure OpenAI API", Contact = new OpenApiContact() @@ -104,7 +106,7 @@ public static IServiceCollection AddOpenApiService(this IServiceCollection servi Url = new Uri("https://aka.ms/aoai-proxy.net") }, }; - options.SwaggerDoc(Constants.Version, info); + options.SwaggerDoc(settings.DocVersion, info); options.AddSecurityDefinition( "apiKey", @@ -132,4 +134,4 @@ public static IServiceCollection AddOpenApiService(this IServiceCollection servi return services; } -} \ No newline at end of file +} diff --git a/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs index 4f587217..a3ccdf25 100644 --- a/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs +++ b/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs @@ -1,4 +1,6 @@ -namespace AzureOpenAIProxy.ApiApp.Models; +using System.Text.Json.Serialization; + +namespace AzureOpenAIProxy.ApiApp.Models; /// /// This represent the event detail data for response by admin event endpoint. @@ -13,32 +15,38 @@ public class AdminEventDetails : EventDetails /// /// Gets or sets the event start date. /// - public required DateTimeOffset? DateStart { get; set; } + [JsonRequired] + public DateTimeOffset DateStart { get; set; } /// /// Gets or sets the event end date. /// - public required DateTimeOffset? DateEnd { get; set; } + [JsonRequired] + public DateTimeOffset DateEnd { get; set; } /// /// Gets or sets the event start to end date timezone. /// - public required string? TimeZone { get; set; } + [JsonRequired] + public string TimeZone { get; set; } = string.Empty; /// /// Gets or sets the event active status. /// - public required bool? IsActive { get; set; } + [JsonRequired] + public bool IsActive { get; set; } /// /// Gets or sets the event organizer name. /// - public required string? OrganizerName { get; set; } + [JsonRequired] + public string OrganizerName { get; set; } = string.Empty; /// /// Gets or sets the event organizer email. /// - public required string? OrganizerEmail { get; set; } + [JsonRequired] + public string OrganizerEmail { get; set; } = string.Empty; /// /// Gets or sets the event coorganizer name. diff --git a/src/AzureOpenAIProxy.ApiApp/Models/CreateChatCompletionResponse.cs b/src/AzureOpenAIProxy.ApiApp/Models/CreateChatCompletionResponse.cs new file mode 100644 index 00000000..75a51d7e --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Models/CreateChatCompletionResponse.cs @@ -0,0 +1,647 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +using AzureOpenAIProxy.ApiApp.Converters; + +namespace AzureOpenAIProxy.ApiApp.Models; + +/// +/// The response from creating a chat completion. +/// +/// +/// For more information, see azure-rest-api-specs(2024-06-01) +/// +public class CreateChatCompletionResponse +{ + /// + /// Gets or sets a unique identifier for the chat completion. + /// + [JsonPropertyName("id"), Required] + public string? Id { get; set; } + + /// + /// Gets or sets the object type. + /// + [JsonPropertyName("object"), Required] + public ChatCompletionResponseObject? Object { get; set; } + + /// + /// Gets or sets the Unix timestamp (in seconds) of when the chat completion was created. + /// + [JsonPropertyName("created"), Required] + public long? Created { get; set; } + + /// + /// Gets or sets the model used for the chat completion. + /// + [JsonPropertyName("model"), Required] + public string? Model { get; set; } + + /// + /// Gets or sets usage statistics for the completion request. + /// + [JsonPropertyName("usage")] + public CompletionUsage? Usage { get; set; } + + /// + /// Gets or sets the system fingerprint. + /// Can be used in conjunction with the `seed` request parameter to understand when backend changes have been made that might impact determinism. + /// + [JsonPropertyName("system_fingerprint")] + public string? SystemFingerprint { get; set; } + + /// + /// Gets or sets content filtering results for zero or more prompts in the request. + /// In a streaming request, results for different prompts may arrive at different times or in different orders. + /// + [JsonPropertyName("prompt_filter_results")] + public List? PromptFilterResults { get; set; } + + /// + /// Gets or sets a list of choices. + /// + [JsonPropertyName("choices"), Required] + public List? Choices { get; set; } +} + +/// +/// Represents a choice in the chat completion response. +/// +public class ChatCompletionChoice +{ + /// + /// Gets or sets an index. + /// + [JsonPropertyName("index")] + public int? Index { get; set; } + + /// + /// Gets or sets the finish reason. + /// + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; set; } + + /// + /// Gets or sets a chat completion message generated by the model. + /// + [JsonPropertyName("message")] + public ChatCompletionResponseMessage? Message { get; set; } + + /// + /// Information about the content filtering category (hate, sexual, violence, self_harm), if it has been detected, as well as the severity level (very_low, low, medium, high-scale that determines the intensity and risk level of harmful content) and if it has been filtered or not. Information about third party text and profanity, if it has been detected, and if it has been filtered or not. And information about customer block list, if it has been filtered and its id. + /// + [JsonPropertyName("content_filter_results")] + public ContentFilterChoiceResults? ContentFilterResults { get; set; } + + /// + /// Gets or sets log probability information for the choice. + /// + [JsonPropertyName("logprobs")] + public ChatCompletionChoiceLogProbs? LogProbs { get; set; } +} + +/// +/// A chat completion message generated by the model. +/// +public class ChatCompletionResponseMessage +{ + /// + /// Gets or sets the role of the author of the response message. + /// + [JsonPropertyName("role")] + public ChatCompletionResponseMessageRole? Role { get; set; } + + /// + /// Gets or sets the contents of the message. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Gets or sets the tool calls generated by the model, such as function calls. + /// + [JsonPropertyName("tool_calls")] + public List? ToolCalls { get; set; } + + /// + /// Gets or sets the function call + /// Deprecated and replaced by `tool_calls`. The name and arguments of a function that should be called, as generated by the model. + /// + [JsonPropertyName("function_call")] + public ChatCompletionFunctionCall? FunctionCall { get; set; } + + /// + /// Gets or sets a representation of the additional context information available when Azure OpenAI chat extensions are involved in the generation of a corresponding chat completions response. + /// This context information is only populated when using an Azure OpenAI request configured to use a matching extension. + /// + [JsonPropertyName("context")] + public AzureChatExtensionsMessageContext? Context { get; set; } +} + +/// +/// Information about the content filtering category (hate, sexual, violence, self_harm), if it has been detected, +/// as well as the severity level (very_low, low, medium, high-scale that determines the intensity and risk level of harmful content) +/// and if it has been filtered or not. Information about third-party text and profanity, if it has been detected, and if it has been filtered or not. +/// Also includes information about the customer block list, if it has been filtered and its ID. +/// +public class ContentFilterChoiceResults +{ + /// + /// Gets or sets the severity result for sexual content. + /// + [JsonPropertyName("sexual")] + public ContentFilterSeverityResult? Sexual { get; set; } + + /// + /// Gets or sets the severity result for violent content. + /// + [JsonPropertyName("violence")] + public ContentFilterSeverityResult? Violence { get; set; } + + /// + /// Gets or sets the severity result for hateful content. + /// + [JsonPropertyName("hate")] + public ContentFilterSeverityResult? Hate { get; set; } + + /// + /// Gets or sets the severity result for self-harm content. + /// + [JsonPropertyName("self_harm")] + public ContentFilterSeverityResult? SelfHarm { get; set; } + + /// + /// Gets or sets the detected result for profane content. + /// + [JsonPropertyName("profanity")] + public ContentFilterDetectedResult? Profanity { get; set; } + + /// + /// Gets or sets error details for content filtering. + /// + [JsonPropertyName("error")] + public ErrorBase? Error { get; set; } + + /// + /// Gets or sets the detected result for protected material in text. + /// + [JsonPropertyName("protected_material_text")] + public ContentFilterDetectedResult? ProtectedMaterialText { get; set; } + + /// + /// Gets or sets the detected result for protected material in code, including citation information. + /// + [JsonPropertyName("protected_material_code")] + public ContentFilterDetectedWithCitationResult? ProtectedMaterialCode { get; set; } +} + +/// +/// Log probability information for the choice. +/// +public class ChatCompletionChoiceLogProbs +{ + /// + /// Gets or sets a list of message content tokens with log probability information. + /// + [JsonPropertyName("content"), Required] + public List? Content { get; set; } +} + +/// +/// Usage statistics for the completion request. +/// +public class CompletionUsage +{ + /// + /// Gets or sets number of tokens in the prompt. + /// + [JsonPropertyName("prompt_tokens"), Required] + public int? PromptTokens { get; set; } + + /// + /// Gets or sets number of tokens in the generated completion. + /// + [JsonPropertyName("completion_tokens"), Required] + public int? CompletionTokens { get; set; } + + /// + /// Gets of sets total number of tokens used in the request (prompt + completion). + /// + [JsonPropertyName("total_tokens"), Required] + public int? TotalTokens { get; set; } +} + +/// +/// Content filtering results for a single prompt in the request. +/// +public class PromptFilterResult +{ + /// + /// Gets or sets prompt index. + /// + [JsonPropertyName("prompt_index")] + public int? PromptIndex { get; set; } + + /// + /// Gets or sets information about the content filtering category (hate, sexual, violence, self_harm), + /// if it has been detected, as well as the severity level (very_low, low, medium, high-scale that determines the intensity and risk level of harmful content) and + /// if it has been filtered or not. Information about jailbreak content and profanity, + /// if it has been detected, and if it has been filtered or not. And information about customer block list, if it has been filtered and its id. + /// + [JsonPropertyName("content_filter_results")] + public ContentFilterPromptResults? ContentFilterResults { get; set; } +} + +/// +/// Represents a tool call generated by the model. +/// +public class ChatCompletionMessageToolCall +{ + /// + /// Gets or sets the ID of the tool call. + /// + [JsonPropertyName("id"), Required] + public string? Id { get; set; } + + /// + /// Gets or sets the type of the tool call, in this case `function`. + /// + [JsonPropertyName("type"), Required] + public ToolCallType? Type { get; set; } + + /// + /// Gets or sets the function that the model called. + /// + [JsonPropertyName("function"), Required] + public FunctionObject? Function { get; set; } +} + +/// +/// The function that the model called. +/// +public class FunctionObject +{ + /// + /// Gets or sets the name of the function to call. + /// + [JsonPropertyName("name"), Required] + public string? Name { get; set; } + + /// + /// Gets or sets the arguments to call the function with, as generated by the model in JSON format. + /// Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema. Validate the arguments in your code before calling your function. + /// + [JsonPropertyName("arguments"), Required] + public string? Arguments { get; set; } +} + +/// +/// Deprecated and replaced by `tool_calls`. The name and arguments of a function that should be called, as generated by the model. +/// +public class ChatCompletionFunctionCall +{ + /// + /// Gets or sets the name of the function to call. + /// + [JsonPropertyName("name"), Required] + public string? Name { get; set; } + + /// + /// Gets or sets the arguments to call the function with, as generated by the model in JSON format. + /// Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema. Validate the arguments in your code before calling your function. + /// + [JsonPropertyName("arguments"), Required] + public string? Arguments { get; set; } +} + +/// +/// A representation of the additional context information available when Azure OpenAI chat extensions are involved +/// in the generation of a corresponding chat completions response. This context information is only populated when +/// using an Azure OpenAI request configured to use a matching extension. +/// +public class AzureChatExtensionsMessageContext +{ + /// + /// Gets or sets the data source retrieval result, used to generate the assistant message in the response. + /// + [JsonPropertyName("citations")] + public List? Citations { get; set; } + + /// + /// Gets or sets the detected intent from the chat history, used to pass to the next turn to carry over the context. + /// + [JsonPropertyName("intent")] + public string? Intent { get; set; } +} + +/// +/// Content filtering results with citation information. +/// +public class ContentFilterDetectedWithCitationResult +{ + /// + /// Gets or sets a value indicating whether the content was filtered. + /// + [JsonPropertyName("filtered"), Required] + public bool? Filtered { get; set; } + + /// + /// Gets or sets a value indicating whether the content was detected. + /// + [JsonPropertyName("detected"), Required] + public bool? Detected { get; set; } + + /// + /// Gets or sets the citation details related to the content filtering result. + /// + [JsonPropertyName("citation")] + public CitationObject? Citation { get; set; } +} + +/// +/// Represents the citation details, including the URL and license information. +/// +public class CitationObject +{ + /// + /// Gets or sets the URL of the citation. + /// + [JsonPropertyName("URL")] + public string? URL { get; set; } + + /// + /// Gets or sets the license information associated with the citation. + /// + [JsonPropertyName("license")] + public string? License { get; set; } +} + +/// +/// Token log probability information. +/// +public class ChatCompletionTokenLogProb +{ + /// + /// Gets or sets the token. + /// + [JsonPropertyName("token"), Required] + public string? Token { get; set; } + + /// + /// Gets or sets the log probability of this token. + /// + [JsonPropertyName("logprob"), Required] + public double? LogProb { get; set; } + + /// + /// Gets or sets a list of integers representing the UTF-8 bytes representation of the token. + /// + [JsonPropertyName("bytes"), Required] + public List? Bytes { get; set; } + + /// + /// Gets or sets list of the most likely tokens and their log probability, at this token position. + /// In rare cases, there may be fewer than the number of requested `top_logprobs` returned. + /// + [JsonPropertyName("top_logprobs"), Required] + public List? TopLogProbs { get; set; } +} + +/// +/// List of the most likely tokens and their log probability, at this token position. +/// In rare cases, there may be fewer than the number of requested `top_logprobs` returned. +/// +public class TopLogProbs +{ + /// + /// Gets or sets the token. + /// + [JsonPropertyName("token"), Required] + public string? Token { get; set; } + + /// + /// Gets or sets the log probability of this token. + /// + [JsonPropertyName("logprob"), Required] + public double? LogProb { get; set; } + + /// + /// Gets or sets a list of integers representing the UTF-8 bytes representation of the token. + /// Useful in instances where characters are represented by multiple tokens and their byte representations must be combined to generate the correct text representation. + /// Can be `null` if there is no bytes representation for the token. + /// + [JsonPropertyName("bytes"), Required] + public List? Bytes { get; set; } +} + +/// +/// Information about the content filtering category (hate, sexual, violence, self_harm), if it has been detected, +/// as well as the severity level (very_low, low, medium, high-scale that determines the intensity and risk level of harmful content) +/// and if it has been filtered or not. Information about jailbreak content and profanity, if it has been detected, +/// and if it has been filtered or not. And information about customer block list, if it has been filtered and its id. +/// +public class ContentFilterPromptResults +{ + /// + /// Gets or sets the severity result for sexual content. + /// + [JsonPropertyName("sexual")] + public ContentFilterSeverityResult? Sexual { get; set; } + + /// + /// Gets or sets the severity result for violent content. + /// + [JsonPropertyName("violence")] + public ContentFilterSeverityResult? Violence { get; set; } + + /// + /// Gets or sets the severity result for hateful content. + /// + [JsonPropertyName("hate")] + public ContentFilterSeverityResult? Hate { get; set; } + + /// + /// Gets or sets the severity result for self-harm content. + /// + [JsonPropertyName("self_harm")] + public ContentFilterSeverityResult? SelfHarm { get; set; } + + /// + /// Gets or sets the detected result for profane content. + /// + [JsonPropertyName("profanity")] + public ContentFilterDetectedResult? Profanity { get; set; } + + /// + /// Gets or sets error details for content filtering. + /// + [JsonPropertyName("error")] + public ErrorBase? Error { get; set; } + + /// + /// Gets or sets the detected result for jailbreak content. + /// + [JsonPropertyName("jailbreak")] + public ContentFilterDetectedResult? Jailbreak { get; set; } +} + +/// +/// Citation information for a chat completions response message. +/// +public class Citation +{ + /// + /// Gets or sets the content of the citation. + /// + [JsonPropertyName("content"), Required] + public string? Content { get; set; } + + /// + /// Gets or sets the title of the citation. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the URL of the citation. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Gets or sets the file path of the citation. + /// + [JsonPropertyName("filepath")] + public string? Filepath { get; set; } + + /// + /// Gets or sets the chunk ID of the citation. + /// + [JsonPropertyName("chunk_id")] + public string? ChunkId { get; set; } +} + +/// +/// Represents the result of content detection, indicating whether specific content was detected and whether it was filtered. +/// +public class ContentFilterDetectedResult +{ + /// + /// Gets or sets a value indicating whether the content has been filtered. + /// + [JsonPropertyName("filtered"), Required] + public bool? Filtered { get; set; } + + /// + /// Gets or sets a value indicating whether the content has been detected. + /// + [JsonPropertyName("detected"), Required] + public bool? Detected { get; set; } +} + +/// +/// Severity information for content filtering. +/// +public class ContentFilterSeverityResult +{ + /// + /// Gets or sets a value indicating whether the content has been filtered. + /// + [JsonPropertyName("filtered"), Required] + public bool? Filtered { get; set; } + + /// + /// Gets or sets the severity level of the content. + /// + [JsonPropertyName("severity"), Required] + public ContentFilterSeverity? Severity { get; set; } +} + +/// +/// Error details for content filtering. +/// +public class ErrorBase +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// Gets or sets the error message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +/// +/// The type of the tool call, in this case `function`. +/// +[JsonConverter(typeof(EnumMemberConverter))] +public enum ToolCallType +{ + /// + /// The tool call type is function. + /// + [EnumMember(Value = "function")] + Function +} + +/// +/// The role of the author of the response message. +/// +[JsonConverter(typeof(EnumMemberConverter))] +public enum ChatCompletionResponseMessageRole +{ + /// + /// The role of the assistant generating the response. + /// + [EnumMember(Value = "assistant")] + Assistant +} + +/// +/// The object type. +/// +[JsonConverter(typeof(EnumMemberConverter))] +public enum ChatCompletionResponseObject +{ + /// + /// The object type is chat completion. + /// + [EnumMember(Value = "chat.completion")] + ChatCompletion +} + +/// +/// Severity levels for content filtering. +/// +[JsonConverter(typeof(EnumMemberConverter))] +public enum ContentFilterSeverity +{ + /// + /// General content or related content in generic or non-harmful contexts. + /// + [EnumMember(Value = "safe")] + Safe, + + /// + /// Harmful content at a low intensity and risk level. + /// + [EnumMember(Value = "low")] + Low, + + /// + /// Harmful content at a medium intensity and risk level. + /// + [EnumMember(Value = "medium")] + Medium, + + /// + /// Harmful content at a high intensity and risk level. + /// + [EnumMember(Value = "high")] + High +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs index 5c33fafb..ae3594f2 100644 --- a/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs +++ b/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs @@ -1,8 +1,5 @@ -using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using AzureOpenAIProxy.ApiApp.Models; - /// /// This represents the event's detailed data for response by EventEndpoint. /// @@ -11,25 +8,30 @@ public class EventDetails /// /// Gets or sets the event id. /// - public required string? EventId { get; set; } + [JsonRequired] + public Guid EventId { get; set; } /// /// Gets or sets the event title name. /// - public required string? Title { get; set; } + [JsonRequired] + public string Title { get; set; } = string.Empty; /// /// Gets or sets the event summary. /// - public required string? Summary { get; set; } + [JsonRequired] + public string Summary { get; set; } = string.Empty; /// /// Gets or sets the Azure OpenAI Service request max token capacity. /// - public required int? MaxTokenCap { get; set; } + [JsonRequired] + public int MaxTokenCap { get; set; } /// /// Gets or sets the Azure OpenAI Service daily request capacity. /// - public required int? DailyRequestCap { get; set; } + [JsonRequired] + public int DailyRequestCap { get; set; } } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index e64fbddf..4c5a0e55 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -1,5 +1,7 @@ using AzureOpenAIProxy.ApiApp.Endpoints; using AzureOpenAIProxy.ApiApp.Extensions; +using AzureOpenAIProxy.ApiApp.Repositories; +using AzureOpenAIProxy.ApiApp.Services; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +16,12 @@ // Add OpenAPI service builder.Services.AddOpenApiService(); +// Add admin services +builder.Services.AddAdminEventService(); + +// Add admin repositories +builder.Services.AddAdminEventRepository(); + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -37,15 +45,17 @@ app.UseHttpsRedirection(); app.AddWeatherForecast(); + +// Proxy endpoints app.AddChatCompletions(); -// Event Endpoints -app.AddEventList(); +// Playground endpoints +app.AddListEvents(); -// Admin Endpoints -app.AddAdminEvents(); -app.AddAdminEventList(); -app.AddUpdateAdminEvents(); -app.CreateAdminEvent(); +// Admin endpoints +app.AddNewAdminEvent(); +app.AddListAdminEvents(); +app.AddGetAdminEvent(); +app.AddUpdateAdminEvent(); await app.RunAsync(); diff --git a/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs new file mode 100644 index 00000000..552b6416 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs @@ -0,0 +1,85 @@ +using AzureOpenAIProxy.ApiApp.Models; + +namespace AzureOpenAIProxy.ApiApp.Repositories; + +/// +/// This provides interfaces to the class. +/// +public interface IAdminEventRepository +{ + /// + /// Creates a new record of event details. + /// + /// Event details instance. + /// Returns the event details instance created. + Task CreateEvent(AdminEventDetails eventDetails); + + /// + /// Gets the list of events. + /// + /// Returns the list of events. + Task> GetEvents(); + + /// + /// Gets the event details. + /// + /// Event ID. + /// Returns the event details record. + Task GetEvent(Guid eventId); + + /// + /// Updates the event details. + /// + /// Event ID. + /// Event details instance. + /// Returns the updated record of the event details. + Task UpdateEvent(Guid eventId, AdminEventDetails eventDetails); +} + +/// +/// This represents the repository entity for the admin event. +/// +public class AdminEventRepository : IAdminEventRepository +{ + /// + public async Task CreateEvent(AdminEventDetails eventDetails) + { + throw new NotImplementedException(); + } + + /// + public async Task> GetEvents() + { + throw new NotImplementedException(); + } + + /// + public async Task GetEvent(Guid eventId) + { + throw new NotImplementedException(); + } + + /// + public async Task UpdateEvent(Guid eventId, AdminEventDetails eventDetails) + { + throw new NotImplementedException(); + } +} + +/// +/// This represents the extension class for +/// +public static class AdminEventRepositoryExtensions +{ + /// + /// Adds the instance to the service collection. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddAdminEventRepository(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/AzureOpenAIProxy.ApiApp/Services/AdminEventService.cs b/src/AzureOpenAIProxy.ApiApp/Services/AdminEventService.cs new file mode 100644 index 00000000..09f21797 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Services/AdminEventService.cs @@ -0,0 +1,96 @@ +using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Repositories; + +namespace AzureOpenAIProxy.ApiApp.Services; + +/// +/// This provides interfaces to class. +/// +public interface IAdminEventService +{ + /// + /// Creates a new event. + /// + /// Event payload. + /// Returns the event payload created. + Task CreateEvent(AdminEventDetails eventDetails); + + /// + /// Gets the list of events. + /// + /// Returns the list of events. + Task> GetEvents(); + + /// + /// Gets the event details. + /// + /// Event ID. + /// Returns the event details. + Task GetEvent(Guid eventId); + + /// + /// Updates the event details. + /// + /// Event ID. + /// Event details to update. + /// Returns the updated event details. + Task UpdateEvent(Guid eventId, AdminEventDetails eventDetails); +} + +/// +/// This represents the service entity for admin event. +/// +public class AdminEventService(IAdminEventRepository repository) : IAdminEventService +{ + private readonly IAdminEventRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + + /// + public async Task CreateEvent(AdminEventDetails eventDetails) + { + var result = await this._repository.CreateEvent(eventDetails).ConfigureAwait(false); + + return result; + } + + /// + public async Task> GetEvents() + { + var result = await this._repository.GetEvents().ConfigureAwait(false); + + return result; + } + + /// + public async Task GetEvent(Guid eventId) + { + var result = await this._repository.GetEvent(eventId).ConfigureAwait(false); + + return result; + } + + /// + public async Task UpdateEvent(Guid eventId, AdminEventDetails eventDetails) + { + var result = await this._repository.UpdateEvent(eventId, eventDetails).ConfigureAwait(false); + + return result; + } +} + +/// +/// This represents the extension class for +/// +public static class AdminEventServiceExtensions +{ + /// + /// Adds the instance to the service collection. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddAdminEventService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/App.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/App.razor index 6b73ab6b..4d95987f 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/App.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/App.razor @@ -5,10 +5,10 @@ - + @* *@ + - diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor index 1bad0f45..56bf7fcf 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor @@ -21,7 +21,7 @@ 🗙 - + - + \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor.css index 038baf17..a6731151 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor.css +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/MainLayout.razor.css @@ -27,23 +27,23 @@ main { text-decoration: none; } - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } @media (max-width: 640.98px) { .top-row { justify-content: space-between; } - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } } @media (min-width: 641px) { @@ -64,11 +64,11 @@ main { z-index: 1; } - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } .top-row, article { padding-left: 2rem !important; diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/NavMenu.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/NavMenu.razor.css index 4e15395e..e8902497 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/NavMenu.razor.css +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Layout/NavMenu.razor.css @@ -1,4 +1,4 @@ -.navbar-toggler { +/*.navbar-toggler { appearance: none; cursor: pointer; width: 3.5rem; @@ -94,12 +94,12 @@ display: none; } - .nav-scrollable { - /* Never collapse the sidebar for wide screens */ - display: block; + .nav-scrollable {*/ +/* Never collapse the sidebar for wide screens */ +/*display: block;*/ - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); +/* Allow sidebar to scroll for tall menus */ +/*height: calc(100vh - 3.5rem); overflow-y: auto; } -} +}*/ diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminEvents.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminEvents.razor new file mode 100644 index 00000000..a70d183e --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminEvents.razor @@ -0,0 +1,9 @@ +@page "/admin/events" + +AdminEvents + +

AdminEvents

+ +

This component demonstrates showing admin events.

+ + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminNewEvent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminNewEvent.razor new file mode 100644 index 00000000..6341ab35 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminNewEvent.razor @@ -0,0 +1,5 @@ +@page "/admin/events/new" + +New event + +

New event

diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor index d05c2a66..7c8ab364 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor @@ -7,4 +7,4 @@ Welcome to your new app. - \ No newline at end of file + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor index 5e52f38a..06fe0b5d 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor @@ -1,5 +1,18 @@ @page "/playground" +@rendermode InteractiveServer Playground Page -

playground page!

\ No newline at end of file + +

Azure OpenAI Proxy Playground

+ + + + + + + + Chat Window + + +
diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor new file mode 100644 index 00000000..40ebae28 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor @@ -0,0 +1,29 @@ +@page "/tests" +@rendermode InteractiveServer + +

Component Tests

+ +

Debug Button

+ + + +

Deployment Models

+ + + +@code { + private object? targetValue; + private string? selectedModel; + + private async Task SetInput(int value) + { + targetValue = value; + await Task.CompletedTask; + } + + private async Task SetDeploymentModel(string value) + { + selectedModel = value; + await Task.CompletedTask; + } +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventIsActiveComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventIsActiveComponent.razor new file mode 100644 index 00000000..2940a276 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventIsActiveComponent.razor @@ -0,0 +1,18 @@ +
+
+
+ +@code { + [Parameter] + public required bool IsActive { get; set; } + + private string GetActiveClass(bool? isActive) + { + if (!isActive.HasValue) + { + return "deactivated"; + } + + return isActive.Value ? "activated" : "deactivated"; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventIsActiveComponent.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventIsActiveComponent.razor.css new file mode 100644 index 00000000..a68c5fec --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventIsActiveComponent.razor.css @@ -0,0 +1,23 @@ +.admin-event-active-state { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; +} + +.activated { + width: 10px; + height: 10px; + background-color: green; + border-radius: 50%; + display: inline-block; +} + +.deactivated { + width: 10px; + height: 10px; + background-color: red; + border-radius: 50%; + display: inline-block; +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventsComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventsComponent.razor new file mode 100644 index 00000000..09c43446 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventsComponent.razor @@ -0,0 +1,92 @@ +@using AzureOpenAIProxy.PlaygroundApp.Models + + + @if (eventDetails == null) + { +

Loading...

+ } + else + { +
+ + + + + + + + + + + + + + + + + +
+ +
+ @if (pagination.TotalItemCount.HasValue) + { + for (var pageIndex = 0; pageIndex <= pagination.LastPageIndex; pageIndex++) + { + var capturedIndex = pageIndex; + + @(capturedIndex + 1) + + } + } +
+ } +
+ +@code { + private IQueryable? eventDetails; + private PaginationState pagination = new PaginationState { ItemsPerPage = 10 }; + + [Parameter] + public string? Id { get; set; } + + protected override async Task OnInitializedAsync() + { + // Simulate asynchronous loading to demonstrate streaming rendering + await Task.Delay(100); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + + // make dummy data + eventDetails = Enumerable.Range(1, 150).Select(index => new AdminEventDetails + { + EventId = Guid.NewGuid(), + Title = $"event title #{index}", + Summary = "dummy summary", + Description = "dummy description", + DateStart = DateTimeOffset.Now, + DateEnd = DateTimeOffset.Now.AddDays(7 + index), + TimeZone = "KST", + IsActive = index % 3 == 0, + OrganizerName = $"Charlie_{index}", + OrganizerEmail = $"user_{index}@gmail.com", + CoorganizerName = $"Bravo_{index}", + CoorganizerEmail = $"support_{index}@gmail.com", + MaxTokenCap = (100 + index) * 100, + DailyRequestCap = index * 10 + }).AsQueryable(); + + pagination.TotalItemCountChanged += (sender, eventArgs) => StateHasChanged(); + } + + private async Task GoToPageAsync(int pageIndex) + { + await pagination.SetCurrentPageIndexAsync(pageIndex); + } + + private Appearance PageButtonAppearance(int pageIndex) + => pagination.CurrentPageIndex == pageIndex ? Appearance.Accent : Appearance.Neutral; + + private string? AriaCurrentValue(int pageIndex) + => pagination.CurrentPageIndex == pageIndex ? "page" : null; +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventsComponent.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventsComponent.razor.css new file mode 100644 index 00000000..7eb01214 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/AdminEventsComponent.razor.css @@ -0,0 +1,17 @@ +.fluent-datagrid-cell { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.page-button-box { + display: flex; + justify-content: center; + align-items: center; + margin-top: 20px; +} + +.page-button { + margin-left: 10px; +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor new file mode 100644 index 00000000..33d9589a --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor @@ -0,0 +1,20 @@ + + + This is "System message" tab. + + + This is "Parameters" tab. + + + +

[TEST] Active tab changed to: @SelectedTab?.Id

+ +@code { + FluentTab? SelectedTab; + + private async Task ChangeTab(FluentTab tab) + { + SelectedTab = tab; + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugButtonComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugButtonComponent.razor new file mode 100644 index 00000000..8335d49e --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugButtonComponent.razor @@ -0,0 +1,25 @@ +@inject IToastService ToastService + + +Debug + +@code { + [Parameter] + public string? Id { get; set; } + + [Parameter] + public object? Input { get; set; } + + private async Task ShowToast() + { + if (Input is null) + { + ToastService.ShowToast(ToastIntent.Warning, "Input is null."); + await Task.CompletedTask; + return; + } + + ToastService.ShowToast(ToastIntent.Success, $"{Input} (Type: {Input.GetType()})"); + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugTargetComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugTargetComponent.razor new file mode 100644 index 00000000..bfb54641 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugTargetComponent.razor @@ -0,0 +1,18 @@ + + 123 + 456 + 789 + + +@code { + [Parameter] + public string? Id { get; set; } + + [Parameter] + public EventCallback OnValueChanged { get; set; } + + private async Task SetValue(int value) + { + await OnValueChanged.InvokeAsync(value); + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DeploymentModelListComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DeploymentModelListComponent.razor new file mode 100644 index 00000000..ac39ba4c --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DeploymentModelListComponent.razor @@ -0,0 +1,74 @@ + +
+ + * +
+ + + +
+ +@code { + private Option? selectedOption { get; set; } = new(); + + [Parameter] + public string? Id { get; set; } + + [Parameter] + public EventCallback OnUserOptionSelected { get; set; } + + private async Task OnValueChanged() + { + string? selectedValue = selectedOption?.Value?.ToString(); + await OnUserOptionSelected.InvokeAsync(selectedValue); + } + + static List> deploymentModelOptions = new() + { + new Option { Value = "AL", Text = "Alabama" }, + new Option { Value = "AK", Text = "Alaska" }, + new Option { Value = "AZ", Text = "Arizona" }, + new Option { Value = "AR", Text = "Arkansas" }, + new Option { Value = "CA", Text = "California" }, + new Option { Value = "CO", Text = "Colorado" }, + new Option { Value = "CT", Text = "Connecticut" }, + new Option { Value = "DE", Text = "Delaware" }, + new Option { Value = "FL", Text = "Florida" }, + new Option { Value = "GA", Text = "Georgia" }, + new Option { Value = "HI", Text = "Hawaii" }, + new Option { Value = "ID", Text = "Idaho" }, + new Option { Value = "IL", Text = "Illinois" }, + new Option { Value = "IN", Text = "Indiana" }, + new Option { Value = "IA", Text = "Iowa" }, + new Option { Value = "KS", Text = "Kansas" }, + new Option { Value = "KY", Text = "Kentucky" }, + new Option { Value = "LA", Text = "Louisiana" }, + new Option { Value = "ME", Text = "Maine" }, + new Option { Value = "MD", Text = "Maryland" }, + new Option { Value = "MA", Text = "Massachussets" }, + new Option { Value = "MI", Text = "Michigain" }, + new Option { Value = "MN", Text = "Minnesota" }, + new Option { Value = "MS", Text = "Mississippi" }, + new Option { Value = "MO", Text = "Missouri" }, + new Option { Value = "MT", Text = "Montana" }, + new Option { Value = "NE", Text = "Nebraska" }, + new Option { Value = "NV", Text = "Nevada" }, + new Option { Value = "NH", Text = "New Hampshire" }, + new Option { Value = "NJ", Text = "New Jersey" }, + new Option { Value = "NM", Text = "New Mexico" }, + new Option { Value = "NY", Text = "New York" }, + new Option { Value = "NC", Text = "North Carolina" }, + new Option { Value = "ND", Text = "North Dakota" }, + new Option { Value = "OH", Text = "Ohio" }, + new Option { Value = "OK", Text = "Oklahoma" }, + new Option { Value = "OR", Text = "Oregon" }, + new Option { Value = "PA", Text = "Pennsylvania" } + }; +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/_Imports.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/_Imports.razor index e8aaa070..1e0a44ec 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/_Imports.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/_Imports.razor @@ -14,3 +14,5 @@ @using AzureOpenAIProxy.PlaygroundApp @using AzureOpenAIProxy.PlaygroundApp.Components +@using AzureOpenAIProxy.PlaygroundApp.Components.UI +@using AzureOpenAIProxy.PlaygroundApp.Components.UI.Admin diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Models/AdminEventDetails.cs b/src/AzureOpenAIProxy.PlaygroundApp/Models/AdminEventDetails.cs new file mode 100644 index 00000000..0221a475 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Models/AdminEventDetails.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; + +namespace AzureOpenAIProxy.PlaygroundApp.Models; + +/// +/// This represent the event detail data for response by admin event endpoint. +/// +public class AdminEventDetails : EventDetails +{ + /// + /// Gets or sets the event description. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the event start date. + /// + [JsonRequired] + public DateTimeOffset DateStart { get; set; } + + /// + /// Gets or sets the event end date. + /// + [JsonRequired] + public DateTimeOffset DateEnd { get; set; } + + /// + /// Gets or sets the event start to end date timezone. + /// + [JsonRequired] + public string TimeZone { get; set; } = string.Empty; + + /// + /// Gets or sets the event active status. + /// + [JsonRequired] + public bool IsActive { get; set; } + + /// + /// Gets or sets the event organizer name. + /// + [JsonRequired] + public string OrganizerName { get; set; } = string.Empty; + + /// + /// Gets or sets the event organizer email. + /// + [JsonRequired] + public string OrganizerEmail { get; set; } = string.Empty; + + /// + /// Gets or sets the event coorganizer name. + /// + public string? CoorganizerName { get; set; } + + /// + /// Gets or sets the event coorganizer email. + /// + public string? CoorganizerEmail { get; set; } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Models/EventDetails.cs b/src/AzureOpenAIProxy.PlaygroundApp/Models/EventDetails.cs new file mode 100644 index 00000000..56cc3a7c --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Models/EventDetails.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace AzureOpenAIProxy.PlaygroundApp.Models; + +/// +/// This represents the event's detailed data for response by EventEndpoint. +/// +public class EventDetails +{ + /// + /// Gets or sets the event id. + /// + [JsonRequired] + public Guid EventId { get; set; } + + /// + /// Gets or sets the event title name. + /// + [JsonRequired] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the event summary. + /// + [JsonRequired] + public string Summary { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure OpenAI Service request max token capacity. + /// + [JsonRequired] + public int MaxTokenCap { get; set; } + + /// + /// Gets or sets the Azure OpenAI Service daily request capacity. + /// + [JsonRequired] + public int DailyRequestCap { get; set; } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Program.cs b/src/AzureOpenAIProxy.PlaygroundApp/Program.cs index a61639f7..7db92b99 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Program.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Program.cs @@ -36,4 +36,4 @@ app.MapRazorComponents() .AddInteractiveServerRenderMode(); -await app.RunAsync(); +await app.RunAsync(); \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/wwwroot/app.css b/src/AzureOpenAIProxy.PlaygroundApp/wwwroot/app.css index 2bd9b789..5588cc12 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/wwwroot/app.css +++ b/src/AzureOpenAIProxy.PlaygroundApp/wwwroot/app.css @@ -13,7 +13,7 @@ a, .btn-link { } .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { - box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; } .content { diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Converters/EnumMemberConverterTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Converters/EnumMemberConverterTests.cs new file mode 100644 index 00000000..aa722d97 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Converters/EnumMemberConverterTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Runtime.Serialization; +using FluentAssertions; +using AzureOpenAIProxy.ApiApp.Converters; + +namespace AzureOpenAIProxy.ApiApp.Tests.Converters; + +public class EnumMemberConverterTests +{ + private enum TestEnum + { + [EnumMember(Value = "first_value")] + FirstValue, + + [EnumMember(Value = "second.value")] + SecondValue, + + [EnumMember(Value = "thirdvalue")] + ThirdValue, + + UnmappedValue + } + + private readonly JsonConverter _converter = new EnumMemberConverter(); + + [Fact] + public void Given_EnumMemberAttribute_When_Deserializing_Then_ShouldReturnCorrectEnumValue() + { + // Arrange + var json = "\"first_value\""; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Deserialize(json, options); + + // Assert + result.Should().Be(TestEnum.FirstValue); + } + + [Fact] + public void Given_DotInEnumMemberAttribute_When_Deserializing_Then_ShouldReturnCorrectEnumValue() + { + // Arrange + var json = "\"second.value\""; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Deserialize(json, options); + + // Assert + result.Should().Be(TestEnum.SecondValue); + } + + [Fact] + public void Given_ThirdEnumMemberAttribute_When_Deserializing_Then_ShouldReturnCorrectEnumValue() + { + // Arrange + var json = "\"thirdvalue\""; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Deserialize(json, options); + + // Assert + result.Should().Be(TestEnum.ThirdValue); + } + + [Fact] + public void Given_NoEnumMemberAttribute_When_Deserializing_Then_ShouldReturnCorrectEnumValue() + { + // Arrange + var json = "\"UnmappedValue\""; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Deserialize(json, options); + + // Assert + result.Should().Be(TestEnum.UnmappedValue); + } + + [Fact] + public void Given_InvalidEnumValue_When_Deserializing_Then_ShouldThrowJsonException() + { + // Arrange + var json = "\"invalid_value\""; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + + // Assert + Action action = () => JsonSerializer.Deserialize(json, options); + action.Should().Throw() + .WithMessage("Unable to convert \"invalid_value\" to Enum*"); + } + + [Fact] + public void Given_NullValue_When_Deserializing_Then_ShouldThrowJsonException() + { + // Arrange + var json = "null"; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + + // Assert + Action action = () => JsonSerializer.Deserialize(json, options); + action.Should().Throw() + .WithMessage("Unable to convert null to Enum*"); + } + + [Fact] + public void Given_EnumMemberAttribute_When_Serializing_Then_ShouldReturnCorrectJsonString() + { + // Arrange + var value = TestEnum.FirstValue; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Serialize(value, options); + + // Assert + result.Should().Be("\"first_value\""); + } + + [Fact] + public void Given_DotInEnumMemberAttribute_When_Serializing_Then_ShouldReturnCorrectJsonString() + { + // Arrange + var value = TestEnum.SecondValue; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Serialize(value, options); + + // Assert + result.Should().Be("\"second.value\""); + } + + [Fact] + public void Given_ThirdEnumMemberAttribute_When_Serializing_Then_ShouldReturnCorrectJsonString() + { + // Arrange + var value = TestEnum.ThirdValue; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Serialize(value, options); + + // Assert + result.Should().Be("\"thirdvalue\""); + } + + [Fact] + public void Given_NoEnumMemberAttribute_When_Serializing_Then_ShouldReturnCorrectJsonString() + { + // Arrange + var value = TestEnum.UnmappedValue; + + // Act + var options = new JsonSerializerOptions + { + Converters = { _converter } + }; + var result = JsonSerializer.Serialize(value, options); + + // Assert + result.Should().Be("\"UnmappedValue\""); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/OpenApiSettingsExtensionsTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/OpenApiSettingsExtensionsTests.cs new file mode 100644 index 00000000..4cf01827 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/OpenApiSettingsExtensionsTests.cs @@ -0,0 +1,121 @@ +using AzureOpenAIProxy.ApiApp.Extensions; + +using FluentAssertions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute; + +namespace AzureOpenAIProxy.ApiApp.Tests.Extensions; + +public class OpenApiSettingsExtensionsTests +{ + [Fact] + public void Given_Null_OpenApiSettings_When_Added_ToServiceProvider_Then_It_Should_Throw_Exception() + { + // Arrange + var config = default(IConfiguration); + + var sp = Substitute.For(); + ServiceProviderServiceExtensions.GetService(sp).Returns(config); + + // Act + Action action = () => sp.GetOpenApiSettings(); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Empty_OpenApiSettings_When_Added_ToServiceProvider_Then_It_Should_Throw_Exception() + { + // Arrange + var dict = new Dictionary() + { + { "OpenApi", "" } + }; +#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); +#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + + var sp = Substitute.For(); + ServiceProviderServiceExtensions.GetService(sp).Returns(config); + + // Act + Action action = () => sp.GetOpenApiSettings(); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Empty_OpenApiSettings_When_Added_ToServiceCollection_Then_It_Should_Throw_Exception() + { + // Arrange + var dict = new Dictionary() + { + { "OpenApi", "" } + }; +#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); +#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + + var sc = new ServiceCollection(); + sc.AddSingleton(config); + + // Act + Action action = () => sc.GetOpenApiSettings(); + + // Assert + action.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("v1.0.0")] + public void Given_OpenApiSettings_When_Added_ToServiceProvider_Then_It_Should_Return_DocVersion(string docVersion) + { + // Arrange + var dict = new Dictionary() + { + { "OpenApi:DocVersion", docVersion } + }; +#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); +#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + + var sp = Substitute.For(); + ServiceProviderServiceExtensions.GetService(sp).Returns(config); + + // Act + var result = sp.GetOpenApiSettings(); + + // Assert + result.DocVersion.Should().Be(docVersion); + } + + [Theory] + [InlineData("")] + [InlineData("v1.0.0")] + public void Given_OpenApiSettings_When_Added_ToServiceCollection_Then_It_Should_Return_DocVersion(string docVersion) + { + // Arrange + var dict = new Dictionary() + { + { "OpenApi:DocVersion", docVersion } + }; +#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); +#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. + + var sc = new ServiceCollection(); + sc.AddSingleton(config); + + // Act + var result = sc.GetOpenApiSettings(); + + // Assert + result.DocVersion.Should().Be(docVersion); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Models/CreateChatCompletionResponseTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Models/CreateChatCompletionResponseTests.cs new file mode 100644 index 00000000..6cdcdf5d --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Models/CreateChatCompletionResponseTests.cs @@ -0,0 +1,278 @@ +using System.Text.Json; +using AzureOpenAIProxy.ApiApp.Models; +using FluentAssertions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Models; + +public class CreateChatCompletionResponseTests +{ + private readonly string _exampleJson = @" + { + ""id"": ""string"", + ""object"": ""chat.completion"", + ""created"": 1620241923, + ""model"": ""string"", + ""usage"": { + ""prompt_tokens"": 0, + ""completion_tokens"": 0, + ""total_tokens"": 0 + }, + ""system_fingerprint"": ""string"", + ""prompt_filter_results"": [ + { + ""prompt_index"": 0, + ""content_filter_results"": { + ""sexual"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""violence"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""hate"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""self_harm"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""profanity"": { + ""filtered"": true, + ""detected"": true + }, + ""error"": { + ""code"": ""string"", + ""message"": ""string"" + }, + ""jailbreak"": { + ""filtered"": true, + ""detected"": true + } + } + } + ], + ""choices"": [ + { + ""index"": 0, + ""finish_reason"": ""string"", + ""message"": { + ""role"": ""assistant"", + ""content"": ""string"", + ""tool_calls"": [ + { + ""id"": ""string"", + ""type"": ""function"", + ""function"": { + ""name"": ""string"", + ""arguments"": ""string"" + } + } + ], + ""function_call"": { + ""name"": ""string"", + ""arguments"": ""string"" + }, + ""context"": { + ""citations"": [ + { + ""content"": ""string"", + ""title"": ""string"", + ""url"": ""string"", + ""filepath"": ""string"", + ""chunk_id"": ""string"" + } + ], + ""intent"": ""string"" + } + }, + ""content_filter_results"": { + ""sexual"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""violence"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""hate"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""self_harm"": { + ""filtered"": true, + ""severity"": ""safe"" + }, + ""profanity"": { + ""filtered"": true, + ""detected"": true + }, + ""error"": { + ""code"": ""string"", + ""message"": ""string"" + }, + ""protected_material_text"": { + ""filtered"": true, + ""detected"": true + }, + ""protected_material_code"": { + ""filtered"": true, + ""detected"": true, + ""citation"": { + ""URL"": ""string"", + ""license"": ""string"" + } + } + }, + ""logprobs"": { + ""content"": [ + { + ""token"": ""string"", + ""logprob"": 0, + ""bytes"": [ + 0 + ], + ""top_logprobs"": [ + { + ""token"": ""string"", + ""logprob"": 0, + ""bytes"": [ + 0 + ] + } + ] + } + ] + } + } + ] + }"; + + private readonly CreateChatCompletionResponse exampleResponse = new CreateChatCompletionResponse + { + Id = "string", + Object = ChatCompletionResponseObject.ChatCompletion, + Created = 1620241923, + Model = "string", + Usage = new CompletionUsage + { + PromptTokens = 0, + CompletionTokens = 0, + TotalTokens = 0 + }, + SystemFingerprint = "string", + PromptFilterResults = new List + { + new PromptFilterResult + { + PromptIndex = 0, + ContentFilterResults = new ContentFilterPromptResults + { + Sexual = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + Violence = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + Hate = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + SelfHarm = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + Profanity = new ContentFilterDetectedResult { Filtered = true, Detected = true }, + Error = new ErrorBase { Code = "string", Message = "string" }, + Jailbreak = new ContentFilterDetectedResult { Filtered = true, Detected = true } + } + } + }, + Choices = new List + { + new ChatCompletionChoice + { + Index = 0, + FinishReason = "string", + Message = new ChatCompletionResponseMessage + { + Role = ChatCompletionResponseMessageRole.Assistant, + Content = "string", + ToolCalls = new List + { + new ChatCompletionMessageToolCall + { + Id = "string", + Type = ToolCallType.Function, + Function = new FunctionObject + { + Name = "string", + Arguments = "string" + } + } + }, + FunctionCall = new ChatCompletionFunctionCall { Name = "string", Arguments = "string" }, + Context = new AzureChatExtensionsMessageContext + { + Citations = new List + { + new Citation + { + Content = "string", + Title = "string", + Url = "string", + Filepath = "string", + ChunkId = "string" + } + }, + Intent = "string" + } + }, + ContentFilterResults = new ContentFilterChoiceResults + { + Sexual = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + Violence = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + Hate = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + SelfHarm = new ContentFilterSeverityResult { Filtered = true, Severity = ContentFilterSeverity.Safe }, + Profanity = new ContentFilterDetectedResult { Filtered = true, Detected = true }, + Error = new ErrorBase { Code = "string", Message = "string" }, + ProtectedMaterialText = new ContentFilterDetectedResult { Filtered = true, Detected = true }, + ProtectedMaterialCode = new ContentFilterDetectedWithCitationResult + { + Filtered = true, + Detected = true, + Citation = new CitationObject { URL = "string", License = "string" } + } + }, + LogProbs = new ChatCompletionChoiceLogProbs + { + Content = new List + { + new ChatCompletionTokenLogProb + { + Token = "string", + LogProb = 0, + Bytes = new List { 0 }, + TopLogProbs = new List + { + new TopLogProbs { Token = "string", LogProb = 0, Bytes = new List { 0 } } + } + } + } + } + } + } + }; + + [Fact] + public void Given_ExampleResponse_When_Serialized_Then_ShouldMatchExpectedJson() + { + // Act + var serializedJson = JsonSerializer.Serialize(exampleResponse, new JsonSerializerOptions { WriteIndented = false }); + + // Assert + serializedJson.Should().Be(_exampleJson.Replace("\r", "").Replace("\n", "").Replace(" ", "")); + } + + [Fact] + public void Given_ExampleJson_When_Deserialized_Then_ShouldReturnValidObject() + { + // Arrange & Act + var deserializedResponse = JsonSerializer.Deserialize(_exampleJson); + + // Assert + deserializedResponse.Should().NotBeNull(); + deserializedResponse.Should().BeEquivalentTo(exampleResponse); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs new file mode 100644 index 00000000..0e00c5d4 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs @@ -0,0 +1,80 @@ +using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Repositories; + +using FluentAssertions; + +using Microsoft.Extensions.DependencyInjection; + +namespace AzureOpenAIProxy.ApiApp.Tests.Repositories; + +public class AdminEventRepositoryTests +{ + [Fact] + public void Given_ServiceCollection_When_AddAdminEventRepository_Invoked_Then_It_Should_Contain_AdminEventRepository() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAdminEventRepository(); + + // Assert + services.SingleOrDefault(p => p.ServiceType == typeof(IAdminEventRepository)).Should().NotBeNull(); + } + + [Fact] + public void Given_Instance_When_CreateEvent_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var eventDetails = new AdminEventDetails(); + var repository = new AdminEventRepository(); + + // Act + Func func = async () => await repository.CreateEvent(eventDetails); + + // Assert + func.Should().ThrowAsync(); + } + + [Fact] + public void Given_Instance_When_GetEvents_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var repository = new AdminEventRepository(); + + // Act + Func func = async () => await repository.GetEvents(); + + // Assert + func.Should().ThrowAsync(); + } + + [Fact] + public void Given_Instance_When_GetEvent_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var eventId = Guid.NewGuid(); + var repository = new AdminEventRepository(); + + // Act + Func func = async () => await repository.GetEvent(eventId); + + // Assert + func.Should().ThrowAsync(); + } + + [Fact] + public void Given_Instance_When_UpdateEvent_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var eventId = Guid.NewGuid(); + var eventDetails = new AdminEventDetails(); + var repository = new AdminEventRepository(); + + // Act + Func func = async () => await repository.UpdateEvent(eventId, eventDetails); + + // Assert + func.Should().ThrowAsync(); + } +} diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminEventServiceTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminEventServiceTests.cs new file mode 100644 index 00000000..cf56f8e2 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminEventServiceTests.cs @@ -0,0 +1,87 @@ +using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Repositories; +using AzureOpenAIProxy.ApiApp.Services; + +using FluentAssertions; + +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute; + +namespace AzureOpenAIProxy.ApiApp.Tests.Services; + +public class AdminEventServiceTests +{ + [Fact] + public void Given_ServiceCollection_When_AddAdminEventService_Invoked_Then_It_Should_Contain_AdminEventService() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddAdminEventService(); + + // Assert + services.SingleOrDefault(p => p.ServiceType == typeof(IAdminEventService)).Should().NotBeNull(); + } + + [Fact] + public void Given_Instance_When_CreateEvent_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var eventDetails = new AdminEventDetails(); + var repository = Substitute.For(); + var service = new AdminEventService(repository); + + // Act + Func func = async () => await service.CreateEvent(eventDetails); + + // Assert + func.Should().ThrowAsync(); + } + + [Fact] + public void Given_Instance_When_GetEvents_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var repository = Substitute.For(); + var service = new AdminEventService(repository); + + // Act + Func func = async () => await service.GetEvents(); + + // Assert + func.Should().ThrowAsync(); + } + + [Fact] + public void Given_Instance_When_GetEvent_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var eventId = Guid.NewGuid(); + var repository = Substitute.For(); + var service = new AdminEventService(repository); + + // Act + Func func = async () => await service.GetEvent(eventId); + + // Assert + func.Should().ThrowAsync(); + } + + [Fact] + public void Given_Instance_When_UpdateEvent_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var eventId = Guid.NewGuid(); + var eventDetails = new AdminEventDetails(); + var repository = Substitute.For(); + var service = new AdminEventService(repository); + + // Act + Func func = async () => await service.UpdateEvent(eventId, eventDetails); + + // Assert + func.Should().ThrowAsync(); + } +} diff --git a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Schemas/ChatCompletionSchemasOpenApiTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Schemas/ChatCompletionSchemasOpenApiTests.cs new file mode 100644 index 00000000..4905faa5 --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Schemas/ChatCompletionSchemasOpenApiTests.cs @@ -0,0 +1,1167 @@ +using System.Text.Json; + +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +using FluentAssertions; +namespace AzureOpenAIProxy.AppHost.Tests.ApiApp.Schemas; + +/// +/// It includes tests for required fields, reference validation, enum values, and properties +/// +public class ChatCompletionSchemasOpenApiTests(AspireAppHostFixture host) : IClassFixture +{ + // Required Fields Validation + + [Fact] + public async Task Given_ChatCompletionResponseSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var createChatCompletionResponseSchema = schemas.GetProperty("CreateChatCompletionResponse"); + var requiredFields = createChatCompletionResponseSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "id", "object", "created", "model", "choices" }); + } + + [Fact] + public async Task Given_ChatCompletionChoiceLogProbsSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionChoiceLogProbsSchema = schemas.GetProperty("ChatCompletionChoiceLogProbs"); + var requiredFields = chatCompletionChoiceLogProbsSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "content" }); + } + + [Fact] + public async Task Given_CompletionUsageSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var completionUsageSchema = schemas.GetProperty("CompletionUsage"); + var requiredFields = completionUsageSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "prompt_tokens", "completion_tokens", "total_tokens" }); + } + + [Fact] + public async Task Given_ChatCompletionMessageToolCallSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionMessageToolCallSchema = schemas.GetProperty("ChatCompletionMessageToolCall"); + var requiredFields = chatCompletionMessageToolCallSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "id", "type", "function" }); + } + + [Fact] + public async Task Given_FunctionObjectSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var functionObjectSchema = schemas.GetProperty("FunctionObject"); + var requiredFields = functionObjectSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "name", "arguments" }); + } + + [Fact] + public async Task Given_ChatCompletionFunctionCallSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionFunctionCallSchema = schemas.GetProperty("ChatCompletionFunctionCall"); + var requiredFields = chatCompletionFunctionCallSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "name", "arguments" }); + } + + [Fact] + public async Task Given_ContentFilterDetectedResultSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterDetectedResultSchema = schemas.GetProperty("ContentFilterDetectedResult"); + var requiredFields = contentFilterDetectedResultSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "detected", "filtered" }); + } + + [Fact] + public async Task Given_ContentFilterDetectedWithCitationResultSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterDetectedWithCitationResultSchema = schemas.GetProperty("ContentFilterDetectedWithCitationResult"); + var requiredFields = contentFilterDetectedWithCitationResultSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "detected", "filtered" }); + } + + [Fact] + public async Task Given_ChatCompletionTokenLogProbSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionTokenLogProbSchema = schemas.GetProperty("ChatCompletionTokenLogProb"); + var requiredFields = chatCompletionTokenLogProbSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "token", "logprob", "bytes", "top_logprobs" }); + } + + [Fact] + public async Task Given_TopLogProbsSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var topLogProbsSchema = schemas.GetProperty("TopLogProbs"); + var requiredFields = topLogProbsSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "token", "logprob", "bytes" }); + } + + [Fact] + public async Task Given_CitationSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var citationSchema = schemas.GetProperty("Citation"); + var requiredFields = citationSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "content" }); + } + + [Fact] + public async Task Given_ContentFilterSeverityResultSchema_When_ValidatingRequiredFields_Then_RequiredFieldsShouldBePresent() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterSeverityResultSchema = schemas.GetProperty("ContentFilterSeverityResult"); + var requiredFields = contentFilterSeverityResultSchema.GetProperty("required").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + requiredFields.Should().Contain(new[] { "severity", "filtered" }); + } + + // $ref Validation + + [Fact] + public async Task Given_CreateChatCompletionResponseSchema_When_ValidatingPromptFilterResultsRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var createChatCompletionResponseSchema = schemas.GetProperty("CreateChatCompletionResponse"); + var prompt_filter_resultsFields = createChatCompletionResponseSchema.GetProperty("properties") + .GetProperty("prompt_filter_results"); + + // Assert + prompt_filter_resultsFields.GetProperty("items").GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/PromptFilterResult"); + } + + [Fact] + public async Task Given_CreateChatCompletionResponseSchema_When_ValidatingChoicesRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var createChatCompletionResponseSchema = schemas.GetProperty("CreateChatCompletionResponse"); + var choicesFields = createChatCompletionResponseSchema.GetProperty("properties") + .GetProperty("choices"); + + // Assert + choicesFields.GetProperty("items") + .GetProperty("$ref").GetString() + .Should().Be("#/components/schemas/ChatCompletionChoice"); + } + + [Fact] + public async Task Given_ChatCompletionChoiceSchema_When_ValidatingMessageRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionChoiceSchema = schemas.GetProperty("ChatCompletionChoice"); + var messageFields = chatCompletionChoiceSchema.GetProperty("properties") + .GetProperty("message"); + + // Assert + messageFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ChatCompletionResponseMessage"); + } + + [Fact] + public async Task Given_ChatCompletionChoiceSchema_When_ValidatingContentFilterResultsRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionChoiceSchema = schemas.GetProperty("ChatCompletionChoice"); + var content_filter_resultsFields = chatCompletionChoiceSchema.GetProperty("properties") + .GetProperty("content_filter_results"); + + // Assert + content_filter_resultsFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterChoiceResults"); + } + + [Fact] + public async Task Given_ChatCompletionChoiceSchema_When_ValidatingLogProbsRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionChoiceSchema = schemas.GetProperty("ChatCompletionChoice"); + var logprobsFields = chatCompletionChoiceSchema.GetProperty("properties") + .GetProperty("logprobs"); + + // Assert + logprobsFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ChatCompletionChoiceLogProbs"); + } + + [Fact] + public async Task Given_CreateChatCompletionResponseSchema_When_ValidatingObjectRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var createChatCompletionResponseSchema = schemas.GetProperty("CreateChatCompletionResponse"); + var objectFields = createChatCompletionResponseSchema.GetProperty("properties") + .GetProperty("object"); + + // Assert + objectFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ChatCompletionResponseObject"); + } + + [Fact] + public async Task Given_CreateChatCompletionResponseSchema_When_ValidatingUsageRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var createChatCompletionResponseSchema = schemas.GetProperty("CreateChatCompletionResponse"); + var usageFields = createChatCompletionResponseSchema.GetProperty("properties") + .GetProperty("usage"); + + // Assert + usageFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/CompletionUsage"); + } + + [Fact] + public async Task Given_ChatCompletionResponseMessageSchema_When_ValidatingRoleRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionResponseMessageSchema = schemas.GetProperty("ChatCompletionResponseMessage"); + var roleFields = chatCompletionResponseMessageSchema.GetProperty("properties") + .GetProperty("role"); + + // Assert + roleFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ChatCompletionResponseMessageRole"); + } + + [Fact] + public async Task Given_ChatCompletionResponseMessageSchema_When_ValidatingToolCallsRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionResponseMessageSchema = schemas.GetProperty("ChatCompletionResponseMessage"); + var toolCallsFields = chatCompletionResponseMessageSchema.GetProperty("properties") + .GetProperty("tool_calls"); + + // Assert + toolCallsFields.GetProperty("items").GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ChatCompletionMessageToolCall"); + } + + [Fact] + public async Task Given_ChatCompletionResponseMessageSchema_When_ValidatingFunctionCallRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionResponseMessageSchema = schemas.GetProperty("ChatCompletionResponseMessage"); + var function_callFields = chatCompletionResponseMessageSchema.GetProperty("properties") + .GetProperty("function_call"); + + // Assert + function_callFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ChatCompletionFunctionCall"); + } + + [Fact] + public async Task Given_ChatCompletionResponseMessageSchema_When_ValidatingContextRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionResponseMessageSchema = schemas.GetProperty("ChatCompletionResponseMessage"); + var contextFields = chatCompletionResponseMessageSchema.GetProperty("properties") + .GetProperty("context"); + + // Assert + contextFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/AzureChatExtensionsMessageContext"); + } + + [Fact] + public async Task Given_ContentFilterChoiceResultsSchema_When_ValidatingProtectedMaterialTextRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterChoiceResultsSchema = schemas.GetProperty("ContentFilterChoiceResults"); + var protected_material_textFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("protected_material_text"); + + // Assert + protected_material_textFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterDetectedResult"); + } + + [Fact] + public async Task Given_ContentFilterChoiceResultsSchema_When_ValidatingProtectedMaterialCodeRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterChoiceResultsSchema = schemas.GetProperty("ContentFilterChoiceResults"); + var protected_material_codeFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("protected_material_code"); + + // Assert + protected_material_codeFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterDetectedWithCitationResult"); + } + + [Fact] + public async Task Given_ChatCompletionChoiceLogProbsSchema_When_ValidatingContentRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionChoiceLogProbsSchema = schemas.GetProperty("ChatCompletionChoiceLogProbs"); + var contentFields = chatCompletionChoiceLogProbsSchema.GetProperty("properties") + .GetProperty("content"); + + // Assert + contentFields.GetProperty("items").GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ChatCompletionTokenLogProb"); + } + + [Fact] + public async Task Given_PromptFilterResultSchema_When_ValidatingContentFilterResultsRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var promptFilterResultSchema = schemas.GetProperty("PromptFilterResult"); + var content_filter_resultsFields = promptFilterResultSchema.GetProperty("properties") + .GetProperty("content_filter_results"); + + // Assert + content_filter_resultsFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterPromptResults"); + } + + [Fact] + public async Task Given_ChatCompletionMessageToolCallSchema_When_ValidatingTypeRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionMessageToolCallSchema = schemas.GetProperty("ChatCompletionMessageToolCall"); + var typeFields = chatCompletionMessageToolCallSchema.GetProperty("properties") + .GetProperty("type"); + + // Assert + typeFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ToolCallType"); + } + + [Fact] + public async Task Given_ChatCompletionMessageToolCallSchema_When_ValidatingFunctionRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionMessageToolCallSchema = schemas.GetProperty("ChatCompletionMessageToolCall"); + var functionFields = chatCompletionMessageToolCallSchema.GetProperty("properties") + .GetProperty("function"); + + // Assert + functionFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/FunctionObject"); + } + + [Fact] + public async Task Given_AzureChatExtensionsMessageContextSchema_When_ValidatingCitationsRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var azureChatExtensionsMessageContextSchema = schemas.GetProperty("AzureChatExtensionsMessageContext"); + var citationsFields = azureChatExtensionsMessageContextSchema.GetProperty("properties") + .GetProperty("citations"); + + // Assert + citationsFields.GetProperty("items").GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/Citation"); + } + + [Fact] + public async Task Given_ContentFilterPromptResultsSchema_When_ValidatingJailbreakRef_Then_RefShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterPromptResultsSchema = schemas.GetProperty("ContentFilterPromptResults"); + var jailbreakFields = contentFilterPromptResultsSchema.GetProperty("properties") + .GetProperty("jailbreak"); + + // Assert + jailbreakFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterDetectedResult"); + } + + [Fact] + public async Task Given_ContentFilterPromptResultsSchema_When_ValidatingSeverityRefs_Then_RefsShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterPromptResultsSchema = schemas.GetProperty("ContentFilterPromptResults"); + var sexualFields = contentFilterPromptResultsSchema.GetProperty("properties") + .GetProperty("sexual"); + var violenceFields = contentFilterPromptResultsSchema.GetProperty("properties") + .GetProperty("violence"); + var hateFields = contentFilterPromptResultsSchema.GetProperty("properties") + .GetProperty("hate"); + var self_harmFields = contentFilterPromptResultsSchema.GetProperty("properties") + .GetProperty("self_harm"); + var profanityFields = contentFilterPromptResultsSchema.GetProperty("properties") + .GetProperty("profanity"); + var errorFields = contentFilterPromptResultsSchema.GetProperty("properties") + .GetProperty("error"); + + // Assert + sexualFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + violenceFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + hateFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + self_harmFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + profanityFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterDetectedResult"); + errorFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ErrorBase"); + } + + [Fact] + public async Task Given_ContentFilterChoiceResultsSchema_When_ValidatingSeverityRefs_Then_RefsShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterChoiceResultsSchema = schemas.GetProperty("ContentFilterChoiceResults"); + var sexualFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("sexual"); + var violenceFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("violence"); + var hateFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("hate"); + var self_harmFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("self_harm"); + var profanityFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("profanity"); + var errorFields = contentFilterChoiceResultsSchema.GetProperty("properties") + .GetProperty("error"); + + // Assert + sexualFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + violenceFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + hateFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + self_harmFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterSeverityResult"); + profanityFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ContentFilterDetectedResult"); + errorFields.GetProperty("$ref") + .GetString().Should().Be("#/components/schemas/ErrorBase"); + } + + // Enum validation + + [Fact] + public async Task Given_ChatCompletionResponseObjectSchema_When_ValidatingEnum_Then_EnumValuesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionResponseObjectSchema = schemas.GetProperty("ChatCompletionResponseObject"); + var enumValues = chatCompletionResponseObjectSchema.GetProperty("enum").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + enumValues.Should().Contain(new[] { "chat.completion" }); + } + + [Fact] + public async Task Given_ChatCompletionResponseMessageRoleSchema_When_ValidatingEnum_Then_EnumValuesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionResponseMessageRoleSchema = schemas.GetProperty("ChatCompletionResponseMessageRole"); + var enumValues = chatCompletionResponseMessageRoleSchema.GetProperty("enum").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + enumValues.Should().Contain(new[] { "assistant" }); + } + + [Fact] + public async Task Given_ContentFilterSeveritySchema_When_ValidatingEnum_Then_EnumValuesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterSeveritySchema = schemas.GetProperty("ContentFilterSeverity"); + var enumValues = contentFilterSeveritySchema.GetProperty("enum").EnumerateArray().Select(x => x.GetString()).ToList(); + + // Assert + enumValues.Should().Contain(new[] { "safe", "low", "medium", "high" }); + } + + // Properties validation + + [Fact] + public async Task Given_CreateChatCompletionResponseSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var createChatCompletionResponseSchema = schemas.GetProperty("CreateChatCompletionResponse"); + var properties = createChatCompletionResponseSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "id", "object", "created", "model", "usage", "system_fingerprint", "prompt_filter_results", "choices" }); + } + + [Fact] + public async Task Given_ChatCompletionChoiceSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionChoiceSchema = schemas.GetProperty("ChatCompletionChoice"); + var properties = chatCompletionChoiceSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "index", "finish_reason", "message", "content_filter_results", "logprobs" }); + } + + [Fact] + public async Task Given_ChatCompletionResponseMessageSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionResponseMessageSchema = schemas.GetProperty("ChatCompletionResponseMessage"); + var properties = chatCompletionResponseMessageSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "role", "content", "tool_calls", "function_call", "context" }); + } + + [Fact] + public async Task Given_ChatCompletionFunctionCallSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionFunctionCallSchema = schemas.GetProperty("ChatCompletionFunctionCall"); + var properties = chatCompletionFunctionCallSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "name", "arguments" }); + } + + [Fact] + public async Task Given_AzureChatExtensionsMessageContextSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var azureChatExtensionsMessageContextSchema = schemas.GetProperty("AzureChatExtensionsMessageContext"); + var properties = azureChatExtensionsMessageContextSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "citations", "intent" }); + } + + [Fact] + public async Task Given_ContentFilterSeverityResultSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterSeverityResultSchema = schemas.GetProperty("ContentFilterSeverityResult"); + var properties = contentFilterSeverityResultSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "severity", "filtered" }); + } + + [Fact] + public async Task Given_CompletionUsageSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var completionUsageSchema = schemas.GetProperty("CompletionUsage"); + var properties = completionUsageSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "prompt_tokens", "completion_tokens", "total_tokens" }); + } + + [Fact] + public async Task Given_ContentFilterDetectedResultSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterDetectedResultSchema = schemas.GetProperty("ContentFilterDetectedResult"); + var properties = contentFilterDetectedResultSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "filtered", "detected" }); + } + + [Fact] + public async Task Given_ContentFilterDetectedWithCitationResultSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterDetectedWithCitationResultSchema = schemas.GetProperty("ContentFilterDetectedWithCitationResult"); + var properties = contentFilterDetectedWithCitationResultSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "filtered", "detected", "citation" }); + } + + [Fact] + public async Task Given_FunctionObjectSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var functionObjectSchema = schemas.GetProperty("FunctionObject"); + var properties = functionObjectSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "name", "arguments" }); + } + + [Fact] + public async Task Given_ChatCompletionChoiceLogProbsSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionChoiceLogProbsSchema = schemas.GetProperty("ChatCompletionChoiceLogProbs"); + var properties = chatCompletionChoiceLogProbsSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "content" }); + } + + [Fact] + public async Task Given_ChatCompletionTokenLogProbSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionTokenLogProbSchema = schemas.GetProperty("ChatCompletionTokenLogProb"); + var properties = chatCompletionTokenLogProbSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "token", "logprob", "bytes", "top_logprobs" }); + } + + [Fact] + public async Task Given_TopLogProbsSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var topLogProbsSchema = schemas.GetProperty("TopLogProbs"); + var properties = topLogProbsSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "token", "logprob", "bytes" }); + } + + [Fact] + public async Task Given_CitationSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var citationSchema = schemas.GetProperty("Citation"); + var properties = citationSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "content", "title", "url", "filepath", "chunk_id" }); + } + + [Fact] + public async Task Given_PromptFilterResultSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var promptFilterResultSchema = schemas.GetProperty("PromptFilterResult"); + var properties = promptFilterResultSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "prompt_index", "content_filter_results" }); + } + + [Fact] + public async Task Given_ContentFilterPromptResultsSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterPromptResultsSchema = schemas.GetProperty("ContentFilterPromptResults"); + var properties = contentFilterPromptResultsSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "sexual", "violence", "hate", "self_harm", "profanity", "error", "jailbreak" }); + } + + [Fact] + public async Task Given_ContentFilterChoiceResultsSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var contentFilterChoiceResultsSchema = schemas.GetProperty("ContentFilterChoiceResults"); + var properties = contentFilterChoiceResultsSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "sexual", "violence", "hate", "self_harm", "profanity", "error", "protected_material_text", "protected_material_code" }); + } + + [Fact] + public async Task Given_ChatCompletionMessageToolCallSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var chatCompletionMessageToolCallSchema = schemas.GetProperty("ChatCompletionMessageToolCall"); + var properties = chatCompletionMessageToolCallSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "id", "type", "function" }); + } + + [Fact] + public async Task Given_ErrorBaseSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var errorBaseSchema = schemas.GetProperty("ErrorBase"); + var properties = errorBaseSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "code", "message" }); + } + + [Fact] + public async Task Given_CitationObjectSchema_When_ValidatingProperties_Then_PropertiesShouldBeCorrect() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + var schemas = openapi!.RootElement.GetProperty("components").GetProperty("schemas"); + + // Act + var citationObjectSchema = schemas.GetProperty("CitationObject"); + var properties = citationObjectSchema.GetProperty("properties").EnumerateObject().Select(x => x.Name).ToList(); + + // Assert + properties.Should().Contain(new[] { "URL", "license" }); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs index 22adb326..4af8e386 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/AppHostProgramTests.cs @@ -4,7 +4,7 @@ using FluentAssertions; -namespace AzureOpenAIProxy.Tests; +namespace AzureOpenAIProxy.AppHost.Tests; public class AppHostProgramTests(AspireAppHostFixture host) : IClassFixture { diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Components/Pages/PlaygroundPageTest.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Components/Pages/PlaygroundPageTest.cs deleted file mode 100644 index 16b905bf..00000000 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Components/Pages/PlaygroundPageTest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using AzureOpenAIProxy.AppHost.Tests.Fixtures; - -namespace AzureOpenAIProxy.AppHost.Tests.PlaygroundApp.Components.Pages; - -public class PlaygroundPageTest(AspireAppHostFixture host) : IClassFixture -{ - [Fact] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_OK() - { - // Arrange - var httpClient = host.App!.CreateHttpClient("playgroundapp"); - await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - - // Act - var response = await httpClient.GetAsync("/playground"); - - // Assert - response.EnsureSuccessStatusCode(); // Status Code 200-299 - } -} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs new file mode 100644 index 00000000..ced6d1df --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs @@ -0,0 +1,69 @@ +using System.Net; + +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +using FluentAssertions; + +namespace AzureOpenAIProxy.AppHost.Tests.PlaygroundApp.Pages; + +public class AdminNewEventPageTests(AspireAppHostFixture host) : IClassFixture +{ + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_OK() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var response = await httpClient.GetAsync("/admin/events/new"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Theory] + [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_CSS_Elements(string expected) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var html = await httpClient.GetStringAsync("/admin/events/new"); + + // Assert + html.Should().Contain(expected); + } + + [Theory] + [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.lib.module.js")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_JavaScript_Elements(string expected) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var html = await httpClient.GetStringAsync("/admin/events/new"); + + // Assert + html.Should().Contain(expected); + } + + [Theory] + [InlineData("
")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_HTML_Elements(string expected) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var html = await httpClient.GetStringAsync("/admin/events/new"); + + // Assert + html.Should().Contain(expected); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs index 07f8a419..e0e15ec2 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; namespace AzureOpenAIProxy.AppHost.Tests.PlaygroundApp.Pages; + public class AdminPageTests(AspireAppHostFixture host) : IClassFixture { [Fact] @@ -21,12 +22,28 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_OK( response.StatusCode.Should().Be(HttpStatusCode.OK); } + [Theory] + [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_CSS_Elements(string expected) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var html = await httpClient.GetStringAsync("/admin"); + + // Assert + html.Should().Contain(expected); + } + [Theory] [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.lib.module.js")] public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_JavaScript_Elements(string expected) { // Arrange using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); // Act var html = await httpClient.GetStringAsync("/admin"); @@ -41,6 +58,7 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_HTM { // Arrange using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); // Act var html = await httpClient.GetStringAsync("/admin"); diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs index 8d3b9bc1..2ed277df 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs @@ -28,6 +28,7 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_CSS { // Arrange using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); // Act var html = await httpClient.GetStringAsync("/"); @@ -42,6 +43,7 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav { // Arrange using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); // Act var html = await httpClient.GetStringAsync("/"); @@ -56,6 +58,7 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_HTM { // Arrange using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); // Act var html = await httpClient.GetStringAsync("/"); diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs new file mode 100644 index 00000000..db9a3d6a --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs @@ -0,0 +1,67 @@ +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +using FluentAssertions; + +namespace AzureOpenAIProxy.AppHost.Tests.PlaygroundApp.Pages; + +public class PlaygroundPageTests(AspireAppHostFixture host) : IClassFixture +{ + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_OK() + { + // Arrange + var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var response = await httpClient.GetAsync("/playground"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Theory] + [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_CSS_Elements(string expected) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var html = await httpClient.GetStringAsync("/playground"); + + // Assert + html.Should().Contain(expected); + } + + [Theory] + [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.lib.module.js")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_JavaScript_Elements(string expected) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var html = await httpClient.GetStringAsync("/playground"); + + // Assert + html.Should().Contain(expected); + } + + [Theory] + [InlineData("
")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_HTML_Elements(string expected) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("playgroundapp"); + await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var html = await httpClient.GetStringAsync("/playground"); + + // Assert + html.Should().Contain(expected); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/AzureOpenAIProxy.PlaygroundApp.Tests.csproj b/test/AzureOpenAIProxy.PlaygroundApp.Tests/AzureOpenAIProxy.PlaygroundApp.Tests.csproj index 0b9dccf2..0ed50954 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/AzureOpenAIProxy.PlaygroundApp.Tests.csproj +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/AzureOpenAIProxy.PlaygroundApp.Tests.csproj @@ -23,9 +23,9 @@ - + --> diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/AdminEventsPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/AdminEventsPageTests.cs new file mode 100644 index 00000000..6708b13b --- /dev/null +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/AdminEventsPageTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; + +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +[Property("Category", "Integration")] +public class AdminEventsPageTests : PageTest +{ + public override BrowserNewContextOptions ContextOptions() => new() + { + IgnoreHTTPSErrors = true, + }; + + [SetUp] + public async Task Init() + { + await Page.GotoAsync("https://localhost:5001/admin/events"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + [Test] + public async Task Given_Events_Page_When_Navigated_Then_It_Should_Have_ListEventDetailsComponent() + { + // Act + var adminEventsComponent = await Page.QuerySelectorAsync("#admin-events"); + + // Assert + adminEventsComponent.Should().NotBeNull(); + } + + [Test] + public async Task Given_Events_Page_When_Navigated_Then_It_Should_Have_EventDetailsTable() + { + // wait for construct table + await Task.Delay(2000); + + // Act + var adminEventsTable = await Page.QuerySelectorAsync("#admin-events-table"); + + // Assert + adminEventsTable.Should().NotBeNull(); + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } +} diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/HomePageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/HomePageTests.cs index aad307da..61d2e50d 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/HomePageTests.cs +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/HomePageTests.cs @@ -1,28 +1,38 @@ -using Microsoft.Playwright; -using Microsoft.Playwright.NUnit; - -namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; - -[Parallelizable(ParallelScope.Self)] -[TestFixture] -[Property("Category", "Integration")] -public class HomePageTests : PageTest -{ - public override BrowserNewContextOptions ContextOptions() => new() - { - IgnoreHTTPSErrors = true, - }; - - [Test] - public async Task Given_Root_Page_When_Navigated_Then_It_Should_No_Sidebar() - { - // Arrange - await this.Page.GotoAsync("https://localhost:5001"); - - // Act - var sidebar = this.Page.Locator("div.sidebar"); - - // Assert - Expect(sidebar).Equals(null); - } -} +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +[Property("Category", "Integration")] +public class HomePageTests : PageTest +{ + public override BrowserNewContextOptions ContextOptions() => new() + { + IgnoreHTTPSErrors = true, + }; + + [SetUp] + public async Task Init() + { + await Page.GotoAsync("https://localhost:5001"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + [Test] + public void Given_Root_Page_When_Navigated_Then_It_Should_No_Sidebar() + { + // Act + var sidebar = Page.Locator("div.sidebar"); + + // Assert + Expect(sidebar).Equals(null); + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } +} diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs new file mode 100644 index 00000000..c4b4da2a --- /dev/null +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; + +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +[Property("Category", "Integration")] +public class PlaygroundPageTests : PageTest +{ + public override BrowserNewContextOptions ContextOptions() => new() { IgnoreHTTPSErrors = true, }; + + [SetUp] + public async Task Init() + { + await Page.GotoAsync("https://localhost:5001/playground/"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + [Test] + public async Task Given_Page_When_Endpoint_Invoked_Then_It_Should_Show_Header() + { + // Arrange + var header = Page.Locator("div.layout") + .Locator("header.header") + .Locator("div.header-gutters") + .Locator("h1"); + + // Act + var headerText = await header.TextContentAsync(); + + // Assert + headerText.Should().Be("Azure OpenAI Proxy Playground"); + } + + [Test] + [TestCase("config-grid")] + [TestCase("chat-grid")] + public async Task Given_Page_When_Endpoint_Invoked_Then_It_Should_Show_Panels(string id) + { + // Act + var panel = Page.Locator($"div.{id}"); + + // Assert + await Expect(panel).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_ConfigTab_Should_Be_Displayed() + { + // Act + var configTab = Page.Locator("fluent-tabs#config-tab"); + + // Assert + await Expect(configTab).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_Id_Should_Be_System_Message_Tab() + { + // Act + var systemMessagePanel = Page.Locator("fluent-tab-panel#system-message-tab-panel"); + var parameterPanel = Page.Locator("fluent-tab-panel#parameters-tab-panel"); + + // Assert + await Expect(systemMessagePanel).ToBeVisibleAsync(); + await Expect(parameterPanel).ToBeHiddenAsync(); + } + + [Test] + [TestCase( + "fluent-tab#parameters-tab", + "fluent-tab-panel#parameters-tab-panel", + "fluent-tab-panel#system-message-tab-panel" + )] + [TestCase( + "fluent-tab#system-message-tab", + "fluent-tab-panel#system-message-tab-panel", + "fluent-tab-panel#parameters-tab-panel" + )] + public async Task Given_ConfigTab_When_Changed_Then_Tab_Should_Be_Updated( + string selectedTabSelector, + string selectedPanelSelector, + string hiddenPanelSelector + ) + { + // Arrange + var selectedTab = Page.Locator(selectedTabSelector); + var selectedPanel = Page.Locator(selectedPanelSelector); + var hiddenPanel = Page.Locator(hiddenPanelSelector); + + // Act + await selectedTab.ClickAsync(); + + // Assert + await Expect(selectedPanel).ToBeVisibleAsync(); + await Expect(hiddenPanel).ToBeHiddenAsync(); + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs new file mode 100644 index 00000000..0d4ca2f1 --- /dev/null +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs @@ -0,0 +1,128 @@ +using FluentAssertions; + +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +[Property("Category", "Integration")] +public class TestsPageTests : PageTest +{ + public override BrowserNewContextOptions ContextOptions() => new() + { + IgnoreHTTPSErrors = true, + }; + + [SetUp] + public async Task Setup() + { + // Arrange + await Page.GotoAsync("https://localhost:5001/tests"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + [Test] + public async Task Given_No_Input_On_DebugTarget_When_DebugButton_Clicked_Then_Toast_Should_Show_NullMessage() + { + // Arrange + var button = Page.Locator("fluent-button#debug-button"); + + // Act + await button.ClickAsync(); + + // Assert + await Expect(Page.Locator(".fluent-toast-title")).ToHaveTextAsync("Input is null."); + } + + [Test] + [TestCase(123, typeof(int))] + [TestCase(456, typeof(int))] + [TestCase(789, typeof(int))] + public async Task Given_Input_On_DebugTarget_When_DebugButton_Clicked_Then_Toast_Should_Show_Input(int inputValue, Type expectedType) + { + // Arrange + var radio = Page.Locator("fluent-radio-group#debug-target") + .Locator($"fluent-radio[current-value='{inputValue}']"); + var button = Page.Locator("fluent-button#debug-button"); + + // Act + await radio.ClickAsync(); + await button.ClickAsync(); + + // Assert + await Expect(Page.Locator(".fluent-toast-title")).ToHaveTextAsync($"{inputValue} (Type: {expectedType})"); + } + + [Test] + [TestCase("deployment-model-label", "Deployment")] + public async Task Given_Label_When_Page_Loaded_Then_Label_Should_Be_Visible(string id, string expected) + { + // Arrange + var label = Page.Locator($"label#{id}"); + + // Act + var result = await label.TextContentAsync(); + + // Assert + result.Should().Be(expected); + } + + [Test] + [TestCase("deployment-model-list-options")] + public async Task Given_DropdownList_When_Page_Loaded_Then_DropdownList_Should_Be_Visible(string id) + { + // Act + var select = Page.Locator($"fluent-select#{id}"); + + // Assert + await Expect(select).ToBeVisibleAsync(); + } + + [Test] + [TestCase("deployment-model-list-options")] + public async Task Given_DropdownList_When_DropdownList_Clicked_And_DropdownOptions_Appeared_Then_All_DropdownOptions_Should_Be_Visible(string id) + { + // Arrange + var fluentSelect = Page.Locator($"fluent-select#{id}"); + + // Act + await fluentSelect.ClickAsync(); + var fluentOptions = fluentSelect.Locator("fluent-option"); + + // Assert + for (int i = 0; i < await fluentOptions.CountAsync(); i++) + { + await Expect(fluentOptions.Nth(i)).ToBeVisibleAsync(); + } + } + + [Test] + [TestCase(2, "AZ", typeof(string))] + [TestCase(4, "CA", typeof(string))] + [TestCase(6, "CT", typeof(string))] + [TestCase(8, "FL", typeof(string))] + public async Task Given_DropdownOptions_And_ExpectedValue_When_Third_DropdownOption_Selected_And_DropdownValue_Updated_Then_DropdownValue_Should_Match_ExpectedValue(int index, string expectedValue, Type expectedType) + { + // Arrange + var fluentSelect = Page.Locator("fluent-select#deployment-model-list-options"); + await fluentSelect.ClickAsync(); + var fluentOptions = fluentSelect.Locator("fluent-option"); + var button = Page.Locator("fluent-button#debug-button-deployment-model-list"); + + // Act + await fluentOptions.Nth(index).ScrollIntoViewIfNeededAsync(); + await fluentOptions.Nth(index).ClickAsync(); + await button.ClickAsync(); + + // Assert + await Expect(Page.Locator(".fluent-toast-title")).ToHaveTextAsync($"{expectedValue} (Type: {expectedType})"); + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } +} \ No newline at end of file