From 6963548380907fc4596fecf9ccca18d0e391c2a2 Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:35:42 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Chore:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterviewAssistant.ApiService.csproj | 3 +++ 1 file changed, 3 insertions(+) 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 @@ + + + From 8962b2c5355e7be3c0a0155c76a8f562ed1de55c Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:36:11 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Feat:=20PDF=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/PdfGenerationService.cs | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/InterviewAssistant.ApiService/Services/PdfGenerationService.cs 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; + } +} From 71eb68f75d667418ce6c33fc41607110b4e29a4f Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:36:27 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Feat:=20PDF=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8D=B8=EB=A6=AC=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Delegates/PdfDownloadDelegate.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/InterviewAssistant.ApiService/Delegates/PdfDownloadDelegate.cs 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}"); + } + } +} From 8ada437348b34cdddeeeb90219c9bd9fa3321599 Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:36:48 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Feat:=20Pdf=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9A=94=EC=B2=AD=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Models/PdfDownloadRequest.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/InterviewAssistant.Common/Models/PdfDownloadRequest.cs 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(); +} From a0b1e736a58f7fbef08c1bfa56ca61144c4e746b Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:37:02 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Feat:=20PDF=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Endpoints/PdfDownloadEndpoint.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/InterviewAssistant.ApiService/Endpoints/PdfDownloadEndpoint.cs 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; + } +} From 533d6d9b1de504f43db16db38f077dcf25ea5bd7 Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:37:16 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Feat:=20Program.cs=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/InterviewAssistant.ApiService/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) 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(); From 2d7216f81c25ad25a944041206955be0ca001331 Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:37:35 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Feat:=20=EC=9B=B9=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=97=90=20PDF=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Clients/ChatApiClient.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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; + } } From 60041885d708a10f8ca1908d24a0b89f0e844663 Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:37:59 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Feat:=20=EB=B2=84=ED=8A=BC=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=9B=B9=EB=8B=A8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Pages/Report.razor | 49 +++++++++++++++++++ .../Services/ChatService.cs | 14 ++++++ .../wwwroot/js/chatFunctions.js | 20 ++++++++ 3 files changed, 83 insertions(+) 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()) + { + + }
@@ -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 From 54dbceb4cce4fcf40e8f05bd823ad9008872b13a Mon Sep 17 00:00:00 2001 From: gitjiho Date: Mon, 18 Aug 2025 02:38:13 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Feat:=20PDF=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Delegates/PdfDownloadDelegateTests.cs | 132 ++++++++++++++++++ .../Services/PdfGenerationServiceTests.cs | 129 +++++++++++++++++ .../Services/ChatServicePdfTests.cs | 96 +++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 test/InterviewAssistant.ApiService.Tests/Delegates/PdfDownloadDelegateTests.cs create mode 100644 test/InterviewAssistant.ApiService.Tests/Services/PdfGenerationServiceTests.cs create mode 100644 test/InterviewAssistant.Web.Tests/Services/ChatServicePdfTests.cs 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); + } +}