From 7e374e852d34d572e51f8dffe964e515334b849d Mon Sep 17 00:00:00 2001 From: dkswoals Date: Fri, 23 May 2025 22:30:34 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Middlewares/GlobalExceptionHandler.cs | 33 +++++++++++++++++++ src/InterviewAssistant.ApiService/Program.cs | 2 ++ 2 files changed, 35 insertions(+) create mode 100644 src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs diff --git a/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs b/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs new file mode 100644 index 0000000..a7d5966 --- /dev/null +++ b/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Diagnostics; + +namespace InterviewAssistant.ApiService.Middleware; + +/// +/// 전역 예외 처리기 +/// +public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + logger.LogError(exception, "예외 발생: {Message}", exception.Message); + + var response = new + { + error = "서버 오류가 발생했습니다.", + message = exception.Message, + timestamp = DateTime.UtcNow + }; + + httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + httpContext.Response.ContentType = "application/json"; + + await httpContext.Response.WriteAsync(JsonSerializer.Serialize(response), cancellationToken); + + return true; + } +} diff --git a/src/InterviewAssistant.ApiService/Program.cs b/src/InterviewAssistant.ApiService/Program.cs index bfbb690..e369d44 100644 --- a/src/InterviewAssistant.ApiService/Program.cs +++ b/src/InterviewAssistant.ApiService/Program.cs @@ -6,6 +6,7 @@ using InterviewAssistant.ApiService.Data; using InterviewAssistant.ApiService.Repositories; using InterviewAssistant.ApiService.Extensions; +using InterviewAssistant.ApiService.Middleware; using Microsoft.SemanticKernel; using Microsoft.EntityFrameworkCore; @@ -49,6 +50,7 @@ return McpClientFactory.CreateAsync(transport).GetAwaiter().GetResult(); }); +builder.Services.AddExceptionHandler(); builder.Services.AddSingleton(sp => { From dc69e3125d91f9f00f4961cce0afe74e1c3fa920 Mon Sep 17 00:00:00 2001 From: dkswoals Date: Fri, 23 May 2025 22:42:59 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Test:=20=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GlobalExceptionHandlerTests.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs diff --git a/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs b/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..ab54868 --- /dev/null +++ b/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs @@ -0,0 +1,99 @@ +using System.Net; +using System.Text.Json; + +using InterviewAssistant.ApiService.Middleware; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +using NSubstitute; + +using Shouldly; + +namespace InterviewAssistant.ApiService.Tests.Middleware; + +[TestFixture] +public class GlobalExceptionHandlerTests +{ + private ILogger _logger; + private GlobalExceptionHandler _handler; + private DefaultHttpContext _httpContext; + + [SetUp] + public void Setup() + { + _logger = Substitute.For>(); + _handler = new GlobalExceptionHandler(_logger); + _httpContext = new DefaultHttpContext(); + _httpContext.Response.Body = new MemoryStream(); + } + + [Test] + public async Task TryHandleAsync_WithException_ShouldReturnTrueAndSetCorrectResponse() + { + // Arrange + var exception = new InvalidOperationException("테스트 예외"); + + // Act + var result = await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None); + + // Assert + result.ShouldBeTrue(); + _httpContext.Response.StatusCode.ShouldBe(500); + _httpContext.Response.ContentType.ShouldBe("application/json"); + + // 응답 내용 확인 + _httpContext.Response.Body.Position = 0; + var responseContent = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync(); + var responseObject = JsonSerializer.Deserialize(responseContent); + + responseObject.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다."); + responseObject.GetProperty("message").GetString().ShouldBe("테스트 예외"); + responseObject.TryGetProperty("timestamp", out _).ShouldBeTrue(); + } + + [Test] + [TestCase(typeof(NullReferenceException), "Null reference")] + [TestCase(typeof(FileNotFoundException), "File not found")] + [TestCase(typeof(UnauthorizedAccessException), "Access denied")] + public async Task TryHandleAsync_WithDifferentExceptions_ShouldHandleCorrectly(Type exceptionType, string message) + { + // Arrange + var exception = (Exception)Activator.CreateInstance(exceptionType, message)!; + + // Act + var result = await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None); + + // Assert + result.ShouldBeTrue(); + _httpContext.Response.StatusCode.ShouldBe(500); + + _httpContext.Response.Body.Position = 0; + var responseContent = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync(); + var responseObject = JsonSerializer.Deserialize(responseContent); + + responseObject.GetProperty("message").GetString().ShouldBe(message); + } + + [Test] + public async Task TryHandleAsync_ShouldGenerateValidJsonResponse() + { + // Arrange + var exception = new InvalidOperationException("JSON 검증"); + + // Act + await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None); + + // Assert + _httpContext.Response.Body.Position = 0; + var responseContent = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync(); + + // JSON 유효성 검증 + Should.NotThrow(() => JsonSerializer.Deserialize(responseContent)); + + var responseObject = JsonSerializer.Deserialize(responseContent); + responseObject.TryGetProperty("error", out _).ShouldBeTrue(); + responseObject.TryGetProperty("message", out _).ShouldBeTrue(); + responseObject.TryGetProperty("timestamp", out _).ShouldBeTrue(); + } +} From 23fd588fa39a22e9e99e508753349f155def41d0 Mon Sep 17 00:00:00 2001 From: dkswoals Date: Fri, 23 May 2025 22:44:18 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Refactor:=20=EB=84=A4=EC=9E=84=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Middlewares/GlobalExceptionHandler.cs | 2 +- src/InterviewAssistant.ApiService/Program.cs | 2 +- .../Middlewares/GlobalExceptionHandlerTests.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs b/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs index a7d5966..a08fc79 100644 --- a/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs +++ b/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Diagnostics; -namespace InterviewAssistant.ApiService.Middleware; +namespace InterviewAssistant.ApiService.Middlewares; /// /// 전역 예외 처리기 diff --git a/src/InterviewAssistant.ApiService/Program.cs b/src/InterviewAssistant.ApiService/Program.cs index e369d44..909d944 100644 --- a/src/InterviewAssistant.ApiService/Program.cs +++ b/src/InterviewAssistant.ApiService/Program.cs @@ -6,7 +6,7 @@ using InterviewAssistant.ApiService.Data; using InterviewAssistant.ApiService.Repositories; using InterviewAssistant.ApiService.Extensions; -using InterviewAssistant.ApiService.Middleware; +using InterviewAssistant.ApiService.Middlewares; using Microsoft.SemanticKernel; using Microsoft.EntityFrameworkCore; diff --git a/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs b/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs index ab54868..c36e4c8 100644 --- a/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs +++ b/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; -using InterviewAssistant.ApiService.Middleware; +using InterviewAssistant.ApiService.Middlewares; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -10,7 +10,7 @@ using Shouldly; -namespace InterviewAssistant.ApiService.Tests.Middleware; +namespace InterviewAssistant.ApiService.Tests.Middlewares; [TestFixture] public class GlobalExceptionHandlerTests From ac4be4257c73d2a67b9af744f208b6bb4d467568 Mon Sep 17 00:00:00 2001 From: dkswoals Date: Sat, 24 May 2025 16:48:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Test:=20=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=EA=B8=B0=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GlobalExceptionHandlerTestServerTests.cs | 314 ++++++++++++++++++ ...InterviewAssistant.ApiService.Tests.csproj | 1 + 2 files changed, 315 insertions(+) create mode 100644 test/InterviewAssistant.ApiService.Tests/Integration/GlobalExceptionHandlerTestServerTests.cs diff --git a/test/InterviewAssistant.ApiService.Tests/Integration/GlobalExceptionHandlerTestServerTests.cs b/test/InterviewAssistant.ApiService.Tests/Integration/GlobalExceptionHandlerTestServerTests.cs new file mode 100644 index 0000000..9c309db --- /dev/null +++ b/test/InterviewAssistant.ApiService.Tests/Integration/GlobalExceptionHandlerTestServerTests.cs @@ -0,0 +1,314 @@ +using System.Net; +using System.Text.Json; +using System.Linq; + +using InterviewAssistant.ApiService.Data; +using InterviewAssistant.ApiService.Endpoints; +using InterviewAssistant.ApiService.Middlewares; +using InterviewAssistant.ApiService.Repositories; +using InterviewAssistant.ApiService.Services; +using InterviewAssistant.Common.Models; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using ModelContextProtocol.Client; + +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +using OpenAI; + +using Shouldly; + +namespace InterviewAssistant.ApiService.Tests.Integration; + +[TestFixture] +public class GlobalExceptionHandlerTestServerTests +{ + private TestServer _server; + private HttpClient _client; + private IKernelService _mockKernelService; + private IInterviewRepository _mockRepository; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + _mockKernelService = Substitute.For(); + _mockRepository = Substitute.For(); + + var hostBuilder = CreateHostBuilder(); + _server = new TestServer(hostBuilder); + _client = _server.CreateClient(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _client?.Dispose(); + _server?.Dispose(); + } + + [SetUp] + public void SetUp() + { + _mockKernelService.ClearReceivedCalls(); + _mockRepository.ClearReceivedCalls(); + } + + [Test] + public async Task ChatCompletion_WhenKernelServiceThrowsException_ShouldReturnGlobalExceptionResponse() + { + // Arrange + var validResumeId = Guid.NewGuid(); + var validJobId = Guid.NewGuid(); + + var chatRequest = new ChatRequest + { + ResumeId = validResumeId, + JobDescriptionId = validJobId, + Messages = new List + { + new() { Role = MessageRoleType.User, Message = "면접을 시작합니다" } + } + }; + + _mockRepository.GetResumeByIdAsync(validResumeId) + .Returns(new Models.ResumeEntry { Id = validResumeId, Content = "테스트 이력서" }); + _mockRepository.GetJobByIdAsync(validJobId) + .Returns(new Models.JobDescriptionEntry { Id = validJobId, Content = "테스트 채용공고" }); + + _mockKernelService.InvokeInterviewAgentAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>()) + .Throws(new InvalidOperationException("커널 서비스 오류 발생")); + + var json = JsonSerializer.Serialize(chatRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/chat/complete", content); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); + + var responseContent = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonSerializer.Deserialize(responseContent); + + errorResponse.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다."); + errorResponse.GetProperty("message").GetString().ShouldBe("커널 서비스 오류 발생"); + errorResponse.TryGetProperty("timestamp", out _).ShouldBeTrue(); + } + + [Test] + public async Task ChatCompletion_WhenRepositoryThrowsException_ShouldReturnGlobalExceptionResponse() + { + // Arrange + var validResumeId = Guid.NewGuid(); + var validJobId = Guid.NewGuid(); + + var chatRequest = new ChatRequest + { + ResumeId = validResumeId, + JobDescriptionId = validJobId, + Messages = new List + { + new() { Role = MessageRoleType.User, Message = "면접을 시작합니다" } + } + }; + + _mockRepository.GetResumeByIdAsync(validResumeId) + .Throws(new InvalidOperationException("데이터베이스 연결 오류")); + + var json = JsonSerializer.Serialize(chatRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/chat/complete", content); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); + + var responseContent = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonSerializer.Deserialize(responseContent); + + errorResponse.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다."); + errorResponse.GetProperty("message").GetString().ShouldBe("데이터베이스 연결 오류"); + errorResponse.TryGetProperty("timestamp", out _).ShouldBeTrue(); + } + + [Test] + public async Task InterviewData_WhenKernelServiceThrowsException_ShouldReturnGlobalExceptionResponse() + { + // Arrange + var interviewDataRequest = new InterviewDataRequest + { + ResumeId = Guid.NewGuid(), + JobDescriptionId = Guid.NewGuid(), + ResumeUrl = "https://example.com/resume.pdf", + JobDescriptionUrl = "https://example.com/job.pdf" + }; + + _mockKernelService.PreprocessAndInvokeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Throws(new ArgumentException("잘못된 URL 형식")); + + var json = JsonSerializer.Serialize(interviewDataRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/chat/interview-data", content); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); + + var responseContent = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonSerializer.Deserialize(responseContent); + + errorResponse.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다."); + errorResponse.GetProperty("message").GetString().ShouldBe("잘못된 URL 형식"); + errorResponse.TryGetProperty("timestamp", out _).ShouldBeTrue(); + } + + [Test] + public async Task ChatCompletion_WhenSuccessful_ShouldNotTriggerGlobalExceptionHandler() + { + // Arrange + var validResumeId = Guid.NewGuid(); + var validJobId = Guid.NewGuid(); + + var chatRequest = new ChatRequest + { + ResumeId = validResumeId, + JobDescriptionId = validJobId, + Messages = new List + { + new() { Role = MessageRoleType.User, Message = "면접을 시작합니다" } + } + }; + + _mockRepository.GetResumeByIdAsync(validResumeId) + .Returns(new Models.ResumeEntry { Id = validResumeId, Content = "테스트 이력서" }); + _mockRepository.GetJobByIdAsync(validJobId) + .Returns(new Models.JobDescriptionEntry { Id = validJobId, Content = "테스트 채용공고" }); + + var successResponses = new[] { "안녕하세요, 면접을 시작하겠습니다." }; + _mockKernelService.InvokeInterviewAgentAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>()) + .Returns(successResponses.ToAsyncEnumerable()); + + var json = JsonSerializer.Serialize(chatRequest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/chat/complete", content); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.ShouldNotContain("서버 오류가 발생했습니다."); + } + + [Test] + public async Task NonExistentEndpoint_ShouldReturn404_NotTriggerGlobalExceptionHandler() + { + // Act + var response = await _client.GetAsync("/api/nonexistent"); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + private IWebHostBuilder CreateHostBuilder() + { + return new WebHostBuilder() + .UseTestServer() + .UseEnvironment("Testing") + .ConfigureServices(services => + { + // 전역 예외 처리기 등록 + services.AddExceptionHandler(); + services.AddProblemDetails(); + + // 라우팅 서비스 추가 + services.AddRouting(); + + // 테스트용 모의 서비스 등록 + services.AddScoped(_ => _mockKernelService); + services.AddScoped(_ => _mockRepository); + + // 테스트용 인메모리 데이터베이스 + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + services.AddSingleton(connection); + + services.AddDbContext(options => + options.UseSqlite(connection)); + + // 기본 모의 객체들 - sealed 클래스들은 실제 인스턴스나 대체 방법 사용 + var kernelBuilder = Kernel.CreateBuilder(); + var testKernel = kernelBuilder.Build(); + services.AddSingleton(testKernel); + + services.AddSingleton(Substitute.For()); + + // OpenAIClient도 sealed일 수 있으므로 null 또는 실제 인스턴스 사용 + services.AddSingleton(_ => null!); + services.AddSingleton(Substitute.For()); + + // JSON 직렬화 설정 + services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.WriteIndented = true; + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + }); + + // 로깅 설정 + services.AddLogging(); + }) + .Configure(app => + { + // 전역 예외 처리기 사용 + app.UseExceptionHandler(); + + // 라우팅 사용 + app.UseRouting(); + + // 엔드포인트 매핑 + app.UseEndpoints(endpoints => + { + // Chat Completion 엔드포인트 매핑 + endpoints.MapChatCompletionEndpoint(); + }); + }); + } +} diff --git a/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj b/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj index 0c6d6d7..e1f0002 100644 --- a/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj +++ b/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj @@ -11,6 +11,7 @@ +