diff --git a/src/InterviewAssistant.ApiService/Delegates/PdfDownloadDelegate.cs b/src/InterviewAssistant.ApiService/Delegates/PdfDownloadDelegate.cs new file mode 100644 index 0000000..c8a76f7 --- /dev/null +++ b/src/InterviewAssistant.ApiService/Delegates/PdfDownloadDelegate.cs @@ -0,0 +1,35 @@ +using InterviewAssistant.Common.Models; +using InterviewAssistant.ApiService.Services; +using Microsoft.AspNetCore.Mvc; + +namespace InterviewAssistant.ApiService.Delegates; + +/// +/// PDF 다운로드를 처리하는 Delegate +/// +public static class PdfDownloadDelegate +{ + /// + /// 면접 결과 리포트를 PDF로 다운로드합니다. + /// + /// PDF 생성 요청 데이터 + /// PDF 생성 서비스 + /// PDF 파일 + public static async Task DownloadInterviewReportPdfAsync( + [FromBody] PdfDownloadRequest request, + IPdfGenerationService pdfService) + { + try + { + var pdfBytes = await pdfService.GenerateInterviewReportPdfAsync(request.Report, request.ChatHistory); + + var fileName = $"면접결과_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; + + return Results.File(pdfBytes, "application/pdf", fileName); + } + catch (Exception ex) + { + return Results.Problem($"PDF 생성 중 오류가 발생했습니다: {ex.Message}"); + } + } +} diff --git a/src/InterviewAssistant.ApiService/Endpoints/PdfDownloadEndpoint.cs b/src/InterviewAssistant.ApiService/Endpoints/PdfDownloadEndpoint.cs new file mode 100644 index 0000000..1150108 --- /dev/null +++ b/src/InterviewAssistant.ApiService/Endpoints/PdfDownloadEndpoint.cs @@ -0,0 +1,31 @@ +using InterviewAssistant.ApiService.Delegates; +using InterviewAssistant.Common.Models; + +namespace InterviewAssistant.ApiService.Endpoints; + +/// +/// PDF 다운로드 엔드포인트 +/// +public static class PdfDownloadEndpoint +{ + /// + /// PDF 다운로드 엔드포인트를 등록합니다. + /// + /// 웹 애플리케이션 + /// 라우트 그룹 빌더 + public static RouteGroupBuilder MapPdfDownloadEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/pdf"); + + group.MapPost("/download-report", PdfDownloadDelegate.DownloadInterviewReportPdfAsync) + .WithName("DownloadInterviewReportPdf") + .WithDisplayName("Download Interview Report PDF") + .WithDescription("면접 결과 리포트를 PDF로 다운로드합니다.") + .WithOpenApi() + .Produces(200, "application/pdf") + .ProducesValidationProblem() + .Accepts("application/json"); + + return group; + } +} diff --git a/src/InterviewAssistant.ApiService/InterviewAssistant.ApiService.csproj b/src/InterviewAssistant.ApiService/InterviewAssistant.ApiService.csproj index d0dc051..e9e2d30 100644 --- a/src/InterviewAssistant.ApiService/InterviewAssistant.ApiService.csproj +++ b/src/InterviewAssistant.ApiService/InterviewAssistant.ApiService.csproj @@ -15,6 +15,9 @@ + + + diff --git a/src/InterviewAssistant.ApiService/Program.cs b/src/InterviewAssistant.ApiService/Program.cs index 909d944..9400528 100644 --- a/src/InterviewAssistant.ApiService/Program.cs +++ b/src/InterviewAssistant.ApiService/Program.cs @@ -32,6 +32,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); //OpenAPI 설정 builder.Services.AddOpenApi(); @@ -99,6 +100,9 @@ // Chat Completion 엔드포인트 매핑 app.MapChatCompletionEndpoint(); +// PDF 다운로드 엔드포인트 매핑 +app.MapPdfDownloadEndpoints(); + // .NET Aspire 헬스체크 및 모니터링 엔드포인트 매핑 app.MapDefaultEndpoints(); diff --git a/src/InterviewAssistant.ApiService/Services/PdfGenerationService.cs b/src/InterviewAssistant.ApiService/Services/PdfGenerationService.cs new file mode 100644 index 0000000..2c78bd6 --- /dev/null +++ b/src/InterviewAssistant.ApiService/Services/PdfGenerationService.cs @@ -0,0 +1,194 @@ +using InterviewAssistant.Common.Models; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Markdig; +using System.Text.RegularExpressions; + +namespace InterviewAssistant.ApiService.Services; + +/// +/// PDF 생성 서비스 인터페이스 +/// +public interface IPdfGenerationService +{ + /// + /// 면접 결과 리포트를 PDF로 생성합니다. + /// + /// 면접 결과 리포트 + /// 채팅 기록 + /// PDF 바이트 배열 + Task GenerateInterviewReportPdfAsync(InterviewReportModel report, List chatHistory); +} + +/// +/// PDF 생성 서비스 구현 +/// +public class PdfGenerationService : IPdfGenerationService +{ + public async Task GenerateInterviewReportPdfAsync(InterviewReportModel report, List chatHistory) + { + // QuestPDF 라이선스 설정 (Community 라이선스) + QuestPDF.Settings.License = LicenseType.Community; + + return await Task.FromResult(Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(2, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(11)); + + page.Header() + .Text("면접 결과 리포트") + .SemiBold().FontSize(20).FontColor(Colors.Blue.Medium); + + page.Content() + .PaddingVertical(1, Unit.Centimetre) + .Column(column => + { + column.Spacing(20); + + // 종합 분석 (마크다운 처리) + column.Item().Text("종합 분석").SemiBold().FontSize(16); + var overallFeedbackHtml = ConvertMarkdownToPlainText(report.OverallFeedback ?? "분석 결과가 없습니다."); + column.Item().PaddingLeft(10).Text(overallFeedbackHtml); + + // 강점 (마크다운 처리) + column.Item().Text("강점").SemiBold().FontSize(16); + if (report.Strengths?.Any() == true) + { + foreach (var strength in report.Strengths) + { + var strengthText = ConvertMarkdownToPlainText(strength); + column.Item().PaddingLeft(10).Text($"• {strengthText}"); + } + } + else + { + column.Item().PaddingLeft(10).Text("• 강점이 기록되지 않았습니다."); + } + + // 개선점 (마크다운 처리) + column.Item().Text("개선점").SemiBold().FontSize(16); + if (report.Weaknesses?.Any() == true) + { + foreach (var weakness in report.Weaknesses) + { + var weaknessText = ConvertMarkdownToPlainText(weakness); + column.Item().PaddingLeft(10).Text($"• {weaknessText}"); + } + } + else + { + column.Item().PaddingLeft(10).Text("• 개선점이 기록되지 않았습니다."); + } + + // 질문 유형 분석 (원 그래프 형태로 표시) + if (report.ChartData?.Labels?.Any() == true && report.ChartData?.Values?.Any() == true) + { + column.Item().Text("질문 유형 분석").SemiBold().FontSize(16); + + // 시각적 차트 표현 + column.Item().PaddingLeft(10).Column(chartColumn => + { + var total = report.ChartData.Values.Sum(); + if (total > 0) + { + chartColumn.Item().Text("📊 질문 유형별 분포").FontSize(12).SemiBold(); + chartColumn.Item().PaddingVertical(5); + + for (int i = 0; i < Math.Min(report.ChartData.Labels.Count, report.ChartData.Values.Count); i++) + { + var percentage = CalculatePercentage(report.ChartData.Values[i], total); + var barLength = (int)(percentage / 5); // 5%당 하나의 블록 + var bar = new string('█', Math.Max(1, barLength)); + + chartColumn.Item().Row(row => + { + row.ConstantItem(80).Text($"{report.ChartData.Labels[i]}:"); + row.ConstantItem(150).Text(bar).FontColor(GetChartColor(i)); + row.RelativeItem().Text($"{report.ChartData.Values[i]}개 ({percentage:F1}%)"); + }); + } + } + }); + } + + // 대화 기록 (마크다운 처리) + if (chatHistory?.Any() == true) + { + column.Item().Text("대화 기록").SemiBold().FontSize(16); + + foreach (var message in chatHistory) + { + var roleText = message.Role switch + { + MessageRoleType.User => "지원자", + MessageRoleType.Assistant => "면접관", + _ => message.Role.ToString() + }; + + var messageText = ConvertMarkdownToPlainText(message.Message ?? ""); + + column.Item().PaddingLeft(10).Column(messageColumn => + { + messageColumn.Item().Text($"[{roleText}]").SemiBold().FontColor( + message.Role == MessageRoleType.User ? Colors.Blue.Medium : Colors.Green.Medium); + messageColumn.Item().PaddingLeft(10).Text(messageText); + messageColumn.Item().PaddingVertical(5); + }); + } + } + }); + + page.Footer() + .AlignCenter() + .Text(x => + { + x.Span("생성일: "); + x.Span(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")).SemiBold(); + }); + }); + }).GeneratePdf()); + } + + /// + /// 마크다운을 일반 텍스트로 변환합니다. + /// + private static string ConvertMarkdownToPlainText(string markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + return string.Empty; + + // 마크다운을 HTML로 변환 + var html = Markdown.ToHtml(markdown); + + // HTML 태그 제거하여 일반 텍스트로 변환 + var plainText = Regex.Replace(html, "<.*?>", string.Empty); + + // HTML 엔티티 디코딩 + plainText = System.Net.WebUtility.HtmlDecode(plainText); + + return plainText.Trim(); + } + + /// + /// 차트 색상을 반환합니다. + /// + private static string GetChartColor(int index) + { + var colors = new[] { Colors.Blue.Medium, Colors.Green.Medium, Colors.Orange.Medium, Colors.Red.Medium, Colors.Purple.Medium }; + return colors[index % colors.Length]; + } + + /// + /// 퍼센티지를 계산합니다. + /// + private static double CalculatePercentage(int value, int total) + { + if (total == 0) return 0; + return (double)value / total * 100; + } +} diff --git a/src/InterviewAssistant.Common/Models/PdfDownloadRequest.cs b/src/InterviewAssistant.Common/Models/PdfDownloadRequest.cs new file mode 100644 index 0000000..7ecdc49 --- /dev/null +++ b/src/InterviewAssistant.Common/Models/PdfDownloadRequest.cs @@ -0,0 +1,17 @@ +namespace InterviewAssistant.Common.Models; + +/// +/// PDF 다운로드 요청 모델 +/// +public class PdfDownloadRequest +{ + /// + /// 면접 결과 리포트 + /// + public InterviewReportModel Report { get; set; } = new(); + + /// + /// 채팅 기록 + /// + public List ChatHistory { get; set; } = new(); +} diff --git a/src/InterviewAssistant.Web/Clients/ChatApiClient.cs b/src/InterviewAssistant.Web/Clients/ChatApiClient.cs index b9ea3f2..72910ed 100644 --- a/src/InterviewAssistant.Web/Clients/ChatApiClient.cs +++ b/src/InterviewAssistant.Web/Clients/ChatApiClient.cs @@ -22,6 +22,14 @@ public interface IChatApiClient IAsyncEnumerable SendInterviewDataAsync(InterviewDataRequest request); Task GenerateReportAsync(List messages); + + /// + /// 면접 결과 리포트를 PDF로 다운로드합니다. + /// + /// 면접 결과 리포트 + /// 채팅 기록 + /// PDF 바이트 배열 + Task DownloadReportPdfAsync(InterviewReportModel report, List chatHistory); } /// @@ -88,4 +96,25 @@ public async IAsyncEnumerable SendInterviewDataAsync(InterviewData _logger.LogError("리포트 생성 실패: {StatusCode}", httpResponse.StatusCode); return null; } + + public async Task DownloadReportPdfAsync(InterviewReportModel report, List chatHistory) + { + _logger.LogInformation("API에 PDF 다운로드 요청"); + + var request = new PdfDownloadRequest + { + Report = report, + ChatHistory = chatHistory + }; + + var httpResponse = await _http.PostAsJsonAsync("/api/pdf/download-report", request); + + if (httpResponse.IsSuccessStatusCode) + { + return await httpResponse.Content.ReadAsByteArrayAsync(); + } + + _logger.LogError("PDF 다운로드 실패: {StatusCode}", httpResponse.StatusCode); + return null; + } } diff --git a/src/InterviewAssistant.Web/Components/Pages/Report.razor b/src/InterviewAssistant.Web/Components/Pages/Report.razor index f2a27bf..0ad8d5f 100644 --- a/src/InterviewAssistant.Web/Components/Pages/Report.razor +++ b/src/InterviewAssistant.Web/Components/Pages/Report.razor @@ -11,6 +11,22 @@ 면접 결과 리포트 AI가 분석한 면접 결과 요약입니다. + + @if (reportSummary != null && chatHistory.Any()) + { + + @if (isDownloading) + { + + PDF 생성 중... + } + else + { + + PDF 다운로드 + } + + } @@ -76,6 +92,7 @@ private List chatHistory = new(); private InterviewReportModel? reportSummary; private bool isInitialized = false; + private bool isDownloading = false; protected override async Task OnInitializedAsync() { @@ -91,6 +108,38 @@ await base.OnInitializedAsync(); } + private async Task DownloadPdfAsync() + { + if (reportSummary == null || !chatHistory.Any()) + { + return; + } + + isDownloading = true; + StateHasChanged(); + + try + { + var pdfBytes = await ChatService.DownloadReportPdfAsync(reportSummary, chatHistory); + + if (pdfBytes != null) + { + var fileName = $"면접결과_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; + await JSRuntime.InvokeVoidAsync("downloadFile", Convert.ToBase64String(pdfBytes), fileName, "application/pdf"); + } + } + catch (Exception ex) + { + // 에러 처리 (필요에 따라 사용자에게 알림) + Console.WriteLine($"PDF 다운로드 오류: {ex.Message}"); + } + finally + { + isDownloading = false; + StateHasChanged(); + } + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) diff --git a/src/InterviewAssistant.Web/Services/ChatService.cs b/src/InterviewAssistant.Web/Services/ChatService.cs index 9106047..258737c 100644 --- a/src/InterviewAssistant.Web/Services/ChatService.cs +++ b/src/InterviewAssistant.Web/Services/ChatService.cs @@ -27,6 +27,14 @@ public interface IChatService Task GenerateReportAsync(List messages); List GetLastChatHistory(); InterviewReportModel? GetLastReportSummary(); + + /// + /// 면접 결과 리포트를 PDF로 다운로드합니다. + /// + /// 면접 결과 리포트 + /// 채팅 기록 + /// PDF 바이트 배열 + Task DownloadReportPdfAsync(InterviewReportModel report, List chatHistory); } /// @@ -126,4 +134,10 @@ public async IAsyncEnumerable SendInterviewDataAsync(InterviewData public List GetLastChatHistory() => _lastChatHistory; public InterviewReportModel? GetLastReportSummary() => _lastReportSummary; + + public async Task DownloadReportPdfAsync(InterviewReportModel report, List chatHistory) + { + _logger.LogInformation("PDF 다운로드 요청 시작"); + return await _client.DownloadReportPdfAsync(report, chatHistory); + } } \ No newline at end of file diff --git a/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js b/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js index beccb7c..c59f145 100644 --- a/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js +++ b/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js @@ -142,4 +142,24 @@ window.setBodyOverflow = (style) => { window.isMessageSendInProgress = function () { return window.isSend || false; +}; + +// PDF 다운로드 함수 +window.downloadFile = function (base64Data, fileName, contentType) { + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: contentType }); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); }; \ No newline at end of file diff --git a/test/InterviewAssistant.ApiService.Tests/Delegates/PdfDownloadDelegateTests.cs b/test/InterviewAssistant.ApiService.Tests/Delegates/PdfDownloadDelegateTests.cs new file mode 100644 index 0000000..3fdbd57 --- /dev/null +++ b/test/InterviewAssistant.ApiService.Tests/Delegates/PdfDownloadDelegateTests.cs @@ -0,0 +1,132 @@ +using InterviewAssistant.ApiService.Delegates; +using InterviewAssistant.ApiService.Services; +using InterviewAssistant.Common.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Shouldly; + +namespace InterviewAssistant.ApiService.Tests.Delegates; + +[TestFixture] +public class PdfDownloadDelegateTests +{ + private IPdfGenerationService _pdfService; + private IServiceProvider _serviceProvider; + + [SetUp] + public void Setup() + { + _pdfService = Substitute.For(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(sp => sp); + _serviceProvider = services.BuildServiceProvider(); + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposableServiceProvider) + { + disposableServiceProvider.Dispose(); + } + } + + private HttpContext CreateHttpContext() + { + return new DefaultHttpContext + { + RequestServices = _serviceProvider + }; + } + + [Test] + public async Task DownloadInterviewReportPdfAsync_WithValidRequest_ShouldReturnPdfFile() + { + // Arrange + var request = new PdfDownloadRequest + { + Report = new InterviewReportModel + { + OverallFeedback = "테스트 피드백", + Strengths = ["강점1", "강점2"], + Weaknesses = ["개선점1"] + }, + ChatHistory = new List + { + new() { Role = MessageRoleType.User, Message = "안녕하세요" } + } + }; + + var expectedPdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // %PDF + _pdfService.GenerateInterviewReportPdfAsync(request.Report, request.ChatHistory) + .Returns(expectedPdfBytes); + + // Act + var result = await PdfDownloadDelegate.DownloadInterviewReportPdfAsync(request, _pdfService); + + // Assert + result.ShouldNotBeNull(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + + httpContext.Response.StatusCode.ShouldBe(200); + httpContext.Response.ContentType.ShouldBe("application/pdf"); + } + + [Test] + public async Task DownloadInterviewReportPdfAsync_WithEmptyRequest_ShouldStillWork() + { + // Arrange + var request = new PdfDownloadRequest + { + Report = new InterviewReportModel(), + ChatHistory = new List() + }; + + var expectedPdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; + _pdfService.GenerateInterviewReportPdfAsync(request.Report, request.ChatHistory) + .Returns(expectedPdfBytes); + + // Act + var result = await PdfDownloadDelegate.DownloadInterviewReportPdfAsync(request, _pdfService); + + // Assert + result.ShouldNotBeNull(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + + httpContext.Response.StatusCode.ShouldBe(200); + } + + [Test] + public async Task DownloadInterviewReportPdfAsync_WhenServiceThrowsException_ShouldReturnProblem() + { + // Arrange + var request = new PdfDownloadRequest + { + Report = new InterviewReportModel(), + ChatHistory = new List() + }; + + _pdfService.GenerateInterviewReportPdfAsync(request.Report, request.ChatHistory) + .Throws(new InvalidOperationException("PDF 생성 오류")); + + // Act + var result = await PdfDownloadDelegate.DownloadInterviewReportPdfAsync(request, _pdfService); + + // Assert + result.ShouldNotBeNull(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + + httpContext.Response.StatusCode.ShouldBe(500); + } +} diff --git a/test/InterviewAssistant.ApiService.Tests/Services/PdfGenerationServiceTests.cs b/test/InterviewAssistant.ApiService.Tests/Services/PdfGenerationServiceTests.cs new file mode 100644 index 0000000..7431ddf --- /dev/null +++ b/test/InterviewAssistant.ApiService.Tests/Services/PdfGenerationServiceTests.cs @@ -0,0 +1,129 @@ +using InterviewAssistant.ApiService.Services; +using InterviewAssistant.Common.Models; +using NUnit.Framework; +using Shouldly; + +namespace InterviewAssistant.ApiService.Tests.Services; + +[TestFixture] +public class PdfGenerationServiceTests +{ + private IPdfGenerationService _pdfService; + + [SetUp] + public void Setup() + { + _pdfService = new PdfGenerationService(); + } + + [Test] + public async Task GenerateInterviewReportPdfAsync_WithValidData_ShouldReturnPdfBytes() + { + // Arrange + var report = new InterviewReportModel + { + OverallFeedback = "전반적으로 **좋은** 면접이었습니다.", + Strengths = ["명확한 의사소통", "적극적인 태도"], + Weaknesses = ["구체적 예시 부족"], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [5, 3, 2] + } + }; + + var chatHistory = new List + { + new() { Role = MessageRoleType.Assistant, Message = "안녕하세요! 자기소개 부탁드립니다." }, + new() { Role = MessageRoleType.User, Message = "저는 **개발자**입니다." } + }; + + // Act + var result = await _pdfService.GenerateInterviewReportPdfAsync(report, chatHistory); + + // Assert + result.ShouldNotBeNull(); + result.Length.ShouldBeGreaterThan(0); + + // PDF 헤더 확인 (PDF 파일은 %PDF로 시작) + var pdfHeader = System.Text.Encoding.ASCII.GetString(result.Take(4).ToArray()); + pdfHeader.ShouldBe("%PDF"); + } + + [Test] + public async Task GenerateInterviewReportPdfAsync_WithEmptyData_ShouldStillGeneratePdf() + { + // Arrange + var report = new InterviewReportModel(); + var chatHistory = new List(); + + // Act + var result = await _pdfService.GenerateInterviewReportPdfAsync(report, chatHistory); + + // Assert + result.ShouldNotBeNull(); + result.Length.ShouldBeGreaterThan(0); + } + + [Test] + public async Task GenerateInterviewReportPdfAsync_WithMarkdownContent_ShouldProcessMarkdown() + { + // Arrange + var report = new InterviewReportModel + { + OverallFeedback = "**굵은 글씨**와 *기울임*이 포함된 텍스트입니다.", + Strengths = ["# 제목이 있는 강점", "- 리스트 형태의 강점"], + Weaknesses = ["`코드`가 포함된 개선점"] + }; + + var chatHistory = new List + { + new() { Role = MessageRoleType.User, Message = "## 제목\n\n이것은 **마크다운** 텍스트입니다." } + }; + + // Act + var result = await _pdfService.GenerateInterviewReportPdfAsync(report, chatHistory); + + // Assert + result.ShouldNotBeNull(); + result.Length.ShouldBeGreaterThan(0); + } + + [Test] + public async Task GenerateInterviewReportPdfAsync_WithChartData_ShouldIncludeChartVisualization() + { + // Arrange + var report = new InterviewReportModel + { + OverallFeedback = "차트가 포함된 리포트입니다.", + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성", "문제해결"], + Values = [10, 5, 3, 2] + } + }; + + var chatHistory = new List(); + + // Act + var result = await _pdfService.GenerateInterviewReportPdfAsync(report, chatHistory); + + // Assert + result.ShouldNotBeNull(); + result.Length.ShouldBeGreaterThan(0); + } + + [Test] + public async Task GenerateInterviewReportPdfAsync_WithNullReport_ShouldThrowNullReferenceException() + { + // Arrange + InterviewReportModel? report = null; + var chatHistory = new List(); + + // Act & Assert + var exception = await Should.ThrowAsync( + async () => await _pdfService.GenerateInterviewReportPdfAsync(report!, chatHistory)); + + exception.ShouldNotBeNull(); + } +} diff --git a/test/InterviewAssistant.Web.Tests/Services/ChatServicePdfTests.cs b/test/InterviewAssistant.Web.Tests/Services/ChatServicePdfTests.cs new file mode 100644 index 0000000..77e49e1 --- /dev/null +++ b/test/InterviewAssistant.Web.Tests/Services/ChatServicePdfTests.cs @@ -0,0 +1,96 @@ +using InterviewAssistant.Common.Models; +using InterviewAssistant.Web.Clients; +using InterviewAssistant.Web.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; + +namespace InterviewAssistant.Web.Tests.Services; + +[TestFixture] +public class ChatServicePdfTests +{ + private IChatApiClient _apiClient; + private IChatService _chatService; + private ILoggerFactory _loggerFactory; + + [SetUp] + public void Setup() + { + _apiClient = Substitute.For(); + _loggerFactory = Substitute.For(); + _loggerFactory.CreateLogger().Returns(Substitute.For>()); + + _chatService = new ChatService(_apiClient, _loggerFactory); + } + + [TearDown] + public void TearDown() + { + _loggerFactory?.Dispose(); + } + + [Test] + public async Task DownloadReportPdfAsync_WithValidData_ShouldReturnPdfBytes() + { + // Arrange + var report = new InterviewReportModel + { + OverallFeedback = "테스트 피드백", + Strengths = ["강점1"], + Weaknesses = ["개선점1"] + }; + + var chatHistory = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + var expectedPdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // %PDF + _apiClient.DownloadReportPdfAsync(report, chatHistory).Returns(expectedPdfBytes); + + // Act + var result = await _chatService.DownloadReportPdfAsync(report, chatHistory); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBe(expectedPdfBytes); + await _apiClient.Received(1).DownloadReportPdfAsync(report, chatHistory); + } + + [Test] + public async Task DownloadReportPdfAsync_WhenApiClientReturnsNull_ShouldReturnNull() + { + // Arrange + var report = new InterviewReportModel(); + var chatHistory = new List(); + + _apiClient.DownloadReportPdfAsync(report, chatHistory).Returns(Task.FromResult(null)); + + // Act + var result = await _chatService.DownloadReportPdfAsync(report, chatHistory); + + // Assert + result.ShouldBeNull(); + } + + [Test] + public async Task DownloadReportPdfAsync_ShouldCallApiClientOnce() + { + // Arrange + var report = new InterviewReportModel { OverallFeedback = "테스트" }; + var chatHistory = new List + { + new() { Role = MessageRoleType.Assistant, Message = "안녕하세요" } + }; + + var pdfBytes = new byte[] { 1, 2, 3, 4, 5 }; + _apiClient.DownloadReportPdfAsync(report, chatHistory).Returns(pdfBytes); + + // Act + await _chatService.DownloadReportPdfAsync(report, chatHistory); + + // Assert + await _apiClient.Received(1).DownloadReportPdfAsync(report, chatHistory); + } +}
AI가 분석한 면접 결과 요약입니다.