eCourt-Doc-Edit/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs
Suphonchai Phoonsawat 70f71f4c5c Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16)
แก้ตามผลใช้งานจริง 2 ประเด็น:
1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ
   ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays)
   + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง
   (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0)
2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ
   ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word
   จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด
   rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft)

- ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init,
  useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19
- Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings
  แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup
  ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00

219 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using WordVersioning.Api.Services;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties
/// + Document/v0/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class GenerateTests(IntegrationTestFixture fixture)
{
[Fact]
public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV0()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "generate หลัก");
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
// response ครบตาม FR-10 + downloadUrl ใช้ได้ทันที
Assert.Equal(0, doc.VersionNumber);
Assert.Equal($"/api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download", doc.DownloadUrl);
using var download = await client.GetAsync(doc.DownloadUrl);
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
var fileBytes = await download.Content.ReadAsByteArrayAsync();
// (1) ค่าทุก field ถูกแทนที่ใน SDT
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(fileBytes, "position"));
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
// (2) multi-run SDT: ค่าใหม่เขียนที่ run ข้อความยาวสุด (v9 — ที่นี่คือ run bold ซึ่งชนะด้วย tie แบบเจตนา,
// ดู comment ใน TestDocx) และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text);
Assert.True(runWithText.Bold);
Assert.False(runWithText.Italic);
// (3) custom properties ถูก stamp ลงไฟล์ — add-in ใช้ค่านี้ตรวจ tracked document (FR-1/FR-10)
Assert.Equal(AddInMetadata.AddInSignatureGuid, TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
Assert.Equal(doc.DocumentId.ToString(), TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
// (4) สถานะใน Postgres: Document ผูก template + v0 + field values ครบ
await using var db = fixture.CreateDb();
var document = await db.Documents.AsNoTracking()
.Include(d => d.Versions)
.SingleAsync(d => d.Id == doc.DocumentId);
Assert.Equal(templateId, document.TemplateId);
Assert.Equal(AddInMetadata.AddInSignatureGuid, document.AddInSignature);
Assert.EndsWith(".docx", document.FileName);
var version = Assert.Single(document.Versions);
Assert.Equal(0, version.VersionNumber);
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // จาก claim sub เท่านั้น
var fieldValues = await db.DocumentFieldValues.AsNoTracking()
.Where(v => v.DocumentId == doc.DocumentId)
.ToDictionaryAsync(v => v.FieldKey);
Assert.Equal(4, fieldValues.Count);
Assert.Equal("สมหญิง รักงาน", fieldValues["employee_name"].Value);
Assert.Equal("75000", fieldValues["salary"].Value);
Assert.All(fieldValues.Values, v => Assert.Equal(doc.VersionId, v.SourceVersionId));
Assert.All(fieldValues.Values, v => Assert.Equal(fixture.TestUserId, v.UpdatedByUserId));
}
[Fact]
public async Task Generate_MissingRequiredField_Returns400()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "required ขาด");
var values = new Dictionary<string, string>(TestFlow.DefaultValues);
values.Remove("employee_name"); // required field หาย
using var response = await client.PostAsJsonAsync(
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadAsStringAsync();
Assert.Contains("employee_name", error);
}
[Theory]
[InlineData("salary", "ไม่ใช่เลข")]
[InlineData("start_date", "ไม่ใช่วันที่")]
public async Task Generate_WrongFieldType_Returns400(string key, string badValue)
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"type ผิด {key}");
var values = new Dictionary<string, string>(TestFlow.DefaultValues) { [key] = badValue };
using var response = await client.PostAsJsonAsync(
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Generate_IgnoresUndefinedValues()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "ค่าเกินนิยาม");
var values = new Dictionary<string, string>(TestFlow.DefaultValues)
{
["field_ที่ไม่มีใน_template"] = "ส่งเกินมา", // policy FR-11: ignore
};
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
Assert.Equal(0, doc.VersionNumber);
await using var db = fixture.CreateDb();
Assert.False(await db.DocumentFieldValues.AsNoTracking()
.AnyAsync(v => v.DocumentId == doc.DocumentId && v.FieldKey == "field_ที่ไม่มีใน_template"));
}
[Fact]
public async Task Generate_UnknownTemplate_Returns404()
{
using var client = fixture.CreateClient();
using var response = await client.PostAsJsonAsync(
"api/documents/generate",
new { templateId = Guid.NewGuid(), values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
/// <summary>FR-12 — detail ของเอกสาร: merge นิยาม × ค่าล่าสุด แบบรองรับ orphan ทั้งสองทาง
/// (template replace ไฟล์แล้ว: def ใหม่ไม่มีค่า / ค่าเก่าไม่มี def)</summary>
[Fact]
public async Task GetDetail_MergesDefsAndValues_BothOrphanWays()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "orphan merge");
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
// replace ไฟล์ template: เหลือ employee_name + เพิ่ม note → position/salary/start_date กลายเป็นค่า orphan
using var replace = await client.PutAsync(
$"api/templates/{templateId}/file",
new MultipartFormDataContent
{
{ new ByteArrayContent(TestDocx.BuildReplacementTemplateDocx())
{ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType) } },
"file", "replaced.docx" },
});
replace.EnsureSuccessStatusCode();
using var detailResponse = await client.GetAsync($"api/documents/{doc.DocumentId}");
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
var detail = await detailResponse.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
Assert.Equal(templateId, detail!.TemplateId);
var byKey = detail.Fields.ToDictionary(f => f.Key);
// def ใหม่ (note) ยังไม่มีค่า — แสดงเป็นค่าว่าง + ไม่มี source version
Assert.Equal("", byKey["note"].Value);
Assert.Null(byKey["note"].SourceVersionId);
Assert.Equal("note", byKey["note"].Label);
// def เดิมที่ยังอยู่ (employee_name) — ค่า v0 ยังอยู่ครบ
Assert.Equal("สมหญิง รักงาน", byKey["employee_name"].Value);
Assert.Equal(doc.VersionId, byKey["employee_name"].SourceVersionId);
// ค่าเก่าที่ def หายไป (position/salary/start_date) — ต่อท้ายโดยไม่มีนิยาม
Assert.Null(byKey["position"].Label);
Assert.Equal("วิศวกรซอฟต์แวร์", byKey["position"].Value);
Assert.Equal("75000", byKey["salary"].Value);
Assert.Equal("2026-08-01", byKey["start_date"].Value);
}
/// <summary>FR-12 — รายการเอกสารต่อ template: versionCount/Latest ถูกต้อง เรียงกิจกรรมล่าสุดก่อน</summary>
[Fact]
public async Task ListDocuments_ShowsVersionCounts_LatestFirst()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "รายการเอกสาร");
var firstDoc = await TestFlow.GenerateDocumentAsync(client, templateId);
var secondDoc = await TestFlow.GenerateDocumentAsync(client, templateId);
// เพิ่ม v2 ให้เอกสารแรก → กิจกรรมล่าสุดเป็นของเอกสารแรก
using var upload = await client.PostAsync(
$"api/documents/{firstDoc.DocumentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx("แก้ไข"), "doc.docx", Guid.NewGuid().ToString(),
$$"""{"employee_name":"สมหญิง แก้ไขแล้ว","position":"วิศวกรซอฟต์แวร์","salary":"80000","start_date":"2026-08-01"}"""));
upload.EnsureSuccessStatusCode();
using var listResponse = await client.GetAsync($"api/templates/{templateId}/documents");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var items = await listResponse.Content.ReadFromJsonAsync<List<TemplateDocItem>>(IntegrationTestFixture.Json);
Assert.Equal(2, items!.Count);
Assert.Equal(firstDoc.DocumentId, items[0].DocumentId); // ล่าสุดก่อน
Assert.Equal(2, items[0].VersionCount);
Assert.Equal(1, items[0].LatestVersionNumber);
Assert.Equal(secondDoc.DocumentId, items[1].DocumentId);
Assert.Equal(1, items[1].VersionCount);
}
/// <summary>FR-12 — detail ของเอกสารที่ไม่ผูก template (track เองจาก add-in): Fields ว่าง templateId null</summary>
[Fact]
public async Task GetDetail_OfStandaloneDocument_HasEmptyFields()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "เอกสารอิสระ.docx", Guid.NewGuid().ToString()));
upload.EnsureSuccessStatusCode();
using var response = await client.GetAsync($"api/documents/{documentId}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var detail = await response.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
Assert.Null(detail!.TemplateId);
Assert.Null(detail.TemplateName);
Assert.Empty(detail.Fields);
}
}