[CLAUDE] App: golive harden — LeaveBalance concurrency + ItTicket authz-order + DocxRenderer + Travel/Vehicle tests
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
Pre-golive verification (S56) surfaced 4 issues; all fixed + verified (228 test pass, 0 build warning). #3 LeaveBalance lost-update (DB11 concurrency): terminal-approve deduction was an in-memory read-modify-write (UsedDays += NumDays) under a bare SaveChanges, so two concurrent terminal approvals of the same (user,type,year) lost an update. Fix: atomic server-side ExecuteUpdateAsync (UsedDays = UsedDays + n) inside an explicit Serializable transaction (matches the codegen/Proposal/TravelVehicle convention; serializes the auto-create-row race too). Exactly-once guard (Status != DaGuiDuyet) intact. No migration. #5 ItTicket reassign existence-oracle: AssignItTicketHandler checked ticket-NotFound before the Admin-OR-dept-IT Forbidden guard. Reordered so authorization runs first -> fail-closed (a non-IT/non-admin caller gets Forbidden for any ticketId, existent or not). #6 DocxRenderer CS8602: null-guard MainDocumentPart + Document with clear exceptions (cleared 2 build warnings -> 0). #4 Travel/Vehicle ApproveV2: added smoke tests (Submit->Approve terminal + outsider-Forbidden) — previously zero coverage. Tests 216 -> 228 (+12). database-agent DB-layer review PASS; em-main cross-stack review clean (reviewer workflow stage did not emit StructuredOutput -> em-main covered the cross-stack review by reading every diff). Bundles agent-memory harvest (S56). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,144 @@
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using SolutionErp.Infrastructure.Forms;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Forms;
|
||||
|
||||
// S56 #6 (2026-06-09) — DocxRenderer.cs nullable-deref guard (CS8602 fix dòng :30,:40).
|
||||
// Trước đây 0 test cho DocxRenderer (form-engine render path). Cover:
|
||||
// - MainDocumentPart null → InvalidOperationException("Template .docx không có MainDocumentPart")
|
||||
// (file .docx hợp lệ package nhưng KHÔNG có main document part — fail-closed message rõ).
|
||||
// - Happy path: template có {{placeholder}} → render thay đúng + giữ text ngoài placeholder.
|
||||
// - Placeholder không có key trong data → giữ nguyên literal {{...}} (không crash).
|
||||
//
|
||||
// DocxRenderer.RenderAsync đọc từ ĐĨA (File.ReadAllBytes) → test ghi file .docx tạm rồi xóa.
|
||||
// OpenXml 3.5.1: WordprocessingDocument.Create(path, type) tạo package RỖNG (no MainDocumentPart);
|
||||
// muốn happy path phải AddMainDocumentPart() + gán Document/Body.
|
||||
public class DocxRendererTests
|
||||
{
|
||||
// Tạo file .docx tạm có MainDocumentPart + 1 paragraph chứa text cho trước. Trả path (caller xóa).
|
||||
private static string WriteTemplateWithText(string paragraphText)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"se-docx-test-{Guid.NewGuid():N}.docx");
|
||||
using (var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document))
|
||||
{
|
||||
var main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document(
|
||||
new Body(
|
||||
new Paragraph(
|
||||
new Run(
|
||||
new Text(paragraphText) { Space = SpaceProcessingModeValues.Preserve }))));
|
||||
main.Document.Save();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Tạo file .docx tạm RỖNG (package hợp lệ nhưng KHÔNG AddMainDocumentPart) → MainDocumentPart == null.
|
||||
private static string WriteTemplateWithoutMainPart()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"se-docx-nomain-{Guid.NewGuid():N}.docx");
|
||||
using (WordprocessingDocument.Create(path, WordprocessingDocumentType.Document))
|
||||
{
|
||||
// Cố ý KHÔNG AddMainDocumentPart() — package zip hợp lệ, main part khuyết.
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Đọc lại bytes kết quả → combine toàn bộ <w:t> text để assert nội dung sau render.
|
||||
private static string ExtractBodyText(byte[] docxBytes)
|
||||
{
|
||||
using var ms = new MemoryStream(docxBytes);
|
||||
using var doc = WordprocessingDocument.Open(ms, isEditable: false);
|
||||
var body = doc.MainDocumentPart?.Document?.Body;
|
||||
body.Should().NotBeNull("kết quả render phải có Body hợp lệ để đọc text");
|
||||
var texts = body!.Descendants<Text>().Select(t => t.Text);
|
||||
return string.Concat(texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_MissingMainDocumentPart_ThrowsInvalidOperationWithClearMessage()
|
||||
{
|
||||
var path = WriteTemplateWithoutMainPart();
|
||||
try
|
||||
{
|
||||
var renderer = new DocxRenderer();
|
||||
var act = async () => await renderer.RenderAsync(
|
||||
path,
|
||||
new Dictionary<string, string?> { ["name"] = "X" },
|
||||
"out.docx");
|
||||
|
||||
(await act.Should().ThrowAsync<InvalidOperationException>())
|
||||
.WithMessage("*MainDocumentPart*", "guard fail-closed thay vì NullReferenceException mơ hồ");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_ReplacesPlaceholder_WithProvidedValue()
|
||||
{
|
||||
var path = WriteTemplateWithText("Xin chào {{name}}, dự án {{project}}.");
|
||||
try
|
||||
{
|
||||
var renderer = new DocxRenderer();
|
||||
var result = await renderer.RenderAsync(
|
||||
path,
|
||||
new Dictionary<string, string?> { ["name"] = "Anh Huy", ["project"] = "FLOCK" },
|
||||
"ket-qua.docx");
|
||||
|
||||
result.FileName.Should().Be("ket-qua.docx");
|
||||
result.ContentType.Should().Be(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
ExtractBodyText(result.Content).Should().Be("Xin chào Anh Huy, dự án FLOCK.",
|
||||
"cả 2 placeholder được thay; text tĩnh giữ nguyên");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_UnknownPlaceholder_KeepsLiteralToken_NoCrash()
|
||||
{
|
||||
var path = WriteTemplateWithText("Mã: {{maHopDong}} — {{khongCoKey}}");
|
||||
try
|
||||
{
|
||||
var renderer = new DocxRenderer();
|
||||
var result = await renderer.RenderAsync(
|
||||
path,
|
||||
new Dictionary<string, string?> { ["maHopDong"] = "FLOCK/01/MB" },
|
||||
"out.docx");
|
||||
|
||||
// Key có → thay; key thiếu → giữ literal {{khongCoKey}} (không ném, không rỗng hoá nhầm).
|
||||
ExtractBodyText(result.Content).Should().Be("Mã: FLOCK/01/MB — {{khongCoKey}}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_NullDataValue_ReplacesWithEmptyString()
|
||||
{
|
||||
var path = WriteTemplateWithText("Ghi chú:{{note}}");
|
||||
try
|
||||
{
|
||||
var renderer = new DocxRenderer();
|
||||
var result = await renderer.RenderAsync(
|
||||
path,
|
||||
new Dictionary<string, string?> { ["note"] = null },
|
||||
"out.docx");
|
||||
|
||||
ExtractBodyText(result.Content).Should().Be("Ghi chú:", "value null → thay bằng chuỗi rỗng");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user