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>
This commit is contained in:
parent
b3c1c667f7
commit
70f71f4c5c
23 changed files with 408 additions and 241 deletions
|
|
@ -5,8 +5,8 @@ using WordVersioning.Api.Services;
|
|||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template แบบ stateless
|
||||
/// (ค่าครบ + stamp เฉพาะ TemplateId + ไม่เกิด Document/version ใดๆ ในระบบ)</summary>
|
||||
/// <summary>FR-16 — ไฟล์ร่าง (draft): generate docx จาก template โดยไม่เกิด Document/version ใน DB
|
||||
/// (v9: เก็บไฟล์ชั่วคราวใต้ drafts/ + คืน presigned URL ให้เว็บเปิดใน Word + ค่าที่แทนที่ได้ formatting ไทยถูกต้อง)</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
|
|
@ -19,13 +19,16 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft",
|
||||
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้าง
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ไม่ใช่ 201 — ไม่มีอะไรถูกสร้างใน DB
|
||||
|
||||
// คืนไฟล์ docx พร้อมชื่อไฟล์จากชื่อ template (Content-Disposition attachment)
|
||||
Assert.Equal(TestDocx.DocxContentType, response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.NotNull(response.Content.Headers.ContentDisposition?.FileNameStar);
|
||||
|
||||
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
// คืน presigned URL อายุสั้น (เปิดใน Word ผ่าน ms-word:ofe / ดาวน์โหลดสำรอง) — ดึงไฟล์จริงได้ทันที
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
Assert.False(string.IsNullOrWhiteSpace(link.Url));
|
||||
Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30));
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// (1) ค่าทุก field ถูกแทนที่ใน SDT — เหมือน generate ปกติ
|
||||
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
|
||||
|
|
@ -33,7 +36,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
|
||||
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
||||
|
||||
// (2) multi-run SDT ยังรักษา formatting ของ run แรก (bold)
|
||||
// (2) multi-run SDT ยังรักษา formatting ของ run ผู้บริจาคค่า (bold — ชนะด้วย tie แบบเจตนา, ดู comment ใน TestDocx)
|
||||
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
||||
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
||||
Assert.True(runWithText.Bold);
|
||||
|
|
@ -43,7 +46,7 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
|
||||
Assert.Null(TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
|
||||
|
||||
// (4) stateless จริง: ไม่เกิด Document/version ใดๆ จาก template นี้
|
||||
// (4) ไม่เกิด Document/version ใดๆ ใน DB จาก template นี้ (ไฟล์เก็บชั่วคราวใน MinIO เท่านั้น ไม่ใช่ record)
|
||||
await using var db = fixture.CreateDb();
|
||||
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.TemplateId == templateId));
|
||||
Assert.False(await db.DocumentVersions.AsNoTracking()
|
||||
|
|
@ -52,6 +55,63 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
.AnyAsync(v => db.Documents.Any(d => d.Id == v.DocumentId && d.TemplateId == templateId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking()
|
||||
{
|
||||
// template ไทยจริง (แบบ T14) แตก placeholder เป็น [ / ข้อความ / ] — ค่าต้องลง run ข้อความ
|
||||
// ที่ถือ w:hint="cs" + w:cs ไม่ใช่ run ปีกกา ไม่งั้น Word ตัดคำ/กระจายไทยผิดทั้งบรรทัด (v9)
|
||||
var templateId = await CreateThaiTemplateAsync();
|
||||
using var client = fixture.CreateClient();
|
||||
const string detail = "โจทก์ฟ้องร้องให้เพิกถอนนิติกรรมสัญญาซื้อขายที่ดินโฉนดเลขที่ 12345 เนื่องจากถูกปลอมลายมือชื่อ";
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft",
|
||||
new { values = new Dictionary<string, string> { ["detail1"] = detail, ["remark1"] = "หมายเหตุผู้พิพากษา" } },
|
||||
IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// ค่ายาวอยู่ใน run เดียวที่ถือ complex script flags ตาม run ข้อความไทยของ template
|
||||
// (run ปีกกาถูกลบ text แต่คง rPr ไว้ — โล่ง มองไม่เห็นใน Word เหมือนพฤติกรรมเดิมของ generate)
|
||||
var run = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "detail1").Where(r => r.Text.Length > 0));
|
||||
Assert.Equal(detail, run.Text);
|
||||
Assert.True(run.CsHint);
|
||||
Assert.True(run.ComplexScript);
|
||||
|
||||
// SDT เปล่า: run ใหม่สืบทอด rPr จาก sdtPr (ฟอนต์ไทยของผู้แต่ง template) ไม่ใช่ default font
|
||||
var emptyRun = Assert.Single(TestDocx.ReadSdtRunInfo(fileBytes, "remark1").Where(r => r.Text.Length > 0));
|
||||
Assert.Equal("หมายเหตุผู้พิพากษา", emptyRun.Text);
|
||||
Assert.Equal("TH SarabunPSK", emptyRun.AsciiFont);
|
||||
}
|
||||
|
||||
/// <summary>สร้าง template จาก BuildThaiTemplateDocx พร้อมตั้ง field defs (detail1 required, remark1 ทางเลือก)</summary>
|
||||
private async Task<Guid> CreateThaiTemplateAsync() {
|
||||
using var client = fixture.CreateClient();
|
||||
using var create = await client.PostAsync(
|
||||
"api/templates",
|
||||
ApiExtensions.TemplateForm(TestDocx.BuildThaiTemplateDocx(), $"thai {Guid.NewGuid():N}"));
|
||||
create.EnsureSuccessStatusCode();
|
||||
var created = (await create.Content.ReadFromJsonAsync<CreateTemplateResult>(IntegrationTestFixture.Json))!;
|
||||
|
||||
using var put = await client.PutAsJsonAsync($"api/templates/{created.Id}", new
|
||||
{
|
||||
fields = created.Fields.Select(f => new
|
||||
{
|
||||
key = f.Key,
|
||||
label = f.Key,
|
||||
type = "text",
|
||||
isRequired = f.Key == "detail1",
|
||||
sortOrder = f.SortOrder,
|
||||
}),
|
||||
}, IntegrationTestFixture.Json);
|
||||
put.EnsureSuccessStatusCode();
|
||||
return created.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_MissingRequiredField_Returns400()
|
||||
{
|
||||
|
|
@ -123,7 +183,28 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var fileBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
|
||||
{
|
||||
// Program.cs ตั้ง rule `expire-drafts` ให้ bucket ตั้งแต่ startup แล้ว — ตรวจ path สร้างใหม่/ซ้ำ/เปลี่ยนค่า
|
||||
// ผ่าน rule id อื่นกับ prefix ทดสอบ (ทั้งหมดยิง MinIO จริง) — ค่าคืน true = เขียน config จริง, false = ถูกต้องอยู่แล้ว
|
||||
var storage = fixture.CreateStorage();
|
||||
var bucket = fixture.MinioConfig.VersionsBucket;
|
||||
|
||||
Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // สร้าง
|
||||
Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 1, "test-expire-probe")); // idempotent
|
||||
Assert.True(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // sync ค่าใหม่
|
||||
Assert.False(await storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 2, "test-expire-probe")); // ตั้งแล้วเช่นกัน
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
storage.EnsurePrefixExpiryAsync(bucket, "test-drafts-probe/", 0, "test-expire-probe"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue