[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

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:
pqhuy1987
2026-06-09 17:51:38 +07:00
parent bef582594e
commit a20cde89fb
13 changed files with 555 additions and 19 deletions

View File

@ -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);
}
}
}