From 3f0eca95e0dee942eb1009aea50ce70d2747bd0b Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Sat, 7 Sep 2024 14:26:53 +0900 Subject: [PATCH 01/26] Add AdminResourceDetails model (#306) --- .../Models/AdminEventDetails.cs | 118 ++-- .../Models/AdminResourceDetails.cs | 84 +++ .../Models/EventDetails.cs | 72 +-- .../Models/AdminResourceDetailsTests.cs | 69 +++ .../CreateChatCompletionResponseTests.cs | 555 +++++++++--------- 5 files changed, 526 insertions(+), 372 deletions(-) create mode 100644 src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs create mode 100644 test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminResourceDetailsTests.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs index a3ccdf25..aa5dc597 100644 --- a/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs +++ b/src/AzureOpenAIProxy.ApiApp/Models/AdminEventDetails.cs @@ -1,60 +1,60 @@ -using System.Text.Json.Serialization; - -namespace AzureOpenAIProxy.ApiApp.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; } +using System.Text.Json.Serialization; + +namespace AzureOpenAIProxy.ApiApp.Models; + +/// +/// This represent the entity for the event details for admin. +/// +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.ApiApp/Models/AdminResourceDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs new file mode 100644 index 00000000..b1464200 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs @@ -0,0 +1,84 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +using AzureOpenAIProxy.ApiApp.Converters; + +namespace AzureOpenAIProxy.ApiApp.Models; + +/// +/// This represent the entity for the resource details for admin. +/// +public class AdminResourceDetails +{ + /// + /// Gets or sets the event id. + /// + [JsonRequired] + public Guid ResourceId { get; set; } + + /// + /// Gets or sets the friendly name of the resource. + /// + [JsonRequired] + public string FriendlyName { get; set; } = string.Empty; + + /// + /// Gets or sets the deployment name of the resource. + /// + [JsonRequired] + public string DeploymentName { get; set; } = string.Empty; + + /// + /// Gets or sets the resource type. + /// + [JsonRequired] + public ResourceType ResourceType { get; set; } = ResourceType.None; + + /// + /// Gets or sets the resource endpoint. + /// + [JsonRequired] + public string Endpoint { get; set; } = string.Empty; + + /// + /// Gets or sets the resource API key. + /// + [JsonRequired] + public string ApiKey { get; set; } = string.Empty; + + /// + /// Gets or sets the resource region. + /// + [JsonRequired] + public string Region { get; set; } = string.Empty; + + /// + /// Gets or sets the value indicating whether the resource is active. + /// + [JsonRequired] + public bool IsActive { get; set; } +} + +/// +/// /// This defines the type of the resource. +[JsonConverter(typeof(EnumMemberConverter))] +public enum ResourceType +{ + /// + /// Indicates the resource type is not defined. + /// + [EnumMember(Value = "none")] + None, + + /// + /// Indicates the chat resource type. + /// + [EnumMember(Value = "chat")] + Chat, + + /// + /// Indicates the image resource type. + /// + [EnumMember(Value = "image")] + Image, +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs index ae3594f2..459ff655 100644 --- a/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs +++ b/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs @@ -1,37 +1,37 @@ -using System.Text.Json.Serialization; - -/// -/// 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; } +using System.Text.Json.Serialization; + +/// +/// This represent the entity for the event details for users. +/// +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/test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminResourceDetailsTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminResourceDetailsTests.cs new file mode 100644 index 00000000..fb94114b --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminResourceDetailsTests.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +using AzureOpenAIProxy.ApiApp.Models; + +using FluentAssertions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Models; + +public class AdminResourceDetailsTests +{ + private static readonly AdminResourceDetails examplePayload = new() + { + ResourceId = Guid.Parse("67f410a3-c5e4-4326-a3ad-5812b9adfc06"), + FriendlyName = "Test Resource", + DeploymentName = "Test Deployment", + ResourceType = ResourceType.Chat, + Endpoint = "https://test.endpoint.com", + ApiKey = "test-api-key", + Region = "test-region", + IsActive = true + }; + + private static readonly string exampleJson = """ + { + "resourceId": "67f410a3-c5e4-4326-a3ad-5812b9adfc06", + "friendlyName": "Test Resource", + "deploymentName": "Test Deployment", + "resourceType": "chat", + "endpoint": "https://test.endpoint.com", + "apiKey": "test-api-key", + "region": "test-region", + "isActive": true + } + """; + + private static readonly JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + [Fact] + public void Given_ExamplePayload_When_Serialized_Then_It_Should_Match_Json() + { + // Act + var serialised = JsonSerializer.Serialize(examplePayload, options); + + // Assert + serialised.Should().ContainAll( + "\"resourceId\":", "\"67f410a3-c5e4-4326-a3ad-5812b9adfc06\"", + "\"friendlyName\":", "\"Test Resource\"", + "\"deploymentName\":", "\"Test Deployment\"", + "\"resourceType\":", "\"chat\"", + "\"endpoint\":", "\"https://test.endpoint.com\"", + "\"apiKey\":", "\"test-api-key\"", + "\"region\":", "\"test-region\"", + "\"isActive\":", "true"); + } + + [Fact] + public void Given_ExampleJson_When_Deserialized_Then_It_Should_Match_Object() + { + // Arrange & Act + var deserialised = JsonSerializer.Deserialize(exampleJson, options); + + // Assert + deserialised.Should().BeEquivalentTo(examplePayload); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Models/CreateChatCompletionResponseTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Models/CreateChatCompletionResponseTests.cs index 6cdcdf5d..ed1e58ff 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Models/CreateChatCompletionResponseTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Models/CreateChatCompletionResponseTests.cs @@ -1,278 +1,279 @@ -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); - } +using System.Text.Json; + +using AzureOpenAIProxy.ApiApp.Models; + +using FluentAssertions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Models; + +public class CreateChatCompletionResponseTests +{ + private static 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 static readonly CreateChatCompletionResponse examplePayload = new () + { + 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_ExamplePayload_When_Serialized_Then_It_Should_Match_Json() + { + // Act + var serialised = JsonSerializer.Serialize(examplePayload, new JsonSerializerOptions { WriteIndented = false }); + + // Assert + serialised.Should().Be(exampleJson.Replace("\r", "").Replace("\n", "").Replace(" ", "")); + } + + [Fact] + public void Given_ExampleJson_When_Deserialized_Then_It_Should__Match_Object() + { + // Arrange & Act + var deserialised = JsonSerializer.Deserialize(exampleJson); + + // Assert + deserialised.Should().BeEquivalentTo(examplePayload); + } } \ No newline at end of file From 068b0e522156d19b6cb7b355ba6708a8f9acd9f6 Mon Sep 17 00:00:00 2001 From: o-ii <148066227+o-ii@users.noreply.github.com> Date: Sat, 7 Sep 2024 15:49:46 +0900 Subject: [PATCH 02/26] =?UTF-8?q?[DevOps]=20.spectral.yaml=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20404,=20400=20=EC=A0=95=EC=9D=98=20#268=20(#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .spectral.yaml | 22 +++++++++++++++++++ .../Endpoints/AdminEventEndpoints.cs | 2 ++ .../Endpoints/ProxyChatCompletionsEndpoint.cs | 2 ++ 3 files changed, 26 insertions(+) diff --git a/.spectral.yaml b/.spectral.yaml index 99107fa4..0990247a 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -81,3 +81,25 @@ rules: - field: 'content' function: truthy message: Content is required + + # Ensure endpoints with path variables define a 404 response + # path에 path variable이 있다면 404 응답 코드가 있어야 함 + path-variables-require-404: + description: Path variables must include a 404 response + severity: error + given: $.paths[*][*].parameters[?(@.in == 'path')]^^ + then: + - field: responses.404 + function: truthy + message: Response 404 is required + + # Ensure POST, PUT, PATCH methods define a 400 response + # verb가 POST, PUT, PATCH일 경우 400 응답코드가 있어야 함 + post-put-patch-require-400: + description: POST, PUT, PATCH methods must include a 400 response + given: $.paths[*][?(@property == 'post' || @property == 'put' || @property == 'patch')] + severity: error + then: + - field: responses.400 + function: truthy + message: Response 400 is required \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs index beb5371f..2687693f 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs @@ -116,6 +116,7 @@ public static RouteHandlerBuilder AddGetAdminEvent(this WebApplication app) }) .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("GetAdminEvent") @@ -148,6 +149,7 @@ public static RouteHandlerBuilder AddUpdateAdminEvent(this WebApplication app) }) .Accepts(contentType: "application/json") .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") + .Produces(statusCode: StatusCodes.Status400BadRequest) .Produces(statusCode: StatusCodes.Status401Unauthorized) .Produces(statusCode: StatusCodes.Status404NotFound) .Produces(statusCode: StatusCodes.Status500InternalServerError, contentType: "text/plain") diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyChatCompletionsEndpoint.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyChatCompletionsEndpoint.cs index 84d3e9e2..d404646d 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyChatCompletionsEndpoint.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/ProxyChatCompletionsEndpoint.cs @@ -57,7 +57,9 @@ public static RouteHandlerBuilder AddChatCompletions(this WebApplication app) .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("openai") .WithName("GetChatCompletions") From 999dfd708a5734b2a589f2ac8a4f287589a65f21 Mon Sep 17 00:00:00 2001 From: sikutisa <32262904+sikutisa@users.noreply.github.com> Date: Fri, 13 Sep 2024 00:28:24 +0900 Subject: [PATCH 03/26] [Admin] Table Storage Connection Setting (#299) --- .../AzureOpenAIProxy.ApiApp.csproj | 1 + .../Configurations/KeyVaultSettings.cs | 4 +- .../OpenAISettingsBuilderExtensions.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 39 +++++++++++++++++-- .../KeyVaultSecretNames.cs | 18 +++++++++ src/AzureOpenAIProxy.ApiApp/Program.cs | 3 ++ src/AzureOpenAIProxy.ApiApp/appsettings.json | 5 ++- .../ServiceCollectionExtensionsTests.cs | 18 ++++----- 8 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/AzureOpenAIProxy.ApiApp/KeyVaultSecretNames.cs diff --git a/src/AzureOpenAIProxy.ApiApp/AzureOpenAIProxy.ApiApp.csproj b/src/AzureOpenAIProxy.ApiApp/AzureOpenAIProxy.ApiApp.csproj index 92c1aae3..554f15c3 100644 --- a/src/AzureOpenAIProxy.ApiApp/AzureOpenAIProxy.ApiApp.csproj +++ b/src/AzureOpenAIProxy.ApiApp/AzureOpenAIProxy.ApiApp.csproj @@ -7,6 +7,7 @@ + diff --git a/src/AzureOpenAIProxy.ApiApp/Configurations/KeyVaultSettings.cs b/src/AzureOpenAIProxy.ApiApp/Configurations/KeyVaultSettings.cs index 80c3a51b..763d861e 100644 --- a/src/AzureOpenAIProxy.ApiApp/Configurations/KeyVaultSettings.cs +++ b/src/AzureOpenAIProxy.ApiApp/Configurations/KeyVaultSettings.cs @@ -16,7 +16,7 @@ public class KeyVaultSettings public string? VaultUri { get; set; } /// - /// Gets or sets the secret name. + /// Gets or sets the secret names. /// - public string? SecretName { get; set; } + public Dictionary SecretNames { get; set; } = []; } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Extensions/OpenAISettingsBuilderExtensions.cs b/src/AzureOpenAIProxy.ApiApp/Extensions/OpenAISettingsBuilderExtensions.cs index dc05682b..d4944acf 100644 --- a/src/AzureOpenAIProxy.ApiApp/Extensions/OpenAISettingsBuilderExtensions.cs +++ b/src/AzureOpenAIProxy.ApiApp/Extensions/OpenAISettingsBuilderExtensions.cs @@ -48,7 +48,7 @@ public static IOpenAISettingsBuilder WithKeyVault(this IOpenAISettingsBuilder bu var client = sp.GetService() ?? throw new InvalidOperationException($"{nameof(SecretClient)} service is not registered."); - var value = client.GetSecret(settings.SecretName!).Value.Value; + var value = client.GetSecret(settings.SecretNames[KeyVaultSecretNames.OpenAI]!).Value.Value; var instances = JsonSerializer.Deserialize>(value); diff --git a/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs b/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs index ae2971bb..0f170517 100644 --- a/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs +++ b/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Azure.Identity; +using Azure.Data.Tables; +using Azure.Identity; using Azure.Security.KeyVault.Secrets; using AzureOpenAIProxy.ApiApp.Builders; @@ -35,9 +36,9 @@ public static IServiceCollection AddKeyVaultService(this IServiceCollection serv throw new InvalidOperationException($"{nameof(KeyVaultSettings.VaultUri)} is not defined."); } - if (string.IsNullOrWhiteSpace(settings.SecretName) == true) + if (string.IsNullOrWhiteSpace(settings.SecretNames[KeyVaultSecretNames.OpenAI]) == true) { - throw new InvalidOperationException($"{nameof(KeyVaultSettings.SecretName)} is not defined."); + throw new InvalidOperationException($"{nameof(KeyVaultSettings.SecretNames)}.{KeyVaultSecretNames.OpenAI} is not defined."); } var client = new SecretClient(new Uri(settings.VaultUri), new DefaultAzureCredential()); @@ -134,4 +135,36 @@ public static IServiceCollection AddOpenApiService(this IServiceCollection servi return services; } + + /// + /// Adds the TableServiceClient to the services collection. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddTableStorageService(this IServiceCollection services) + { + services.AddSingleton(sp => { + var configuration = sp.GetService() + ?? throw new InvalidOperationException($"{nameof(IConfiguration)} service is not registerd."); + + var settings = configuration.GetSection(AzureSettings.Name).GetSection(KeyVaultSettings.Name).Get() + ?? throw new InvalidOperationException($"{nameof(KeyVaultSettings)} could not be retrieved from the configuration."); + + var clientSecret = sp.GetService() + ?? throw new InvalidOperationException($"{nameof(SecretClient)} service is not registered."); + + if (string.IsNullOrWhiteSpace(settings.SecretNames[KeyVaultSecretNames.Storage]) == true) + { + throw new InvalidOperationException($"{nameof(KeyVaultSettings.SecretNames)}.{KeyVaultSecretNames.Storage} is not defined."); + } + + var storageKeyVault = clientSecret.GetSecret(settings.SecretNames[KeyVaultSecretNames.Storage]!); + + var client = new TableServiceClient(storageKeyVault.Value.Value); + + return client; + }); + + return services; + } } diff --git a/src/AzureOpenAIProxy.ApiApp/KeyVaultSecretNames.cs b/src/AzureOpenAIProxy.ApiApp/KeyVaultSecretNames.cs new file mode 100644 index 00000000..a8778adb --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/KeyVaultSecretNames.cs @@ -0,0 +1,18 @@ +namespace AzureOpenAIProxy.ApiApp; + +/// +/// This represents the keyvault secret names in appsettings.json +/// +public static class KeyVaultSecretNames +{ + /// + /// Keyvault secret name for OpenAI instance settings + /// + public const string OpenAI = "OpenAI"; + + /// + /// Keyvault secret name for table storage connection string + /// + public const string Storage = "Storage"; + +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index 4c5a0e55..5bcaf935 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -16,6 +16,9 @@ // Add OpenAPI service builder.Services.AddOpenApiService(); +// Add TableStorageClient +builder.Services.AddTableStorageService(); + // Add admin services builder.Services.AddAdminEventService(); diff --git a/src/AzureOpenAIProxy.ApiApp/appsettings.json b/src/AzureOpenAIProxy.ApiApp/appsettings.json index 6c89d496..a6725e09 100644 --- a/src/AzureOpenAIProxy.ApiApp/appsettings.json +++ b/src/AzureOpenAIProxy.ApiApp/appsettings.json @@ -25,7 +25,10 @@ }, "KeyVault": { "VaultUri": "https://{{key-vault-name}}.vault.azure.net/", - "SecretName": "azure-openai-instances" + "SecretNames": { + "OpenAI": "azure-openai-instances", + "Storage": "storage-connection-string" + } } }, diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs index 142c1eac..5a77eda8 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -92,7 +92,7 @@ public void Given_NullOrEmpty_VaultUri_When_Invoked_AddKeyVaultService_Then_It_S var dict = new Dictionary() { { "Azure:KeyVault:VaultUri", vaultUri! }, - { "Azure:KeyVault:SecretName", secretName }, + { "Azure:KeyVault:SecretNames:OpenAI", secretName }, }; #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(); @@ -108,16 +108,16 @@ public void Given_NullOrEmpty_VaultUri_When_Invoked_AddKeyVaultService_Then_It_S } [Theory] - [InlineData("http://localhost", default(string))] - [InlineData("http://localhost", "")] - public void Given_NullOrEmpty_SecretName_When_Invoked_AddKeyVaultService_Then_It_Should_Throw_Exception(string vaultUri, string? secretName) + [InlineData("http://localhost", default(string), typeof(KeyNotFoundException))] + [InlineData("http://localhost", "", typeof(InvalidOperationException))] + public void Given_NullOrEmpty_SecretName_When_Invoked_AddKeyVaultService_Then_It_Should_Throw_Exception(string vaultUri, string? secretName, Type exceptionType) { // Arrange var services = new ServiceCollection(); var dict = new Dictionary() { { "Azure:KeyVault:VaultUri", vaultUri }, - { "Azure:KeyVault:SecretName", secretName! }, + { "Azure:KeyVault:SecretNames:OpenAI", secretName! }, }; #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(); @@ -129,7 +129,7 @@ public void Given_NullOrEmpty_SecretName_When_Invoked_AddKeyVaultService_Then_It Action action = () => services.BuildServiceProvider().GetService(); // Assert - action.Should().Throw(); + action.Should().Throw().Which.Should().BeOfType(exceptionType); } [Theory] @@ -141,7 +141,7 @@ public void Given_Invalid_VaultUri_When_Invoked_AddKeyVaultService_Then_It_Shoul var dict = new Dictionary() { { "Azure:KeyVault:VaultUri", vaultUri }, - { "Azure:KeyVault:SecretName", secretName }, + { "Azure:KeyVault:SecretNames:OpenAI", secretName }, }; #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(); @@ -165,7 +165,7 @@ public void Given_AppSettings_When_Invoked_AddKeyVaultService_Then_It_Should_Ret var dict = new Dictionary() { { "Azure:KeyVault:VaultUri", vaultUri }, - { "Azure:KeyVault:SecretName", secretName }, + { "Azure:KeyVault:SecretNames:OpenAI", secretName }, }; #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(); @@ -190,7 +190,7 @@ public void Given_AppSettings_When_Invoked_AddKeyVaultService_Then_It_Should_Ret var dict = new Dictionary() { { "Azure:KeyVault:VaultUri", vaultUri }, - { "Azure:KeyVault:SecretName", secretName }, + { "Azure:KeyVault:SecretNames:OpenAI", secretName }, }; #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(); From 4edf7a8b2db8e523fdf32626acc200c12a860110 Mon Sep 17 00:00:00 2001 From: sikutisa <32262904+sikutisa@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:40:31 +0900 Subject: [PATCH 04/26] add tests (#317) Related to: #300 --- .../ServiceCollectionExtensionsTests.cs | 161 +++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs index 5a77eda8..f2697cd3 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -1,4 +1,6 @@ -using Azure.Security.KeyVault.Secrets; +using Azure; +using Azure.Data.Tables; +using Azure.Security.KeyVault.Secrets; using AzureOpenAIProxy.ApiApp.Extensions; @@ -7,6 +9,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + namespace AzureOpenAIProxy.ApiApp.Tests.Extensions; public class ServiceCollectionExtensionsTests @@ -206,4 +210,157 @@ public void Given_AppSettings_When_Invoked_AddKeyVaultService_Then_It_Should_Ret // Assert result?.VaultUri.Should().BeEquivalentTo(expected); } -} + + [Fact] + public void Given_ServiceCollection_When_Invoked_AddTableStorageService_Then_It_Should_Contain_TableServiceClient() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddTableStorageService(); + + // Assert + services.SingleOrDefault(p => p.ServiceType == typeof(TableServiceClient)).Should().NotBeNull(); + } + + [Fact] + public void Given_ServiceCollection_When_Invoked_AddTableStorageService_Then_It_Should_Throw_Exception() + { + // Arrange + var services = new ServiceCollection(); + services.AddTableStorageService(); + + // Act + Action action = () => services.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Empty_AzureSettings_When_Invoked_AddTableStorageService_Then_It_Should_Throw_Exception() + { + // Arrange + var services = new ServiceCollection(); + var dict = new Dictionary() + { + { "Azure", string.Empty}, + }; +#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. + services.AddSingleton(config); + services.AddTableStorageService(); + + // Act + Action action = () => services.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Empty_KeyVaultSettings_When_Invoked_AddTableStorageService_Then_It_Should_Throw_Exception() + { + // Arrange + var services = new ServiceCollection(); + var dict = new Dictionary() + { + { "Azure:KeyVault", string.Empty }, + }; +#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. + services.AddSingleton(config); + services.AddTableStorageService(); + + // Act + Action action = () => services.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Missing_SecretClient_When_Invoked_AddTableStorageService_Then_It_Should_Throw_Exception() + { + // Arrange + var services = new ServiceCollection(); + var dict = new Dictionary() + { + { "Azure:KeyVault:SecretNames:Storage", "secret-name" }, + }; +#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. + services.AddSingleton(config); + services.AddTableStorageService(); + + // Act + Action action = () => services.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Theory] + [InlineData(default(string), typeof(KeyNotFoundException))] + [InlineData("", typeof(InvalidOperationException))] + public void Given_NullOrEmpty_SecretName_When_Invoked_AddTableStorageService_Then_It_Shoud_Throw_Exception(string? secretName, Type exceptionType) + { + // Arrange + var services = new ServiceCollection(); + var dict = new Dictionary() + { + { "Azure:KeyVault:SecretNames:Storage", secretName }, + }; +#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. + services.AddSingleton(config); + + var sc = Substitute.For(); + services.AddSingleton(sc); + + services.AddTableStorageService(); + + // Act + Action action = () => services.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw().Which.Should().BeOfType(exceptionType); + } + + [Theory] + [InlineData("secret-name", "DefaultEndpointsProtocol=https;AccountName=account;AccountKey=ZmFrZWtleQ==;EndpointSuffix=core.windows.net")] + public void Given_AppSettings_When_Invoked_AddTableStorageService_Then_It_Should_Return_TableServiceClient(string secretName, string connectionString) + { + // Arrange + var services = new ServiceCollection(); + var dict = new Dictionary() + { + { "Azure:KeyVault:SecretNames:Storage", secretName }, + }; +#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. + services.AddSingleton(config); + + var sc = Substitute.For(); + var sp = new SecretProperties(secretName); + var secret = SecretModelFactory.KeyVaultSecret(sp,connectionString); + + sc.GetSecret(secretName).Returns(Response.FromValue(secret, Substitute.For())); + services.AddSingleton(sc); + + services.AddTableStorageService(); + + // Act + var result = services.BuildServiceProvider().GetService(); + + // Assert + result.Should().NotBeNull() + .And.BeOfType(); + } +} \ No newline at end of file From 85529e47103eaa0887ca53bc660dcefa87143d91 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 13 Sep 2024 12:38:00 +0900 Subject: [PATCH 05/26] [Playground] Add chat window component (#316) --- .../Models/AdminResourceDetails.cs | 3 +- .../Components/Pages/Home.razor | 2 +- .../Components/Pages/Playground.razor | 4 +- .../Components/UI/ChatHistoryComponent.razor | 23 ++ .../Components/UI/ChatPromptComponent.razor | 47 +++++ .../Components/UI/ChatWindowComponent.razor | 80 ++----- .../UI/OldChatWindowComponent.razor | 72 +++++++ .../Models/ChatMessage.cs | 38 ++++ .../Pages/PlaygroundPageTests.cs | 198 +++++++++++++++++- 9 files changed, 398 insertions(+), 69 deletions(-) create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatHistoryComponent.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatPromptComponent.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Models/ChatMessage.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs index b1464200..b610f077 100644 --- a/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs +++ b/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs @@ -60,7 +60,8 @@ public class AdminResourceDetails } /// -/// /// This defines the type of the resource. +/// This defines the type of the resource. +/// [JsonConverter(typeof(EnumMemberConverter))] public enum ResourceType { diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor index 7c8ab364..ec9c95ef 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. - + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor index 06fe0b5d..ac7463cb 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor @@ -11,8 +11,8 @@ - - Chat Window + + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatHistoryComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatHistoryComponent.razor new file mode 100644 index 00000000..c402e9fc --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatHistoryComponent.razor @@ -0,0 +1,23 @@ +@using AzureOpenAIProxy.PlaygroundApp.Models + + + @if (this.Messages != null && this.Messages.Any()) + { + foreach (var message in this.Messages) + { + + +

@message.Message

+
+
+ } + } +
+ +@code { + [Parameter] + public string? Id { get; set; } + + [Parameter] + public List? Messages { get; set; } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatPromptComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatPromptComponent.razor new file mode 100644 index 00000000..b6f5bff4 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatPromptComponent.razor @@ -0,0 +1,47 @@ + + + + + Clear + + + Send + + + + +@code { + private string? prompt; + + [Parameter] + public string? Id { get; set; } + + [Parameter] + public EventCallback OnPromptSent { get; set; } + + [Parameter] + public EventCallback OnChatCleared { get; set; } + + private async Task UpdatePrompt(ChangeEventArgs e) + { + this.prompt = e.Value!.ToString(); + + await Task.CompletedTask; + } + + private async Task CompleteChat() + { + if (string.IsNullOrWhiteSpace(this.prompt) == true) + { + return; + } + + await this.OnPromptSent.InvokeAsync(this.prompt); + this.prompt = string.Empty; + } + + private async Task ClearChat() + { + await this.OnChatCleared.InvokeAsync(); + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor index 06b3c0db..043b582c 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor @@ -1,72 +1,34 @@ -@using AzureOpenAIProxy.PlaygroundApp.Clients -@inject IOpenAIApiClient Api +@using AzureOpenAIProxy.PlaygroundApp.Models -
-

Chat Window

- -
-
- -
-
- -
-
- -
-
- -
-
- - -
-
-
+ + + + @code { - private string? endpoint = "https://localhost:7001/"; - private string? apiKey = "abcdef"; - private string? deploymentName = "model-gpt35turbo16k-0613"; - private int? maxTokens = 4096; - private float? temperature = 0.7f; - private string? systemPrompt = "You are an AI assistant that helps people find information."; - private string? userPrompt; - private string? assistantMessage; - private string? userInput; + private List? messages; - private async Task SendAsync() + [Parameter] + public string? Id { get; set; } + + protected override async Task OnInitializedAsync() { - userPrompt = userInput; + this.messages = []; + + await Task.CompletedTask; + } - try - { - var options = new OpenAIApiClientOptions() - { - Endpoint = endpoint, - ApiKey = apiKey, - DeploymentName = deploymentName, - MaxTokens = maxTokens, - Temperature = temperature, - SystemPrompt = systemPrompt, - UserPrompt = userInput, - }; - var result = await Api.CompleteChatAsync(options); - assistantMessage = result; + private async Task SendPrompt(string prompt) + { + this.messages!.Add(new ChatMessage() { Role = MessageRole.User, Message = prompt }); - } - catch (Exception ex) - { - assistantMessage = ex.Message; - } + await Task.CompletedTask; } - private async Task ClearAsync() + private async Task ClearMessage() { - userPrompt = string.Empty; - assistantMessage = string.Empty; - userInput = string.Empty; + this.messages!.Clear(); await Task.CompletedTask; } -} +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor new file mode 100644 index 00000000..06b3c0db --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor @@ -0,0 +1,72 @@ +@using AzureOpenAIProxy.PlaygroundApp.Clients +@inject IOpenAIApiClient Api + +
+

Chat Window

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ +@code { + private string? endpoint = "https://localhost:7001/"; + private string? apiKey = "abcdef"; + private string? deploymentName = "model-gpt35turbo16k-0613"; + private int? maxTokens = 4096; + private float? temperature = 0.7f; + private string? systemPrompt = "You are an AI assistant that helps people find information."; + private string? userPrompt; + private string? assistantMessage; + private string? userInput; + + private async Task SendAsync() + { + userPrompt = userInput; + + try + { + var options = new OpenAIApiClientOptions() + { + Endpoint = endpoint, + ApiKey = apiKey, + DeploymentName = deploymentName, + MaxTokens = maxTokens, + Temperature = temperature, + SystemPrompt = systemPrompt, + UserPrompt = userInput, + }; + var result = await Api.CompleteChatAsync(options); + assistantMessage = result; + + } + catch (Exception ex) + { + assistantMessage = ex.Message; + } + } + + private async Task ClearAsync() + { + userPrompt = string.Empty; + assistantMessage = string.Empty; + userInput = string.Empty; + + await Task.CompletedTask; + } +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Models/ChatMessage.cs b/src/AzureOpenAIProxy.PlaygroundApp/Models/ChatMessage.cs new file mode 100644 index 00000000..15f7cb6c --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Models/ChatMessage.cs @@ -0,0 +1,38 @@ +namespace AzureOpenAIProxy.PlaygroundApp.Models; + +/// +/// This represents the entity for chat message. +/// +public class ChatMessage +{ + /// + /// Gets or sets the message role. + /// + public MessageRole? Role { get; set; } + + /// + /// Gets or sets the message. + /// + public string? Message { get; set; } +} + +/// +/// This defines the role of the message. +/// +public enum MessageRole +{ + /// + /// Indicates the role is not defined. + /// + Undefined, + + /// + /// Indicates the user role. + /// + User, + + /// + /// Indicates the assistant role. + /// + Assistant +} diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs index c4b4da2a..88d80724 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs @@ -51,7 +51,8 @@ public async Task Given_Page_When_Endpoint_Invoked_Then_It_Should_Show_Panels(st public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_ConfigTab_Should_Be_Displayed() { // Act - var configTab = Page.Locator("fluent-tabs#config-tab"); + var configTab = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab"); // Assert await Expect(configTab).ToBeVisibleAsync(); @@ -61,8 +62,12 @@ public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_ConfigTab_Should_Be 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"); + var systemMessagePanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator("fluent-tab-panel#system-message-tab-panel"); + var parameterPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator("fluent-tab-panel#parameters-tab-panel"); // Assert await Expect(systemMessagePanel).ToBeVisibleAsync(); @@ -87,9 +92,15 @@ string hiddenPanelSelector ) { // Arrange - var selectedTab = Page.Locator(selectedTabSelector); - var selectedPanel = Page.Locator(selectedPanelSelector); - var hiddenPanel = Page.Locator(hiddenPanelSelector); + var selectedTab = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator(selectedTabSelector); + var selectedPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator(selectedPanelSelector); + var hiddenPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator(hiddenPanelSelector); // Act await selectedTab.ClickAsync(); @@ -99,6 +110,181 @@ string hiddenPanelSelector await Expect(hiddenPanel).ToBeHiddenAsync(); } + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatWindow_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatHistory_Should_Be_Empty() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + + var text = await element.TextContentAsync(); + + // Assert + text.Should().BeNullOrEmpty(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPrompt_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptArea_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("button-clear")] + [TestCase("button-send")] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptButton_Should_Be_Displayed(string id) + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator($"div > fluent-button#{id}"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed_OnTheRight(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var style = await history.Locator("div#message-00").GetAttributeAsync("style"); + + // Assert + style.Should().ContainAll("justify-content:", "end"); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var item = await history.Locator("div#message-00 p").TextContentAsync(); + + // Assert + item.Should().Be(text); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatPromptArea_Should_Be_Empty(string text) + { + // Arrange + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var item = await prompt.Locator("textarea").TextContentAsync(); + + // Assert + item.Should().BeNullOrEmpty(); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Clear_Clicked_Then_ChatHistoryMessage_Should_Be_Cleared(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + var clear = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-clear"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + await clear.ClickAsync(); + + var result = await history.TextContentAsync(); + + // Assert + result.Should().BeNullOrEmpty(); + } + [TearDown] public async Task CleanUp() { From 1f7d4180edcf8bd95c9a0db87ab96ec575d15190 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 13 Sep 2024 13:47:47 +0900 Subject: [PATCH 06/26] Update GHA workflow --- .github/workflows/azure-dev.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index 7f7b121b..20754de9 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -25,7 +25,8 @@ jobs: AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} AZURE_OPENAI_KEYS: ${{ secrets.AZURE_OPENAI_KEYS }} AZURE_KEYVAULT_URI: ${{ secrets.AZURE_KEYVAULT_URI }} - AZURE_KEYVAULT_SECRET_NAME: ${{ vars.AZURE_KEYVAULT_SECRET_NAME }} + AZURE_KEYVAULT_SECRET_NAME_OPENAI: ${{ vars.AZURE_KEYVAULT_SECRET_NAME_OPENAI }} + AZURE_KEYVAULT_SECRET_NAME_STORAGE: ${{ vars.AZURE_KEYVAULT_SECRET_NAME_STORAGE }} steps: - name: Checkout @@ -54,7 +55,8 @@ jobs: $appsettings = Get-Content -Path "./src/AzureOpenAIProxy.ApiApp/appsettings.json" -Raw | ConvertFrom-Json $appsettings.Azure.OpenAI.Instances = @() $appsettings.Azure.KeyVault.VaultUri = "${{ env.AZURE_KEYVAULT_URI }}" - $appsettings.Azure.KeyVault.SecretName = "${{ env.AZURE_KEYVAULT_SECRET_NAME }}" + $appsettings.Azure.KeyVault.SecretNames.OpenAI = "${{ env.AZURE_KEYVAULT_SECRET_NAME_OPENAI }}" + $appsettings.Azure.KeyVault.SecretNames.Storage = "${{ env.AZURE_KEYVAULT_SECRET_NAME_STORAGE }}" $appsettings | ConvertTo-Json -Depth 100 | Set-Content -Path "./src/AzureOpenAIProxy.ApiApp/appsettings.json" -Encoding UTF8 -Force - name: Install Spectral Cli From a50f07dbcffc451dbda41539e3260bd0df1f8f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=98=81?= Date: Sat, 14 Sep 2024 14:32:21 +0900 Subject: [PATCH 07/26] =?UTF-8?q?[DevOps]=20Table=20Storage=EC=9A=A9=20bic?= =?UTF-8?q?ep=20=ED=8C=8C=EC=9D=BC=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20#283=20?= =?UTF-8?q?(#292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/aspire.bicep | 41 ++++++++ infra/core/storage/storage-account.bicep | 118 +++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 infra/core/storage/storage-account.bicep diff --git a/infra/aspire.bicep b/infra/aspire.bicep index add37d05..e0f2712d 100644 --- a/infra/aspire.bicep +++ b/infra/aspire.bicep @@ -17,6 +17,15 @@ param enabledForDeployment bool = true param enabledForTemplateDeployment bool = true param enableRbacAuthorization bool = true +//TODO: 배포 시점에 사용자 princpalId, apiapp principalId를 받는 방법 조사 +//param creatorAdminPrincipalId string = '' +//param apiAppUserPrincipalId string = '' + +// parameters for storage account +param storageAccountName string = '' +// tableNames passed as a comma separated string from command line +param tableNames string = 'events' + var abbrs = loadJsonContent('./abbreviations.json') // Tags that should be applied to all resources. @@ -39,6 +48,9 @@ var resourceToken = uniqueString(resourceGroup().id) #disable-next-line no-unused-vars // var apiServiceName = 'python-api' +// tables for storage account seperated by comma +var tables = split(tableNames, ',') + // Add resources to be provisioned below. // Provision Key Vault @@ -54,6 +66,35 @@ module keyVault './core/security/keyvault.bicep' = { } } +// Provision Storage Account +module storageAccount './core/storage/storage-account.bicep' = { + name: 'storageAccount' + params: { + name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' + location: location + tags: tags + tables: tables + keyVaultName: keyVault.outputs.name + } +} + +// TODO: Key vault Secret 권한부여, 생성한 사람에게 관리자 권한을, 그 외에는 secret user 권한을 부여 +//resource keyVaultSecretRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { +// name: guid(resourceGroup().id, resolvedKeyVaultName, 'secret-role-assignment') +// properties: { +// principalId: creatorAdminPrincipalId +// roleDefinitionId: '00482A5A-887F-4FB3-B363-3B7FE8E74483' // administrator role +// } +//} +// +//resource keyVaultSecretApiAppRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { +// name: guid(resourceGroup().id, resolvedKeyVaultName, 'secret-apiapp-role-assignment') +// properties: { +// principalId: apiAppUserPrincipalId +// roleDefinitionId: '4633458B-17DE-408A-B874-0445C86B69E6' // secret user role +// } +//} + // Add outputs from the deployment here, if needed. // // This allows the outputs to be referenced by other bicep deployments in the deployment pipeline, diff --git a/infra/core/storage/storage-account.bicep b/infra/core/storage/storage-account.bicep new file mode 100644 index 00000000..9a2fab4a --- /dev/null +++ b/infra/core/storage/storage-account.bicep @@ -0,0 +1,118 @@ +metadata description = 'Creates an Azure storage account.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@allowed([ + 'Cool' + 'Hot' + 'Premium' ]) +param accessTier string = 'Hot' +param allowBlobPublicAccess bool = true +param allowCrossTenantReplication bool = true +param allowSharedKeyAccess bool = true +param containers array = [] +param corsRules array = [] +param defaultToOAuthAuthentication bool = false +param deleteRetentionPolicy object = {} +@allowed([ 'AzureDnsZone', 'Standard' ]) +param dnsEndpointType string = 'Standard' +param files array = [] +param kind string = 'StorageV2' +param minimumTlsVersion string = 'TLS1_2' +param queues array = [] +param shareDeleteRetentionPolicy object = {} +param supportsHttpsTrafficOnly bool = true +param tables array = [] +param networkAcls object = { + bypass: 'AzureServices' + defaultAction: 'Allow' +} +@allowed([ 'Enabled', 'Disabled' ]) +param publicNetworkAccess string = 'Enabled' +param sku object = { name: 'Standard_LRS' } +param keyVaultName string = '' + +resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = { + name: name + location: location + tags: tags + kind: kind + sku: sku + properties: { + accessTier: accessTier + allowBlobPublicAccess: allowBlobPublicAccess + allowCrossTenantReplication: allowCrossTenantReplication + allowSharedKeyAccess: allowSharedKeyAccess + defaultToOAuthAuthentication: defaultToOAuthAuthentication + dnsEndpointType: dnsEndpointType + minimumTlsVersion: minimumTlsVersion + networkAcls: networkAcls + publicNetworkAccess: publicNetworkAccess + supportsHttpsTrafficOnly: supportsHttpsTrafficOnly + } + + resource blobServices 'blobServices' = if (!empty(containers)) { + name: 'default' + properties: { + cors: { + corsRules: corsRules + } + deleteRetentionPolicy: deleteRetentionPolicy + } + resource container 'containers' = [for container in containers: { + name: container.name + properties: { + // todo: Warning use-safe-access: Use the safe access (.?) operator instead of checking object contents with the 'contains' function. [https://aka.ms/bicep/linter/use-safe-access] + publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' + } + }] + } + + resource fileServices 'fileServices' = if (!empty(files)) { + name: 'default' + properties: { + cors: { + corsRules: corsRules + } + shareDeleteRetentionPolicy: shareDeleteRetentionPolicy + } + } + + resource queueServices 'queueServices' = if (!empty(queues)) { + name: 'default' + properties: { + + } + resource queue 'queues' = [for queue in queues: { + name: queue.name + properties: { + metadata: {} + } + }] + } + + resource tableServices 'tableServices' = if (!empty(tables)) { + name: 'default' + properties: {} + // create tables pre-defined in aspire.bicep + resource table 'tables' = [for table in tables: { + name: table + properties: {} + }] + } +} + +// Save Storage Account Connection String in Key Vault Secret +module keyVaultSecrets '../../core/security/keyvault-secret.bicep' = { + name: 'keyVaultSecrets' + params: { + name: 'storage-connection-string' + secretValue:'DefaultEndpointsProtocol=https;EndpointSuffix=${environment().suffixes.storage};AccountName=${storage.name};AccountKey=${storage.listKeys().keys[0].value};BlobEndpoint=${storage.properties.primaryEndpoints.blob};FileEndpoint=${storage.properties.primaryEndpoints.file};QueueEndpoint=${storage.properties.primaryEndpoints.queue};TableEndpoint=${storage.properties.primaryEndpoints.table}' + keyVaultName:keyVaultName + } +} + +output id string = storage.id +output name string = storage.name +output primaryEndpoints object = storage.properties.primaryEndpoints From 5656d99831aaaf36259fc4c00cd6a9fc7d445f2f Mon Sep 17 00:00:00 2001 From: o-ii <148066227+o-ii@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:50:57 +0900 Subject: [PATCH 08/26] [OpenAPI] Add endpoint for create new admin resource details #307 (#313) --- .../Endpoints/AdminEndpointUrls.cs | 8 + .../Endpoints/AdminResourceEndpoints.cs | 54 ++++ .../Filters/OpenApiTagFilter.cs | 2 +- src/AzureOpenAIProxy.ApiApp/Program.cs | 2 + .../AdminCreateResourcesOpenApiTests.cs | 302 ++++++++++++++++++ 5 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs create mode 100644 test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateResourcesOpenApiTests.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs index 0fdba526..1ad428ab 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEndpointUrls.cs @@ -22,4 +22,12 @@ public static class AdminEndpointUrls /// - POST method for new event creation /// public const string AdminEvents = "/admin/events"; + + /// + /// Declares the admin resource details endpoint. + /// + /// + /// - POST method for new resource creation + /// + public const string AdminResources = "/admin/resources"; } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs new file mode 100644 index 00000000..4dddf5fd --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminResourceEndpoints.cs @@ -0,0 +1,54 @@ +using AzureOpenAIProxy.ApiApp.Models; +using AzureOpenAIProxy.ApiApp.Services; + +using Microsoft.AspNetCore.Mvc; + +namespace AzureOpenAIProxy.ApiApp.Endpoints; + +/// +/// This represents the endpoint entity for resource details by admin +/// +public static class AdminResourceEndpoints +{ + /// + /// Adds the admin resource endpoint + /// + /// instance. + /// Returns instance. + public static RouteHandlerBuilder AddNewAdminResource(this WebApplication app) + { + var builder = app.MapPost(AdminEndpointUrls.AdminResources, async ( + [FromBody] AdminResourceDetails payload, + IAdminEventService service, + ILoggerFactory loggerFactory) => + { + var logger = loggerFactory.CreateLogger(nameof(AdminResourceEndpoints)); + logger.LogInformation("Received a new resource request"); + + if (payload is null) + { + logger.LogError("No payload found"); + + return Results.BadRequest("Payload is null"); + } + + 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("CreateAdminResource") + .WithOpenApi(operation => + { + operation.Summary = "Create admin resource"; + operation.Description = "Create admin resource"; + + return operation; + }); + + return builder; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Filters/OpenApiTagFilter.cs b/src/AzureOpenAIProxy.ApiApp/Filters/OpenApiTagFilter.cs index 9ed10fa3..be00f63d 100644 --- a/src/AzureOpenAIProxy.ApiApp/Filters/OpenApiTagFilter.cs +++ b/src/AzureOpenAIProxy.ApiApp/Filters/OpenApiTagFilter.cs @@ -16,7 +16,7 @@ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) [ new OpenApiTag { Name = "weather", Description = "Weather forecast operations" }, new OpenApiTag { Name = "openai", Description = "Azure OpenAI operations" }, - new OpenApiTag { Name = "admin", Description = "Admin for organizing events" }, + new OpenApiTag { Name = "admin", Description = "Admin operations for managing events and resources" }, new OpenApiTag { Name = "events", Description = "User events" } ]; } diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index 5bcaf935..8e40db95 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -61,4 +61,6 @@ app.AddGetAdminEvent(); app.AddUpdateAdminEvent(); +app.AddNewAdminResource(); + await app.RunAsync(); diff --git a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateResourcesOpenApiTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateResourcesOpenApiTests.cs new file mode 100644 index 00000000..d5054f51 --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateResourcesOpenApiTests.cs @@ -0,0 +1,302 @@ +using System.Text.Json; + +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +using FluentAssertions; + +using IdentityModel.Client; + +namespace AzureOpenAIProxy.AppHost.Tests.ApiApp.Endpoints; + +public class AdminCreateResourcesOpenApiTests(AspireAppHostFixture host) : IClassFixture +{ + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Path() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/resources"); + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Verb() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/resources") + .GetProperty("post"); + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("admin")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Tags(string tag) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/resources") + .GetProperty("post") + .GetProperty("tags"); + result.ValueKind.Should().Be(JsonValueKind.Array); + result.EnumerateArray().Select(p => p.GetString()).Should().Contain(tag); + } + + [Theory] + [InlineData("summary")] + [InlineData("description")] + [InlineData("operationId")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Value(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/resources") + .GetProperty("post") + .GetProperty(attribute); + result.ValueKind.Should().Be(JsonValueKind.String); + } + + [Theory] + [InlineData("requestBody")] + [InlineData("responses")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Object(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/resources") + .GetProperty("post") + .GetProperty(attribute); + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("200")] + [InlineData("400")] + [InlineData("401")] + [InlineData("500")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Response(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/admin/resources") + .GetProperty("post") + .GetProperty("responses") + .GetProperty(attribute); + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Schemas() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas"); + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Model() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminResourceDetails"); + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("resourceId", true)] + [InlineData("friendlyName", true)] + [InlineData("deploymentName", true)] + [InlineData("resourceType", true)] + [InlineData("endpoint", true)] + [InlineData("apiKey", true)] + [InlineData("region", true)] + [InlineData("isActive", true)] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Required(string attribute, bool isRequired) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminResourceDetails") + .TryGetStringArray("required") + .ToList(); + result.Contains(attribute).Should().Be(isRequired); + } + + [Theory] + [InlineData("resourceId")] + [InlineData("friendlyName")] + [InlineData("deploymentName")] + [InlineData("resourceType")] + [InlineData("endpoint")] + [InlineData("apiKey")] + [InlineData("region")] + [InlineData("isActive")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Property(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminResourceDetails") + .GetProperty("properties") + .GetProperty(attribute); + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("resourceId", "string")] + [InlineData("friendlyName", "string")] + [InlineData("deploymentName", "string")] + [InlineData("resourceType", "string")] + [InlineData("endpoint", "string")] + [InlineData("apiKey", "string")] + [InlineData("region", "string")] + [InlineData("isActive", "boolean")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Type(string attribute, string type) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminResourceDetails") + .GetProperty("properties") + .GetProperty(attribute); + + if (!result.TryGetProperty("type", out var typeProperty)) + { + var refPath = result.TryGetString("$ref").TrimStart('#', '/').Split('/'); + var refSchema = openapi.RootElement; + + foreach (var part in refPath) + { + refSchema = refSchema.GetProperty(part); + } + + typeProperty = refSchema.GetProperty("type"); + } + + typeProperty.GetString().Should().Be(type); + } + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Validate_ResourceType_As_Enum() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminResourceDetails") + .GetProperty("properties") + .GetProperty("resourceType"); + + var refPath = result.TryGetString("$ref").TrimStart('#', '/').Split('/'); + var refSchema = openapi.RootElement; + + foreach (var part in refPath) + { + refSchema = refSchema.GetProperty(part); + } + + var enumValues = refSchema.GetProperty("enum") + .EnumerateArray() + .Select(p => p.GetString()) + .ToList(); + + enumValues.Should().BeEquivalentTo(["none", "chat", "image"]); + } +} \ No newline at end of file From af47323ad84450f7fa7cb90b6d35927c96ad49e5 Mon Sep 17 00:00:00 2001 From: o-ii <148066227+o-ii@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:54:58 +0900 Subject: [PATCH 09/26] Refactor AdminCreateResourcesOpenApiTests to use InlineData for test cases (#322) --- .../AdminGetEventDetailsOpenApiTests.cs | 78 +++++++++++-------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs index dc15ccd5..dbb0e846 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs @@ -174,24 +174,6 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Res result.ValueKind.Should().Be(JsonValueKind.Object); } - public static IEnumerable AttributeData => - [ - ["eventId", true, "string"], - ["title", true, "string"], - ["summary", true, "string"], - ["description", false, "string"], - ["dateStart", true, "string"], - ["dateEnd", true, "string"], - ["timeZone", true, "string"], - ["isActive", true, "boolean"], - ["organizerName", true, "string"], - ["organizerEmail", true, "string"], - ["coorganizerName", false, "string"], - ["coorganizerEmail", false, "string"], - ["maxTokenCap", true, "integer"], - ["dailyRequestCap", true, "integer"] - ]; - [Fact] public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Schemas() { @@ -228,16 +210,26 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Mod } [Theory] - [MemberData(nameof(AttributeData))] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Required(string attribute, bool isRequired, string type) + [InlineData("eventId", true)] + [InlineData("title", true)] + [InlineData("summary", true)] + [InlineData("description", false)] + [InlineData("dateStart", true)] + [InlineData("dateEnd", true)] + [InlineData("timeZone", true)] + [InlineData("isActive", true)] + [InlineData("organizerName", true)] + [InlineData("organizerEmail", true)] + [InlineData("coorganizerName", false)] + [InlineData("coorganizerEmail", false)] + [InlineData("maxTokenCap", true)] + [InlineData("dailyRequestCap", true)] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Required(string attribute, bool isRequired) { // Arrange using var httpClient = host.App!.CreateHttpClient("apiapp"); await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - var isReq = isRequired; - var typeStr = type; - // Act var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); var openapi = JsonSerializer.Deserialize(json); @@ -252,16 +244,26 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Req } [Theory] - [MemberData(nameof(AttributeData))] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Property(string attribute, bool isRequired, string type) + [InlineData("eventId")] + [InlineData("title")] + [InlineData("summary")] + [InlineData("description")] + [InlineData("dateStart")] + [InlineData("dateEnd")] + [InlineData("timeZone")] + [InlineData("isActive")] + [InlineData("organizerName")] + [InlineData("organizerEmail")] + [InlineData("coorganizerName")] + [InlineData("coorganizerEmail")] + [InlineData("maxTokenCap")] + [InlineData("dailyRequestCap")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Property(string attribute) { // Arrange using var httpClient = host.App!.CreateHttpClient("apiapp"); await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - var isReq = isRequired; - var typeStr = type; - // Act var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); var openapi = JsonSerializer.Deserialize(json); @@ -276,16 +278,26 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Pro } [Theory] - [MemberData(nameof(AttributeData))] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Type(string attribute, bool isRequired, string type) + [InlineData("eventId", "string")] + [InlineData("title", "string")] + [InlineData("summary", "string")] + [InlineData("description", "string")] + [InlineData("dateStart", "string")] + [InlineData("dateEnd", "string")] + [InlineData("timeZone", "string")] + [InlineData("isActive", "boolean")] + [InlineData("organizerName", "string")] + [InlineData("organizerEmail", "string")] + [InlineData("coorganizerName", "string")] + [InlineData("coorganizerEmail", "string")] + [InlineData("maxTokenCap", "integer")] + [InlineData("dailyRequestCap", "integer")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Type(string attribute, string type) { // Arrange using var httpClient = host.App!.CreateHttpClient("apiapp"); await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - var isReq = isRequired; - var typeStr = type; - // Act var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); var openapi = JsonSerializer.Deserialize(json); From 4c2e4db7a3e0057ea1f99c4000cd614e0515ec21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EB=AF=BC=EC=A7=84?= <114579651+pmj-chosim@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:08:09 +0900 Subject: [PATCH 10/26] [Playground] 177 auth UI component (#280) --- .../Components/Pages/Home.razor | 1 - .../Components/Pages/Playground.razor | 1 + .../Components/Pages/Tests.razor | 67 +- .../Components/UI/ApiKeyInputComponent.razor | 22 + .../Pages/PlaygroundPageTests.cs | 625 ++++++++++-------- .../Pages/TestsPageTests.cs | 275 ++++---- 6 files changed, 541 insertions(+), 450 deletions(-) create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor index ec9c95ef..51c675bf 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Home.razor @@ -1,5 +1,4 @@ @page "/" -@using AzureOpenAIProxy.PlaygroundApp.Components.UI Home diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor index ac7463cb..4ade2e46 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor @@ -8,6 +8,7 @@ + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor index 40ebae28..42bbe51b 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor @@ -1,29 +1,38 @@ -@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; - } -} +@page "/tests" +@rendermode InteractiveServer + +

Component Tests

+ +

Debug Button

+ + + + + + +

Deployment Models

+ + + +@code { + private object? targetValue; + private string? apiKey; + private string? selectedModel; + + private async Task SetInput(int value) + { + targetValue = value; + await Task.CompletedTask; + } + + private void HandleApiKeyInput(string apiKeyValue) + { + apiKey = apiKeyValue; + } + + private async Task SetDeploymentModel(string value) + { + selectedModel = value; + await Task.CompletedTask; + } +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor new file mode 100644 index 00000000..d81073fc --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor @@ -0,0 +1,22 @@ +API key input + +@code { + private string? apiKeyValue { get; set; } + + [Parameter] + public string? Id { get; set; } + + [Parameter] + public EventCallback OnKeyInput { get; set; } + + private async Task SetApiKey(ChangeEventArgs e) + { + apiKeyValue = e.Value!.ToString(); + if (string.IsNullOrWhiteSpace(apiKeyValue) == true) + { + return; + } + + await OnKeyInput.InvokeAsync(apiKeyValue); + } +} diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs index 88d80724..6692fde3 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs @@ -1,293 +1,332 @@ -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("div.config-grid") - .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("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator("fluent-tab-panel#system-message-tab-panel"); - var parameterPanel = Page.Locator("div.config-grid") - .Locator("fluent-tabs#config-tab") - .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("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator(selectedTabSelector); - var selectedPanel = Page.Locator("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator(selectedPanelSelector); - var hiddenPanel = Page.Locator("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator(hiddenPanelSelector); - - // Act - await selectedTab.ClickAsync(); - - // Assert - await Expect(selectedPanel).ToBeVisibleAsync(); - await Expect(hiddenPanel).ToBeHiddenAsync(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatWindow_Should_Be_Displayed() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatHistory_Should_Be_Empty() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - - var text = await element.TextContentAsync(); - - // Assert - text.Should().BeNullOrEmpty(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPrompt_Should_Be_Displayed() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptArea_Should_Be_Displayed() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - [TestCase("button-clear")] - [TestCase("button-send")] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptButton_Should_Be_Displayed(string id) - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator($"div > fluent-button#{id}"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed_OnTheRight(string text) - { - // Arrange - var history = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - - var style = await history.Locator("div#message-00").GetAttributeAsync("style"); - - // Assert - style.Should().ContainAll("justify-content:", "end"); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed(string text) - { - // Arrange - var history = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - - var item = await history.Locator("div#message-00 p").TextContentAsync(); - - // Assert - item.Should().Be(text); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatPromptArea_Should_Be_Empty(string text) - { - // Arrange - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - - var item = await prompt.Locator("textarea").TextContentAsync(); - - // Assert - item.Should().BeNullOrEmpty(); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Clear_Clicked_Then_ChatHistoryMessage_Should_Be_Cleared(string text) - { - // Arrange - var history = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - var clear = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-clear"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - await clear.ClickAsync(); - - var result = await history.TextContentAsync(); - - // Assert - result.Should().BeNullOrEmpty(); - } - - [TearDown] - public async Task CleanUp() - { - await Page.CloseAsync(); - } -} \ No newline at end of file +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("div.config-grid") + .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("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator("fluent-tab-panel#system-message-tab-panel"); + var parameterPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .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("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator(selectedTabSelector); + var selectedPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator(selectedPanelSelector); + var hiddenPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tab") + .Locator(hiddenPanelSelector); + + // Act + await selectedTab.ClickAsync(); + + // Assert + await Expect(selectedPanel).ToBeVisibleAsync(); + await Expect(hiddenPanel).ToBeHiddenAsync(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatWindow_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatHistory_Should_Be_Empty() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + + var text = await element.TextContentAsync(); + + // Assert + text.Should().BeNullOrEmpty(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPrompt_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptArea_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("button-clear")] + [TestCase("button-send")] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptButton_Should_Be_Displayed(string id) + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator($"div > fluent-button#{id}"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed_OnTheRight(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var style = await history.Locator("div#message-00").GetAttributeAsync("style"); + + // Assert + style.Should().ContainAll("justify-content:", "end"); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var item = await history.Locator("div#message-00 p").TextContentAsync(); + + // Assert + item.Should().Be(text); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatPromptArea_Should_Be_Empty(string text) + { + // Arrange + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var item = await prompt.Locator("textarea").TextContentAsync(); + + // Assert + item.Should().BeNullOrEmpty(); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Clear_Clicked_Then_ChatHistoryMessage_Should_Be_Cleared(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + var clear = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-clear"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + await clear.ClickAsync(); + + var result = await history.TextContentAsync(); + + // Assert + result.Should().BeNullOrEmpty(); + } + + [Test] + public async Task Given_ApiKeynputField_When_Endpoint_Invoked_Then_It_Should_Be_Visible() + { + // Arrange + var apiKeyInput = Page.Locator("fluent-text-field#api-key").Locator("input"); + + // Act & Assert + await Expect(apiKeyInput).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ApiKeyInputField_When_Endpoint_Invoked_Then_It_Should_Be_Password_Type() + { + // Arrange + var apiKeyInput = Page.Locator("fluent-text-field#api-key").Locator("input"); + + // Act + var inputType = await apiKeyInput.GetAttributeAsync("type"); + + // Assert + inputType.Should().Be("password"); + } + + [Test] + [TestCase("test-api-key-1")] + [TestCase("example-key-123")] + public async Task Given_ApiKeyInputField_When_Changed_Then_It_Should_Be_Updated(string apiKey) + { + // Arrange + var apiKeyInput = Page.Locator("fluent-text-field#api-key").Locator("input"); + + // Act + await apiKeyInput.FillAsync(apiKey); + + // Assert + await Expect(apiKeyInput).ToHaveValueAsync(apiKey); + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } +} + diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs index 0d4ca2f1..f6be2968 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs @@ -1,128 +1,149 @@ -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(); - } +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("3F2504E0-4F89-11D3-9A0C-0305E82C3301", typeof(string))] + [TestCase("b9f6741c-2f44-4d9b-bd63-c0e6b97cc83f", typeof(string))] + + public async Task Given_Input_On_ApiKeyField_When_DebugButton_Clicked_Then_Toast_Should_Show_Input(string apiKey, Type expectedType) + { + // Arrange + var apiKeyInput = Page.Locator("fluent-text-field#api-key-input").Locator("input"); + var debugButton = Page.Locator("fluent-button#debug-api-key"); + + // Act + await apiKeyInput.FillAsync(apiKey); + await debugButton.ClickAsync(); + + // Assert + await Expect(Page.Locator(".fluent-toast-title")).ToHaveTextAsync($"{apiKey} (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 From 6657befdcc97c5fd7d5c22b15e11a816dc74a3d5 Mon Sep 17 00:00:00 2001 From: sikutisa <32262904+sikutisa@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:15:16 +0900 Subject: [PATCH 11/26] [Backend API] Add Table Name property to appsettings.json (#320) --- .../Configurations/StorageAccountSettings.cs | 17 +++ .../Configurations/TableStorageSettings.cs | 17 +++ .../Extensions/ServiceCollectionExtensions.cs | 25 ++++ src/AzureOpenAIProxy.ApiApp/Program.cs | 3 + .../Repositories/AdminEventRepository.cs | 11 +- src/AzureOpenAIProxy.ApiApp/appsettings.json | 5 + .../ServiceCollectionExtensionsTests.cs | 124 ++++++++++++++++++ .../Repositories/AdminEventRepositoryTests.cs | 53 +++++++- 8 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 src/AzureOpenAIProxy.ApiApp/Configurations/StorageAccountSettings.cs create mode 100644 src/AzureOpenAIProxy.ApiApp/Configurations/TableStorageSettings.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Configurations/StorageAccountSettings.cs b/src/AzureOpenAIProxy.ApiApp/Configurations/StorageAccountSettings.cs new file mode 100644 index 00000000..0517a976 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Configurations/StorageAccountSettings.cs @@ -0,0 +1,17 @@ +namespace AzureOpenAIProxy.ApiApp.Configurations; + +/// +/// This represents the settings entity for storage account. +/// +public class StorageAccountSettings +{ + /// + /// Gets the name of the configuration settings. + /// + public const string Name = "StorageAccount"; + + /// + /// Gets or sets the instance. + /// + public TableStorageSettings TableStorage { get; set; } = new(); +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Configurations/TableStorageSettings.cs b/src/AzureOpenAIProxy.ApiApp/Configurations/TableStorageSettings.cs new file mode 100644 index 00000000..2e9ecf2a --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Configurations/TableStorageSettings.cs @@ -0,0 +1,17 @@ +namespace AzureOpenAIProxy.ApiApp.Configurations; + +/// +/// This represents the settings entity for Azure Table Stroage. +/// +public class TableStorageSettings +{ + /// + /// Gets the name of the configuration settings. + /// + public const string Name = "TableStorage"; + + /// + /// Gets or sets the table name. + /// + public string? TableName { get; set; } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs b/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs index 0f170517..00783177 100644 --- a/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs +++ b/src/AzureOpenAIProxy.ApiApp/Extensions/ServiceCollectionExtensions.cs @@ -165,6 +165,31 @@ public static IServiceCollection AddTableStorageService(this IServiceCollection return client; }); + return services; + } + + /// + /// Gets the storage account configuration settings by reading appsettings.json. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddStorageAccountSettings(this IServiceCollection services) + { + services.AddSingleton(sp => { + var configuration = sp.GetService() + ?? throw new InvalidOperationException($"{nameof(IConfiguration)} service is not registered."); + + var settings = configuration.GetSection(AzureSettings.Name).GetSection(StorageAccountSettings.Name).Get() + ?? throw new InvalidOperationException($"{nameof(StorageAccountSettings)} could not be retrieved from the configuration."); + + if (string.IsNullOrWhiteSpace(settings.TableStorage.TableName) == true) + { + throw new InvalidOperationException($"{StorageAccountSettings.Name}.{TableStorageSettings.Name} is not defined."); + } + + return settings; + }); + return services; } } diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index 8e40db95..17d22e2e 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -16,6 +16,9 @@ // Add OpenAPI service builder.Services.AddOpenApiService(); +// Add Storage Account settings +builder.Services.AddStorageAccountSettings(); + // Add TableStorageClient builder.Services.AddTableStorageService(); diff --git a/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs index 552b6416..4881da1c 100644 --- a/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs +++ b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs @@ -1,4 +1,8 @@ -using AzureOpenAIProxy.ApiApp.Models; +using Azure.Data.Tables; + +using AzureOpenAIProxy.ApiApp.Configurations; +using AzureOpenAIProxy.ApiApp.Extensions; +using AzureOpenAIProxy.ApiApp.Models; namespace AzureOpenAIProxy.ApiApp.Repositories; @@ -39,8 +43,11 @@ public interface IAdminEventRepository /// /// This represents the repository entity for the admin event. /// -public class AdminEventRepository : IAdminEventRepository +public class AdminEventRepository(TableServiceClient tableServiceClient, StorageAccountSettings storageAccountSettings) : IAdminEventRepository { + private readonly TableServiceClient _tableServiceClient = tableServiceClient ?? throw new ArgumentNullException(nameof(tableServiceClient)); + private readonly StorageAccountSettings _storageAccountSettings = storageAccountSettings ?? throw new ArgumentNullException(nameof(storageAccountSettings)); + /// public async Task CreateEvent(AdminEventDetails eventDetails) { diff --git a/src/AzureOpenAIProxy.ApiApp/appsettings.json b/src/AzureOpenAIProxy.ApiApp/appsettings.json index a6725e09..fc890d6e 100644 --- a/src/AzureOpenAIProxy.ApiApp/appsettings.json +++ b/src/AzureOpenAIProxy.ApiApp/appsettings.json @@ -29,6 +29,11 @@ "OpenAI": "azure-openai-instances", "Storage": "storage-connection-string" } + }, + "StorageAccount": { + "TableStorage": { + "TableName": "events" + } } }, diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs index f2697cd3..45b25634 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -2,6 +2,7 @@ using Azure.Data.Tables; using Azure.Security.KeyVault.Secrets; +using AzureOpenAIProxy.ApiApp.Configurations; using AzureOpenAIProxy.ApiApp.Extensions; using FluentAssertions; @@ -363,4 +364,127 @@ public void Given_AppSettings_When_Invoked_AddTableStorageService_Then_It_Should result.Should().NotBeNull() .And.BeOfType(); } + + [Fact] + public void Given_Empty_AzureSettings_When_Added_ToServiceCollection_Then_It_Should_Throw_Exception() + { + // Arrange + var dict = new Dictionary() + { + { "Azure", string.Empty } + }; +#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); + + sc.AddStorageAccountSettings(); + + // Act + Action action = () => sc.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Empty_StorageAccountSettings_When_Added_ToServiceCollection_Then_It_Should_Throw_Exception() + { + // Arrange + var dict = new Dictionary() + { + { "Azure:StorageAccount", string.Empty } + }; +#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); + + sc.AddStorageAccountSettings(); + + // Act + Action action = () => sc.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Empty_TableStorageSettings_When_Added_ToServiceCollection_Then_It_Should_Throw_Exception() + { + // Arrange + var dict = new Dictionary() + { + { "Azure:StorageAccount:TableStorage", string.Empty } + }; +#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); + + sc.AddStorageAccountSettings(); + + // Act + Action action = () => sc.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Theory] + [InlineData(default(string))] + [InlineData("")] + public void Given_NullOrEmpty_TableName_When_Added_ToServiceColleciton_Then_It_Should_Throw_Exception(string? tableName) + { + // Arrange + var dict = new Dictionary() + { + { "Azure:StorageAccount:TableStorage:TableName", tableName } + }; +#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); + + sc.AddStorageAccountSettings(); + + // Act + Action action = () => sc.BuildServiceProvider().GetService(); + + // Assert + action.Should().Throw(); + } + + [Theory] + [InlineData("table-name")] + public void Given_Appsettings_When_Added_ToServiceCollection_Then_It_Should_Return_StorageAccountSettings(string tableName) + { + // Arrange + var dict = new Dictionary() + { + { "Azure:StorageAccount:TableStorage:TableName", tableName } + }; +#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); + + sc.AddStorageAccountSettings(); + + // Act + var settings = sc.BuildServiceProvider().GetService(); + + // Assert + settings?.TableStorage.TableName.Should().BeEquivalentTo(tableName); + } } \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs index 0e00c5d4..33755e47 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs @@ -1,10 +1,17 @@ -using AzureOpenAIProxy.ApiApp.Models; +using Azure.Data.Tables; + +using AzureOpenAIProxy.ApiApp.Configurations; +using AzureOpenAIProxy.ApiApp.Models; using AzureOpenAIProxy.ApiApp.Repositories; +using Castle.Core.Configuration; + using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + namespace AzureOpenAIProxy.ApiApp.Tests.Repositories; public class AdminEventRepositoryTests @@ -22,12 +29,42 @@ public void Given_ServiceCollection_When_AddAdminEventRepository_Invoked_Then_It services.SingleOrDefault(p => p.ServiceType == typeof(IAdminEventRepository)).Should().NotBeNull(); } + [Fact] + public void Given_Null_TableServiceClient_When_Creating_AdminEventRepository_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = Substitute.For(); + var tableServiceClient = default(TableServiceClient); + + // Act + Action action = () => new AdminEventRepository(tableServiceClient, settings); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Null_StorageAccountSettings_When_Creating_AdminEventRepository_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = default(StorageAccountSettings); + var tableServiceClient = Substitute.For(); + + // Act + Action action = () => new AdminEventRepository(tableServiceClient, settings); + + // Assert + action.Should().Throw(); + } + [Fact] public void Given_Instance_When_CreateEvent_Invoked_Then_It_Should_Throw_Exception() { // Arrange + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); var eventDetails = new AdminEventDetails(); - var repository = new AdminEventRepository(); + var repository = new AdminEventRepository(tableServiceClient, settings); // Act Func func = async () => await repository.CreateEvent(eventDetails); @@ -40,7 +77,9 @@ public void Given_Instance_When_CreateEvent_Invoked_Then_It_Should_Throw_Excepti public void Given_Instance_When_GetEvents_Invoked_Then_It_Should_Throw_Exception() { // Arrange - var repository = new AdminEventRepository(); + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); + var repository = new AdminEventRepository(tableServiceClient, settings); // Act Func func = async () => await repository.GetEvents(); @@ -53,8 +92,10 @@ public void Given_Instance_When_GetEvents_Invoked_Then_It_Should_Throw_Exception public void Given_Instance_When_GetEvent_Invoked_Then_It_Should_Throw_Exception() { // Arrange + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); var eventId = Guid.NewGuid(); - var repository = new AdminEventRepository(); + var repository = new AdminEventRepository(tableServiceClient, settings); // Act Func func = async () => await repository.GetEvent(eventId); @@ -67,9 +108,11 @@ public void Given_Instance_When_GetEvent_Invoked_Then_It_Should_Throw_Exception( public void Given_Instance_When_UpdateEvent_Invoked_Then_It_Should_Throw_Exception() { // Arrange + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); var eventId = Guid.NewGuid(); var eventDetails = new AdminEventDetails(); - var repository = new AdminEventRepository(); + var repository = new AdminEventRepository(tableServiceClient, settings); // Act Func func = async () => await repository.UpdateEvent(eventId, eventDetails); From 31fa2f21c46cf4dee5de4c2c7f3db3deef994795 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Mon, 16 Sep 2024 15:30:23 +0900 Subject: [PATCH 12/26] Refactor config components (#326) --- .../Components/Pages/Playground.razor | 3 +- .../Components/Pages/Tests.razor | 38 --- .../Components/UI/ApiKeyInputComponent.razor | 8 +- .../Components/UI/ConfigTabComponent.razor | 27 +- .../Components/UI/ConfigWindowComponent.razor | 31 ++ .../Components/UI/DebugButtonComponent.razor | 25 -- .../Components/UI/DebugTargetComponent.razor | 18 -- .../UI/DeploymentModelListComponent.razor | 87 ++---- .../Pages/PlaygroundPageChatWindowTests.cs | 183 ++++++++++++ .../Pages/PlaygroundPageConfigWindowTests.cs | 212 +++++++++++++ .../Pages/PlaygroundPageTests.cs | 279 +----------------- .../Pages/TestsPageTests.cs | 149 ---------- 12 files changed, 476 insertions(+), 584 deletions(-) delete mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor delete mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugButtonComponent.razor delete mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugTargetComponent.razor create mode 100644 test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageChatWindowTests.cs create mode 100644 test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs delete mode 100644 test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor index 4ade2e46..ceb2684f 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor @@ -8,8 +8,7 @@ - - + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor deleted file mode 100644 index 42bbe51b..00000000 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Tests.razor +++ /dev/null @@ -1,38 +0,0 @@ -@page "/tests" -@rendermode InteractiveServer - -

Component Tests

- -

Debug Button

- - - - - - -

Deployment Models

- - - -@code { - private object? targetValue; - private string? apiKey; - private string? selectedModel; - - private async Task SetInput(int value) - { - targetValue = value; - await Task.CompletedTask; - } - - private void HandleApiKeyInput(string apiKeyValue) - { - apiKey = apiKeyValue; - } - - private async Task SetDeploymentModel(string value) - { - selectedModel = value; - await Task.CompletedTask; - } -} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor index d81073fc..d6b3f660 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ApiKeyInputComponent.razor @@ -1,4 +1,10 @@ -API key input + + + @code { private string? apiKeyValue { get; set; } diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor index 33d9589a..3895e562 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor @@ -1,20 +1,23 @@ - - - This is "System message" tab. - - - This is "Parameters" tab. - - - -

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

+ + + + This is "System message" tab. + + + This is "Parameters" tab. + + + @code { - FluentTab? SelectedTab; + private FluentTab? selectedTab { get; set; } + + [Parameter] + public string? Id { get; set; } private async Task ChangeTab(FluentTab tab) { - SelectedTab = tab; + this.selectedTab = tab; await Task.CompletedTask; } } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor new file mode 100644 index 00000000..e63e28df --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor @@ -0,0 +1,31 @@ +@using AzureOpenAIProxy.PlaygroundApp.Models + + + Setup + + + + + + +@code { + private string? apiKey; + private string? deploymentModel; + + [Parameter] + public string? Id { get; set; } + + private async Task SetApiKey(string apiKey) + { + this.apiKey = apiKey; + + await Task.CompletedTask; + } + + private async Task SetDeploymentModel(string deploymentModel) + { + this.deploymentModel = deploymentModel; + + 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 deleted file mode 100644 index 8335d49e..00000000 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugButtonComponent.razor +++ /dev/null @@ -1,25 +0,0 @@ -@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 deleted file mode 100644 index bfb54641..00000000 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DebugTargetComponent.razor +++ /dev/null @@ -1,18 +0,0 @@ - - 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 index ac39ba4c..463ed83f 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DeploymentModelListComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/DeploymentModelListComponent.razor @@ -1,74 +1,39 @@ - -
- - * -
- - - -
+ + + @code { - private Option? selectedOption { get; set; } = new(); + private List>? deploymentModels; + private Option? selectedOption { get; set; } [Parameter] public string? Id { get; set; } [Parameter] public EventCallback OnUserOptionSelected { get; set; } - - private async Task OnValueChanged() + + protected override async Task OnInitializedAsync() { - string? selectedValue = selectedOption?.Value?.ToString(); - await OnUserOptionSelected.InvokeAsync(selectedValue); + this.deploymentModels = new() + { + new Option { Value = "model-gpt35turbo16k-0613", Text = "model-gpt35turbo16k-0613" }, + new Option { Value = "model-gpt4o-20240513", Text = "model-gpt4o-20240513" }, + }; + + await Task.CompletedTask; } - static List> deploymentModelOptions = new() + private async Task SetDeploymentModel() { - 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" } - }; + var selectedValue = selectedOption?.Value!; + + await OnUserOptionSelected.InvokeAsync(selectedValue); + } } \ No newline at end of file diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageChatWindowTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageChatWindowTests.cs new file mode 100644 index 00000000..52cff76d --- /dev/null +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageChatWindowTests.cs @@ -0,0 +1,183 @@ +using FluentAssertions; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[TestFixture] +[Property("Category", "Integration")] +public partial class PlaygroundPageTests +{ + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatWindow_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatHistory_Should_Be_Empty() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + + var text = await element.TextContentAsync(); + + // Assert + text.Should().BeNullOrEmpty(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPrompt_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptArea_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("button-clear")] + [TestCase("button-send")] + public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptButton_Should_Be_Displayed(string id) + { + // Act + var element = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator($"div > fluent-button#{id}"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed_OnTheRight(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var style = await history.Locator("div#message-00").GetAttributeAsync("style"); + + // Assert + style.Should().ContainAll("justify-content:", "end"); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var item = await history.Locator("div#message-00 p").TextContentAsync(); + + // Assert + item.Should().Be(text); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatPromptArea_Should_Be_Empty(string text) + { + // Arrange + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + + var item = await prompt.Locator("textarea").TextContentAsync(); + + // Assert + item.Should().BeNullOrEmpty(); + } + + [Test] + [TestCase("abcde")] + public async Task Given_ChatPrompt_When_Clear_Clicked_Then_ChatHistoryMessage_Should_Be_Cleared(string text) + { + // Arrange + var history = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-history"); + var prompt = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("fluent-text-area#prompt"); + var send = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-send"); + var clear = Page.Locator("div.chat-grid") + .Locator("div#chat-window") + .Locator("div#chat-prompt") + .Locator("div > fluent-button#button-clear"); + + // Act + await prompt.Locator("textarea").FillAsync(text); + await send.ClickAsync(); + await clear.ClickAsync(); + + var result = await history.TextContentAsync(); + + // Assert + result.Should().BeNullOrEmpty(); + } +} diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs new file mode 100644 index 00000000..0fa7827c --- /dev/null +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs @@ -0,0 +1,212 @@ +using FluentAssertions; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[TestFixture] +[Property("Category", "Integration")] +public partial class PlaygroundPageTests +{ + [Test] + public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_Header_Should_Be_Displayed() + { + // Act + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator("h2"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("api-key", "API key")] + public async Task Given_ApiKeynputField_When_Endpoint_Invoked_Then_Label_Should_Be_Visible(string id, string label) + { + // Arrange + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("label[for='api-key-field']"); + + // Act + var result = await element.TextContentAsync(); + + // Assert + result.Should().StartWith(label); + } + + [Test] + [TestCase("api-key")] + public async Task Given_ApiKeynputField_When_Endpoint_Invoked_Then_It_Should_Be_Visible(string id) + { + // Act + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-text-field#api-key-field"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("api-key")] + public async Task Given_ApiKeyInputField_When_Endpoint_Invoked_Then_It_Should_Be_Password_Type(string id) + { + // Arrange + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-text-field#api-key-field") + .Locator("input"); + + // Act + var inputType = await element.GetAttributeAsync("type"); + + // Assert + inputType.Should().Be("password"); + } + + [Test] + [TestCase("api-key", "test-api-key-1")] + [TestCase("api-key", "example-key-123")] + public async Task Given_ApiKeyInputField_When_Changed_Then_It_Should_Be_Updated(string id, string apiKey) + { + // Arrange + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-text-field#api-key-field") + .Locator("input"); + + // Act + await element.FillAsync(apiKey); + + // Assert + await Expect(element).ToHaveValueAsync(apiKey); + } + + [Test] + [TestCase("deployment-model-list", "Deployment")] + public async Task Given_Label_When_Page_Loaded_Then_Label_Should_Be_Visible(string id, string label) + { + // Arrange + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("label[for='deployment-model-list-options']"); + + // Act + var result = await element.TextContentAsync(); + + // Assert + result.Should().StartWith(label); + } + + [Test] + [TestCase("deployment-model-list")] + public async Task Given_DropdownList_When_Page_Loaded_Then_DropdownList_Should_Be_Visible(string id) + { + // Act + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-select#deployment-model-list-options"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("deployment-model-list")] + public async Task Given_DropdownList_When_DropdownList_Clicked_And_DropdownOptions_Appeared_Then_All_DropdownOptions_Should_Be_Visible(string id) + { + // Arrange + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-select#deployment-model-list-options"); + + // Act + await element.ClickAsync(); + var options = element.Locator("fluent-option"); + + // Assert + await Expect(options.Nth(0)).Not.ToBeVisibleAsync(); + for (int i = 1; i < await options.CountAsync(); i++) + { + await Expect(options.Nth(i)).ToBeVisibleAsync(); + } + } + + [Test] + [TestCase("config-tab")] + public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_ConfigTab_Should_Be_Displayed(string id) + { + // Act + var element = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-tabs#config-tabs"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + [TestCase("config-tab")] + public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_Id_Should_Be_System_Message_Tab(string id) + { + // Act + var systemMessagePanel = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-tabs#config-tabs") + .Locator("fluent-tab-panel#system-message-tab-panel"); + var parameterPanel = Page.Locator("div.config-grid") + .Locator("div#config-window") + .Locator($"div#{id}") + .Locator("fluent-tabs#config-tabs") + .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("div.config-grid") + .Locator("fluent-tabs#config-tabs") + .Locator(selectedTabSelector); + var selectedPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tabs") + .Locator(selectedPanelSelector); + var hiddenPanel = Page.Locator("div.config-grid") + .Locator("fluent-tabs#config-tabs") + .Locator(hiddenPanelSelector); + + // Act + await selectedTab.ClickAsync(); + + // Assert + await Expect(selectedPanel).ToBeVisibleAsync(); + await Expect(hiddenPanel).ToBeHiddenAsync(); + } +} diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs index 6692fde3..6772ba03 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs @@ -8,7 +8,7 @@ namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; [Parallelizable(ParallelScope.Self)] [TestFixture] [Property("Category", "Integration")] -public class PlaygroundPageTests : PageTest +public partial class PlaygroundPageTests : PageTest { public override BrowserNewContextOptions ContextOptions() => new() { IgnoreHTTPSErrors = true, }; @@ -47,286 +47,9 @@ public async Task Given_Page_When_Endpoint_Invoked_Then_It_Should_Show_Panels(st await Expect(panel).ToBeVisibleAsync(); } - [Test] - public async Task Given_ConfigTab_When_Endpoint_Invoked_Then_ConfigTab_Should_Be_Displayed() - { - // Act - var configTab = Page.Locator("div.config-grid") - .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("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator("fluent-tab-panel#system-message-tab-panel"); - var parameterPanel = Page.Locator("div.config-grid") - .Locator("fluent-tabs#config-tab") - .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("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator(selectedTabSelector); - var selectedPanel = Page.Locator("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator(selectedPanelSelector); - var hiddenPanel = Page.Locator("div.config-grid") - .Locator("fluent-tabs#config-tab") - .Locator(hiddenPanelSelector); - - // Act - await selectedTab.ClickAsync(); - - // Assert - await Expect(selectedPanel).ToBeVisibleAsync(); - await Expect(hiddenPanel).ToBeHiddenAsync(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatWindow_Should_Be_Displayed() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatHistory_Should_Be_Empty() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - - var text = await element.TextContentAsync(); - - // Assert - text.Should().BeNullOrEmpty(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPrompt_Should_Be_Displayed() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptArea_Should_Be_Displayed() - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - [TestCase("button-clear")] - [TestCase("button-send")] - public async Task Given_ChatGrid_When_Endpoint_Invoked_Then_ChatPromptButton_Should_Be_Displayed(string id) - { - // Act - var element = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator($"div > fluent-button#{id}"); - - // Assert - await Expect(element).ToBeVisibleAsync(); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed_OnTheRight(string text) - { - // Arrange - var history = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - - var style = await history.Locator("div#message-00").GetAttributeAsync("style"); - - // Assert - style.Should().ContainAll("justify-content:", "end"); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatHistoryMessage_Should_Be_Displayed(string text) - { - // Arrange - var history = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - - var item = await history.Locator("div#message-00 p").TextContentAsync(); - - // Assert - item.Should().Be(text); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Send_Clicked_Then_ChatPromptArea_Should_Be_Empty(string text) - { - // Arrange - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - - var item = await prompt.Locator("textarea").TextContentAsync(); - - // Assert - item.Should().BeNullOrEmpty(); - } - - [Test] - [TestCase("abcde")] - public async Task Given_ChatPrompt_When_Clear_Clicked_Then_ChatHistoryMessage_Should_Be_Cleared(string text) - { - // Arrange - var history = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-history"); - var prompt = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("fluent-text-area#prompt"); - var send = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-send"); - var clear = Page.Locator("div.chat-grid") - .Locator("div#chat-window") - .Locator("div#chat-prompt") - .Locator("div > fluent-button#button-clear"); - - // Act - await prompt.Locator("textarea").FillAsync(text); - await send.ClickAsync(); - await clear.ClickAsync(); - - var result = await history.TextContentAsync(); - - // Assert - result.Should().BeNullOrEmpty(); - } - - [Test] - public async Task Given_ApiKeynputField_When_Endpoint_Invoked_Then_It_Should_Be_Visible() - { - // Arrange - var apiKeyInput = Page.Locator("fluent-text-field#api-key").Locator("input"); - - // Act & Assert - await Expect(apiKeyInput).ToBeVisibleAsync(); - } - - [Test] - public async Task Given_ApiKeyInputField_When_Endpoint_Invoked_Then_It_Should_Be_Password_Type() - { - // Arrange - var apiKeyInput = Page.Locator("fluent-text-field#api-key").Locator("input"); - - // Act - var inputType = await apiKeyInput.GetAttributeAsync("type"); - - // Assert - inputType.Should().Be("password"); - } - - [Test] - [TestCase("test-api-key-1")] - [TestCase("example-key-123")] - public async Task Given_ApiKeyInputField_When_Changed_Then_It_Should_Be_Updated(string apiKey) - { - // Arrange - var apiKeyInput = Page.Locator("fluent-text-field#api-key").Locator("input"); - - // Act - await apiKeyInput.FillAsync(apiKey); - - // Assert - await Expect(apiKeyInput).ToHaveValueAsync(apiKey); - } - [TearDown] public async Task CleanUp() { await Page.CloseAsync(); } } - diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs deleted file mode 100644 index f6be2968..00000000 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/TestsPageTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -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("3F2504E0-4F89-11D3-9A0C-0305E82C3301", typeof(string))] - [TestCase("b9f6741c-2f44-4d9b-bd63-c0e6b97cc83f", typeof(string))] - - public async Task Given_Input_On_ApiKeyField_When_DebugButton_Clicked_Then_Toast_Should_Show_Input(string apiKey, Type expectedType) - { - // Arrange - var apiKeyInput = Page.Locator("fluent-text-field#api-key-input").Locator("input"); - var debugButton = Page.Locator("fluent-button#debug-api-key"); - - // Act - await apiKeyInput.FillAsync(apiKey); - await debugButton.ClickAsync(); - - // Assert - await Expect(Page.Locator(".fluent-toast-title")).ToHaveTextAsync($"{apiKey} (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 From 5cdf5f54b03789fc4a1fda7b0fb69fb207ecfca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AC=B8=EC=A7=80=ED=98=84?= <87014797+jihyunmoon16@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:55:57 +0900 Subject: [PATCH 13/26] [OpenAPI] Add endpoint for list of deployment models (#294) --- .../Endpoints/PlaygroundEndpointUrls.cs | 8 + .../Endpoints/PlaygroundEndpoints.cs | 34 +++ .../Models/DeploymentModelDetails.cs | 14 ++ src/AzureOpenAIProxy.ApiApp/Program.cs | 1 + .../GetDeploymentModelsOpenApiTest.cs | 230 ++++++++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 src/AzureOpenAIProxy.ApiApp/Models/DeploymentModelDetails.cs create mode 100644 test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/GetDeploymentModelsOpenApiTest.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpointUrls.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpointUrls.cs index b8ccbd2b..13faee0e 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpointUrls.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpointUrls.cs @@ -12,4 +12,12 @@ public static class PlaygroundEndpointUrls /// - GET method for listing all events /// public const string Events = "/events"; + + /// + /// Declares the deployment models list endpoint. + /// + /// + /// - GET method for listing all deployment models + /// + public const string DeploymentModels = "/events/{eventId}/deployment-models"; } diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs index c21d6029..91c01b8f 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Mvc; + namespace AzureOpenAIProxy.ApiApp.Endpoints; /// @@ -32,4 +34,36 @@ public static RouteHandlerBuilder AddListEvents(this WebApplication app) return builder; } + + + /// + /// Adds the get deployment models + /// + /// instance. + /// Returns instance. + public static RouteHandlerBuilder AddListDeploymentModels(this WebApplication app) + { + // Todo: Issue #170 https://github.com/aliencube/azure-openai-sdk-proxy/issues/170 + var builder = app.MapGet(PlaygroundEndpointUrls.DeploymentModels, ( + [FromRoute] string eventId + ) => + { + return Results.Ok(); + }) + .Produces>(statusCode: StatusCodes.Status200OK, contentType: "application/json") + .Produces(statusCode: StatusCodes.Status401Unauthorized) + .Produces(statusCode: StatusCodes.Status404NotFound) + .Produces(statusCode: StatusCodes.Status500InternalServerError, contentType: "text/plain") + .WithTags("events") + .WithName("GetDeploymentModels") + .WithOpenApi(operation => + { + operation.Summary = "Gets all deployment models"; + operation.Description = "This endpoint gets all deployment models avaliable"; + + return operation; + }); + + return builder; + } } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Models/DeploymentModelDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/DeploymentModelDetails.cs new file mode 100644 index 00000000..ab2ef317 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Models/DeploymentModelDetails.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +/// +/// This represent the event detail data for response by admin event endpoint. +/// +public class DeploymentModelDetails +{ + /// + /// Gets or sets the deployment model name. + /// + [JsonRequired] + public string Name { get; set; } = string.Empty; + +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index 17d22e2e..f35b83ee 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -57,6 +57,7 @@ // Playground endpoints app.AddListEvents(); +app.AddListDeploymentModels(); // Admin endpoints app.AddNewAdminEvent(); diff --git a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/GetDeploymentModelsOpenApiTest.cs b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/GetDeploymentModelsOpenApiTest.cs new file mode 100644 index 00000000..51e72085 --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/GetDeploymentModelsOpenApiTest.cs @@ -0,0 +1,230 @@ +using System.Text.Json; + +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +using FluentAssertions; + +using IdentityModel.Client; + + +namespace AzureOpenAIProxy.AppHost.Tests.ApiApp.Endpoints; + +public class GetDeploymentModelsOpenApiTests(AspireAppHostFixture host) : IClassFixture +{ + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Path() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var apiDocument = JsonSerializer.Deserialize(json); + + // Assert + var result = apiDocument!.RootElement.GetProperty("paths") + .TryGetProperty("/events/{eventId}/deployment-models", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Verb() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var apiDocument = JsonSerializer.Deserialize(json); + + // Assert + var result = apiDocument!.RootElement.GetProperty("paths") + .GetProperty("/events/{eventId}/deployment-models") + .TryGetProperty("get", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("events")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Tags(string tag) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var apiDocument = JsonSerializer.Deserialize(json); + + // Assert + var result = apiDocument!.RootElement.GetProperty("paths") + .GetProperty("/events/{eventId}/deployment-models") + .GetProperty("get") + .TryGetProperty("tags", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Array); + result.EnumerateArray().Select(p => p.GetString()).Should().Contain(tag); + } + + [Theory] + [InlineData("summary")] + [InlineData("description")] + [InlineData("operationId")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Value(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var apiDocument = JsonSerializer.Deserialize(json); + + // Assert + var result = apiDocument!.RootElement.GetProperty("paths") + .GetProperty("/events/{eventId}/deployment-models") + .GetProperty("get") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.String); + } + + + [Theory] + [InlineData("eventId")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Path_Parameter(string name) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("paths") + .GetProperty("/events/{eventId}/deployment-models") + .GetProperty("get") + .GetProperty("parameters") + .EnumerateArray() + .Where(p => p.GetProperty("in").GetString() == "path") + .Select(p => p.GetProperty("name").ToString()); + result.Should().Contain(name); + } + + [Theory] + [InlineData("responses")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Object(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var apiDocument = JsonSerializer.Deserialize(json); + + // Assert + var result = apiDocument!.RootElement.GetProperty("paths") + .GetProperty("/events/{eventId}/deployment-models") + .GetProperty("get") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("200")] + [InlineData("401")] + [InlineData("404")] + [InlineData("500")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Response(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var apiDocument = JsonSerializer.Deserialize(json); + + // Assert + var result = apiDocument!.RootElement.GetProperty("paths") + .GetProperty("/events/{eventId}/deployment-models") + .GetProperty("get") + .GetProperty("responses") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + + [Fact] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Model() + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .TryGetProperty("DeploymentModelDetails", out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("name", true)] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Required(string attribute, bool isRequired) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("DeploymentModelDetails") + .TryGetStringArray("required") + .ToList(); + result.Contains(attribute).Should().Be(isRequired); + } + + [Theory] + [InlineData("name")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Property(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("DeploymentModelDetails") + .GetProperty("properties") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("name", "string")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Type(string attribute, string type) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("DeploymentModelDetails") + .GetProperty("properties") + .GetProperty(attribute); + result.TryGetString("type").Should().Be(type); + } +} \ No newline at end of file From e0d0ee4d44a2b33623b3e5b8fcc3e90b89f47cf6 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 20 Sep 2024 18:45:40 +0900 Subject: [PATCH 14/26] [Backend API] Implement `ITableEntity` to events and resources (#329) --- .../Models/AdminResourceDetails.cs | 21 ++- .../Models/EventDetails.cs | 21 ++- .../Models/AdminEventDetailsTests.cs | 87 +++++++++++ .../Models/EventDetailsTests.cs | 58 ++++++++ .../AdminCreateEventsOpenApiTests.cs | 104 +++++++++++++ .../AdminGetEventDetailsOpenApiTests.cs | 140 +----------------- 6 files changed, 290 insertions(+), 141 deletions(-) create mode 100644 test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminEventDetailsTests.cs create mode 100644 test/AzureOpenAIProxy.ApiApp.Tests/Models/EventDetailsTests.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs index b610f077..7cb8e1c5 100644 --- a/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs +++ b/src/AzureOpenAIProxy.ApiApp/Models/AdminResourceDetails.cs @@ -1,6 +1,9 @@ using System.Runtime.Serialization; using System.Text.Json.Serialization; +using Azure; +using Azure.Data.Tables; + using AzureOpenAIProxy.ApiApp.Converters; namespace AzureOpenAIProxy.ApiApp.Models; @@ -8,7 +11,7 @@ namespace AzureOpenAIProxy.ApiApp.Models; /// /// This represent the entity for the resource details for admin. /// -public class AdminResourceDetails +public class AdminResourceDetails : ITableEntity { /// /// Gets or sets the event id. @@ -57,6 +60,22 @@ public class AdminResourceDetails /// [JsonRequired] public bool IsActive { get; set; } + + /// + [JsonIgnore] + public string PartitionKey { get; set; } = string.Empty; + + /// + [JsonIgnore] + public string RowKey { get; set; } = string.Empty; + + /// + [JsonIgnore] + public DateTimeOffset? Timestamp { get; set; } + + /// + [JsonIgnore] + public ETag ETag { get; set; } } /// diff --git a/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs b/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs index 459ff655..d70c3952 100644 --- a/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs +++ b/src/AzureOpenAIProxy.ApiApp/Models/EventDetails.cs @@ -1,9 +1,12 @@ using System.Text.Json.Serialization; +using Azure; +using Azure.Data.Tables; + /// /// This represent the entity for the event details for users. /// -public class EventDetails +public class EventDetails : ITableEntity { /// /// Gets or sets the event id. @@ -34,4 +37,20 @@ public class EventDetails /// [JsonRequired] public int DailyRequestCap { get; set; } + + /// + [JsonIgnore] + public string PartitionKey { get; set; } = string.Empty; + + /// + [JsonIgnore] + public string RowKey { get; set; } = string.Empty; + + /// + [JsonIgnore] + public DateTimeOffset? Timestamp { get; set; } + + /// + [JsonIgnore] + public ETag ETag { get; set; } } \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminEventDetailsTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminEventDetailsTests.cs new file mode 100644 index 00000000..3ab3ad7f --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Models/AdminEventDetailsTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; + +using AzureOpenAIProxy.ApiApp.Models; + +using FluentAssertions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Models; + +public class AdminEventDetailsTests +{ + private static readonly AdminEventDetails examplePayload = new() + { + EventId = Guid.Parse("67f410a3-c5e4-4326-a3ad-5812b9adfc06"), + Title = "Test Title", + Summary = "Test Summary", + Description = "Test Description", + DateStart = DateTimeOffset.Parse("2024-12-01T12:34:56+00:00"), + DateEnd = DateTimeOffset.Parse("2024-12-02T12:34:56+00:00"), + TimeZone = "Asia/Seoul", + IsActive = true, + OrganizerName = "Test Organizer", + OrganizerEmail = "organiser@testemail.com", + CoorganizerName = "Test Coorganizer", + CoorganizerEmail = "coorganiser@testemail.com", + MaxTokenCap = 1000, + DailyRequestCap = 4000, + }; + + private static readonly string exampleJson = """ + { + "eventId": "67f410a3-c5e4-4326-a3ad-5812b9adfc06", + "title": "Test Title", + "summary": "Test Summary", + "description": "Test Description", + "dateStart": "2024-12-01T12:34:56+00:00", + "dateEnd": "2024-12-02T12:34:56+00:00", + "timeZone": "Asia/Seoul", + "isActive": true, + "organizerName": "Test Organizer", + "organizerEmail": "organiser@testemail.com", + "coorganizerName": "Test Coorganizer", + "coorganizerEmail": "coorganiser@testemail.com", + "maxTokenCap": 1000, + "dailyRequestCap": 4000 + } + """; + + private static readonly JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + [Fact] + public void Given_ExamplePayload_When_Serialized_Then_It_Should_Match_Json() + { + // Act + var serialised = JsonSerializer.Serialize(examplePayload, options); + + // Assert + serialised.Should().ContainAll( + "\"eventId\":", "\"67f410a3-c5e4-4326-a3ad-5812b9adfc06\"", + "\"title\":", "\"Test Title\"", + "\"summary\":", "\"Test Summary\"", + "\"description\":", "\"Test Description\"", + "\"dateStart\":", "\"2024-12-01T12:34:56+00:00\"", + "\"dateEnd\":", "\"2024-12-02T12:34:56+00:00\"", + "\"timeZone\":", "\"Asia/Seoul\"", + "\"isActive\":", "true", + "\"organizerName\":", "\"Test Organizer\"", + "\"organizerEmail\":", "\"organiser@testemail.com\"", + "\"coorganizerName\":", "\"Test Coorganizer\"", + "\"coorganizerEmail\":", "\"coorganiser@testemail.com\"", + "\"maxTokenCap\":", "1000", + "\"dailyRequestCap\":", "4000"); + } + + [Fact] + public void Given_ExampleJson_When_Deserialized_Then_It_Should_Match_Object() + { + // Arrange & Act + var deserialised = JsonSerializer.Deserialize(exampleJson, options); + + // Assert + deserialised.Should().BeEquivalentTo(examplePayload); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Models/EventDetailsTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Models/EventDetailsTests.cs new file mode 100644 index 00000000..f5e987d0 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Models/EventDetailsTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; + +using FluentAssertions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Models; + +public class EventDetailsTests +{ + private static readonly EventDetails examplePayload = new() + { + EventId = Guid.Parse("67f410a3-c5e4-4326-a3ad-5812b9adfc06"), + Title = "Test Title", + Summary = "Test Summary", + MaxTokenCap = 1000, + DailyRequestCap = 4000, + }; + + private static readonly string exampleJson = """ + { + "eventId": "67f410a3-c5e4-4326-a3ad-5812b9adfc06", + "title": "Test Title", + "summary": "Test Summary", + "maxTokenCap": 1000, + "dailyRequestCap": 4000 + } + """; + + private static readonly JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + [Fact] + public void Given_ExamplePayload_When_Serialized_Then_It_Should_Match_Json() + { + // Act + var serialised = JsonSerializer.Serialize(examplePayload, options); + + // Assert + serialised.Should().ContainAll( + "\"eventId\":", "\"67f410a3-c5e4-4326-a3ad-5812b9adfc06\"", + "\"title\":", "\"Test Title\"", + "\"summary\":", "\"Test Summary\"", + "\"maxTokenCap\":", "1000", + "\"dailyRequestCap\":", "4000"); + } + + [Fact] + public void Given_ExampleJson_When_Deserialized_Then_It_Should_Match_Object() + { + // Arrange & Act + var deserialised = JsonSerializer.Deserialize(exampleJson, options); + + // Assert + deserialised.Should().BeEquivalentTo(examplePayload); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateEventsOpenApiTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateEventsOpenApiTests.cs index 79e566f4..466600c8 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateEventsOpenApiTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminCreateEventsOpenApiTests.cs @@ -4,6 +4,8 @@ using FluentAssertions; +using IdentityModel.Client; + namespace AzureOpenAIProxy.AppHost.Tests.ApiApp.Endpoints; public class AdminCreateEventsOpenApiTests(AspireAppHostFixture host) : IClassFixture @@ -165,4 +167,106 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Mod .TryGetProperty("AdminEventDetails", out var property) ? property : default; result.ValueKind.Should().Be(JsonValueKind.Object); } + + [Theory] + [InlineData("eventId", true)] + [InlineData("title", true)] + [InlineData("summary", true)] + [InlineData("description", false)] + [InlineData("dateStart", true)] + [InlineData("dateEnd", true)] + [InlineData("timeZone", true)] + [InlineData("isActive", true)] + [InlineData("organizerName", true)] + [InlineData("organizerEmail", true)] + [InlineData("coorganizerName", false)] + [InlineData("coorganizerEmail", false)] + [InlineData("maxTokenCap", true)] + [InlineData("dailyRequestCap", true)] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Required(string attribute, bool isRequired) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminEventDetails") + .TryGetStringArray("required") + .ToList(); + result.Contains(attribute).Should().Be(isRequired); + } + + [Theory] + [InlineData("eventId")] + [InlineData("title")] + [InlineData("summary")] + [InlineData("description")] + [InlineData("dateStart")] + [InlineData("dateEnd")] + [InlineData("timeZone")] + [InlineData("isActive")] + [InlineData("organizerName")] + [InlineData("organizerEmail")] + [InlineData("coorganizerName")] + [InlineData("coorganizerEmail")] + [InlineData("maxTokenCap")] + [InlineData("dailyRequestCap")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Property(string attribute) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminEventDetails") + .GetProperty("properties") + .TryGetProperty(attribute, out var property) ? property : default; + result.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Theory] + [InlineData("eventId", "string")] + [InlineData("title", "string")] + [InlineData("summary", "string")] + [InlineData("description", "string")] + [InlineData("dateStart", "string")] + [InlineData("dateEnd", "string")] + [InlineData("timeZone", "string")] + [InlineData("isActive", "boolean")] + [InlineData("organizerName", "string")] + [InlineData("organizerEmail", "string")] + [InlineData("coorganizerName", "string")] + [InlineData("coorganizerEmail", "string")] + [InlineData("maxTokenCap", "integer")] + [InlineData("dailyRequestCap", "integer")] + public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Type(string attribute, string type) + { + // Arrange + using var httpClient = host.App!.CreateHttpClient("apiapp"); + await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + + // Act + var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); + var openapi = JsonSerializer.Deserialize(json); + + // Assert + var result = openapi!.RootElement.GetProperty("components") + .GetProperty("schemas") + .GetProperty("AdminEventDetails") + .GetProperty("properties") + .GetProperty(attribute); + result.TryGetString("type").Should().Be(type); + } } diff --git a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs index dbb0e846..c2d84bfb 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/ApiApp/Endpoints/AdminGetEventDetailsOpenApiTests.cs @@ -4,8 +4,6 @@ using FluentAssertions; -using IdentityModel.Client; - namespace AzureOpenAIProxy.AppHost.Tests.ApiApp.Endpoints; public class AdminGetEventDetailsOpenApiTests(AspireAppHostFixture host) : IClassFixture @@ -154,6 +152,7 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Obj [Theory] [InlineData("200")] [InlineData("401")] + [InlineData("404")] [InlineData("500")] public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Response(string attribute) { @@ -173,141 +172,4 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Res .TryGetProperty(attribute, out var property) ? property : default; result.ValueKind.Should().Be(JsonValueKind.Object); } - - [Fact] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Schemas() - { - // Arrange - using var httpClient = host.App!.CreateHttpClient("apiapp"); - await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - - // Act - var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); - var openapi = JsonSerializer.Deserialize(json); - - // Assert - var result = openapi!.RootElement.GetProperty("components") - .TryGetProperty("schemas", out var property) ? property : default; - result.ValueKind.Should().Be(JsonValueKind.Object); - } - - [Fact] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Model() - { - // Arrange - using var httpClient = host.App!.CreateHttpClient("apiapp"); - await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - - // Act - var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); - var openapi = JsonSerializer.Deserialize(json); - - // Assert - var result = openapi!.RootElement.GetProperty("components") - .GetProperty("schemas") - .TryGetProperty("AdminEventDetails", out var property) ? property : default; - result.ValueKind.Should().Be(JsonValueKind.Object); - } - - [Theory] - [InlineData("eventId", true)] - [InlineData("title", true)] - [InlineData("summary", true)] - [InlineData("description", false)] - [InlineData("dateStart", true)] - [InlineData("dateEnd", true)] - [InlineData("timeZone", true)] - [InlineData("isActive", true)] - [InlineData("organizerName", true)] - [InlineData("organizerEmail", true)] - [InlineData("coorganizerName", false)] - [InlineData("coorganizerEmail", false)] - [InlineData("maxTokenCap", true)] - [InlineData("dailyRequestCap", true)] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Required(string attribute, bool isRequired) - { - // Arrange - using var httpClient = host.App!.CreateHttpClient("apiapp"); - await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - - // Act - var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); - var openapi = JsonSerializer.Deserialize(json); - - // Assert - var result = openapi!.RootElement.GetProperty("components") - .GetProperty("schemas") - .GetProperty("AdminEventDetails") - .TryGetStringArray("required") - .ToList(); - result.Contains(attribute).Should().Be(isRequired); - } - - [Theory] - [InlineData("eventId")] - [InlineData("title")] - [InlineData("summary")] - [InlineData("description")] - [InlineData("dateStart")] - [InlineData("dateEnd")] - [InlineData("timeZone")] - [InlineData("isActive")] - [InlineData("organizerName")] - [InlineData("organizerEmail")] - [InlineData("coorganizerName")] - [InlineData("coorganizerEmail")] - [InlineData("maxTokenCap")] - [InlineData("dailyRequestCap")] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Property(string attribute) - { - // Arrange - using var httpClient = host.App!.CreateHttpClient("apiapp"); - await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - - // Act - var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); - var openapi = JsonSerializer.Deserialize(json); - - // Assert - var result = openapi!.RootElement.GetProperty("components") - .GetProperty("schemas") - .GetProperty("AdminEventDetails") - .GetProperty("properties") - .TryGetProperty(attribute, out var property) ? property : default; - result.ValueKind.Should().Be(JsonValueKind.Object); - } - - [Theory] - [InlineData("eventId", "string")] - [InlineData("title", "string")] - [InlineData("summary", "string")] - [InlineData("description", "string")] - [InlineData("dateStart", "string")] - [InlineData("dateEnd", "string")] - [InlineData("timeZone", "string")] - [InlineData("isActive", "boolean")] - [InlineData("organizerName", "string")] - [InlineData("organizerEmail", "string")] - [InlineData("coorganizerName", "string")] - [InlineData("coorganizerEmail", "string")] - [InlineData("maxTokenCap", "integer")] - [InlineData("dailyRequestCap", "integer")] - public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Type(string attribute, string type) - { - // Arrange - using var httpClient = host.App!.CreateHttpClient("apiapp"); - await host.ResourceNotificationService.WaitForResourceAsync("apiapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); - - // Act - var json = await httpClient.GetStringAsync("/swagger/v1.0.0/swagger.json"); - var openapi = JsonSerializer.Deserialize(json); - - // Assert - var result = openapi!.RootElement.GetProperty("components") - .GetProperty("schemas") - .GetProperty("AdminEventDetails") - .GetProperty("properties") - .GetProperty(attribute); - result.TryGetString("type").Should().Be(type); - } } From c7991e7e50eea27e28da313a28ec0764bdd43534 Mon Sep 17 00:00:00 2001 From: YooJung Chun Date: Fri, 20 Sep 2024 18:58:45 +0900 Subject: [PATCH 15/26] [Playground] System Message Tab: UI Component only #289 (#312) --- .../Components/Pages/Playground.razor | 13 +- .../Components/UI/ConfigTabComponent.razor | 14 +- .../Components/UI/ConfigWindowComponent.razor | 13 +- .../UI/SystemMessageTabComponent.razor | 70 ++++++++ .../Pages/PlaygroundPageConfigWindowTests.cs | 159 ++++++++++++++++++ .../Pages/PlaygroundPageTests.cs | 110 ++++++------ 6 files changed, 321 insertions(+), 58 deletions(-) create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/SystemMessageTabComponent.razor diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor index ceb2684f..4873e6c7 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Playground.razor @@ -8,7 +8,7 @@ - + @@ -16,3 +16,14 @@ + +@code { + private string? systemMessage; + + private async Task SetSystemMessage(string systemMessage) + { + this.systemMessage = systemMessage; + + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor index 3895e562..fcfeafb7 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigTabComponent.razor @@ -1,7 +1,7 @@ - This is "System message" tab. + This is "Parameters" tab. @@ -11,13 +11,25 @@ @code { private FluentTab? selectedTab { get; set; } + private string? systemMessage; [Parameter] public string? Id { get; set; } + [Parameter] + public EventCallback OnSystemMessageChanged { get; set; } + private async Task ChangeTab(FluentTab tab) { this.selectedTab = tab; + await Task.CompletedTask; } + + private async Task SetSystemMessage(string systemMessage) + { + this.systemMessage = systemMessage; + + await OnSystemMessageChanged.InvokeAsync(systemMessage); + } } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor index e63e28df..739e4e7c 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ConfigWindowComponent.razor @@ -5,16 +5,20 @@ - + @code { private string? apiKey; private string? deploymentModel; + private string? systemMessage; [Parameter] public string? Id { get; set; } + [Parameter] + public EventCallback OnSystemMessageChanged { get; set; } + private async Task SetApiKey(string apiKey) { this.apiKey = apiKey; @@ -28,4 +32,11 @@ await Task.CompletedTask; } + + private async Task SetSystemMessage(string systemMessage) + { + this.systemMessage = systemMessage; + + await OnSystemMessageChanged.InvokeAsync(systemMessage); + } } \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/SystemMessageTabComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/SystemMessageTabComponent.razor new file mode 100644 index 00000000..677ec4a5 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/SystemMessageTabComponent.razor @@ -0,0 +1,70 @@ + + + + Apply changes + + + Reset to default + + + +
+ + +
+ +
+ +@code { + private string? userSystemMessage; + private string? userInput; + private string? defaultSystemMessage = "You are an AI assistant that helps people find information."; + private bool isApplyButtonEnabled; + private bool isResetButtonEnabled; + + [Parameter] + public string? Id { get; set; } + + [Parameter] + public EventCallback OnSystemMessageChanged { get; set; } + + protected override async Task OnInitializedAsync() + { + userSystemMessage = defaultSystemMessage; + + await OnSystemMessageChanged.InvokeAsync(userSystemMessage); + } + + private async Task OnInputChanged(ChangeEventArgs userInput) + { + this.userInput = userInput.Value!.ToString(); + isApplyButtonEnabled = true; + isResetButtonEnabled = true; + + await Task.CompletedTask; + } + + private async Task OnApplyChanges() + { + userSystemMessage = userInput; + isApplyButtonEnabled = false; + + if(userSystemMessage == defaultSystemMessage) + { + isResetButtonEnabled = false; + } + + await OnSystemMessageChanged.InvokeAsync(userSystemMessage); + } + + private async Task OnResetToDefault() + { + userSystemMessage = defaultSystemMessage; + isApplyButtonEnabled = false; + isResetButtonEnabled = false; + + await OnSystemMessageChanged.InvokeAsync(userSystemMessage); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs index 0fa7827c..2b4a100a 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageConfigWindowTests.cs @@ -209,4 +209,163 @@ string hiddenPanelSelector await Expect(selectedPanel).ToBeVisibleAsync(); await Expect(hiddenPanel).ToBeHiddenAsync(); } + + [Test] + [TestCase( + "fluent-tab#system-message-tab", + "fluent-tab-panel#system-message-tab-panel", + "div#system-message-tab-component" + )] + public async Task Given_ConfigTab_When_Selected_Then_Tab_Component_Should_Be_Displayed( + string selectedTabSelector, + string selectedPanelSelector, + string componenetSelector + ) + { + // Arrange + var selectedTab = Page.Locator(selectedTabSelector); + var selectedPanel = Page.Locator(selectedPanelSelector); + var component = Page.Locator(componenetSelector); + + // Act + await selectedTab.ClickAsync(); + + // Assert + await Expect(selectedPanel).ToBeVisibleAsync(); + await Expect(component).ToBeVisibleAsync(); + } + + [Test] + [TestCase( + "fluent-tab#system-message-tab", + "fluent-text-area#system-message-tab-textarea", + "You are an AI assistant that helps people find information." + )] + public async Task Given_ConfigTab_When_Selected_Then_Tab_Component_Should_Have_Default_Value( + string selectedTabSelector, + string componentSelector, + string expectedValue + ) + { + // Arrange + var selectedTab = Page.Locator(selectedTabSelector); + + // Act + await selectedTab.ClickAsync(); + var actualValue = await Page.Locator(componentSelector).GetAttributeAsync("value"); + + // Assert + actualValue.Should().Be(expectedValue); + } + + [Test] + public async Task Given_SystemMessageTab_Buttons_When_TextArea_Value_Changed_Then_All_Buttons_Should_Be_Enabled() + { + // Arrange + var systemMessageTab = Page.Locator("fluent-tab#system-message-tab"); + var systemMessageTextAreaControl = Page.Locator("fluent-text-area#system-message-tab-textarea textarea"); + var applyButton = Page.Locator("fluent-button#system-message-tab-apply-button"); + var resetButton = Page.Locator("fluent-button#system-message-tab-reset-button"); + + // Act + await systemMessageTab.ClickAsync(); + await systemMessageTextAreaControl.FillAsync("New system message"); + await Task.Delay(1000); + + var isApplyButtonEnabled = await applyButton.GetAttributeAsync("disabled"); + var isResetButtonEnabled = await resetButton.GetAttributeAsync("disabled"); + + // Assert + isApplyButtonEnabled.Should().BeNull(); + isResetButtonEnabled.Should().BeNull(); + } + + [Test] + [TestCase("1 New system message 1")] + [TestCase("2 New system message 2")] + public async Task Given_SystemMessageTab_ApplyButton_When_Clicked_Then_Changed_TextArea_Value_Should_Be_Applied_As_SystemMessage_And_ApplyButton_Should_Be_Disabled_And_ResetButton_Should_Be_Enabled( + string expectedValue + ) + { + // Arrange + var systemMessageTab = Page.Locator("fluent-tab#system-message-tab"); + var systemMessageTextArea = Page.Locator("fluent-text-area#system-message-tab-textarea"); + var systemMessageTextAreaControl = Page.Locator("fluent-text-area#system-message-tab-textarea textarea"); + var applyButton = Page.Locator("fluent-button#system-message-tab-apply-button"); + var resetButton = Page.Locator("fluent-button#system-message-tab-reset-button"); + + // Act + await systemMessageTab.ClickAsync(); + await systemMessageTextAreaControl.FillAsync(expectedValue); + await applyButton.ClickAsync(new() { Delay = 500 }); + await Task.Delay(1000); + + var actualValue = await systemMessageTextArea.GetAttributeAsync("value"); + var isApplyButtonEnabled = await applyButton.GetAttributeAsync("disabled"); + var isResetButtonEnabled = await resetButton.GetAttributeAsync("disabled"); + + // Assert + actualValue.Should().Be(expectedValue); + isApplyButtonEnabled.Should().NotBeNull(); + isResetButtonEnabled.Should().BeNull(); + } + + [Test] + [TestCase("You are an AI assistant that helps people find information.")] + public async Task Given_SystemMessageTab_ApplyButton_When_Clicked_Then_Default_TextArea_Value_Should_Be_Applied_As_SystemMessage_And_All_Buttons_Should_Be_Disabled( + string expectedValue + ) + { + // Arrange + var systemMessageTab = Page.Locator("fluent-tab#system-message-tab"); + var systemMessageTextArea = Page.Locator("fluent-text-area#system-message-tab-textarea"); + var systemMessageTextAreaControl = Page.Locator("fluent-text-area#system-message-tab-textarea textarea"); + var applyButton = Page.Locator("fluent-button#system-message-tab-apply-button"); + var resetButton = Page.Locator("fluent-button#system-message-tab-reset-button"); + + // Act + await systemMessageTab.ClickAsync(); + await systemMessageTextAreaControl.FillAsync(expectedValue); + await applyButton.ClickAsync(new() { Delay = 500 }); + await Task.Delay(1000); + + var actualValue = await systemMessageTextArea.GetAttributeAsync("value"); + var isApplyButtonEnabled = await applyButton.GetAttributeAsync("disabled"); + var isResetButtonEnabled = await resetButton.GetAttributeAsync("disabled"); + + // Assert + actualValue.Should().Be(expectedValue); + isApplyButtonEnabled.Should().NotBeNull(); + isResetButtonEnabled.Should().NotBeNull(); + } + + [Test] + [TestCase("You are an AI assistant that helps people find information.")] + public async Task Given_SystemMessageTab_ResetButton_When_Clicked_Then_SystemMessage_And_TextArea_Should_Have_Default_Value_And_All_Buttons_Should_Be_Disabled( + string expectedValue + ) + { + // Arrange + var systemMessageTab = Page.Locator("fluent-tab#system-message-tab"); + var systemMessageTextArea = Page.Locator("fluent-text-area#system-message-tab-textarea"); + var systemMessageTextAreaControl = Page.Locator("fluent-text-area#system-message-tab-textarea textarea"); + var applyButton = Page.Locator("fluent-button#system-message-tab-apply-button"); + var resetButton = Page.Locator("fluent-button#system-message-tab-reset-button"); + + // Act + await systemMessageTab.ClickAsync(); + await systemMessageTextAreaControl.FillAsync("New system message"); + await applyButton.ClickAsync(new() { Delay = 500 }); + await resetButton.ClickAsync(new() { Delay = 500 }); + await Task.Delay(1000); + + var actualValue = await systemMessageTextArea.GetAttributeAsync("value"); + var isApplyButtonEnabled = await applyButton.GetAttributeAsync("disabled"); + var isResetButtonEnabled = await resetButton.GetAttributeAsync("disabled"); + + // Assert + actualValue.Should().Be(expectedValue); + isApplyButtonEnabled.Should().NotBeNull(); + isResetButtonEnabled.Should().NotBeNull(); + } } diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs index 6772ba03..3df85740 100644 --- a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/PlaygroundPageTests.cs @@ -1,55 +1,55 @@ -using FluentAssertions; - -using Microsoft.Playwright; -using Microsoft.Playwright.NUnit; - -namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; - -[Parallelizable(ParallelScope.Self)] -[TestFixture] -[Property("Category", "Integration")] -public partial 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(); - } - - [TearDown] - public async Task CleanUp() - { - await Page.CloseAsync(); - } -} +using FluentAssertions; + +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +[Property("Category", "Integration")] +public partial 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(); + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } +} From f2dc4714fdf047b70dcd9f2537f38e8278d7083f Mon Sep 17 00:00:00 2001 From: sikutisa <32262904+sikutisa@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:39:54 +0900 Subject: [PATCH 16/26] [Backend API] Implement endpoint for view event details (#318) --- .../Endpoints/AdminEventEndpoints.cs | 35 ++++++++++-- src/AzureOpenAIProxy.ApiApp/PartitionKeys.cs | 17 ++++++ .../Repositories/AdminEventRepository.cs | 18 ++++++- .../Repositories/AdminEventRepositoryTests.cs | 53 ++++++++++++++++--- .../Services/AdminEventServiceTests.cs | 45 ++++++++++++++-- 5 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 src/AzureOpenAIProxy.ApiApp/PartitionKeys.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs index 2687693f..b877754b 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/AdminEventEndpoints.cs @@ -1,3 +1,5 @@ +using Azure; + using AzureOpenAIProxy.ApiApp.Models; using AzureOpenAIProxy.ApiApp.Services; @@ -107,12 +109,35 @@ 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.MapGet(AdminEndpointUrls.AdminEventDetails, ( - [FromRoute] string eventId) => + var builder = app.MapGet(AdminEndpointUrls.AdminEventDetails, async ( + [FromRoute] Guid eventId, + 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 request to fetch details for event with ID: {eventId}"); + + try + { + var details = await service.GetEvent(eventId); + return Results.Ok(details); + } + catch(RequestFailedException ex) + { + if(ex.Status == 404) + { + logger.LogError($"Failed to get event details of {eventId}"); + return Results.NotFound(); + } + + logger.LogError(ex, $"Error occurred while fetching event details of {eventId} with status {ex.Status}"); + return Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); + } + catch(Exception ex) + { + logger.LogError(ex, $"Error occurred while fetching event details of {eventId}"); + return Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); + } }) .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") .Produces(statusCode: StatusCodes.Status401Unauthorized) diff --git a/src/AzureOpenAIProxy.ApiApp/PartitionKeys.cs b/src/AzureOpenAIProxy.ApiApp/PartitionKeys.cs new file mode 100644 index 00000000..1949fd7e --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/PartitionKeys.cs @@ -0,0 +1,17 @@ +namespace AzureOpenAIProxy.ApiApp; + +/// +/// This represents the partition keys for azure table storage +/// +public class PartitionKeys +{ + /// + /// Partition key for event details + /// + public const string EventDetails = "event-details"; + + /// + /// Partition key for resource details + /// + public const string ResourceDetails = "resource-details"; +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs index 4881da1c..1ef0dfcb 100644 --- a/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs +++ b/src/AzureOpenAIProxy.ApiApp/Repositories/AdminEventRepository.cs @@ -63,7 +63,14 @@ public async Task> GetEvents() /// public async Task GetEvent(Guid eventId) { - throw new NotImplementedException(); + TableClient tableClient = await GetTableClientAsync(); + + var eventDetail = await tableClient.GetEntityAsync( + rowKey: eventId.ToString(), + partitionKey: PartitionKeys.EventDetails + ).ConfigureAwait(false); + + return eventDetail.Value; } /// @@ -71,6 +78,15 @@ public async Task UpdateEvent(Guid eventId, AdminEventDetails { throw new NotImplementedException(); } + + private async Task GetTableClientAsync() + { + TableClient tableClient = _tableServiceClient.GetTableClient(_storageAccountSettings.TableStorage.TableName); + + await tableClient.CreateIfNotExistsAsync().ConfigureAwait(false); + + return tableClient; + } } /// diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs index 33755e47..ddc0facd 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/AdminEventRepositoryTests.cs @@ -1,16 +1,16 @@ -using Azure.Data.Tables; +using Azure; +using Azure.Data.Tables; using AzureOpenAIProxy.ApiApp.Configurations; using AzureOpenAIProxy.ApiApp.Models; using AzureOpenAIProxy.ApiApp.Repositories; -using Castle.Core.Configuration; - using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace AzureOpenAIProxy.ApiApp.Tests.Repositories; @@ -88,8 +88,10 @@ public void Given_Instance_When_GetEvents_Invoked_Then_It_Should_Throw_Exception func.Should().ThrowAsync(); } - [Fact] - public void Given_Instance_When_GetEvent_Invoked_Then_It_Should_Throw_Exception() + [Theory] + [InlineData(404)] + [InlineData(500)] + public async Task Given_Failure_In_Get_Entity_When_GetEvent_Invoked_Then_It_Should_Throw_Exception(int statusCode) { // Arrange var settings = Substitute.For(); @@ -97,11 +99,48 @@ public void Given_Instance_When_GetEvent_Invoked_Then_It_Should_Throw_Exception( var eventId = Guid.NewGuid(); var repository = new AdminEventRepository(tableServiceClient, settings); + var exception = new RequestFailedException(statusCode, "Request Error", default, default); + + var tableClient = Substitute.For(); + tableServiceClient.GetTableClient(Arg.Any()).Returns(tableClient); + tableClient.GetEntityAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(exception); + // Act - Func func = async () => await repository.GetEvent(eventId); + Func func = () => repository.GetEvent(eventId); // Assert - func.Should().ThrowAsync(); + var assertion = await func.Should().ThrowAsync(); + assertion.Which.Status.Should().Be(statusCode); + } + + [Theory] + [InlineData("c355cc28-d847-4637-aad9-2f03d39aa51f", "event-details")] + public async Task Given_Exist_EventId_When_GetEvent_Invoked_Then_It_Should_Return_AdminEventDetails(string eventId, string partitionKey) + { + // Arrange + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); + var repository = new AdminEventRepository(tableServiceClient, settings); + + var eventDetails = new AdminEventDetails + { + RowKey = eventId, + PartitionKey = partitionKey + }; + + var response = Response.FromValue(eventDetails, Substitute.For()); + + var tableClient = Substitute.For(); + tableServiceClient.GetTableClient(Arg.Any()).Returns(tableClient); + tableClient.GetEntityAsync(partitionKey, eventId) + .Returns(Task.FromResult(response)); + + // Act + var result = await repository.GetEvent(Guid.Parse(eventId)); + + // Assert + result.Should().BeEquivalentTo(eventDetails); } [Fact] diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminEventServiceTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminEventServiceTests.cs index cf56f8e2..f0442c76 100644 --- a/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminEventServiceTests.cs +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Services/AdminEventServiceTests.cs @@ -1,4 +1,6 @@ -using AzureOpenAIProxy.ApiApp.Models; +using Azure; + +using AzureOpenAIProxy.ApiApp.Models; using AzureOpenAIProxy.ApiApp.Repositories; using AzureOpenAIProxy.ApiApp.Services; @@ -7,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace AzureOpenAIProxy.ApiApp.Tests.Services; @@ -54,19 +57,51 @@ public void Given_Instance_When_GetEvents_Invoked_Then_It_Should_Throw_Exception func.Should().ThrowAsync(); } - [Fact] - public void Given_Instance_When_GetEvent_Invoked_Then_It_Should_Throw_Exception() + + [Theory] + [InlineData(404)] + [InlineData(500)] + public async Task Given_Failure_In_Get_Entity_When_GetEvent_Invoked_Then_It_Should_Throw_Exception(int statusCode) { // Arrange var eventId = Guid.NewGuid(); var repository = Substitute.For(); var service = new AdminEventService(repository); + var exception = new RequestFailedException(statusCode, "Request Failed", default, default); + + repository.GetEvent(Arg.Any()).ThrowsAsync(exception); + // Act - Func func = async () => await service.GetEvent(eventId); + Func func = () => service.GetEvent(eventId); // Assert - func.Should().ThrowAsync(); + var assertion = await func.Should().ThrowAsync(); + assertion.Which.Status.Should().Be(statusCode); + } + + [Theory] + [InlineData("c355cc28-d847-4637-aad9-2f03d39aa51f")] + public async Task Given_Exist_EventId_When_GetEvent_Invoked_Then_It_Should_Return_AdminEventDetails(string eventId) + { + // Arrange + var repository = Substitute.For(); + var service = new AdminEventService(repository); + + var eventDetails = new AdminEventDetails + { + RowKey = eventId + }; + + var guid = Guid.Parse(eventId); + + repository.GetEvent(guid).Returns(Task.FromResult(eventDetails)); + + // Act + var result = await service.GetEvent(guid); + + // Assert + result.Should().BeEquivalentTo(eventDetails); } [Fact] From 3cd93e124ed921adea96422e8aff1acf33136ea1 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Wed, 2 Oct 2024 18:40:53 +1000 Subject: [PATCH 17/26] Update Azure OpenAI chat completion options --- Directory.Build.props | 2 +- .../Clients/OpenAIApiClient.cs | 101 +++++++++--------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 89cc5c57..1854c7b9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ enable enable - 8.2.0 + 8.2.1 2.*-* 8.* 8.* diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs index 44856e0a..640251f1 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs @@ -1,50 +1,51 @@ -using Azure; -using Azure.AI.OpenAI; - -using OpenAI.Chat; - -namespace AzureOpenAIProxy.PlaygroundApp.Clients; - -/// -/// This provides interfaces to the class. -/// -public interface IOpenAIApiClient -{ - /// - /// Send a chat completion request to the OpenAI API. - /// - /// instance. - /// Returns the chat completion result. - Task CompleteChatAsync(OpenAIApiClientOptions clientOptions); -} - -/// -/// This represents the OpenAI API client entity. -/// -public class OpenAIApiClient : IOpenAIApiClient -{ - /// - public async Task CompleteChatAsync(OpenAIApiClientOptions clientOptions) - { - var endpoint = new Uri($"{clientOptions.Endpoint!.TrimEnd('/')}/api"); - var credential = new AzureKeyCredential(clientOptions.ApiKey!); - var openai = new AzureOpenAIClient(endpoint, credential); - var chat = openai.GetChatClient(clientOptions.DeploymentName); - - var messages = new List() - { - new SystemChatMessage(clientOptions.SystemPrompt), - new UserChatMessage(clientOptions.UserPrompt), - }; - var options = new ChatCompletionOptions - { - MaxTokens = clientOptions.MaxTokens, - Temperature = clientOptions.Temperature, - }; - - var result = await chat.CompleteChatAsync(messages, options).ConfigureAwait(false); - var response = result.Value.Content.First().Text; - - return response; - } -} +using System.ClientModel; + +using Azure.AI.OpenAI; + +using OpenAI.Chat; + +namespace AzureOpenAIProxy.PlaygroundApp.Clients; + +/// +/// This provides interfaces to the class. +/// +public interface IOpenAIApiClient +{ + /// + /// Send a chat completion request to the OpenAI API. + /// + /// instance. + /// Returns the chat completion result. + Task CompleteChatAsync(OpenAIApiClientOptions clientOptions); +} + +/// +/// This represents the OpenAI API client entity. +/// +public class OpenAIApiClient : IOpenAIApiClient +{ + /// + public async Task CompleteChatAsync(OpenAIApiClientOptions clientOptions) + { + var endpoint = new Uri($"{clientOptions.Endpoint!.TrimEnd('/')}/api"); + var credential = new ApiKeyCredential(clientOptions.ApiKey!); + var openai = new AzureOpenAIClient(endpoint, credential); + var chat = openai.GetChatClient(clientOptions.DeploymentName); + + var messages = new List() + { + new SystemChatMessage(clientOptions.SystemPrompt), + new UserChatMessage(clientOptions.UserPrompt), + }; + var options = new ChatCompletionOptions + { + MaxOutputTokenCount = clientOptions.MaxTokens, + Temperature = clientOptions.Temperature, + }; + + var result = await chat.CompleteChatAsync(messages, options).ConfigureAwait(false); + var response = result.Value.Content.First().Text; + + return response; + } +} From 481be35ab2df52e2045fa0cb66c0f9dbadfadd10 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Wed, 2 Oct 2024 18:45:26 +1000 Subject: [PATCH 18/26] Deprecate the max_token value --- .../Clients/OpenAIApiClient.cs | 2 +- .../Clients/OpenAIApiClientOptions.cs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs index 640251f1..e2e99c2d 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs @@ -39,7 +39,7 @@ public async Task CompleteChatAsync(OpenAIApiClientOptions clientOptions }; var options = new ChatCompletionOptions { - MaxOutputTokenCount = clientOptions.MaxTokens, + MaxOutputTokenCount = clientOptions.MaxOutputTokenCount, Temperature = clientOptions.Temperature, }; diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs index a361536c..56d2f31f 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs @@ -23,7 +23,17 @@ public class OpenAIApiClientOptions /// /// Gets or sets the max_tokens value. /// - public int? MaxTokens { get; set; } + [Obsolete("Use MaxOutputTokenCount instead.")] + public int? MaxTokens + { + get { return this.MaxOutputTokenCount; } + set { this.MaxOutputTokenCount = value; } + } + + /// + /// Gets or sets the max_completion_tokens value. + /// + public int? MaxOutputTokenCount { get; set; } /// /// Gets or sets the temperature value. From 265fc3a9f0bf5fe74ee8aa8426e6ac42256bcc16 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Thu, 3 Oct 2024 11:32:26 +1000 Subject: [PATCH 19/26] Add .gitattributes --- .gitattributes | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..16b8670a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Auto detect text files and perform LF normalization +* text=auto + +*.sln text eol=crlf +*.csproj text eol=crlf +*.props text eol=crlf +*.sh text eol=lf From 5c99af654d3db5a08d1a3ae988d146531e44d3de Mon Sep 17 00:00:00 2001 From: Lucy Oh <56earls@gmail.com> Date: Sun, 6 Oct 2024 14:34:33 +0900 Subject: [PATCH 20/26] [Playground] Refactoring `OpenAIApiClient` #163 (#314) Co-authored-by: Justin Yoo --- src/AzureOpenAIProxy.AppHost/Program.cs | 3 +- .../Clients/OpenAIApiClient.cs | 109 ++++++++++-------- .../Clients/OpenAIApiClientOptions.cs | 4 +- .../Components/UI/ChatWindowComponent.razor | 25 +++- .../UI/OldChatWindowComponent.razor | 2 - .../Configurations/ServicesSettings.cs | 44 +++++++ src/AzureOpenAIProxy.PlaygroundApp/Program.cs | 23 ++++ 7 files changed, 151 insertions(+), 59 deletions(-) create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Configurations/ServicesSettings.cs diff --git a/src/AzureOpenAIProxy.AppHost/Program.cs b/src/AzureOpenAIProxy.AppHost/Program.cs index 6e4f589c..d4dbd959 100644 --- a/src/AzureOpenAIProxy.AppHost/Program.cs +++ b/src/AzureOpenAIProxy.AppHost/Program.cs @@ -5,6 +5,7 @@ builder.AddProject("playgroundapp") .WithExternalHttpEndpoints() - .WithReference(apiapp); + .WithReference(apiapp) + .WithEnvironment("ServiceNames__Backend", apiapp.Resource.Name); await builder.Build().RunAsync(); diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs index e2e99c2d..8e93f995 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs @@ -1,51 +1,58 @@ -using System.ClientModel; - -using Azure.AI.OpenAI; - -using OpenAI.Chat; - -namespace AzureOpenAIProxy.PlaygroundApp.Clients; - -/// -/// This provides interfaces to the class. -/// -public interface IOpenAIApiClient -{ - /// - /// Send a chat completion request to the OpenAI API. - /// - /// instance. - /// Returns the chat completion result. - Task CompleteChatAsync(OpenAIApiClientOptions clientOptions); -} - -/// -/// This represents the OpenAI API client entity. -/// -public class OpenAIApiClient : IOpenAIApiClient -{ - /// - public async Task CompleteChatAsync(OpenAIApiClientOptions clientOptions) - { - var endpoint = new Uri($"{clientOptions.Endpoint!.TrimEnd('/')}/api"); - var credential = new ApiKeyCredential(clientOptions.ApiKey!); - var openai = new AzureOpenAIClient(endpoint, credential); - var chat = openai.GetChatClient(clientOptions.DeploymentName); - - var messages = new List() - { - new SystemChatMessage(clientOptions.SystemPrompt), - new UserChatMessage(clientOptions.UserPrompt), - }; - var options = new ChatCompletionOptions - { - MaxOutputTokenCount = clientOptions.MaxOutputTokenCount, - Temperature = clientOptions.Temperature, - }; - - var result = await chat.CompleteChatAsync(messages, options).ConfigureAwait(false); - var response = result.Value.Content.First().Text; - - return response; - } -} +using System.ClientModel; + +using Azure.AI.OpenAI; + +using AzureOpenAIProxy.PlaygroundApp.Configurations; + +using OpenAI.Chat; + +namespace AzureOpenAIProxy.PlaygroundApp.Clients; + +/// +/// This provides interfaces to the class. +/// +public interface IOpenAIApiClient +{ + /// + /// Send a chat completion request to the OpenAI API. + /// + /// instance. + /// Returns the chat completion result. + Task CompleteChatAsync(OpenAIApiClientOptions clientOptions); +} + +/// +/// This represents the OpenAI API client entity. +/// +public class OpenAIApiClient(ServiceNamesSettings names, ServicesSettings settings) : IOpenAIApiClient +{ + private readonly ServiceNamesSettings _names = names ?? throw new ArgumentNullException(nameof(names)); + private readonly ServicesSettings _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + + /// + public async Task CompleteChatAsync(OpenAIApiClientOptions clientOptions) + { + var service = settings[this._names.Backend!]; + var endpoint = service.Https.FirstOrDefault() ?? service.Http.First(); + + clientOptions.Endpoint = new Uri($"{endpoint!.TrimEnd('/')}/api"); + + var credential = new ApiKeyCredential(clientOptions.ApiKey!); + var openai = new AzureOpenAIClient(clientOptions.Endpoint, credential); + var chat = openai.GetChatClient(clientOptions.DeploymentName); + + var messages = new List() + { + new SystemChatMessage(clientOptions.SystemPrompt), new UserChatMessage(clientOptions.UserPrompt), + }; + var options = new ChatCompletionOptions + { + MaxOutputTokenCount = clientOptions.MaxTokens, Temperature = clientOptions.Temperature, + }; + + var result = await chat.CompleteChatAsync(messages, options).ConfigureAwait(false); + var response = result.Value.Content.First().Text; + + return response; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs index 56d2f31f..ad96fe6b 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClientOptions.cs @@ -8,13 +8,13 @@ public class OpenAIApiClientOptions /// /// Gets or sets the OpenAI API endpoint. /// - public string? Endpoint { get; set; } + public Uri? Endpoint { get; set; } /// /// Gets or sets the OpenAI API key. /// public string? ApiKey { get; set; } - + /// /// Gets or sets the deployment name. /// diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor index 043b582c..cacfc685 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor @@ -1,4 +1,7 @@ -@using AzureOpenAIProxy.PlaygroundApp.Models +@using AzureOpenAIProxy.PlaygroundApp.Clients +@using AzureOpenAIProxy.PlaygroundApp.Models + +@inject IOpenAIApiClient Api @@ -6,6 +9,12 @@ @code { + private string? apiKey = "abcdef"; + private string? deploymentName = "model-gpt35turbo16k-0613"; + private int? maxTokens = 4096; + private float? temperature = 0.7f; + private string? systemPrompt = "You are an AI assistant that helps people find information."; + private List? messages; [Parameter] @@ -21,8 +30,18 @@ private async Task SendPrompt(string prompt) { this.messages!.Add(new ChatMessage() { Role = MessageRole.User, Message = prompt }); - - await Task.CompletedTask; + var options = new OpenAIApiClientOptions() + { + ApiKey = apiKey, + DeploymentName = deploymentName, + MaxTokens = maxTokens, + Temperature = temperature, + SystemPrompt = systemPrompt, + UserPrompt = prompt, + }; + + var result = await Api.CompleteChatAsync(options); + this.messages!.Add(new ChatMessage() { Role = MessageRole.Assistant, Message = result }); } private async Task ClearMessage() diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor index 06b3c0db..949456b9 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/OldChatWindowComponent.razor @@ -25,7 +25,6 @@ @code { - private string? endpoint = "https://localhost:7001/"; private string? apiKey = "abcdef"; private string? deploymentName = "model-gpt35turbo16k-0613"; private int? maxTokens = 4096; @@ -43,7 +42,6 @@ { var options = new OpenAIApiClientOptions() { - Endpoint = endpoint, ApiKey = apiKey, DeploymentName = deploymentName, MaxTokens = maxTokens, diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Configurations/ServicesSettings.cs b/src/AzureOpenAIProxy.PlaygroundApp/Configurations/ServicesSettings.cs new file mode 100644 index 00000000..ad3d5260 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Configurations/ServicesSettings.cs @@ -0,0 +1,44 @@ +namespace AzureOpenAIProxy.PlaygroundApp.Configurations; + +/// +/// This represents the app settings entity for services. +/// +public class ServicesSettings : Dictionary +{ + /// + /// Gets the name of the configuration settings. + /// + public const string Name = "services"; +} + +/// +/// This represents the service settings entity. +/// +public class ServiceSettings +{ + /// + /// Gets or sets the HTTP endpoints. + /// + public List? Http { get; set; } + + /// + /// Gets or sets the HTTPS endpoints. + /// + public List? Https { get; set; } +} + +/// +/// This represents the app settings entity for service names. +/// +public class ServiceNamesSettings +{ + /// + /// Gets the name of the configuration settings. + /// + public const string Name = "ServiceNames"; + + /// + /// Gets or sets the backend service name. + /// + public string? Backend { get; set; } +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Program.cs b/src/AzureOpenAIProxy.PlaygroundApp/Program.cs index 7db92b99..45de03d2 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Program.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Program.cs @@ -1,4 +1,5 @@ using AzureOpenAIProxy.PlaygroundApp.Clients; +using AzureOpenAIProxy.PlaygroundApp.Configurations; using Microsoft.FluentUI.AspNetCore.Components; @@ -14,6 +15,28 @@ builder.Services.AddFluentUIComponents(); +builder.Services.AddSingleton(sp => +{ + var configuration = sp.GetService() + ?? throw new InvalidOperationException($"{nameof(IConfiguration)} service is not registered."); + + var settings = configuration.GetSection(ServicesSettings.Name).Get() + ?? throw new InvalidOperationException($"{nameof(ServicesSettings)} could not be retrieved from the configuration."); + + return settings!; +}); + +builder.Services.AddSingleton(sp => +{ + var configuration = sp.GetService() + ?? throw new InvalidOperationException($"{nameof(IConfiguration)} service is not registered."); + + var settings = configuration.GetSection(ServiceNamesSettings.Name).Get() + ?? throw new InvalidOperationException($"{nameof(ServiceNamesSettings)} could not be retrieved from the configuration."); + + return settings!; +}); + builder.Services.AddScoped(); var app = builder.Build(); From 80244ea6dd533b579536155375ba5f76fbbb2678 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Mon, 7 Oct 2024 15:23:19 +1100 Subject: [PATCH 21/26] Update OpenAIApiClient --- .../Clients/OpenAIApiClient.cs | 7 ++++--- .../Components/UI/ChatWindowComponent.razor | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs index 8e93f995..99e0a971 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Clients/OpenAIApiClient.cs @@ -32,8 +32,8 @@ public class OpenAIApiClient(ServiceNamesSettings names, ServicesSettings settin /// public async Task CompleteChatAsync(OpenAIApiClientOptions clientOptions) { - var service = settings[this._names.Backend!]; - var endpoint = service.Https.FirstOrDefault() ?? service.Http.First(); + var service = this._settings[this._names.Backend!]; + var endpoint = service.Https!.FirstOrDefault() ?? service.Http!.First(); clientOptions.Endpoint = new Uri($"{endpoint!.TrimEnd('/')}/api"); @@ -47,7 +47,8 @@ public async Task CompleteChatAsync(OpenAIApiClientOptions clientOptions }; var options = new ChatCompletionOptions { - MaxOutputTokenCount = clientOptions.MaxTokens, Temperature = clientOptions.Temperature, + MaxOutputTokenCount = clientOptions.MaxOutputTokenCount, + Temperature = clientOptions.Temperature, }; var result = await chat.CompleteChatAsync(messages, options).ConfigureAwait(false); diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor index cacfc685..4da716a1 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/ChatWindowComponent.razor @@ -34,7 +34,7 @@ { ApiKey = apiKey, DeploymentName = deploymentName, - MaxTokens = maxTokens, + MaxOutputTokenCount = maxTokens, Temperature = temperature, SystemPrompt = systemPrompt, UserPrompt = prompt, From f0658a5eaa0ad23b2820eec1d829eee2ea83265e Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Thu, 10 Oct 2024 17:14:20 +1100 Subject: [PATCH 22/26] Fix UI test on AppHost --- .../Pages/AdminNewEventPageTests.cs | 24 +++++++++---------- .../PlaygroundApp/Pages/AdminPageTests.cs | 24 +++++++++---------- .../PlaygroundApp/Pages/HomePageTests.cs | 24 +++++++++---------- .../Pages/PlaygroundPageTests.cs | 24 +++++++++---------- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs index ced6d1df..dcc59d62 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs @@ -52,18 +52,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + // [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"); + // // Act + // var html = await httpClient.GetStringAsync("/admin/events/new"); - // Assert - html.Should().Contain(expected); - } + // // 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 e0e15ec2..8a48dc0f 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs @@ -52,18 +52,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + // [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"); + // // Act + // var html = await httpClient.GetStringAsync("/admin"); - // Assert - html.Should().Contain(expected); - } + // // Assert + // html.Should().Contain(expected); + // } } \ No newline at end of file diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs index 2ed277df..5d57d416 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs @@ -52,18 +52,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + // [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("/"); + // // Act + // var html = await httpClient.GetStringAsync("/"); - // Assert - html.Should().Contain(expected); - } + // // Assert + // html.Should().Contain(expected); + // } } diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs index db9a3d6a..50dd18ea 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs @@ -50,18 +50,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + // [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"); + // // Act + // var html = await httpClient.GetStringAsync("/playground"); - // Assert - html.Should().Contain(expected); - } + // // Assert + // html.Should().Contain(expected); + // } } \ No newline at end of file From e70267219758d6d816379be8271ffad2eddf3d7d Mon Sep 17 00:00:00 2001 From: sikutisa <32262904+sikutisa@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:27:26 +0900 Subject: [PATCH 23/26] [Backend API] Implement endpoint for list of events (#330) * add PlaygroundService Related to: 180 * add EventRepository Related to: #180 * implement EventRepository Related to: #180 * update PlaygroundEndpoints Related to: #180 * add EventRepositoryExtensions Related to: #180 * add PlaygroundServiceExtensions Related to: #180 * inject dependencies of PlaygroundService and EventRepository Related to: #180 * add ConfigureAwait(false) Related to: #180 * add 404 return to PlaygroundEndpoints Related to: #180 * fix comment * add PlaygroundServiceTests Related to: #180 * add EventRepositoryTests Related to: #180 * Revert "add 404 return to PlaygroundEndpoints" This reverts commit f3079305c6ca21291c11c7727993ea4a4c8e22da. * add 404 return to PlaygroundEndpoints Related to: #180 * update test method Related to: #180 * update structures of EventDetails and AdminEventDetails Related to: #180 * sort event details list Related to: #180 * Revert "update structures of EventDetails and AdminEventDetails" This reverts commit a093fee7a7638fd2cfdf794d979cb02091754940. * fix event details sorting criteria Related to: #180 * remove 404 return in /events endpoint QueryAsync does not throw RequestFailedException with status code 404. Related to: #180 * update test code Related to: #180 --- .../Endpoints/PlaygroundEndpoints.cs | 24 +++- src/AzureOpenAIProxy.ApiApp/Program.cs | 6 + .../Repositories/EventRepository.cs | 71 +++++++++++ .../Services/PlaygroundService.cs | 57 +++++++++ .../Repositories/EventRepositoryTests.cs | 120 ++++++++++++++++++ .../Services/PlaygroundServiceTests.cs | 93 ++++++++++++++ 6 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 src/AzureOpenAIProxy.ApiApp/Repositories/EventRepository.cs create mode 100644 src/AzureOpenAIProxy.ApiApp/Services/PlaygroundService.cs create mode 100644 test/AzureOpenAIProxy.ApiApp.Tests/Repositories/EventRepositoryTests.cs create mode 100644 test/AzureOpenAIProxy.ApiApp.Tests/Services/PlaygroundServiceTests.cs diff --git a/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs index 91c01b8f..5a55fe77 100644 --- a/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs +++ b/src/AzureOpenAIProxy.ApiApp/Endpoints/PlaygroundEndpoints.cs @@ -1,3 +1,7 @@ +using Azure; + +using AzureOpenAIProxy.ApiApp.Services; + using Microsoft.AspNetCore.Mvc; namespace AzureOpenAIProxy.ApiApp.Endpoints; @@ -14,10 +18,24 @@ public static class PlaygroundEndpoints /// Returns instance. public static RouteHandlerBuilder AddListEvents(this WebApplication app) { - var builder = app.MapGet(PlaygroundEndpointUrls.Events, () => + // ASSUMPTION: User has already logged in + var builder = app.MapGet(PlaygroundEndpointUrls.Events, async ( + IPlaygroundService service, + ILoggerFactory loggerFactory) => { - // TODO: Issue #179 https://github.com/aliencube/azure-openai-sdk-proxy/issues/179 - return Results.Ok(); + var logger = loggerFactory.CreateLogger(nameof(AdminEventEndpoints)); + logger.LogInformation("Received request to fetch events list"); + + try + { + var eventDetailsList = await service.GetEvents(); + return Results.Ok(eventDetailsList); + } + catch (Exception ex) + { + logger.LogError(ex, $"Error occurred while fetching events list"); + return Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); + } }) .Produces>(statusCode: StatusCodes.Status200OK, contentType: "application/json") .Produces(statusCode: StatusCodes.Status401Unauthorized) diff --git a/src/AzureOpenAIProxy.ApiApp/Program.cs b/src/AzureOpenAIProxy.ApiApp/Program.cs index f35b83ee..969e9caa 100644 --- a/src/AzureOpenAIProxy.ApiApp/Program.cs +++ b/src/AzureOpenAIProxy.ApiApp/Program.cs @@ -28,6 +28,12 @@ // Add admin repositories builder.Services.AddAdminEventRepository(); +// Add playground services +builder.Services.AddPlaygroundService(); + +// Add playground repositories +builder.Services.AddEventRepository(); + var app = builder.Build(); app.MapDefaultEndpoints(); diff --git a/src/AzureOpenAIProxy.ApiApp/Repositories/EventRepository.cs b/src/AzureOpenAIProxy.ApiApp/Repositories/EventRepository.cs new file mode 100644 index 00000000..3cbf4e72 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Repositories/EventRepository.cs @@ -0,0 +1,71 @@ +using Azure.Data.Tables; + +using AzureOpenAIProxy.ApiApp.Configurations; + +namespace AzureOpenAIProxy.ApiApp.Repositories; + +/// +/// This provides interfaces to the class. +/// +public interface IEventRepository +{ + /// + /// Gets the list of events. + /// + /// Returns the list of events. + Task> GetEvents(); +} + +public class EventRepository(TableServiceClient tableServiceClient, StorageAccountSettings storageAccountSettings) : IEventRepository +{ + private readonly TableServiceClient _tableServiceClient = tableServiceClient ?? throw new ArgumentNullException(nameof(tableServiceClient)); + private readonly StorageAccountSettings _storageAccountSettings = storageAccountSettings ?? throw new ArgumentNullException(nameof(storageAccountSettings)); + + /// + /// + /// The results are sorted based on the following criteria: + /// Lexical order of event titles. + /// + public async Task> GetEvents() + { + TableClient tableClient = await GetTableClientAsync(); + + List events = []; + + await foreach(EventDetails eventDetails in tableClient.QueryAsync(e => e.PartitionKey.Equals(PartitionKeys.EventDetails)).ConfigureAwait(false)) + { + events.Add(eventDetails); + } + + events.Sort((e1, e2) => e1.Title.CompareTo(e2.Title)); + + return events; + } + + private async Task GetTableClientAsync() + { + TableClient tableClient = _tableServiceClient.GetTableClient(_storageAccountSettings.TableStorage.TableName); + + await tableClient.CreateIfNotExistsAsync().ConfigureAwait(false); + + return tableClient; + } +} + +/// +/// This represents the extension class for +/// +public static class EventRepositoryExtensions +{ + /// + /// Adds the instance to the service collection. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddEventRepository(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.ApiApp/Services/PlaygroundService.cs b/src/AzureOpenAIProxy.ApiApp/Services/PlaygroundService.cs new file mode 100644 index 00000000..5d9ef331 --- /dev/null +++ b/src/AzureOpenAIProxy.ApiApp/Services/PlaygroundService.cs @@ -0,0 +1,57 @@ +using AzureOpenAIProxy.ApiApp.Repositories; + +namespace AzureOpenAIProxy.ApiApp.Services; + +/// +/// This provides interfaces to class. +/// +public interface IPlaygroundService +{ + /// + /// Get the list of deployment model. + /// + /// Returns the list of deployment models. + Task> GetDeploymentModels(string eventId); + + /// + /// Get the list of events. + /// + /// Returns the list of events. + Task> GetEvents(); +} + +public class PlaygroundService(IEventRepository eventRepository) : IPlaygroundService +{ + private readonly IEventRepository _eventRepository = eventRepository ?? throw new ArgumentNullException(nameof(eventRepository)); + /// + public async Task> GetDeploymentModels(string eventId) + { + throw new NotImplementedException(); + } + + /// + public async Task> GetEvents() + { + var result = await _eventRepository.GetEvents().ConfigureAwait(false); + + return result; + } +} + +/// +/// This represents the extension class for +/// +public static class PlaygroundServiceExtensions +{ + /// + /// Adds the instance to the service collection. + /// + /// instance. + /// Returns instance. + public static IServiceCollection AddPlaygroundService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/EventRepositoryTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/EventRepositoryTests.cs new file mode 100644 index 00000000..b98a0b34 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Repositories/EventRepositoryTests.cs @@ -0,0 +1,120 @@ +using System.Linq.Expressions; + +using Azure; +using Azure.Data.Tables; + +using AzureOpenAIProxy.ApiApp.Configurations; +using AzureOpenAIProxy.ApiApp.Repositories; + +using FluentAssertions; + +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Repositories; + +public class EventRepositoryTests +{ + [Fact] + public void Given_ServiceCollection_When_AddEventRepository_Invoked_Then_It_Should_Contain_EventRepository() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddEventRepository(); + + // Assert + services.SingleOrDefault(p => p.ServiceType == typeof(IEventRepository)).Should().NotBeNull(); + } + + [Fact] + public void Given_Null_TableServiceClient_When_Creating_EventRepository_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = Substitute.For(); + var tableServiceClient = default(TableServiceClient); + + // Act + Action action = () => new EventRepository(tableServiceClient, settings); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Given_Null_StorageAccountSettings_When_Creating_EventRepository_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = default(StorageAccountSettings); + var tableServiceClient = Substitute.For(); + + // Act + Action action = () => new EventRepository(tableServiceClient, settings); + + // Assert + action.Should().Throw(); + } + + [Fact] + public async Task Given_Failure_In_Get_Entities_When_GetEvents_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); + var repository = new EventRepository(tableServiceClient, settings); + + var tableClient = Substitute.For(); + tableServiceClient.GetTableClient(Arg.Any()).Returns(tableClient); + tableClient.QueryAsync(Arg.Any>>()).Throws(new Exception("error occurred")); + + // Act + Func func = repository.GetEvents; + + // Assert + var assertion = await func.Should().ThrowAsync(); + } + + [Fact] + public async Task Given_Exist_Events_When_GetEvents_Invoked_Then_It_Should_Return_EventDetails_List() + { + // Arrange + Random rand = new(); + int listSize = rand.Next(1, 20); + Guid eventId = new(); + + var settings = Substitute.For(); + var tableServiceClient = Substitute.For(); + var repository = new EventRepository(tableServiceClient, settings); + + var eventDetails = new EventDetails + { + RowKey = eventId.ToString(), + PartitionKey = PartitionKeys.EventDetails + }; + + List events = []; + + for(int i = 0; i < listSize; ++i) + { + events.Add(eventDetails); + } + + var pages = Page.FromValues(events, default, Substitute.For()); + var asyncPages = AsyncPageable.FromPages([pages]); + + var tableClient = Substitute.For(); + tableServiceClient.GetTableClient(Arg.Any()).Returns(tableClient); + tableClient.QueryAsync(Arg.Any>>()).Returns(asyncPages); + + // Act + var result = await repository.GetEvents(); + + // Assert + result.Count.Should().Be(listSize); + result.First().Should().BeEquivalentTo(eventDetails); + } + +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.ApiApp.Tests/Services/PlaygroundServiceTests.cs b/test/AzureOpenAIProxy.ApiApp.Tests/Services/PlaygroundServiceTests.cs new file mode 100644 index 00000000..38e93879 --- /dev/null +++ b/test/AzureOpenAIProxy.ApiApp.Tests/Services/PlaygroundServiceTests.cs @@ -0,0 +1,93 @@ +using Azure; + +using AzureOpenAIProxy.ApiApp.Repositories; +using AzureOpenAIProxy.ApiApp.Services; + +using FluentAssertions; + +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace AzureOpenAIProxy.ApiApp.Tests.Services; + +public class PlaygroundServiceTests +{ + [Fact] + public void Given_ServiceCollection_When_AddPlaygroundService_Invoked_Then_It_Should_Contain_PlaygroundService() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddPlaygroundService(); + + // Assert + services.SingleOrDefault(p => p.ServiceType == typeof(IPlaygroundService)).Should().NotBeNull(); + } + + [Fact] + public void Given_Instance_When_GetDeploymentModels_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + string eventId = "test-id"; + var repository = Substitute.For(); + var service = new PlaygroundService(repository); + + // Act + Func func = async () => await service.GetDeploymentModels(eventId); + + // Assert + func.Should().ThrowAsync(); + } + + [Fact] + public async Task Given_Failure_In_Get_Entities_When_GetEvents_Invoked_Then_It_Should_Throw_Exception() + { + // Arrange + var eventId = Guid.NewGuid(); + var repository = Substitute.For(); + var service = new PlaygroundService(repository); + + repository.GetEvents().ThrowsAsync(new Exception("Error occurred")); + + // Act + Func func = service.GetEvents; + + // Assert + var assertion = await func.Should().ThrowAsync(); + } + + [Fact] + public async Task Given_Exist_Events_When_GetEvents_Invoked_Then_It_Should_Return_EventDetails_List() + { + // Arrange + Random rand = new(); + int listSize = rand.Next(1, 20); + Guid eventId = new(); + var repository = Substitute.For(); + var service = new PlaygroundService(repository); + + var eventDetails = new EventDetails + { + RowKey = eventId.ToString() + }; + + List events = []; + for(int i = 0; i < listSize; ++i) + { + events.Add(eventDetails); + } + + repository.GetEvents().Returns(events); + + // Act + var result = await service.GetEvents(); + + // Assert + result.Count.Should().Be(listSize); + result.First().Should().BeEquivalentTo(eventDetails); + } + +} \ No newline at end of file From bbdeeb1a552c99cbb872e380372f0b3165b701f4 Mon Sep 17 00:00:00 2001 From: Inseo Lee Date: Thu, 10 Oct 2024 20:36:06 +0900 Subject: [PATCH 24/26] [Playground] Add Events components (#311) * Implement EventItemComponent * Implement EventListComponent * Add EventList page * Fix flex align problem and replace nested div to FluentCard * Add CSS configurations on EventItemComponent * Adjust padding of EventListComponent * Add comment on EventList page * Rename EventList to Events * Integrate model, Add Id, Alignments * Add progress ring during loading in EventListComponent * Use parameter Id to FluentGrid's Id * Implement test methods for Event components * Show a message when there are no events user joined * Adjust EventItemComponent padding * Add tests for Events page in AppHost testing project * Revert launchSettings.json * Remove redundant references, Make properties nullable * Remove SetParametersAsync at EventItemComponent * Re-order methods by access modifiers priority * Revert launchSettings.json with the final new line * Add async to NavigateToEventDetails * Sort entities in code behind by common .NET coding convention * Rename events-list to event-list * Refactor no-events card with FluentCard * Replace event title element to FluentLabel * Implement event link with FluentNavLink * Relocate OnInitializedAsync * Remove progress ring * Make color follow Fluent UI neutral layer color * Add id attributes on sub-elements in event cards * Rename _events to events, Add getting up to 4 events prior to the showing loop * Remove redundant null initialization in EventListComponent * Integrate linq code to foreach * Integrate no-events item to EventItemComponent and adjust breakpoints * Remove breakpoint parameter, update grid's justification to center * Adjust breakpoint for desktop size * Fix FluentCard's class name * Add new tests dedicated to EventItemComponent * Update grid's justify to FlexStart * Remove EventListComponent Id * Add HasNoEvent parameter for EventItemComponent * Move grid's style code to a dedicated CSS file * Restore EventListComponent's Id parameter and change its parameter name value * Separate CSS code for EventItemComponent to its own CSS file * Refactor EventsPageTests for CSS isolation work * Prune redundant CSS configurations * Integrate more CSS properties * Add title in Events component page * Adjust margin properties to prevent hiding the focus square --- .../Components/Pages/Events.razor | 12 +++ .../Components/UI/EventItemComponent.razor | 47 ++++++++++ .../UI/EventItemComponent.razor.css | 37 ++++++++ .../Components/UI/EventListComponent.razor | 78 ++++++++++++++++ .../UI/EventListComponent.razor.css | 7 ++ .../PlaygroundApp/Pages/EventsPageTests.cs | 69 ++++++++++++++ .../Pages/EventsPageTests.cs | 93 +++++++++++++++++++ 7 files changed, 343 insertions(+) create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Events.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor.css create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor.css create mode 100644 test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs create mode 100644 test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/EventsPageTests.cs diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Events.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Events.razor new file mode 100644 index 00000000..6e3da7e9 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Events.razor @@ -0,0 +1,12 @@ +@page "/events" + +Events + +

Your Events

+ +

This component demonstrates showing up to 4 events that user joined.

+ + + +@code { +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor new file mode 100644 index 00000000..618e6c68 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor @@ -0,0 +1,47 @@ +@if (HasNoEvent) +{ + +
+ + You don’t have any events you have participated in now. + +
+
+} +else +{ + +
+ + + + @Title + + +
+
+
@Summary
+
+
+
+
+
+} + + + + + +@code { + [Parameter] + public string? Id { get; set; } + + [Parameter] + public string? Title { get; set; } + + [Parameter] + public string? Summary { get; set; } + + [Parameter] + public bool HasNoEvent { get; set; } +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor.css new file mode 100644 index 00000000..b4b911fa --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor.css @@ -0,0 +1,37 @@ +::deep .event { + display: flex; + padding: 0em; + flex-direction: column; + align-items: normal; + align-content: center; + justify-content: center; +} + +::deep .no-event { + display: flex; + padding: 0em; + flex-direction: row; + align-items: center; + align-content: center; + justify-content: center; + background-color: transparent; + box-shadow: none !important; + border: hidden; +} + +::deep .event-details-link { + flex-grow: 0; + align-self: center; + margin-block: 0.3em; +} + +div.event-summary.card.border { + background-color: var(--neutral-layer-2); + padding: 1em; + padding-inline: 1.5em; + flex-grow: 1; +} + +div.event-item-flex-wrapper { + flex-grow: 1; +} \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor new file mode 100644 index 00000000..4b2b3913 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor @@ -0,0 +1,78 @@ +@using AzureOpenAIProxy.PlaygroundApp.Models; + + +
+ + @if (events == null || events.Any() == false) + { + + } + else + { + // Shows up to 4 events that the user currently joined. + @foreach (var e in events.Take(4)) + { + + + } + } + +
+ +@code { + private List? events; + + [Parameter] + public string? Id { get; set; } + + protected override async Task OnInitializedAsync() + { + // TODO: Fetch events from the API server. + events = await CreateEventDetailsAsync(); + } + + private async Task> CreateEventDetailsAsync() + { + return await Task.FromResult(new List + { + new EventDetails + { + EventId = Guid.NewGuid(), + Title = "Event 1", + Summary = "Summary 1", + MaxTokenCap = 1000, + DailyRequestCap = 100 + }, + new EventDetails + { + EventId = Guid.NewGuid(), + Title = "Event 2", + Summary = "Et iusto clita ipsum et. Amet lorem est lorem takimata et aliquyam. Aliquyam invidunt dolor erat eu sed ut sadipscing justo sed justo amet magna ea lorem ipsum exerci. Erat diam tempor imperdiet lorem duis. Amet est sanctus tempor kasd erat odio diam accumsan stet. Voluptua aliquyam magna at no vulputate justo labore labore eos stet. Dolore ut ad sadipscing sit elitr ipsum commodo nam invidunt wisi labore vero feugait sanctus sea ad et sadipscing. Possim tempor nonummy erat et no erat lorem in dolore consequat eos feugiat justo vero. Ut eirmod et duis accusam dolore est sea duis dolor et duis illum. Esse ut aliquyam placerat enim amet et labore sadipscing sed stet duo eos at consequat autem accusam lorem invidunt. Sea clita rebum eum et no dolore et sit. Liber aliquyam duo eu. Feugiat sadipscing sed eos sanctus gubergren dolore amet. Erat liber nam ea aliquam ut autem dolores magna aliquyam illum vero vulputate ut accusam est rebum. Et takimata est dolore ut elitr gubergren sanctus ipsum magna magna at sed amet dolores amet. Rebum dolore sit ea et gubergren. Dolore aliquam ipsum in at est justo justo ipsum. Ipsum nisl sea lorem.", + MaxTokenCap = 2000, + DailyRequestCap = 200, + }, + new EventDetails + { + EventId = Guid.NewGuid(), + Title = "Event 3", + Summary = "Lorem ipsum dolor sit amet stet ipsum invidunt amet invidunt magna vero delenit tempor invidunt no rebum eirmod. Duo labore eu no nonumy consequat lobortis consequat consetetur ipsum et ipsum ea eirmod esse. Eirmod rebum voluptua duo et autem eirmod vero amet dolores tincidunt lorem ipsum stet dolore sed aliquyam nonumy consetetur. Rebum no invidunt justo consetetur gubergren sea luptatum ut et amet ut aliquyam lorem ipsum. Nonummy et dolor placerat sit hendrerit invidunt. Et est dolore magna et suscipit duo aliquyam sed dolore ipsum erat nonummy eirmod. Nonummy consequat et et et accusam hendrerit et dolor et. Sanctus gubergren elitr sit takimata accusam lobortis quod sit nonumy nonumy diam clita clita. Ea takimata dolor molestie duo tempor invidunt amet nobis lorem accumsan duo rebum diam ipsum dolores erat ea. Amet nulla eirmod takimata no vel in et sea lobortis ut ullamcorper sadipscing delenit duo takimata ipsum eos consectetuer. Et et ea no duis eu labore quod ipsum feugiat esse lorem clita et nibh iriure diam magna. Sit duis tempor dolore sed et no magna et dolor labore clita erat sed dolores accusam molestie clita. Quis amet eum sit magna kasd eu invidunt nihil. Labore diam erat dignissim labore ipsum qui clita vel eos. Nisl praesent amet consequat ipsum justo quod tempor sed est aliquyam labore lorem accusam diam.", + MaxTokenCap = 3000, + DailyRequestCap = 300, + }, + new EventDetails + { + EventId = Guid.NewGuid(), + Title = "Event 4", + Summary = "Summary 4", + MaxTokenCap = 3000, + DailyRequestCap = 300, + } + }); + } +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor.css new file mode 100644 index 00000000..86c03a42 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor.css @@ -0,0 +1,7 @@ +::deep .event-list { + background-color: var(--neutral-layer-4); + padding-block: 2.0em; + padding-inline: 1.5em; + margin-top: 1rem; + border-radius: 8px; +} diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs new file mode 100644 index 00000000..27532ad5 --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs @@ -0,0 +1,69 @@ +using System.Net; + +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +using FluentAssertions; + +namespace AzureOpenAIProxy.AppHost.Tests.PlaygroundApp.Pages; + +public class EventsPageTests(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("/events"); + + // 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("/events"); + + // 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("/events"); + + // 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("/events"); + + // Assert + html.Should().Contain(expected); + } +} \ No newline at end of file diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/EventsPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/EventsPageTests.cs new file mode 100644 index 00000000..ad139ff7 --- /dev/null +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/EventsPageTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; + +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +[Property("Category", "Integration")] +public class EventsPageTests : PageTest +{ + public override BrowserNewContextOptions ContextOptions() => new() + { + IgnoreHTTPSErrors = true, + }; + + [SetUp] + public async Task Init() + { + await Page.GotoAsync("https://localhost:5001/events"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + // Grid check + [Test] + public async Task Given_Events_Page_When_Navigated_Then_It_Should_Have_EventListComponent() + { + // Act + var eventListComponent = Page.Locator("div.event-list").First; + + // Assert + await Expect(eventListComponent).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_Events_When_Loaded_Then_It_Should_Have_Less_Than_Or_Equal_To_Four_EventItemComponents() + { + // Arrange + var eventList = Page.Locator("#user-event-list"); + var listEvents = await eventList.Locator("div.event-list-item").AllAsync(); + + // Act + var childrenCount = listEvents.Count; + + // Assert + Assert.That(childrenCount, Is.GreaterThan(0)); + Assert.That(childrenCount, Is.LessThanOrEqualTo(4)); + } + + [Test] + public async Task Given_Events_When_Loaded_Then_It_Should_Have_Header_And_Summary_In_The_Card() + { + // Act + var eventCards = await Page.Locator("div.fluent-card-minimal-style.event").AllAsync(); + + // Assert + foreach (var card in eventCards) + { + card.Should().NotBeNull(); + // Check headers + var header = card.Locator("div.fluent-nav-item.event-details-link").First; + await Expect(header).ToBeVisibleAsync(); + + // Check summaries + var summary = card.Locator("div.event-summary.card.border").First; + await Expect(summary).ToBeVisibleAsync(); + } + } + + [Test] + public async Task Given_Events_When_Loaded_Then_Their_Links_Are_Enabled_To_Click() + { + // Act + var eventCards = await Page.Locator("div.fluent-card-minimal-style.event").AllAsync(); + + // Assert + foreach (var card in eventCards) + { + // Getting a link element. + var link = card.Locator("div.fluent-nav-item.event-details-link").First + .Locator("a.fluent-nav-link").First; + + await Expect(link).ToBeEnabledAsync(); + } + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } +} \ No newline at end of file From 83f0f3b0cd72d80851d1a14170826c28c40c13f9 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Sat, 12 Oct 2024 16:50:17 +1100 Subject: [PATCH 25/26] Fix UI tests in AppHost project --- .../Pages/AdminNewEventPageTests.cs | 24 +++++++++---------- .../PlaygroundApp/Pages/AdminPageTests.cs | 24 +++++++++---------- .../PlaygroundApp/Pages/EventsPageTests.cs | 2 +- .../PlaygroundApp/Pages/HomePageTests.cs | 24 +++++++++---------- .../Pages/PlaygroundPageTests.cs | 24 +++++++++---------- 5 files changed, 49 insertions(+), 49 deletions(-) diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs index dcc59d62..2cc2a3f3 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminNewEventPageTests.cs @@ -52,18 +52,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + [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"); + // Act + var html = await httpClient.GetStringAsync("/admin/events/new"); - // // Assert - // html.Should().Contain(expected); - // } + // 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 8a48dc0f..b1b2f936 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/AdminPageTests.cs @@ -52,18 +52,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + [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"); + // Act + var html = await httpClient.GetStringAsync("/admin"); - // // Assert - // html.Should().Contain(expected); - // } + // Assert + html.Should().Contain(expected); + } } \ No newline at end of file diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs index 27532ad5..45377403 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs @@ -53,7 +53,7 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav } [Theory] - [InlineData("
")] + [InlineData("
")] public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_HTML_Elements(string expected) { // Arrange diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs index 5d57d416..9f276ff5 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/HomePageTests.cs @@ -52,18 +52,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + [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("/"); + // Act + var html = await httpClient.GetStringAsync("/"); - // // Assert - // html.Should().Contain(expected); - // } + // Assert + html.Should().Contain(expected); + } } diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs index 50dd18ea..bb680273 100644 --- a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/PlaygroundPageTests.cs @@ -50,18 +50,18 @@ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_Jav 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)); + [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"); + // Act + var html = await httpClient.GetStringAsync("/playground"); - // // Assert - // html.Should().Contain(expected); - // } + // Assert + html.Should().Contain(expected); + } } \ No newline at end of file From 97a3b534ee39c3aefea22772405a53d986de035b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=97=B0=EC=A4=91?= <93513959+KYJKY@users.noreply.github.com> Date: Sat, 12 Oct 2024 15:13:01 +0900 Subject: [PATCH 26/26] [Admin] Component: Create event details - UI component #214 (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: 어드민 UI 컴포넌트 Page 추가 * Test: 어드민 UI 컴포넌트 테스트 추가 * Feat: 버튼 추가 * Fix: 피드백 적용 - AzureOpenAIProxy.sln 파일 이전 내용으로 되돌리기 - AzureOpenAIProxy.PlaygroundApp.Tests/Pages/NewEventDetailsTests.cs 테스트 코드 삭제 * Update PlaygroundApp Model 최신화 * Refactor: 컴포넌트 수정 - Id 파라미터 추가 - 버튼 Id 추가 - TextFieldType 추가 - Id 값 kebab-casing 수정 * Feat: NodaTime을 사용하여 Time Zone Option 추가 * Feat: 기본값 및 이벤트 설정 - 기본 날짜/시간 설정 추가 - 이벤트 추가/취소 버튼 이벤트 바인딩 추가 - Max Token Cap, Daily Request Cap 값 바인딩 * Refactor: 불필요한 코드 정리 * Refactor: Time Zone Select 높이 수정 * Refactor: CSS 적용 방식 수정 - 외부 스타일 시트 적용 * Revert "Refactor: CSS 적용 방식 수정 - 외부 스타일 시트 적용" This reverts commit 197d7ee6a44e87411fb4a290df83d2b127ccdd89. * Refactor: 이벤트 종료 날짜 기본값 수정 (오늘 기준 다음날로 적용) * Refactor: NewEventDetailsComponent.razor * Refactor: Remove @temp~ variables * Fix: FluentDatePicker, FluentTimePicker ValueChanged error fix * Refactor: NewEventDetailsComponent.razor * Test: Add NewEventDetailsComponent.razor test * Test: Add input test * Fix: delete inject * Feat: Get local browser timezone * Refactor: Add JS error handling * Refactor: NewEventDetailsComponent.razor * Test: Add init timezone test * Fix: Browser Timezone > System Timezone * Fix: Test error fix (Now > UtcNow) * Fix: add attribute Culture to FluentDatePicker * Fix: Add culture info in OnAfterRenderAsync * Feat: Convert from Windows timezone to IANA timezone using TimeZoneConverter * Test: Convert from Windows timezone to IANA timezone using TimeZoneConverter * Fix: Check OS to get timezone * Test: Refactoring NewEventDetailsPageTests * Test: Refactoring NewEventDetailsPageTests * Refactor: Refactoring NewEventDetailsComponent and test - Delete TimeZoneConverter - Split input event datetime test * Test: Add NewEventDetailsPageTests.cs to AppHost test --- .../AzureOpenAIProxy.PlaygroundApp.csproj | 1 + .../Components/Pages/AdminNewEvent.razor | 2 + .../UI/Admin/NewEventDetailsComponent.razor | 181 ++++++++++++++++++ .../Admin/NewEventDetailsComponent.razor.css | 25 +++ .../Models/AdminEventDetails.cs | 118 ++++++------ .../Models/EventDetails.cs | 76 ++++---- .../Pages/NewEventDetailsPageTests.cs | 68 +++++++ .../Pages/NewEventDetailsPageTests.cs | 162 ++++++++++++++++ 8 files changed, 536 insertions(+), 97 deletions(-) create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor create mode 100644 src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor.css create mode 100644 test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/NewEventDetailsPageTests.cs create mode 100644 test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/NewEventDetailsPageTests.cs diff --git a/src/AzureOpenAIProxy.PlaygroundApp/AzureOpenAIProxy.PlaygroundApp.csproj b/src/AzureOpenAIProxy.PlaygroundApp/AzureOpenAIProxy.PlaygroundApp.csproj index c9ffe317..84a2b5bf 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/AzureOpenAIProxy.PlaygroundApp.csproj +++ b/src/AzureOpenAIProxy.PlaygroundApp/AzureOpenAIProxy.PlaygroundApp.csproj @@ -10,6 +10,7 @@ + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminNewEvent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminNewEvent.razor index 6341ab35..f1b5b32f 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminNewEvent.razor +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/AdminNewEvent.razor @@ -3,3 +3,5 @@ New event

New event

+ + \ No newline at end of file diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor new file mode 100644 index 00000000..87d9e6e7 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor @@ -0,0 +1,181 @@ +@using AzureOpenAIProxy.PlaygroundApp.Clients +@using AzureOpenAIProxy.PlaygroundApp.Models; + +@using System.Globalization + +@using NodaTime +@using NodaTime.Extensions +@using NodaTime.TimeZones + + + @if (adminEventDetails == null) + { +

Loading...

+ } + else + { + New Event + +
+

Event Infomation

+ + + Title + + + + + Summary + + + + + Description + + + + + Event Start Date + + + + + + Event End Date + + + + + + Time Zone + + @foreach (var timeZone in timeZoneList) + { + @timeZone.Id + } + + +
+ +
+

Event Organizer

+ + + Organizer Name + + + + + + Organizer Email + + +
+ +
+

Event Coorganizers

+ + + Coorgnizer Name + + + + + Coorgnizer Email + + +
+ +
+

Event Configuration

+ + + Max Token Cap + + + + + Daily Request Cap + + +
+ +
+ Add Event + Cancel +
+
+ } +
+ + +@code { + private List? timeZoneList; + private AdminEventDetails? adminEventDetails; + private DateTimeOffset currentTime = DateTimeOffset.UtcNow; + + [Parameter] + public string? Id { get; set; } + + protected override async Task OnInitializedAsync() + { + adminEventDetails = adminEventDetails == null ? new() : adminEventDetails; + + timeZoneList = DateTimeZoneProviders.Tzdb.GetAllZones().ToList(); + + CultureInfo customCulture = (CultureInfo)CultureInfo.CurrentCulture.Clone(); + customCulture.DateTimeFormat.ShortDatePattern = "yyyy-MM-dd"; + customCulture.DateTimeFormat.ShortTimePattern = "HH:mm"; + + CultureInfo.DefaultThreadCurrentCulture = customCulture; + CultureInfo.DefaultThreadCurrentUICulture = customCulture; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + var timezoneId = GetIanaTimezoneId(); + currentTime = GetCurrentDateTimeOffset(timezoneId); + + adminEventDetails.DateStart = currentTime.AddHours(1).AddMinutes(-currentTime.Minute); + adminEventDetails.DateEnd = currentTime.AddDays(1).AddHours(1).AddMinutes(-currentTime.Minute); + adminEventDetails.TimeZone = timezoneId; + + await InvokeAsync(StateHasChanged); + } + } + + private async Task AddEvent() + { + await Task.CompletedTask; + } + + private async Task CancelEvent() + { + await Task.CompletedTask; + } + + private string GetIanaTimezoneId() + { + string timezoneId = TimeZoneInfo.Local.Id; + + if (OperatingSystem.IsWindows()) + { + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(timezoneId, out var ianaTimezoneId)) + { + timezoneId = ianaTimezoneId; + } + } + + return timezoneId; + } + + private DateTimeOffset GetCurrentDateTimeOffset(string timezoneId) + { + var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezoneId); + + return TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZoneInfo); + } +} + diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor.css new file mode 100644 index 00000000..b6975111 --- /dev/null +++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/Admin/NewEventDetailsComponent.razor.css @@ -0,0 +1,25 @@ +section { + margin-bottom: 100px +} + +::deep .create-input-label { + width: 200px; + --type-ramp-base-font-size: 22px; +} + +::deep .create-fluent-stack { + height: 100px; +} + +.button-section { + display: flex; + justify-content: center; + gap: 50px; +} + +.button { + width: 150px; + height: 50px; + font-size: 16px; + margin: 0 10px; +} diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Models/AdminEventDetails.cs b/src/AzureOpenAIProxy.PlaygroundApp/Models/AdminEventDetails.cs index 0221a475..7783fecb 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Models/AdminEventDetails.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Models/AdminEventDetails.cs @@ -1,60 +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; } +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 index 56cc3a7c..bade312c 100644 --- a/src/AzureOpenAIProxy.PlaygroundApp/Models/EventDetails.cs +++ b/src/AzureOpenAIProxy.PlaygroundApp/Models/EventDetails.cs @@ -1,39 +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; } +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/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/NewEventDetailsPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/NewEventDetailsPageTests.cs new file mode 100644 index 00000000..5ce13d5d --- /dev/null +++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/NewEventDetailsPageTests.cs @@ -0,0 +1,68 @@ +using System.Net; + +using AzureOpenAIProxy.AppHost.Tests.Fixtures; + +using FluentAssertions; + +namespace AzureOpenAIProxy.AppHost.Tests.PlaygroundApp.Pages; +public class NewEventDetailsPageTests(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); + //} +} diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/NewEventDetailsPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/NewEventDetailsPageTests.cs new file mode 100644 index 00000000..8731f3a9 --- /dev/null +++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/NewEventDetailsPageTests.cs @@ -0,0 +1,162 @@ +using FluentAssertions; + +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; + +namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +[Property("Category", "Integration")] + +public class NewEventDetailsPageTests : PageTest +{ + public override BrowserNewContextOptions ContextOptions() => new() + { + IgnoreHTTPSErrors = true, + }; + + [SetUp] + public async Task Init() + { + await Page.GotoAsync("https://localhost:5001/admin/events/new"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + [Test] + [TestCase("event-title")] + [TestCase("event-summary")] + [TestCase("event-description")] + [TestCase("event-start-date")] + [TestCase("event-start-time")] + [TestCase("event-end-date")] + [TestCase("event-end-time")] + [TestCase("event-timezone")] + [TestCase("event-organizer-name")] + [TestCase("event-organizer-email")] + [TestCase("event-coorgnizer-name")] + [TestCase("event-coorgnizer-email")] + [TestCase("event-max-token-cap")] + [TestCase("event-daily-request-cap")] + [TestCase("admin-event-detail-add")] + [TestCase("admin-event-detail-cancel")] + public async Task Given_New_Event_Details_Page_When_Navigated_Then_It_Should_Load_Correctly(string id) + { + // Act + var element = Page.Locator($"#{id}"); + + // Assert + await Expect(element).ToBeVisibleAsync(); + } + + [Test] + public async Task Given_Input_Event_Timezone_When_Initialized_Timezone_Then_It_Should_Update_Value() + { + // Arrange + var inputTimezone = Page.Locator("#event-timezone"); + + string timeZone = GetIanaTimezoneId(); + + // Act + string inputTimezoneValue = await inputTimezone.GetAttributeAsync("current-value"); + + // Assert + inputTimezoneValue.Should().Be(timeZone); + } + + [Test] + public async Task Given_Input_Event_Start_Date_When_Initialized_Timezone_Then_It_Should_Update_Value() + { + // Arrange + var inputStartDate = Page.Locator("#event-start-date"); + + string timezoneId = GetIanaTimezoneId(); + DateTimeOffset currentTime = GetCurrentDateTimeOffset(timezoneId); + var startTime = currentTime.AddHours(1).AddMinutes(-currentTime.Minute); + + // Act + var inputStartDateValue = await inputStartDate.GetAttributeAsync("current-value"); + + // Assert + inputStartDateValue.Should().Be(startTime.ToString("yyyy-MM-dd")); + } + + [Test] + public async Task Given_Input_Event_Start_Time_When_Initialized_Timezone_Then_It_Should_Update_Value() + { + // Arrange + var inputStartTime = Page.Locator("#event-start-time"); + + string timezoneId = GetIanaTimezoneId(); + DateTimeOffset currentTime = GetCurrentDateTimeOffset(timezoneId); + var startTime = currentTime.AddHours(1).AddMinutes(-currentTime.Minute); + + // Act + var inputStartTimeValue = await inputStartTime.GetAttributeAsync("current-value"); + + // Assert + inputStartTimeValue.Should().Be(startTime.ToString("HH:mm")); + } + + [Test] + public async Task Given_Input_Event_End_Date_When_Initialized_Timezone_Then_It_Should_Update_Value() + { + // Arrange + var inputEndDate = Page.Locator("#event-end-date"); + + string timezoneId = GetIanaTimezoneId(); + DateTimeOffset currentTime = GetCurrentDateTimeOffset(timezoneId); + var endTime = currentTime.AddDays(1).AddHours(1).AddMinutes(-currentTime.Minute); + + // Act + var inputEndDateValue = await inputEndDate.GetAttributeAsync("current-value"); + + // Assert + inputEndDateValue.Should().Be(endTime.ToString("yyyy-MM-dd")); + } + + [Test] + public async Task Given_Input_Event_End_Time_When_Initialized_Timezone_Then_It_Should_Update_Value() + { + // Arrange + var inputEndTime = Page.Locator("#event-end-time"); + + string timezoneId = GetIanaTimezoneId(); + DateTimeOffset currentTime = GetCurrentDateTimeOffset(timezoneId); + var endTime = currentTime.AddDays(1).AddHours(1).AddMinutes(-currentTime.Minute); + + // Act + var inputEndTimeValue = await inputEndTime.GetAttributeAsync("current-value"); + + // Assert + inputEndTimeValue.Should().Be(endTime.ToString("HH:mm")); + } + + [TearDown] + public async Task CleanUp() + { + await Page.CloseAsync(); + } + + private string GetIanaTimezoneId() + { + string timezoneId = TimeZoneInfo.Local.Id; + + if (OperatingSystem.IsWindows()) + { + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(timezoneId, out var ianaTimezoneId)) + { + timezoneId = ianaTimezoneId; + } + } + + return timezoneId; + } + + private DateTimeOffset GetCurrentDateTimeOffset(string timezoneId) + { + var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezoneId); + + return TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZoneInfo); + } +}